曾经有个线上小事故让我印象很深:业务库的读流量越来越大,一张报表表把从库连接池全部占满,DBA半夜打电话说主库CPU也飙了。查了一圈,根因是项目里写的多个数据库工具类各自new了DataSource,代码里到处硬编码切库,事务一开连接就被钉死在某个库上。那次之后我把项目里所有数据源方案重写了一遍,这也是今天这篇东西的来由。
这篇文章聚焦Spring Boot项目里多数据源的连接管理和动态切换。我会先用真实项目视角讲清楚哪些场景真的需要多数据源,再拆解AbstractRoutingDataSource的底层切换机制,然后把一套可落地的注解+切面动态切换方案完整写出来,最后把MyBatis-Plus、事务、连接池这几个重灾区逐个排掉。适合正在做多库接入、读写分离改造,或者面试前想把这个点彻底吃透的Java开发。
1. 先搞清楚:哪些场景真的需要搞多数据源
很多新人一听到"多数据源"就想到搞一个高大上的动态切换框架。但我见过太多项目其实是被迫上了多数据源,结果引入了一堆分布式事务、连接管理、配置同步的麻烦。所以我先把决策前置:别急着写代码,先对号入座。
1.1 真实存在的三类业务场景
多数据源不是设计模式层面上的炫技,而是被业务逼出来的,常见的有这么几类:
- 读写分离(最常见的单业务库拆分):主库负责写,从库负责读。这种场景下数据源往往是同一个MySQL实例集群的不同节点或不同账号,表结构完全一样。核心痛点是读多写少,需要把查询流量导到从库。
- 业务分库:比如订单库、用户库、库存库,它们属于同一个业务域,但被强制拆到不同库。原因是单实例的库撑不住全量数据,或者是团队/运维边界强制拆分。这种场景往往还伴随跨库事务问题,难度会上一个台阶。
- 异构数据源接入:比如业务库是MySQL,但有一部分数据要从Oracle或PostgreSQL同步过来,或者需要直接查询数仓里的ClickHouse。这种情况常见于数据中台、报表系统,数据模型完全不同。
1.2 你以为需要、但可能不需要的情况
还有一种典型情况:微服务推荐拆库,但并不是所有系统都要在代码里做多数据源。如果你只是做报表查询,完全可以走独立的数据服务API,或者用数据同步工具把数据汇聚到一个只读库里再查。直接在代码里连接一堆业务库,容易变成一张巨大的蜘蛛网。
另外,如果你的目标是横向扩展数据库容量,优先考虑的是缓存、分库分表中间件(比如ShardingSphere),而不是在应用层维护一个"库列表"再自己路由。应用层动态切换更适合"少量数据源、规则明确"的场景。超过几十个数据源还靠动态切换框架硬扛,连接管理、配置下发、故障隔离都会很难受。
我在实际项目中判断是否值得用多数据源,就三条标准:数据源数量可控(个位数到十几个)、切换规则可以由当前请求上下文确定(比如按用户、按租户、按读写类型)、以及业务上能接受跨库事务自己兜底。如果三条里有一条不满足,我会马上劝团队换方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据源切换的底层原理:连接级的路由机制
很多教程一上来就贴代码,告诉你用AbstractRoutingDataSource,但没讲清楚它为什么能实现动态切换。这个类我用了几年,越用越觉得它的设计思想值得写一段:本质上它是在连接获取层做了一层路由代理。
2.1 JDBC连接的获取路径
一个普通的Java程序拿到数据库连接的过程大概是:通过DriverManager.getConnection(url, username, password)拿到物理连接。Spring里我们不会直接操作DriverManager,而是配置一个DataSource,它内部维护了一个连接池,当你要操作数据库时,从池里getConnection()取一个物理连接。
Spring的DataSource在大部分框架里都是一个单例bean。MyBatis执行SQL时会从SqlSessionFactory绑定的DataSource去获取连接。也就是说,对MyBatis来说,它认的是"哪个DataSource",而不是"连接字符串是什么"。
动态数据源的关键突破口就在这个"认DataSource"的环节:如果我们能提供一个DataSource,让它在getConnection()时根据当前线程的上下文动态决定返回哪个真实库的连接,那下游的MyBatis、JdbcTemplate完全无感知。
2.2 AbstractRoutingDataSource的工作方式
Spring的org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource恰恰设计成了这个角色。它本身持有一组目标数据源(用key区分),核心逻辑是抽象方法determineCurrentLookupKey()返回一个key,getConnection()内部会用这个key去目标数据源Map里取真正的DataSource。
说得直白点,你可以把它当做一个"路由器":
java复制public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceKey();
}
}
DataSourceContextHolder存储当前线程应该用哪个库(用ThreadLocal实现)。当业务代码调用了"切换到库A",后续在同一线程内的getConnection()就会拿到库A的连接,只要没有切回来,整个线程期间所有数据库操作都落在库A上。
这里有个很重要的设计含义:切换的粒度是线程级别的"当前上下文"。所以事务边界、线程池、异步任务这些和线程相关的东西,最终都会干扰切换结果。后面我会用专门一节讲这些坑。
2.3 为什么不能直接用两个DataSource然后在Service里选
有人可能会问:我不搞代理路由,我直接定义两个DataSource,在Service里通过@Qualifier选择不同的JdbcTemplate或SqlSessionTemplate不就行了?
这个思路在极少数只查一两次的场景是可行的,但它有严重缺陷:
- 侵入性非常强:每段代码都要显式指定用哪个库,业务代码被数据源选择逻辑淹没。
- 事务无法跨库控制:
@Transactional默认绑定单个DataSource上产生的事务管理器,如果你在方法内切换了底层数据源,事务管理器并不知道,连接归属就会错乱。 - 难以应对动态规则:如果切库逻辑来源于请求参数/租户标识,就得在编码层到处传递,可维护性断崖式下跌。
正是因为这些痛点,业界主流的轻量方案才都收敛到"基于AbstractRoutingDataSource的动态路由"上。至于更重的ShardingSphere,也不是不用它,而是它的职责范围更大(分片、分布式事务、读写分离),对项目改动面也更大。很多中小项目只是为了多接一个库,没必要直接把中间件搬进来。
2.4 AbstractRoutingDataSource的懒初始化与连接池迁移
还有个容易忽略的细节:AbstractRoutingDataSource在初始化时并不会立刻创建所有目标数据源的物理连接。默认情况下,它是在目标数据源被第一次路由命中后才去初始化目标数据源对应的连接池。在afterPropertiesSet()里,它只是把targetDataSources的Map保存到resolvedDataSources里,真正的连接池构建由各DataSource实现类自己完成。如果你用的是HikariCP,通常需要等到第一次getConnection()时才触发连接池的创建和预热。
于是你能看到一种现象:应用启动后,动态数据源路由到A库,B库的数据源显示还没有连接。这本身不是故障,但如果某个库在流量突增时才第一次被打到,首次创建连接池会造成初始化的尖刺延迟。对这个问题我的处理方式是在启动完成后主动做一次针对各数据源的连接预热探测,比如用一个只读查询来触发各连接池初始化。
3. 一套可落地的动态多数据源实现方案
下面给出一个我多次在项目里使用的通用实现,不依赖具体ORM或框架组件,Spring Boot + MyBatis(Plus) + HikariCP都能适配。设计目标:开箱即用、注解驱动、支持服务启动时与运行中动态切换。
3.1 依赖与基础配置
这里以Spring Boot 2.7为例,数据库驱动按需引入:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
如果不是MyBatis-Plus而是纯MyBatis,则把第二项替换成mybatis-spring-boot-starter即可。重点是不要直接把spring.datasource.url写成固定值,因为我们要接管DataSource的组装。
application.yml里我习惯自定义一组配置前缀,例如:
yaml复制spring:
datasource:
dynamic:
primary: master
strict: true
datasource:
master:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.10.1:3306/db_master?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
slave:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.10.2:3306/db_master?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: readonly_user
password: 123456
这些配置不会自动被Spring Boot的DataSourceAutoConfiguration识别,我们需要在配置类里读取并手动创建数据源。这样做的另一个好处是,你可以把数据源配置放到配置中心,热更新时有能力做动态重建。
3.2 核心类:线程上下文、动态路由、切面
要实现注解驱动,需要三个核心组件:
DataSourceContextHolder:线程级key持有。DynamicDataSource:继承AbstractRoutingDataSource,重写determineCurrentLookupKey()。@DataSource注解 +DataSourceAspect切面:方法入口设置key,方法结束后清理。
先看线程上下文。这里有个重要细节:需要用TransmittableThreadLocal(阿里开源)而不是普通的ThreadLocal,否则当主线程往线程池里提交任务时,子线程无法继承父线程的数据源上下文,异步批量处理就会落到默认库上。
java复制public class DataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT = new TransmittableThreadLocal<>();
public static void setDataSource(String dataSourceKey) {
CONTEXT.set(dataSourceKey);
}
public static String getDataSource() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
TransmittableThreadLocal依赖:
xml复制<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.3</version>
</dependency>
如果项目不想引入这个依赖,至少也要保证在使用@Async或自定义线程池时,任务内部重新设置数据源key,否则一定会出现"默认库被异步线程打爆"的线上问题。
再看动态路由数据源:
java复制public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String key = DataSourceContextHolder.getDataSource();
return StringUtils.hasText(key) ? key : null;
}
@Override
protected DataSource determineTargetDataSource() {
// 支持没有设置key时返回默认主库
Object lookupKey = determineCurrentLookupKey();
if (lookupKey == null) {
return super.determineTargetDataSource();
}
DataSource dataSource = this.resolvedDataSources.get(lookupKey);
if (dataSource == null) {
throw new IllegalStateException("无法确定数据源: " + lookupKey);
}
return dataSource;
}
}
注意determineTargetDataSource()里的逻辑:lookupKey为null时交给父类默认逻辑,走defaultTargetDataSource;如果找不到key,直接抛异常,而不是静默回退。否则配置写错时,系统会把数据写到主库,造成隐性故障。这是我吃过亏之后特意加上的。
然后配置类读取配置并注册Bean:
java复制@Configuration
public class DynamicDataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.dynamic.datasource")
public Map<String, DataSourceProperty> dataSourceProperties() {
return new HashMap<>();
}
@Bean
public DataSource dynamicDataSource() {
Map<String, DataSource> targetDataSources = new HashMap<>();
// 根据DataSourceProperty构建HikariDataSource并放入targetDataSources
Map<String, DataSourceProperty> props = dataSourceProperties();
props.forEach((key, val) -> {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(val.getUrl());
ds.setUsername(val.getUsername());
ds.setPassword(val.getPassword());
ds.setDriverClassName(val.getDriverClassName());
ds.setMaximumPoolSize(val.getMaxPoolSize());
ds.setMinimumIdle(val.getMinIdle());
targetDataSources.put(key, ds);
});
DynamicDataSource dynamicDataSource = new DynamicDataSource();
dynamicDataSource.setTargetDataSources(targetDataSources);
// 默认库:取配置中的primary
dynamicDataSource.setDefaultTargetDataSource(targetDataSources.get("master"));
return dynamicDataSource;
}
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource);
// 这里是MyBatis-Plus的用法
// 如果使用原生MyBatis,则使用SqlSessionFactoryBean
// 省略Mybatis-Plus全局配置
return factory.getObject();
}
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
如果你用的是Spring Boot自动配置,通常只需要在配置类里注入DataSource,Spring Boot会把dynamicDataSource()当成主数据源。但因为手动定义了SqlSessionFactory和事务管理器,必须确保它们拿到了我们自定义的那个DataSource,而不是自动配置生成的另一个实例。
我用MyBatis-Plus时习惯于用MybatisSqlSessionFactoryBean而不是SqlSessionFactoryBean,是为了让MyBatis-Plus的诸如分页插件、逻辑删除等拦截器正常注册。具体细节不展开,但如果你发现自己配置的分页插件不生效,先检查是不是自定义SqlSessionFactory时丢失了MyBatis-Plus的全局配置。
3.3 注解与AOP切面的写法
创建注解:
java复制@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSource {
String value() default "master";
}
切面:
java复制@Aspect
@Component
public class DataSourceAspect {
@Around("@annotation(dataSource)")
public Object around(ProceedingJoinPoint point, DataSource dataSource) throws Throwable {
DataSourceContextHolder.setDataSource(dataSource.value());
try {
return point.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
@Around("@within(dataSource)")
public Object aroundClass(ProceedingJoinPoint point, DataSource dataSource) throws Throwable {
DataSourceContextHolder.setDataSource(dataSource.value());
try {
return point.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
}
这里同时支持方法注解和类注解。原因是实际项目里,服务类上的很多方法是共用的列表查询、详情查询,如果每个方法都标注解会显得很啰嗦;我更倾向于在Service实现类上标注该类默认走哪个库,单个特殊方法再单独override。AOP匹配@within部分的作用就是让类级别注解生效,而方法级别注解优先级更高。
3.4 使用效果
代码调用就变得非常干净:
java复制@DataSource("slave")
public List<OrderVO> listOrders(Query query) {
return orderMapper.selectList(...);
}
@DataSource("master")
@Transactional
public void updateOrderStatus(Long orderId, Integer status) {
Order order = orderMapper.selectById(orderId);
order.setStatus(status);
orderMapper.updateById(order);
// 同一个线程后续都用master
}
我自己在使用时的规则是:写操作或者强一致性读,强制标注master;允许延迟的查询走slave。 不标注默认走master,因为主库语义上最安全。这个默认策略可以避免开发忘记标注解导致强一致性读落到从库、读到延迟数据的问题。
4. 那些绕不开的坑:事务、连接池与MyBatis-Plus
动态数据源的核心机制不复杂,真正劝退开发者的是使用过程中各种边界问题。这些坑我在生产环境几乎踩了个遍,逐个说清楚比给一个"完美框架"更有用。
4.1 @Transactional和切换顺序:为什么事务内切库不生效
Spring的@Transactional默认通过DataSourceTransactionManager在事务开始时获取一个数据库连接,事务结束前,所有数据库操作都复用这个连接。DataSourceTransactionManager拿连接时调用了DataSourceUtils.getConnection(dataSource),如果dataSource是我们动态代理的那个路由数据源,那么连接获取是在事务开启的一瞬间完成的。
也就是说,当你的代码执行顺序是这样的:
java复制@DataSource("slave")
@Transactional
public void doSomething() {
// 此时事务管理器已经拿了一个数据源的连接,这个连接可能是slave
DataSourceContextHolder.setDataSource("master");
// 即使你在方法体内手动切换key,事务内拿到还是之前的那个连接
orderMapper.insert(...);
}
这个片段的行为就很怪:注解上写slave,方法体内又手工切master,但实际JDBC连接不会变化。因为在事务存活期间,Spring把连接绑定到了当前线程的TransactionSynchronizationManager资源里,后续拿到的都是同一个物理连接。
我遇到过的坑是在一个方法里先走读接口查数据,又执行了写操作,却忘了读写选择会因为注解切库时事务里的连接根本不理会新的key而“错乱”。解决方案很明确:不要在事务方法内部尝试动态切换数据源。 数据源应该在进入事务之前就确定。具体做法是:
- 不要在同一个方法里既要
@Transactional又要中途切库。 - 如果是类级别标注走了从库,某些写方法必须override为master,并且该写方法最好是一个新事务边界(即独立开启事务);如果你把
@DataSource("master")和@Transactional放在同一个方法上,且类上也标了@DataSource("slave"),要确认AOP的顺序:允许@DataSource切面先执行,再进入事务管理器,才能保证事务开始时拿到的key是master。
这里再补充一个顺序问题。数据源切面和事务切面的顺序谁先执行,直接影响结果。如果事务先启动,连接就已经被绑定,数据源切换再晚已经没有意义。所以最好明确指出切面顺序,给数据源切面加上@Order(Ordered.HIGHEST_PRECEDENCE):
java复制@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class DataSourceAspect { ... }
4.2 MyBatis-Plus中saveOrUpdateBatch在多数据源下的诡异问题
再来说一个被问烂的,也是热词里直接点名的“mybatis的saveorupdatebatch多数据源的问题”。现象通常是:单条saveOrUpdate正常,但调用saveOrUpdateBatch后部分数据写到了主库,部分数据写到了从库,或者干脆报错“SqlSession was not registered for synchronization because synchronization is not active”。
根因要分两层来看。
第一层:SqlSessionFactory隔离。 MyBatis-Plus的saveOrUpdateBatch内部用的是批量SqlSession,它通过SqlSessionUtils.getSqlSession(sqlSessionFactory)获取SqlSession。如果整个项目只配置了一个SqlSessionFactory,而这个SqlSessionFactory绑定的是动态路由DataSource,那么它按理说能感知上下文。但很多人图方便,在系统里引入了多个SqlSessionFactory,或者直接使用了SqlSessionTemplate的静态注入,把某个SqlSessionTemplate固定绑定到某一个数据源,批量操作就会走到那个固定数据源上,不再理会动态路由。
第二层:批量SqlSession没有绑定事务同步。 在没有开启Spring事务的方法里,批量操作会创建一个独立的SqlSession,并在操作完成后立即关闭。如果在这个过程中,你的切面在finally里把DataSourceContextHolder清了,而MyBatis的批量执行器内部持有的连接是在执行中才懒获取的,某些延迟执行的批量语句就会出现上下文丢失,走到默认主库。
所以在实现里,saveOrUpdateBatch失败最常见原因不是SQL本身,而是mybatis-plus内部SqlSession获取时机与@DataSource切面清除时机错位。解决方案有几种:
- 为批量操作单独走一个没有动态切面的内部方法,在SqlSession绑定上下文期间执行完整批处理。
- 如果项目强制使用MyBatis-Plus的多租户插件或分页插件,确保它们不影响SqlSession创建时对数据源的选择。
- 更粗暴但有效的做法:这类批量写方法直接指定走master,并且显式开启事务,让整个批量操作在同一个事务和连接中完成。
java复制@DataSource("master")
@Transactional(rollbackFor = Exception.class)
public void batchUpdate(List<Order> orders) {
// 这里用类注入的Service实现saveOrUpdateBatch会走当前事务中的连接,实测稳定
orderService.saveOrUpdateBatch(orders);
}
我后来干脆在项目里统一了一个约定:所有批量写操作必须加@Transactional,且在整个事务生命周期内不要切库。 这既省心,也避免很多和批量SqlSession关闭时机相关的偶发问题。
4.3 连接池为什么被打满:从10000个已连接开始说起
网络热词里有“tcp连接10000个已连接”这样的词条。如果动态数据源使用不当,也很容易出现某库连接数持续走高的情况。
一个常见场景:你配置了master和slave两个HikariCP连接池,每个连接池maxPoolSize=50。理论上并发再高,100个连接应该封顶。但实际可能因为代码里没有走动态数据源管理,直接new HikariDataSource()创建了多个连接池实例;或者框架内部对同一个数据源创建了多个DataSource包装而不是复用同一个路由实例。多个连接池实例导致连接总数呈倍数增长。
还有一个隐蔽场景:使用第三方库需要传入DataSource,比如定时任务、消息监听、工作流引擎,它们可能自己维护了独立的连接获取逻辑。如果这些组件每次启动时都通过DataSourceBuilder创建新DataSource,每创建一个就有独立连接池,时间一长,连接数堆积到上万完全可能。
排查手段我习惯按三步走:
- 查连接来源:数据库端执行
show processlist或查询performance_schema里的连接host和用户,区分是应用哪个IP发起的。 - 查线程堆栈:对Java进程执行
jstack,看哪些线程卡在getConnection上,确认是否有连接泄漏。 - 查Spring容器中的DataSource实例数:通过
/actuator/beans端点搜索类型为DataSource的Bean,如果不止一个,排查是Spring Boot自动配置重复创建,还是业务代码直接new了。
提示:如果项目已经接了Spring Boot Actuator,并且配了micrometer,
/actuator/metrics/hikaricp.connections可以实时看到每个连接池的active、idle、pending数量。这是定位连接数飙升最直接的工具。
4.4 线程池与异步任务必须手动指定路由
数据源上下文是线程绑定的。如果你在默认主库的请求里往线程池里提交了任务,任务里没有显式标注数据源,它不会自动继承主线程的上下文(普通ThreadLocal不传递,TransmittableThreadLocal也不一定在没配置TaskDecorator时生效)。要解决异步多数据源正确路由,常见有两种方案:
方案一:每次任务内部显式设置数据源。
java复制@Async
@DataSource("slave")
public void asyncQuery() { ... }
在Spring的@Async代理里,注解切面照样会执行,所以这个方案最简单。但前提是异步线程池使用Spring管理的线程,而不是自己直接new Thread。
方案二:为线程池配置TaskDecorator,把上下文从主线程复制到子线程。
java复制@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(runnable -> {
String dataSource = DataSourceContextHolder.getDataSource();
return () -> {
try {
DataSourceContextHolder.setDataSource(dataSource);
runnable.run();
} finally {
DataSourceContextHolder.clear();
}
};
});
executor.setCorePoolSize(8);
executor.setMaxPoolSize(20);
executor.initialize();
return executor;
}
我更倾向于方案二,因为可以把路由细节从业务代码里抽出来,异步任务方法不需要再额外写注解。不过在TaskDecorator里要注意,如果父线程当时没有设置key(默认走主库),子线程也不需要强制set,否则会在多级异步调用中出现脏上下文污染。
5. 生产环境下的数据源扩展思路:从静态两库到多租户与动态注册
一套基础动态数据源最终能撑住一个读写分离的小项目,但生产上会慢慢出现更多需求,比如租户隔离、灰度环境多库、新库上线不停机。最后一节把这些扩展思路和配套改造写清楚。
5.1 永远不要把数据源写死在代码里
前面示例代码里数据源配置是写死在application.yml里的。这种做法够用,但只适合数据源清单基本固定的场景。一旦业务需要经常新增数据源(比如每个新客户一个独立库),继续靠改配置重启就太痛苦了。
这种情况下可以开发一个数据源注册管理组件:用一个Map存放所有已初始化的数据源,同时提供运行时注册和下线接口。AbstractRoutingDataSource的targetDataSources在设计上并没有禁止运行期修改,你可以通过继承并暴露一个方法:
java复制public synchronized void addDataSource(String key, DataSource ds) {
this.targetDataSources.put(key, ds);
this.afterPropertiesSet();
}
每次添加或移除都要重新触发afterPropertiesSet(),它会根据最新的map重建resolvedDataSources。不过这里有一个连接管理上的隐患:移除数据源时,已从连接池分配的连接不会被立刻清掉。建议下线数据源前,先通知调用方摘流量,再等一段时间后关闭对应连接池。
考虑到动态注册的数据源规模,需要把连接池参数设计成允许配置化。HikariCP的连接池实例一旦启动,部分参数不能动态调优,所以创建前要确认maxPoolSize、minIdle、connectionTimeout这些参数符合预期。
5.2 多租户场景下的动态数据源:按上下文选库
当数据源数量变多,基于注解的静态指定就不合适了,因为不可能每个租户写一个注解。比较合适的改造是让DataSourceContextHolder的key从调用方上下文(比如登录用户所属租户ID)推导。
方法是在请求进来时,通过一个拦截器或Filter解析租户ID,根据租户到数据源的映射关系,把最终的数据源key塞进DataSourceContextHolder。Service层完全不需要关心数据源选择,所有mybatis调用直接走路由即可。这样简化业务代码的同时,也容易实现按租户限流、按租户隔离故障。
这套方式需要注意租户上下文的清理。因为Web服务器的线程是复用的,Filter或拦截器如果在finally里没有清除DataSourceContextHolder,下一个请求就会莫名其妙沿用上一个租户的库。我的习惯是把它放在最外层的Filter里统一clear,而不是散落在各个切面里。
5.3 只读事务与动态数据源的配合
还有一个实际很常用但很多人没注意的技巧:Spring的@Transactional(readOnly = true)本身没有强制路由到从库的能力,它只是给底层数据库一个提示,真正选择从库的仍然是determineCurrentLookupKey()的逻辑。
如果你要做读写分离,把“读操作走从库”这个规则交给切面的注解,不如让切面自动感知事务属性。比如你可以这样设计切面:当方法带有ReadOnly事务时,自动设置key为slave,否则为master。这样业务代码连@DataSource("slave")都不用写了。实现上可以通过Spring AOP获取目标方法的注解:
java复制Transactional transactional = method.getAnnotation(Transactional.class);
if (transactional != null && transactional.readOnly()) {
DataSourceContextHolder.setDataSource("slave");
}
这个技巧可以大幅降低开发者的心智负担。但要注意,只读事务走从库只适用于容忍从库延迟的业务。如果一个列表页需要规避从库延迟导致的重复数据问题,还是把它标注为master更安全。
5.4 结合Spring Boot Actuator监控多数据源的健康状态
热词里出现了micrometer和spring boot actuator,这个跟多数据源的关系其实很深。接入动态数据源后,Spring Boot默认的健康检查只会检查默认主DataSource,其他数据源如果不透出监控指标,真挂了应用自身还一无所知。
我通常的配置是为每个数据源显式定义独立的健康检查或自定义指标:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
然后在自定义的DataSourceHealthContributor里遍历所有已注册的数据源,对每个数据源执行一次Connection validation并记录状态。这样不仅/actuator/health能显示每个库的健康度,还能通过micrometer把label为数据库名的hikaricp.connection.timeout等指标输出到Prometheus,为后续告警提供依据。
特别是动态注册的数据源,健康检查必须和注册管理器联动。如果某个库连续健康检查失败,可以自动把路由上的该key摘除,让流量切到灾备库或直接熔断,不让调用方拿到失效连接长时间卡住。
5.5 我最终沉淀的数据源选型建议
最后把个人建议总结成一段经验判断。如果项目里只是主从读写分离,数据源数量很少、切换规则单一,完全可以用自己写的一套基于AbstractRoutingDataSource的方案,轻量且可控。如果业务复杂度升高,比如需要多租户、分片策略、影子库压测、分布式事务,建议直接落到ShardingSphere这类成熟中间件上,而不是自己造轮子。框架的语义化配置、数据节点管理、分布式事务适配等能力,绝不是十天半个月能撸完的。
中间地带(五六个到十几个库,需要统一管理、动态注册)我比较推荐成熟的dynamic-datasource-spring-boot-starter,它内部封装的思路和前面讲的自研方案一致,但把很多边界问题(如嵌套切换、事务内切换保护、seata适配)都处理好了。不想重复造轮子可以直接依赖它,把精力留给业务。
写在最后
动态数据源不是个花哨的技术,它解决的是底层连接如何按上下文正确路由的工程问题。每次我看到新同事兴致勃勃地把数据源切换加到一个高并发服务里,我都会先问一句:事务边界理清楚了吗?线程模型确定了吗?监控和健康检查接了吗?这三个问题想明白,动态数据源方案才不至于在线上出纰漏。
我自己做了这么多年,最踏实的做法始终是:底层数据源的key永远由上下文决定,而不由人的记忆决定;容易出错的地方全部通过注解、切面、拦截器收敛统一,而不是到处散落手工切库。先用一套简洁的轻量实现跑到产出,等到规则复杂到自研方案撑不住,再考虑换框架升级,这样项目利益也是最大化。
