构建支持Oracle与MariaDB异构并存的微服务动态路由数据源


一个团队正在从庞大的单体Oracle数据库向基于MariaDB的微服务架构迁移,这是一个必然会遇到的场景。迁移过程不是一蹴而就的,在相当长的一段时间内,新旧系统必须共存。这意味着部分微服务需要同时访问遗留的Oracle数据库和新建的MariaDB实例。最直接的暴力解决方案是在每个需要双重访问的服务中配置两个DataSource,然后在业务代码中通过@Qualifier之类的注解手动选择。这种方式在服务数量少、逻辑简单时勉强可行,但随着系统复杂度的增加,它会迅速演变成一场维护灾难:配置冗余、代码侵入性强、无法动态切换。

我们需要一个更优雅的解决方案:一个动态路由数据源框架。这个框架的目标是让业务代码对底层具体使用哪个数据库无感知,仅通过一个简单的标识(比如注解)就能决定当前操作的数据源,并且这个数据源的列表和配置能够通过配置中心(Nacos)在运行时动态调整,无需服务重启。

架构决策:静态配置 vs. 动态路由

在设计之初,我们评估了两种主流方案。

方案A:服务内静态多数据源配置

这是最容易想到的方法。在每个微服务的application.yml中定义多个数据源,比如spring.datasource.oraclespring.datasource.mariadb。然后在代码中使用@Qualifier注入不同的JdbcTemplateEntityManager

  • 优势:

    • 实现简单,对Spring Boot多数据源配置熟悉的话,上手很快。
    • 对于固定不变的数据库访问需求,足够稳定。
  • 劣势:

    • 配置僵化: 任何数据源的变更(如IP、密码修改,或新增一个分片库)都需要修改服务的配置文件并重新部署。在微服务架构下,这可能意味着需要协调数十个服务的发布,成本极高。
    • 代码侵入: 业务逻辑层必须清楚地知道存在多个数据源,并负责选择正确的那个。这违反了单一职责原则,业务代码被基础设施细节污染。
    • 扩展性差: 如果未来需要接入第三种数据源,或者对某个数据源进行分库,需要大规模修改代码。

方案B:基于Nacos和AOP的动态路由数据源框架

这个方案的核心是构建一个中间层。它利用Spring的AbstractRoutingDataSource作为路由核心,通过AOP切面拦截方法调用,根据方法上的自定义注解动态地在线程上下文中设置数据源标识。最关键的是,所有的数据源配置都存储在Nacos中,服务通过监听Nacos配置变更,可以在运行时动态地增加、删除或修改数据源,而无需重启。

  • 优势:

    • 配置中心化与动态化: 所有数据源配置在Nacos统一管理,运维便捷。运行时变更配置即可生效,极大提升了灵活性,甚至可以用于实现数据层的灰度发布。
    • 业务代码无感知: 业务开发者只需在需要切换数据源的方法上加一个注解,如@DataSource("oracle_legacy"),无需关心DataSource的创建和管理。
    • 高扩展性: 框架是通用的。未来无论是接入SQL Server还是PostgreSQL,或是增加更多的MariaDB分片,都只是在Nacos中增加一段配置,无需改动任何服务的代码。
  • 劣劣:

    • 初始复杂度高: 需要自行实现框架的核心逻辑,包括动态Bean注册、Nacos监听处理、AOP切面等。
    • 引入新依赖: 强依赖配置中心(Nacos)的稳定性。
    • 事务问题: 跨异构数据源的事务管理变得复杂,默认的@Transactional无法胜任,需要引入JTA或分布式事务框架(如Seata)来解决,但这超出了数据源路由本身的范畴。

在真实项目中,长期的可维护性和灵活性远比初期的实现速度更重要。因此,方案B是我们的最终选择。它虽然前期投入更高,但为整个微服务体系的平滑演进和后续的数据库治理奠定了坚实的基础。

核心实现概览

整个框架的运转流程可以通过下图清晰地展示:

sequenceDiagram
    participant Client as 客户端请求
    participant Controller as Controller层
    participant Service as Service层 (业务逻辑)
    participant DataSourceAspect as 数据源AOP切面
    participant ContextHolder as DynamicDataSourceContextHolder
    participant DynamicDataSource as 动态路由数据源
    participant Nacos
    participant SpringContext as Spring应用上下文

    Client->>Controller: 发起业务请求
    Controller->>Service: 调用Service方法
    Note right of Service: Service方法上标注了 @DataSource("oracle_legacy")
    DataSourceAspect->>Service: @Around: 方法执行前拦截
    DataSourceAspect->>ContextHolder: setDataSourceKey("oracle_legacy")
    Service->>DynamicDataSource: (DAO层)发起数据库操作
    DynamicDataSource->>ContextHolder: getDataSourceKey()
    ContextHolder-->>DynamicDataSource: 返回 "oracle_legacy"
    DynamicDataSource-->>Service: 切换到Oracle数据源并执行SQL
    Service-->>Controller: 返回执行结果
    Controller-->>Client: 返回响应
    DataSourceAspect->>ContextHolder: @Around: finally中清理
    
    %% 动态更新流程
    Nacos->>SpringContext: 推送数据源配置变更
    SpringContext->>DynamicDataSource: 监听到变更, 触发更新逻辑
    SpringContext->>DynamicDataSource: 动态创建/更新/销毁DataSource Bean
    Note over DynamicDataSource: 更新内部维护的 targetDataSources Map

核心组件包括:

  1. @DataSource 注解: 用于标记在方法或类上,指定其应使用的数据源Key。
  2. DynamicDataSourceContextHolder: 使用ThreadLocal存储当前线程需要使用的数据源Key。这是保证线程安全的关键。
  3. DataSourceAspect: AOP切面,拦截带有@DataSource注解的方法。在方法执行前,将注解中的数据源Key设置到ContextHolder;在方法执行后(无论成功或失败),清理ContextHolder,防止内存泄漏和线程复用时的污染。
  4. DynamicRoutingDataSource: 继承自AbstractRoutingDataSource,是整个框架的核心。它重写determineCurrentLookupKey()方法,从ContextHolder中获取当前数据源Key,并据此返回真实的DataSource实例。
  5. DataSourceInitializer: 负责在应用启动和接收到Nacos配置变更时,解析配置、创建或更新DataSource对象(通常是DruidDataSource),并将其动态注册到Spring容器和DynamicRoutingDataSource中。

关键代码与原理解析

让我们一步步构建这个框架。

1. Maven 依赖

首先,确保pom.xml中包含了所有必要的依赖。

<dependencies>
    <!-- Spring Boot基础 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <!-- 数据库驱动 -->
    <dependency>
        <groupId>com.oracle.database.jdbc</groupId>
        <artifactId>ojdbc8</artifactId>
        <version>19.3.0.0</version>
    </dependency>
    <dependency>
        <groupId>org.mariadb.jdbc</groupId>
        <artifactId>mariadb-java-client</artifactId>
    </dependency>
    <!-- 数据源连接池: Druid -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.2.16</version>
    </dependency>
    <!-- MyBatis or JPA -->
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.3.0</version>
    </dependency>
    <!-- Nacos配置中心 -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        <version>2021.0.4.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

2. Nacos 中的核心配置

我们的数据源配置将完全托管在Nacos上。创建一个DATA_IDdynamic-datasource.ymlGROUPDEFAULT_GROUP 的配置。

# dynamic-datasource.yml in Nacos
dynamic:
  datasource:
    # 默认数据源,必须指定一个
    primary: mariadb-master
    # 数据源定义列表
    sources:
      # MariaDB主库
      - key: mariadb-master
        username: root
        password: your_mariadb_password
        url: jdbc:mariadb://localhost:3306/db_new?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
        driver-class-name: org.mariadb.jdbc.Driver
      # 遗留系统Oracle库
      - key: oracle-legacy
        username: legacy_user
        password: your_oracle_password
        url: jdbc:oracle:thin:@//localhost:1521/ORCL
        driver-class-name: oracle.jdbc.OracleDriver

这里的key就是我们将来在@DataSource注解中使用的标识。

3. 框架核心代码

现在开始编写框架的Java代码。

@DataSource 注解

package com.example.dynamic.datasource.annotation;

import java.lang.annotation.*;

/**
 * 数据源切换注解
 * 可以作用于类或方法上,方法上的注解优先级高于类上的注解
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited // 保证子类可以继承父类上的注解
public @interface DataSource {
    /**
     * 数据源的唯一标识Key,与Nacos配置中的key对应
     */
    String value();
}

DynamicDataSourceContextHolder

package com.example.dynamic.datasource.core;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * 动态数据源上下文持有者
 * 使用ThreadLocal来保证线程安全
 */
public final class DynamicDataSourceContextHolder {

    private static final Logger log = LoggerFactory.getLogger(DynamicDataSourceContextHolder.class);
    
    // ThreadLocal存储当前线程使用的数据源key
    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();

    private DynamicDataSourceContextHolder() {}

    /**
     * 设置当前线程的数据源Key
     * @param key 数据源唯一标识
     */
    public static void setDataSourceKey(String key) {
        log.debug("Switching datasource to [{}]", key);
        CONTEXT_HOLDER.set(key);
    }

    /**
     * 获取当前线程的数据源Key
     * @return 数据源唯一标识
     */
    public static String getDataSourceKey() {
        return CONTEXT_HOLDER.get();
    }

    /**
     * 清理当前线程的数据源Key
     * 必须在操作完成后调用,防止内存泄漏
     */
    public static void clearDataSourceKey() {
        log.debug("Clearing datasource context.");
        CONTEXT_HOLDER.remove();
    }
}

一个常见的错误是在使用ThreadLocal后忘记调用remove(),在高并发场景下,如果线程被线程池复用,会导致后续请求错误地使用了上一个请求设置的数据源。

DataSourceAspect

package com.example.dynamic.datasource.aop;

import com.example.dynamic.datasource.annotation.DataSource;
import com.example.dynamic.datasource.core.DynamicDataSourceContextHolder;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

/**
 * 数据源切面处理
 * 使用@Order确保其在@Transactional之前执行
 */
@Aspect
@Component
@Order(1) // 保证该AOP在@Transactional之前执行
public class DataSourceAspect {

    // 定义切点,拦截所有带有@DataSource注解的方法或类
    @Pointcut("@annotation(com.example.dynamic.datasource.annotation.DataSource) || @within(com.example.dynamic.datasource.annotation.DataSource)")
    public void dataSourcePointCut() {}

    @Around("dataSourcePointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        // 优先获取方法上的注解
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        DataSource dataSource = method.getAnnotation(DataSource.class);

        // 如果方法上没有,则获取类上的注解
        if (dataSource == null) {
            dataSource = point.getTarget().getClass().getAnnotation(DataSource.class);
        }

        if (dataSource != null) {
            // 设置数据源
            DynamicDataSourceContextHolder.setDataSourceKey(dataSource.value());
        }

        try {
            // 执行目标方法
            return point.proceed();
        } finally {
            // 方法执行完毕后,清理数据源上下文,这是至关重要的
            DynamicDataSourceContextHolder.clearDataSourceKey();
        }
    }
}

DynamicRoutingDataSource

package com.example.dynamic.datasource.core;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.Map;

public class DynamicRoutingDataSource extends AbstractRoutingDataSource {

    /**
     * 该方法是核心,Spring在每次数据库操作前会调用此方法获取当前应该使用的数据源Key
     */
    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceContextHolder.getDataSourceKey();
    }

    /**
     * 动态添加数据源
     * @param key 数据源Key
     * @param dataSource DataSource实例
     */
    public void addDataSource(String key, DataSource dataSource) {
        Map<Object, Object> targetDataSources = (Map<Object, Object>) super.getResolvedDataSources();
        targetDataSources.put(key, dataSource);
        super.afterPropertiesSet(); // 刷新数据源
    }
    
    /**
     * 动态移除数据源
     * @param key 数据源Key
     */
    public void removeDataSource(String key) {
        Map<Object, Object> targetDataSources = (Map<Object, Object>) super.getResolvedDataSources();
        targetDataSources.remove(key);
        super.afterPropertiesSet();
    }
}

4. 动态配置与初始化

这部分是整个框架最精髓的地方,它连接了Nacos和DynamicRoutingDataSource

DataSourceProperties 配置绑定类

package com.example.dynamic.datasource.config;

import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Properties;

@Component
@ConfigurationProperties(prefix = "dynamic.datasource")
public class DataSourceProperties {

    private String primary;
    private List<DataSourceProperty> sources;

    // Getters and Setters ...

    public static class DataSourceProperty {
        private String key;
        private String username;
        private String password;
        private String url;
        private String driverClassName;
        
        // Druid specific properties can be added here
        // private int initialSize;
        // private int minIdle;
        // ...

        public DruidDataSource toDruidDataSource() {
            DruidDataSource dataSource = new DruidDataSource();
            dataSource.setUsername(username);
            dataSource.setPassword(password);
            dataSource.setUrl(url);
            dataSource.setDriverClassName(driverClassName);

            // 生产级配置: 必须设置合理的连接池参数
            Properties properties = new Properties();
            properties.setProperty("druid.initialSize", "5");
            properties.setProperty("druid.minIdle", "5");
            properties.setProperty("druid.maxActive", "20");
            properties.setProperty("druid.maxWait", "60000");
            properties.setProperty("druid.validationQuery", "SELECT 1"); // Oracle use 'SELECT 1 FROM DUAL'
            properties.setProperty("druid.testOnBorrow", "false");
            properties.setProperty("druid.testOnReturn", "false");
            properties.setProperty("druid.testWhileIdle", "true");
            properties.setProperty("druid.timeBetweenEvictionRunsMillis", "60000");
            properties.setProperty("druid.minEvictableIdleTimeMillis", "300000");
            dataSource.setConnectProperties(properties);
            
            return dataSource;
        }
        
        // Getters and Setters...
    }
}

DynamicDataSourceConfig 核心配置类

package com.example.dynamic.datasource.config;

import com.alibaba.nacos.api.config.annotation.NacosConfigListener;
import com.alibaba.nacos.spring.context.annotation.config.NacosPropertySource;
import com.example.dynamic.datasource.core.DynamicRoutingDataSource;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

@Configuration
@NacosPropertySource(dataId = "dynamic-datasource.yml", autoRefreshed = true)
public class DynamicDataSourceConfig {

    private static final Logger log = LoggerFactory.getLogger(DynamicDataSourceConfig.class);

    @Autowired
    private DataSourceProperties dataSourceProperties;

    // 用于存储当前所有的数据源实例
    private final Map<String, DataSource> dataSourceMap = new HashMap<>();

    @Bean
    @Primary // 标记为主要的DataSource Bean
    public DataSource dynamicDataSource() {
        DynamicRoutingDataSource dynamicRoutingDataSource = new DynamicRoutingDataSource();

        // 首次加载配置
        Map<Object, Object> targetDataSources = new HashMap<>();
        if (dataSourceProperties.getSources() != null) {
            for (DataSourceProperties.DataSourceProperty p : dataSourceProperties.getSources()) {
                DataSource ds = p.toDruidDataSource();
                targetDataSources.put(p.getKey(), ds);
                dataSourceMap.put(p.getKey(), ds);
            }
        }
        
        // 设置所有数据源
        dynamicRoutingDataSource.setTargetDataSources(targetDataSources);
        
        // 设置默认数据源
        DataSource primaryDataSource = dataSourceMap.get(dataSourceProperties.getPrimary());
        if (primaryDataSource == null) {
            throw new IllegalStateException("Primary datasource '" + dataSourceProperties.getPrimary() + "' is not defined.");
        }
        dynamicRoutingDataSource.setDefaultTargetDataSource(primaryDataSource);

        log.info("Dynamic DataSource initialization finished. Primary: [{}], Total sources: {}", dataSourceProperties.getPrimary(), dataSourceMap.size());
        return dynamicRoutingDataSource;
    }

    /**
     * 监听Nacos配置变更的核心方法
     * @param configContent Nacos推送的最新配置内容(YAML格式)
     */
    @NacosConfigListener(dataId = "dynamic-datasource.yml")
    public void onConfigReceived(String configContent) {
        log.info("Received Nacos config update. Reloading datasources...");
        try {
            ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
            // 反序列化新的配置
            DynamicDataSourceWrapper wrapper = mapper.readValue(configContent, DynamicDataSourceWrapper.class);
            DataSourceProperties newProperties = wrapper.getDynamic().getDatasource();
            
            DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dynamicDataSource();
            
            Map<String, DataSourceProperties.DataSourceProperty> newSourceMap = newProperties.getSources().stream()
                .collect(Collectors.toMap(DataSourceProperties.DataSourceProperty::getKey, p -> p));

            // 1. 移除已经不存在的数据源
            dataSourceMap.keySet().stream()
                .filter(key -> !newSourceMap.containsKey(key))
                .forEach(key -> {
                    log.info("Removing datasource: {}", key);
                    routingDataSource.removeDataSource(key);
                    dataSourceMap.remove(key);
                });

            // 2. 新增或更新数据源
            newSourceMap.forEach((key, prop) -> {
                if (!dataSourceMap.containsKey(key) || !isSameDataSource(dataSourceMap.get(key), prop)) {
                    log.info("Adding or updating datasource: {}", key);
                    routingDataSource.addDataSource(key, prop.toDruidDataSource());
                    dataSourceMap.put(key, prop.toDruidDataSource());
                }
            });
            
            // 3. 更新默认数据源 (如果需要)
            if (!Objects.equals(DynamicDataSourceContextHolder.getDataSourceKey(), newProperties.getPrimary())) {
                 DataSource newPrimary = dataSourceMap.get(newProperties.getPrimary());
                 if(newPrimary != null) {
                    routingDataSource.setDefaultTargetDataSource(newPrimary);
                    log.info("Primary datasource updated to: {}", newProperties.getPrimary());
                 }
            }
            log.info("Datasource reload completed.");

        } catch (Exception e) {
            log.error("Failed to reload dynamic datasource from Nacos.", e);
        }
    }

    // 辅助类用于反序列化嵌套的YAML
    private static class DynamicDataSourceWrapper {
        private DynamicWrapper dynamic;
        public static class DynamicWrapper {
            private DataSourceProperties datasource;
            // getters and setters
        }
        // getters and setters
    }
    
    // 简单的比较逻辑,判断数据源配置是否发生变化
    private boolean isSameDataSource(DataSource oldDs, DataSourceProperties.DataSourceProperty newProp) {
        // In a real project, this should be a more robust comparison
        if (oldDs instanceof com.alibaba.druid.pool.DruidDataSource) {
            com.alibaba.druid.pool.DruidDataSource druidDs = (com.alibaba.druid.pool.DruidDataSource) oldDs;
            return druidDs.getUrl().equals(newProp.getUrl()) && druidDs.getUsername().equals(newProp.getUsername());
        }
        return false;
    }
}

单元测试思路:
对这个框架进行测试并非易事。关键点在于:

  1. AOP切面测试: 编写一个测试Service,使用@DataSource注解。在单元测试中调用其方法,然后断言DynamicDataSourceContextHolder.getDataSourceKey()的值是否被正确设置和清理。
  2. Nacos监听器逻辑测试: 模拟Nacos推送配置。可以直接调用onConfigReceived方法,传入一个YAML字符串,然后检查DynamicRoutingDataSource中的targetDataSources是否被正确更新。
  3. 集成测试: 启动一个完整的Spring Boot应用(可能使用Testcontainers来启动一个真实的MariaDB和Oracle实例),通过API接口触发业务逻辑,验证数据是否被正确写入到了预期的数据库中。

遗留问题与未来迭代

这个框架解决了异构数据源动态管理的核心痛点,但在生产环境中,它并非银弹,还存在一些局限性与可优化的方向。

首先,跨数据源事务是该方案最大的挑战。如果一个业务操作,比如placeOrder,需要先在Oracle中扣减旧库存,再在MariaDB中创建新订单,那么一个简单的@Transactional注解会失效。因为它默认只对单一数据源生效。要解决这个问题,必须引入外部的分布式事务协调器,例如基于XA协议的Atomikos,或者采用基于Saga、TCC模式的分布式事务框架Seata。

其次,连接池管理需要更加精细化。当Nacos配置中动态增加了大量数据源时,应用会创建等量的数据库连接池,这会消耗大量的内存和数据库连接资源。可以考虑实现连接池的懒加载机制,即只有当某个数据源第一次被访问时才真正初始化其连接池。或者,为动态添加的数据源设置更为严格和保守的连接池参数。

最后,框架的健壮性可以进一步增强。比如,当Nacos推送的配置格式错误时,当前的实现会抛出异常,但或许更友好的方式是拒绝本次更新,并保持使用旧的配置,同时发出告警。此外,可以增加对数据源健康状况的监控,当某个动态添加的数据源无法连接时,能够自动从路由列表中暂时移除,实现简单的故障转移。


  目录