若依框架多数据源实战:从静态配置到动态路由的企业级架构演进

最近在重构一个老项目的数据库层,遇到了一个挺典型的场景:业务量上来了,单库读写压力陡增,同时有几个新业务模块需要接入独立的分析型数据库。如果还像以前那样,每个新数据源都去改一遍 DruidConfig,加一堆 @Bean,不仅配置繁琐,上线时还得提心吊胆,生怕哪个依赖没处理好导致服务重启。这让我重新审视了若依(RuoYi)框架默认的多数据源配置方式,它确实开箱即用,但在面对需要动态、按需切换数据源的复杂生产环境时,就显得有些力不从心了。

今天想聊的,不是如何照着文档配好几个数据源,而是如何基于若依的骨架,构建一个更灵活、更健壮的数据源管理层。核心目标有两个:一是实现运行时动态切换,无需重启应用;二是优雅地集成读写分离策略,将负载均衡的逻辑从业务代码中剥离出来。这不仅仅是改几行配置,更是一种架构思路的转变——从“静态装配”走向“动态路由”。

1. 重新审视若依的多数据源基石

若依框架默认的多数据源实现,本质上是一个静态的、基于注解的切换机制。它在应用启动时,根据 application.yml 中的配置,将所有定义好的数据源(如 masterslave)一次性初始化并注册到 DynamicDataSource 这个路由器中。业务方法通过 @DataSource 注解显式指定要使用的数据源键名。

这种模式在中小型项目或数据源固定的场景下工作良好。但它的局限性也很明显:

  • 缺乏弹性:每新增一个数据源,都必须修改 DruidConfig 配置类,增加 @Bean 定义,并重新打包部署。
  • 配置与代码耦合:数据源的连接信息硬编码在配置文件中,动态更新(如密码轮转、连接池参数调整)困难。
  • 读写分离逻辑侵入业务:实现读写分离通常需要在 Service 层手动判断操作类型(读/写),然后选择对应的数据源注解,这污染了业务逻辑。

注意:我们追求的“动态”,并非指在单个请求内随意无规则切换,而是指数据源的路由策略可以根据规则(如SQL类型、方法名、分库分表键)在运行时智能决策,且数据源池本身支持热更新。

我们先来看看一个经过初步优化的配置类雏形。它依然基于 @ConfigurationProperties,但为后续的动态化留出了扩展点。

@Configuration
@Slf4j
public class EnhancedDruidConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.druid.master")
    public DataSource masterDataSource() {
        // 主库通常固定存在,作为默认数据源
        return DruidDataSourceBuilder.create().build();
    }

    /**
     * 使用@ConditionalOnProperty控制从库数据源是否启用。
     * 这是一个简单的开关,但仍是静态配置。
     */
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.druid.slave")
    @ConditionalOnProperty(prefix = "spring.datasource.druid.slave", name = "enabled", havingValue = "true")
    public DataSource slaveDataSource() {
        return DruidDataSourceBuilder.create().build();
    }

    @Primary
    @Bean(name = "dynamicDataSource")
    public DataSource dynamicDataSource(DataSource masterDataSource,
                                       @Autowired(required = false) DataSource slaveDataSource) {
        Map<Object, Object> targetDataSources = new HashMap<>(5);
        targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);

        if (slaveDataSource != null) {
            targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
            log.info("Slave data source initialized and registered.");
        }

        DynamicDataSource ds = new DynamicDataSource();
        // 设置默认数据源
        ds.setDefaultTargetDataSource(masterDataSource);
        // 设置目标数据源映射
        ds.setTargetDataSources(targetDataSources);
        return ds;
    }
}

这个版本比原始教程更清晰,但它只是起点。真正的进阶,是从这里开始“拆墙”,让数据源管理活起来。

2. 构建动态数据源管理器:抽象与实现

要实现动态能力,核心是创建一个中心化的数据源管理器。这个管理器负责数据源的生命周期:加载、创建、缓存、销毁和路由查询。它与具体的配置读取解耦,无论是从 YAML 文件、配置中心(如 NacosApollo)还是数据库读取,管理器只关心接口。

首先,定义一个数据源配置的抽象模型:

@Data
public class DataSourceProperty {
    /** 数据源唯一标识(对应DataSourceType或自定义key) */
    private String poolName;
    /** 驱动类名 */
    private String driverClassName;
    /** JDBC URL */
    private String url;
    /** 用户名 */
    private String username;
    /** 密码 */
    private String password;
    /** 连接池类型,如 druid, hikari */
    private String type;
    /** 是否启用 */
    private Boolean enabled;
    /** 扩展属性,用于存放连接池特有参数 */
    private Map<String, Object> connectionProperties;
}

接着,是核心的管理器接口:

public interface DynamicDataSourceManager {
    /**
     * 添加或更新一个数据源
     * @param key 数据源键
     * @param property 数据源属性
     * @return 创建的数据源对象
     */
    DataSource addDataSource(String key, DataSourceProperty property);

    /**
     * 根据键名获取数据源
     */
    DataSource getDataSource(String key);

    /**
     * 移除并关闭一个数据源
     */
    void removeDataSource(String key);

    /**
     * 获取当前所有已注册的数据源键
     */
    Set<String> getDataSourceKeys();

    /**
     * 重新加载所有数据源配置(如监听配置中心变更)
     */
    void reloadAllDataSources(Map<String, DataSourceProperty> configMap);
}

一个基于内存缓存和 Druid 的简单实现如下:

@Component
@Slf4j
public class DefaultDynamicDataSourceManager implements DynamicDataSourceManager, InitializingBean {

    private final Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();
    private final DynamicDataSource dynamicDataSource; // 指向Spring容器中那个全局的动态数据源

    public DefaultDynamicDataSourceManager(DynamicDataSource dynamicDataSource) {
        this.dynamicDataSource = dynamicDataSource;
    }

    @Override
    public DataSource addDataSource(String key, DataSourceProperty property) {
        synchronized (dataSourceMap) {
            if (dataSourceMap.containsKey(key)) {
                log.warn("DataSource [{}] already exists, will be destroyed and recreated.", key);
                removeDataSource(key);
            }

            DruidDataSource ds = DruidDataSourceBuilder.create().build();
            // 使用BeanUtils或手动setter将property中的属性映射到ds
            BeanUtils.copyProperties(property, ds);
            if (property.getConnectionProperties() != null) {
                property.getConnectionProperties().forEach(ds::addConnectionProperty);
            }

            try {
                ds.init();
                dataSourceMap.put(key, ds);
                // 关键步骤:更新全局DynamicDataSource的targetDataSources
                Map<Object, Object> targetSources = dynamicDataSource.getTargetDataSources();
                targetSources.put(key, ds);
                dynamicDataSource.setTargetDataSources(targetSources);
                dynamicDataSource.afterPropertiesSet(); // 触发重新绑定
                log.info("Dynamic DataSource [{}] added and refreshed successfully.", key);
                return ds;
            } catch (Exception e) {
                log.error("Failed to initialize dynamic datasource [{}]", key, e);
                throw new RuntimeException(e);
            }
        }
    }

    @Override
    public void afterPropertiesSet() {
        // 容器启动时,可以从数据库或配置中心加载初始数据源配置
        // 例如:loadInitialConfigFromDatabase();
    }
    // ... 其他接口方法实现
}

现在,我们有了一个可以随时“插拔”数据源的管理器。接下来,需要一种机制来触发管理器的操作,比如监听配置变更事件。

3. 集成配置中心与热更新策略

在生产环境,将数据源配置放在 application.yml 里并不是最佳实践。我们更希望将其外部化到配置中心。这里以订阅 Nacos 配置变更为例,展示如何与上面的管理器联动。

首先,定义一个 Nacos 的配置监听器:

@Component
@Slf4j
public class DataSourceConfigRefreshListener {

    @Autowired
    private DynamicDataSourceManager dataSourceManager;

    /**
     * 假设Nacos上有一个名为 `datasource-config.json` 的配置项
     * 内容是所有动态数据源的JSON数组
     */
    @NacosConfigListener(dataId = "datasource-config.json", groupId = "DEFAULT_GROUP", type = ConfigType.JSON)
    public void onDataSourceConfigReceived(List<DataSourceProperty> newConfigList) {
        log.info("Received new datasource configuration from Nacos.");
        if (CollectionUtils.isEmpty(newConfigList)) {
            return;
        }

        Map<String, DataSourceProperty> newConfigMap = newConfigList.stream()
                .filter(p -> Boolean.TRUE.equals(p.getEnabled()))
                .collect(Collectors.toMap(DataSourceProperty::getPoolName, Function.identity()));

        // 获取当前已存在的key
        Set<String> existingKeys = dataSourceManager.getDataSourceKeys();
        // 主库key通常固定,不参与动态移除
        existingKeys.remove(DataSourceType.MASTER.name());

        // 处理新增或更新的数据源
        newConfigMap.forEach((key, property) -> {
            if (!DataSourceType.MASTER.name().equals(key)) { // 主库不通过此方式动态变更
                dataSourceManager.addDataSource(key, property);
            }
        });

        // 处理需要移除的数据源(在新配置里不存在,且非主库)
        Set<String> keysToRemove = existingKeys.stream()
                .filter(key -> !newConfigMap.containsKey(key))
                .collect(Collectors.toSet());
        keysToRemove.forEach(dataSourceManager::removeDataSource);

        log.info("Dynamic datasource refresh completed.");
    }
}

这样,当运维人员在 Nacos 控制台上修改了数据源连接信息或增删了从库节点,应用在几秒内就能自动感知并完成数据源的重建和路由表的更新,实现真正的无感热更新

4. 实现智能读写分离与负载均衡

有了动态数据源的基础,读写分离的实现就变成了定义路由规则的问题。我们不再需要在每个 Service 方法上纠结该用 @DataSource(DataSourceType.MASTER) 还是 @DataSource(DataSourceType.SLAVE)。目标是:让框架自动判断,读操作走从库(或读库集群),写操作走主库

我们可以利用 Spring 的 AbstractRoutingDataSource 以及 AOP 来实现。若依自带的 DynamicDataSource 已经继承了它,我们需要扩展的是决定使用哪个 key 的环节——即 determineCurrentLookupKey() 方法。

一个常见的策略是基于 Spring 的事务管理器和方法注解。但更精细的做法是,解析即将执行的 SQL。这里介绍一个结合 MyBatis 插件和自定义注解的混合方案。

第一步,定义路由注解和上下文持有器:

/**
 * 强制指定数据源,优先级最高
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TargetDataSource {
    String value();
}

/**
 * 标记方法为只读,暗示可以使用从库
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ReadOnly {
}

/**
 * 用于保存当前线程选择的数据源key
 */
public class DynamicDataSourceContextHolder {
    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();

    public static void setDataSourceKey(String key) {
        CONTEXT_HOLDER.set(key);
    }

    public static String getDataSourceKey() {
        return CONTEXT_HOLDER.get();
    }

    public static void clearDataSourceKey() {
        CONTEXT_HOLDER.remove();
    }
}

第二步,实现一个 AOP 切面,在方法执行前根据规则设置数据源键:

@Aspect
@Component
@Slf4j
@Order(1) // 确保在事务切面之前执行
public class DataSourceRoutingAspect {

    @Around("@annotation(targetDataSource)")
    public Object aroundWithExplicitAnnotation(ProceedingJoinPoint joinPoint, TargetDataSource targetDataSource) throws Throwable {
        String previousKey = DynamicDataSourceContextHolder.getDataSourceKey();
        try {
            DynamicDataSourceContextHolder.setDataSourceKey(targetDataSource.value());
            log.debug("Switched to datasource [{}] by @TargetDataSource.", targetDataSource.value());
            return joinPoint.proceed();
        } finally {
            // 恢复之前的数据源,或清空
            if (previousKey != null) {
                DynamicDataSourceContextHolder.setDataSourceKey(previousKey);
            } else {
                DynamicDataSourceContextHolder.clearDataSourceKey();
            }
        }
    }

    @Around("@annotation(readOnly)")
    public Object aroundReadOnlyMethod(ProceedingJoinPoint joinPoint, ReadOnly readOnly) throws Throwable {
        // 如果没有显式指定数据源,且方法是只读的,则尝试路由到从库
        if (DynamicDataSourceContextHolder.getDataSourceKey() == null) {
            String slaveKey = determineSlaveKey(); // 这里可以实现负载均衡策略,如轮询、随机
            DynamicDataSourceContextHolder.setDataSourceKey(slaveKey);
            log.debug("Routing read-only method to slave [{}].", slaveKey);
        }
        try {
            return joinPoint.proceed();
        } finally {
            DynamicDataSourceContextHolder.clearDataSourceKey();
        }
    }

    /**
     * 简单的从库负载均衡:随机选择
     */
    private String determineSlaveKey() {
        List<String> slaveKeys = getAvailableSlaveKeys(); // 从DataSourceManager获取所有从库key
        if (CollectionUtils.isEmpty(slaveKeys)) {
            return DataSourceType.MASTER.name(); // 没有从库则降级到主库
        }
        Random random = new Random();
        return slaveKeys.get(random.nextInt(slaveKeys.size()));
    }
}

第三步,修改若依的 DynamicDataSource,让其从 ThreadLocal 中获取 key:

public class EnhancedDynamicDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        String key = DynamicDataSourceContextHolder.getDataSourceKey();
        // 如果未指定,或者指定的数据源不存在,则返回null,使用默认数据源(主库)
        if (key != null && getResolvedDataSources().containsKey(key)) {
            return key;
        }
        // 返回null表示使用@Primary标注的默认数据源
        return null;
    }
}

第四步,配置事务管理器,确保在事务中数据源切换正确:

提示:数据源切换必须在事务开启之前完成,否则会导致事务使用错误的数据源。这就是为什么 AOP 切面的 @Order 要设置得比事务切面高。

@Configuration
public class TransactionConfig {

    @Bean
    public PlatformTransactionManager transactionManager(DynamicDataSource dynamicDataSource) {
        return new DataSourceTransactionManager(dynamicDataSource);
    }
}

至此,一个基本的智能读写分离框架就搭建好了。使用方式变得非常简洁:

  • 对于写操作强一致性读,无需任何注解,默认走主库。
  • 对于可以接受延迟的读操作,只需在方法上添加 @ReadOnly 注解。
  • 对于需要明确指定某个特定数据源的操作(比如访问一个独立的报表数据库),使用 @TargetDataSource("report_db")

5. 生产环境下的考量与最佳实践

将这套机制用于生产,还需要考虑以下几个关键点:

1. 连接池监控与告警 动态创建的数据源,其连接池状态也需要被监控。可以利用 Druid 自带的 StatViewServletWebStatFilter,为每个动态数据源注册独立的 DruidDataSourceStatManager 监控入口。

2. 从库健康检查与故障转移determineSlaveKey() 方法中,不能简单地轮询所有从库 key。需要引入健康检查机制,定期对从库进行心跳检测(如执行 SELECT 1),将不可用的从库从候选列表中剔除。可以设计一个简单的健康检查器:

@Component
@Slf4j
public class SlaveHealthChecker {
    private final Map<String, Boolean> healthStatus = new ConcurrentHashMap<>();
    private final DynamicDataSourceManager dataSourceManager;

    @Scheduled(fixedDelay = 30000) // 每30秒检查一次
    public void checkAllSlaves() {
        Set<String> slaveKeys = getAvailableSlaveKeys();
        for (String key : slaveKeys) {
            DataSource ds = dataSourceManager.getDataSource(key);
            if (ds instanceof DruidDataSource) {
                boolean isHealthy = performHealthCheck((DruidDataSource) ds);
                healthStatus.put(key, isHealthy);
                log.debug("Slave [{}] health status: {}", key, isHealthy ? "UP" : "DOWN");
            }
        }
    }

    private boolean performHealthCheck(DruidDataSource ds) {
        try (Connection conn = ds.getConnection();
             Statement stmt = conn.createStatement()) {
            stmt.executeQuery("SELECT 1");
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public List<String> getHealthySlaveKeys() {
        return healthStatus.entrySet().stream()
                .filter(Map.Entry::getValue)
                .map(Map.Entry::getKey)
                .collect(Collectors.toList());
    }
}

然后在 AOP 切面中,使用 slaveHealthChecker.getHealthySlaveKeys() 来获取可用的从库列表。

3. 配置的版本管理与回滚 当通过配置中心管理数据源配置时,务必利用配置中心的版本功能。任何一次数据源配置的修改,都应该有记录,并且能快速回滚到上一个稳定版本。

4. 性能与测试

  • 压测:在启用动态数据源和读写分离后,务必进行全面的压力测试,确保连接池参数、线程池设置合理,不会成为新的瓶颈。
  • 多场景测试:测试主库故障、从库故障、网络分区等异常场景下,系统的降级和恢复能力。
  • 监控指标:在应用监控(如 Micrometer + Prometheus + Grafana)中,增加对动态数据源数量、各数据源连接活跃数、等待数、路由次数(主库/从库)等指标的监控。

5. 一个配置示例(application.yml)

spring:
  datasource:
    druid:
      master: # 主库配置固定
        url: jdbc:mysql://master-host:3306/ruoyi?useUnicode=true&characterEncoding=utf8
        username: root
        password: master-password
        driver-class-name: com.mysql.cj.jdbc.Driver
        initial-size: 5
        max-active: 20
        min-idle: 5
      # 从库配置可以留空,或仅配置一个默认的。真正的从库列表由配置中心下发。
      slave:
        enabled: false # 默认关闭,由动态管理器加载

# 动态数据源管理配置(可选,用于设置管理器本身参数)
ruoyi:
  datasource:
    dynamic:
      health-check-interval-ms: 30000
      default-slave-key: slave_default

这套架构的演进,本质上是将数据源从一个静态的“基础设施组件”,转变为一个由应用自身管理的“动态服务”。它带来的好处是显而易见的:运维更灵活,架构更弹性,业务代码更干净。当然,复杂度也有所提升,需要配套的监控、告警和运维流程来支撑。在实际引入时,建议从非核心业务开始试点,逐步完善各个环节,最终构建出适合自己业务节奏的、稳健的数据层架构。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐