若依前后端分离版多数据源进阶玩法:动态切换+读写分离配置详解
若依框架多数据源实战:从静态配置到动态路由的企业级架构演进
最近在重构一个老项目的数据库层,遇到了一个挺典型的场景:业务量上来了,单库读写压力陡增,同时有几个新业务模块需要接入独立的分析型数据库。如果还像以前那样,每个新数据源都去改一遍 DruidConfig,加一堆 @Bean,不仅配置繁琐,上线时还得提心吊胆,生怕哪个依赖没处理好导致服务重启。这让我重新审视了若依(RuoYi)框架默认的多数据源配置方式,它确实开箱即用,但在面对需要动态、按需切换数据源的复杂生产环境时,就显得有些力不从心了。
今天想聊的,不是如何照着文档配好几个数据源,而是如何基于若依的骨架,构建一个更灵活、更健壮的数据源管理层。核心目标有两个:一是实现运行时动态切换,无需重启应用;二是优雅地集成读写分离策略,将负载均衡的逻辑从业务代码中剥离出来。这不仅仅是改几行配置,更是一种架构思路的转变——从“静态装配”走向“动态路由”。
1. 重新审视若依的多数据源基石
若依框架默认的多数据源实现,本质上是一个静态的、基于注解的切换机制。它在应用启动时,根据 application.yml 中的配置,将所有定义好的数据源(如 master、slave)一次性初始化并注册到 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 文件、配置中心(如 Nacos、Apollo)还是数据库读取,管理器只关心接口。
首先,定义一个数据源配置的抽象模型:
@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 自带的 StatViewServlet 和 WebStatFilter,为每个动态数据源注册独立的 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
这套架构的演进,本质上是将数据源从一个静态的“基础设施组件”,转变为一个由应用自身管理的“动态服务”。它带来的好处是显而易见的:运维更灵活,架构更弹性,业务代码更干净。当然,复杂度也有所提升,需要配套的监控、告警和运维流程来支撑。在实际引入时,建议从非核心业务开始试点,逐步完善各个环节,最终构建出适合自己业务节奏的、稳健的数据层架构。
更多推荐
所有评论(0)