先把结论放在前面:很多团队的数据源对象管理问题,不是出现在写第一个数据源的时候,而是出现在写第二个、第三个数据源,并且还想让它们在同一个项目、同一个Service、同一个事务体系下自由切换的时候。我今年接手的一个中台项目就是典型:订单表在MySQL,分片后的历史订单在ShardingSphere管理的两个库,用户信息在另一个独立PostgreSQL,日志系统又走ClickHouse。看似只是“多配几个连接串”,真正做起来却发现,DataSource不能只当配置文件里的一行url来理解,它是个有完整生命周期的对象。你要管的是它的创建、注册、命名、路由、连接池分配,以及它和其他数据源对象的边界。
这篇文章我会从“数据源对象管理”这个标题出发,把Java后端里最容易忽略的这个基础层拆开讲清楚:DataSource对象到底是什么,多数据源场景下管理数据源对象的矛盾点在哪,Spring Boot + MyBatis-Plus 多数据源怎么做,以及目前很多人问的“将ShardingSphere数据源注册到动态数据源中”到底怎么落地。适合正在做多租户、读写分离、分库分表改造的Java开发同学,也适合想把数据源从“能用”提升到“可维护”的团队参考。
1. 数据源对象不是“配置”,而是一个有生命周期的Java对象
1.1 DataSource 到底封装了什么
很多人写Spring Boot项目,只知道在 application.yml 里填 spring.datasource.url、username、password,然后 @Autowired 一个 DataSource 直接用。这个阶段确实不需要理解太多,因为Spring Boot的自动配置帮我们创建了唯一的数据源对象。
但这个对象的内部并不简单。以最常用的HikariCP为例,一个 HikariDataSource 实例内部维护着:
- 一个连接池,包含多个物理连接
Connection; - 连接池的配置参数,比如最大连接数、最小空闲数、连接超时时间、泄漏检测阈值;
- 真实连接地址、账号、密码等凭证信息,用于创建底层连接;
- 连接心跳、健康检查等定时任务。
用生活类比来说,DataSource 不是一条短信,而是通讯录里的一个联系人。它背后的人是完整的,你能随时通过他发起通信。连接池里的每个 Connection 才是一条真实的通话链路。所以管理数据源对象,管理的是这个联系人的“身份”和“通信能力”,而不是某条具体消息。
1.2 多数据源项目里的数据源对象种类
一旦项目引入多数据源,数据源对象就不再是单个 HikariDataSource,而是会同时出现以下几种类型:
| 对象类型 | 常见实现 | 作用 |
|---|---|---|
| 普通数据库数据源 | HikariDataSource、DruidDataSource | 连接某个具体数据库实例 |
| 动态路由数据源 | DynamicRoutingDataSource、自定义AbstractRoutingDataSource | 根据上下文key分发到具体数据源 |
| 分库分表数据源 | ShardingSphereDataSource | 屏蔽分片逻辑,对外仍表现为一个DataSource |
| 只读数据源 | 由同一连接池配置衍生出的只读代理 | 处理读写分离场景 |
这些对象之间不是平级关系。动态路由数据源会持有多个目标数据源对象,ShardingSphereDataSource 内部又会持有多个物理库的数据源对象。这就在项目里形成了一棵数据源对象树。
1.3 数据源对象管理的三个层面
真正的“数据源对象管理”不是把几个配置项放在一个类里,而是至少包含下面这三层:
第一层是命名管理。每个数据源必须有稳定、唯一的逻辑名,比如 master、slave、order_sharding、user_audit。业务代码里用逻辑名引用,而不是直接写jdbc地址。我见过有团队在Mapper里硬编码数据源key,后面数据库一迁移,改配置改到眼瞎。
第二层是路由管理。要有一个统一的入口,让Service层或Mapper层通过注解或API指定当前线程使用哪个数据源对象。这层必须处理上下文传递、清理、嵌套切换等问题,否则会出现线程串库这种非常隐蔽的故障。
第三层是生命周期管理。数据源对象什么时候创建、什么时候注册到容器、什么时候初始化连接池、什么时候优雅关闭。尤其在Spring容器里,Bean的创建顺序、销毁顺序都会直接影响分片数据源和动态数据源的可用性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多数据源场景下,对象管理到底在解决什么矛盾
2.1 典型场景:读写分离、多业务库、分库分表
先看几个真实场景。读写分离是最常见的:一个主库负责写,一个或多个从库负责读。这时候我们需要两个数据源对象,并且要保证查询请求走从库,写入请求走主库。
多业务库场景更麻烦:用户库一个MySQL,订单库一个MySQL,附件库一个PostgreSQL。业务上可能在同一个Service里先查用户信息,再查订单列表。如果不做数据源对象管理,就得手动创建多个 DataSource Bean,然后在每个Mapper构造时指定它要用哪个数据源,或者干脆在业务代码里手动获取Connection,代码马上就会变得没法看。
分库分表场景则是在单库数据量上来之后出现的。订单表按照用户ID或订单ID分到多个物理库,这时候业务侧不能直接面对具体库,而是面对一个分片后的逻辑库。ShardingSphere做的事就是把多个物理数据源对象包装成一个逻辑数据源对象,对上层隐藏分片路由规则。
这三种场景经常同时出现在一个成熟项目里:既有主从读写分离,又有分片后的订单库,还有独立业务库。这时候没有一个统一的数据源容器,项目离失控就不远了。
2.2 动态数据源的本质是一个路由器
Spring从很早就提供了 AbstractRoutingDataSource 这个抽象类,它本身就是为解决多数据源路由而生的。它的内部维护了一个 Map<Object, DataSource> targetDataSources,还有一个默认数据源。每次调用 getConnection() 时,它会执行 determineCurrentLookupKey() 决定当前应该用哪个key,然后从这个Map里取出对应的真实数据源,返回一个连接。
这个设计非常巧妙:对外部调用方来说,DynamicRoutingDataSource 仍然是一层 DataSource 接口,业务代码拿到的还是连接。但它实际上是一个路由器,内部管理着一组数据源对象。
比如baomidou开源的 dynamic-datasource-spring-boot-starter,它的核心类 DynamicRoutingDataSource 就继承了 AbstractRoutingDataSource,同时扩展了动态添加、移除数据源、按分组切换、嵌套切换等能力。可以说,数据源对象管理落到代码层面,核心就是把这个路由器的内部Map管理好。
2.3 绕不开的三个问题:事务边界、连接池隔离、路由上下文
多数据源管理之所以难,不是难在写路由代码,而是难在这三个基础问题上。
第一个是事务边界。Spring的事务管理在一个事务开始时,会从当前数据源获取一个连接,并把连接绑定到当前线程。如果在事务执行过程中切换数据源,事务管理器拿到的还是旧连接,也就是说事务内路由切换大概率不生效。这是新手最容易踩的坑,我也一样。
第二个是连接池隔离。每个物理数据源都应该有自己的连接池,某个库的连接池被打满,不应该影响另一个库的请求。动态数据源只负责路由,不承担连接池隔离逻辑。所以我们在管理多个数据源对象时,必须为每个目标数据源配置合理的连接池参数,而不是共用同一套。
第三个是路由上下文。动态数据源路由依赖ThreadLocal保存当前线程应该使用哪个key。如果线程池复用了线程,而代码没有清理ThreadLocal,下一次任务就会用到上一次任务的数据源key,造成串库。这个问题在异步任务、批处理框架里尤其常见。
3. Spring Boot + MyBatis-Plus 多数据源的落地方案
3.1 为什么不建议从头手写 AbstractRoutingDataSource
我知道有一部分人喜欢自己写一个 DynamicDataSource 类,再配一个 @DataSource 切面,十几个类维护下来觉得自己把Spring玩明白了。我也这么干过,但后来发现手写方案有几个很麻烦的边界。
手写核心确实不难。一个 ThreadLocal<String> 保存key,继承 AbstractRoutingDataSource 重写 determineCurrentLookupKey(),再用AOP切Service方法,在方法前设置key,方法后清理。这样的代码跑通一个Demo很容易,但一旦上生产会遇到:
- 切面表达式难以准确圈定需要切的数据源,经常切漏或误切;
- 嵌套数据源切换时没有栈结构,内层方法设置key会覆盖外层;
- 多个事务管理器、多个SqlSessionFactory之间的绑定关系容易混乱;
- 没有现成的监控接口,数据源运行状态不透明。
所以如果项目已经用了MyBatis-Plus,我更推荐直接引入 dynamic-datasource-spring-boot-starter。它不是简单的路由封装,而是把命名管理、路由上下文、事务适配、数据源动态注册都做成了标准能力,我们只需要专注于业务路由规则。
3.2 基础配置:从一个 demo 开始
引入依赖:
xml复制<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
配置一个主库 master,一个从库 slave,一个独立业务库 audit:
yaml复制spring:
datasource:
dynamic:
primary: master
strict: true
datasource:
master:
url: jdbc:mysql://localhost:3306/main_db
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
url: jdbc:mysql://localhost:3306/main_db_slave
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
audit:
url: jdbc:postgresql://localhost:5432/audit_db
username: audit
password: audit
driver-class-name: org.postgresql.Driver
在Service层使用:
java复制@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Ds("slave")
public List<User> listUsers() {
return userMapper.selectList(null);
}
@DS("master")
public void updateUser(User user) {
userMapper.updateById(user);
}
@DS("audit")
public void saveAuditLog(AuditLog log) {
auditLogMapper.insert(log);
}
}
这里的 @DS 注解就是数据源对象管理的入口。它做的事情很简单:把value写入当前线程的路由上下文,调用结束后清除。value对应的key会从 DynamicRoutingDataSource 的 Map 里找到真实数据源对象。
3.3 数据源对象管理的几个隐藏细节
第一,strict 参数建议设成 true。默认 strict: false 时,如果找不到对应的key,会回落到primary数据源。这在开发期很友好,但生产环境容易掩盖真实错误。一次我在排查某个诡异数据问题时,发现某条SQL预期走分片库,实际走回了主库,就是因为我们一群人没有发现这个key在配置里根本不存在,结果静默回落。
第二,@DS 注解可以用在类上,也可以用在方法上。方法上的优先级高于类上。嵌套时内层方法会覆盖外层,但注意它不是栈结构,所以不要在同一个方法里做两次不同数据源的嵌套切换,后设置的会把先设置的覆盖掉。
第三,连接池参数建议每个数据源单独配。动态数据源starter的底层默认使用HikariCP,我们可以给某个数据源单独指定连接池大小:
yaml复制spring:
datasource:
dynamic:
datasource:
master:
url: jdbc:mysql://...
hikari:
maximum-pool-size: 30
minimum-idle: 5
audit:
url: jdbc:postgresql://...
hikari:
maximum-pool-size: 10
minimum-idle: 2
这样 master 库压力大时可以给更多连接,audit 库写入量小就少给点。数据源对象管理到这一步,才开始有点“管理”的意思。
4. 把 ShardingSphere 数据源注册进动态数据源:完整实操
4.1 为什么一定要注册,而不是直接替换
ShardingSphere的核心用法是把多个物理数据源包装成一个 ShardingSphereDataSource,业务方直接使用这个逻辑数据源。单独用的时候没问题,但一旦项目里同时存在多个数据源,比如普通master数据源、独立审计库、ShardingSphere分片库,就会遇到一个尴尬:@DS 注解只能路由到动态数据源容器里的key,而 ShardingSphereDataSource 并不是动态数据源容器里的成员。
如果直接让程序从容器里拿ShardingSphereDataSource,那MyBatis-Plus的 @DS 就用不上了。更合理的做法是:先构建好 ShardingSphereDataSource 对象,然后把它作为一个目标数据源,注册到 DynamicRoutingDataSource 中,给它起一个逻辑名如 sharding。这样业务代码继续用 @DS("sharding") 访问,动态数据源负责路由,ShardingSphere负责分片,两边各司其职。
4.2 构建 ShardingSphereDataSource 的代码
假设我们有两个物理库 ds0、ds1,订单表按 order_id 取模分片。用 ShardingSphere 5.x 的Java API构建:
java复制@Configuration
public class ShardingDataSourceConfig {
@Autowired
private DynamicRoutingDataSource dynamicRoutingDataSource;
@PostConstruct
public void registerShardingDataSource() throws SQLException {
Map<String, DataSource> dataSourceMap = new HashMap<>();
dataSourceMap.put("ds0", buildDataSource(
"jdbc:mysql://localhost:3306/order_ds0",
"root", "root"));
dataSourceMap.put("ds1", buildDataSource(
"jdbc:mysql://localhost:3306/order_ds1",
"root", "root"));
ShardingRuleConfiguration shardingRuleConfig = new ShardingRuleConfiguration();
// 配置分表规则,这里以 t_order 按 order_id 取模为例
ShardingTableRuleConfiguration tableRule = new ShardingTableRuleConfiguration(
"t_order", "ds${0..1}.t_order_${0..1}"
);
tableRule.setTableShardingStrategy(new StandardShardingStrategyConfiguration(
"order_id", new ModShardingAlgorithm(2)
));
shardingRuleConfig.getTables().add(tableRule);
Properties props = new Properties();
props.setProperty("sql-show", "true");
ShardingSphereDataSource shardingDataSource = ShardingSphereDataSourceFactory.createDataSource(
dataSourceMap, Collections.singletonList(shardingRuleConfig), props);
// 重点:把 sharding 数据源注册到动态数据源容器
dynamicRoutingDataSource.addDataSource("sharding", shardingDataSource);
}
private DataSource buildDataSource(String url, String username, String password) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setDriverClassName("com.mysql.cj.jdbc.Driver");
config.setMaximumPoolSize(20);
return new HikariDataSource(config);
}
}
ModShardingAlgorithm 是一个简单取模算法的实现类,实际项目中可以自己写,也可以直接用ShardingSphere内置的 ModShardingAlgorithm。核心不在这里,在于最后那一行 addDataSource。
4.3 注册到 DynamicRoutingDataSource 的两种方式
上面我用的是 @PostConstruct。但这里有一个很典型的坑:DynamicRoutingDataSource 本身是Spring容器管理的Bean,如果我们的配置类也在创建阶段就直接注入它,有可能因为Bean初始化顺序不一致,导致注入的对象还没有完成属性填充,或者出现循环依赖。
所以更稳的做法是实现 ApplicationRunner 或 InitializingBean,在Spring容器刷新完成后再注册:
java复制@Component
public class ShardingDataSourceRegistrar implements ApplicationRunner {
@Autowired
private DynamicRoutingDataSource dynamicRoutingDataSource;
@Override
public void run(ApplicationArguments args) throws Exception {
// 构建 shardingDataSource 并注册
dynamicRoutingDataSource.addDataSource("sharding", buildShardingDataSource());
}
}
这种方式的好处是整个Spring上下文已经就绪,所有Bean的依赖关系完成,我们再动手把数据源对象加入动态路由容器,基本不会遇到初始化顺序问题。
还有一点要提醒:如果项目里有 DataSourceInitializer 或者Flyway这类工具,它们可能也会在启动阶段拿 DataSource Bean做脚本初始化。ShardingSphereDataSource 是代理对象,底层有多个物理数据源,如果这些工具拿它去执行建表脚本,可能会把脚本执行到所有物理库上,甚至报错。遇到这种情况,建议用 @Primary 保留一个普通数据源,或者关闭自动初始化。
4.4 验证分片与动态路由同时生效
注册完成后,用法很简单:
java复制@Service
public class OrderService {
@DS("sharding")
public Order getOrder(Long orderId) {
return orderMapper.selectById(orderId);
}
}
执行时,DynamicRoutingDataSource 将请求路由到 sharding 这个key对应的 ShardingSphereDataSource,然后ShardingSphere再根据 order_id 的取模结果把SQL路由到 ds0 或 ds1 的实际表。
怎么验证有没有真的生效?把sql-show打开,看日志。配置了 sql-show=true 时,ShardingSphere会打印:
code复制Logic SQL: SELECT * FROM t_order WHERE order_id = 1001
Actual SQL: ds1 ::: SELECT * FROM t_order_1 WHERE order_id = 1001
同时,动态数据源starter也会在debug级别的日志里打印当前数据源key。如果两者都出现,说明从动态数据源到分片数据源之间的链路是通的。
4.5 这个方案里的坑
第一个坑是事务失效。如果方法上同时加了 @Transactional 和 @DS("sharding"),并且没有额外配置ShardingSphere的事务类型,Spring事务会从 DynamicRoutingDataSource 获取连接。由于动态路由数据源内部代理了多个目标,事务可能只会绑定到其中一个数据源上,导致分片事务失效。ShardingSphere 5.x 支持本地事务、XA事务、SEATA事务,你需要按业务需求选择对应的事务管理器,不能无脑依赖 @Transactional。
第二个坑是数据源类型判断。很多人喜欢在 @Configuration 中写:
java复制if (dataSource instanceof HikariDataSource) { ... }
一旦把ShardingSphere数据源包进来,这个判断就会失效。因为 ShardingSphereDataSource 不是 HikariDataSource 的子类,而是实现了 DataSource 接口的代理对象。做数据源对象管理时,不要依赖具体类型做逻辑分支,要看数据源的能力接口。
第三个坑是启动时ShardingSphere扫描不到数据源。ShardingSphere的 dataSourceMap 里的物理数据源会自己管理连接池,如果你把这些数据源也暴露成Spring Bean,可能会被Spring Boot的数据源自动配置再处理一次,导致重复初始化。稳妥做法是物理数据源不声明为Bean,只做局部变量传入构建方法。
5. 数据源对象管理的运维级经验
5.1 连接池参数不能所有数据源一套
数据源对象管理做到后面,最重要的事情就是连接池容量规划。很多项目把所有数据源的连接池大小都配成一样,这是典型的对象管理缺失。
物理分片库和普通业务库的压力模型完全不同。比如订单分片库,每个分片只承载部分流量,单个分片连接池可以小一点;但主库承担全量写入和事务压力,连接池就得给足。审计库只在关键操作时写入,高峰期可能出现少量突发,但总体并发不高,连接数和超时时间可以适当放松。
我一般会给每个数据源配置单独的Hikari参数,并加上监控指标。HikariCP本身就暴露了JMX信息,可以在 Spring Boot Actuator 里查看每个数据源对象的 active、idle、waiting 等连接数。如果发现某个数据源长期处于活跃连接打满的状态,不是盲目调大连接数,而是先看是不是SQL慢查询导致连接占用时间过长。
5.2 排查数据源串库的套路
串库问题特别诡异,表现是SQL能执行成功,但数据查出来不对,或者写到了错误的库。排查思路要固定。
第一步,看当前线程的路由key。在动态数据源切换的前后打日志,或者临时在AOP切面里打印 DynamicDataSourceContextHolder.peek()。如果发现执行某段异步任务时key是上一次请求留下的,那就是ThreadLocal清理不彻底。
第二步,看线程池复用。使用 ExecutorService 时,如果任务内部切换了数据源,并且任务执行完后没有清理,那么线程被下一个任务复用时就会串库。解决办法是在任务入口处强制设置默认key,或者用 @DS 的传播到线程池内部自行处理。
第三步,看MyBatis-Plus的Mapper缓存。MyBatis的 SqlSession 和 SqlSessionFactory 可以和多个数据源绑定,如果你在配置中手动指定了错误的 SqlSessionFactory,那Mapper执行时可能全程使用同一个数据源,@DS 注解自然不生效。
下面是一个简单的排查对照表:
| 现象 | 可能原因 | 检查点 |
|---|---|---|
| 查询走错库 | 路由key被复用或覆盖 | ThreadLocal、动态数据源日志 |
| 事务内切换不生效 | Spring事务绑定旧连接 | 事务管理器、@DS与@Transactional顺序 |
| 分片SQL执行到所有库 | ShardingSphere规则没生效 | 分片策略、Actual SQL日志 |
| 连接池被打满 | SQL慢查询或连接未释放 | Hikari监控、慢SQL日志 |
5.3 优雅上下线与监控
数据源对象管理还包含下线场景。比如分片扩容,需要临时把某个物理库从ShardingSphere的数据源Map中移除,或者把某个数据源从动态路由容器中摘掉。
运行时移除可以用 DynamicRoutingDataSource 的 removeDataSource(key)。但这里有个注意事项:移除只是让新请求不再路由到该数据源,已经存在的连接池连接不会被强制关闭。如果你希望彻底释放连接池,还需要拿到底层数据源对象,调用 close() 方法。
我一般会在改动动态数据源Map时写一个审计日志,记录谁在什么时间添加或移除了哪个数据源key。因为这类操作一旦出错,影响的不是某一个接口,而是所有通过动态路由访问该数据源的接口。
最后再分享一个实战小习惯:每次启动完项目,先打印一下动态数据源容器里所有的key,确认注册的数据源对象和预期一致。我用 dynamicRoutingDataSource.getCurrentDataSources().keySet() 打印过无数次,这个操作看起来简单,却能帮你第一时间发现某个数据源没注册成功,或者被其他配置覆盖了。数据源对象管理没有多高深,但要稳,就得把每个对象何时出现、何时消失、被谁使用都搞清楚。
