先聊聊为什么要写这篇多数据源实战
但凡做过后台业务系统的同学,早晚都会碰到一个需求:你的服务要同时读写两个甚至多个数据库。我印象比较深的一个场景是,一个订单系统起初只有订单库,后来用户模块被拆出去独立建库了,但老接口还依赖用户表;再后来报表组又单独搞了一个统计库,专门接业务库的binlog,报表库只能读不能写。三个库摆在面前,代码怎么办?最直白的一种做法是给每个数据源单独创建一套SqlSessionFactory、一套Mapper、一套事务管理器,然后包里到处都是xxxMapper1、xxxMapper2,维护成本直接翻倍。
Spring Boot + MyBatis-Plus 的多数据源配置,就是为了解决这种"一个服务要访问多个库"的问题。MyBatis-Plus本身没有内置多数据源能力,但配合dynamic-datasource(苞米豆开源组件,也是MyBatis-Plus官方推荐的多数据源方案),可以把多数据源切换做得很轻量:你只需要在Mapper方法或者Service方法上加一个@DS("库名")注解,框架就能在方法执行期间把请求路由到对应数据库,路由完自动切回来。
这篇博文打算从实际落地的角度,把多数据源从原理到配置、从踩坑到优化完整过一遍。适合谁看?正在改多库老项目的同学,或者给新项目做数据层架构、需要在单服务里整合多套业务库的团队。如果你只是单库单表,那确实用不上多数据源,这篇可以先收藏,等以后遇到再翻。
先说结论:dynamic-datasource不是多数据源唯一的实现手段,但在Spring Boot + MyBatis-Plus的组合下,它是我个人用过的最顺手的一套,理由后面会详细讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 多数据源的典型业务场景:哪些情况下你真的需要它
1.1 一个订单系统拆分出用户库、订单库、报表库
我拿一个常见的电商后台来举例。系统一开始就一张订单表加上一套用户信息表,所有逻辑都在同一个库里跑。业务大了以后,用户端和商家端开始分开迭代,用户数据被独立成库,订单库本身还要承担秒杀大促的写入压力,报表又要单独从消费链路里沉淀数据。于是你面对的就是:
- 订单库:主业务库,读写都有,必须保证事务。
- 用户库:由另一个团队维护,本服务只读部分用户信息。
- 报表库:通常是读多写少,或者只读,偶尔批量写入。
如果不做多数据源,最简单粗暴的办法是直接配置两个、甚至三个DataSource,再手动创建对应SqlSessionFactory和Mapper扫描路径。缺点非常明显——Bean配置量大,Mapper必须按库严格分包,不然扫描冲突;事务管理器也要各自注册,Service里跨库调方法时事务是割裂的。
用了多数据源方案后,代码层面基本只需要关心"哪段逻辑走哪个库",数据源实例本身由框架统一管理。
1.2 读写分离:一主多从的典型诉求
还有一种最常见的场景是读写分离。主库承担写操作,从库扛读流量。比如订单查询接口,一天几百个接口调用,大多数都在查订单列表、订单详情,真正产生写入的只有下单和取消订单两个接口。
这种场景如果不在中间件层面解决,就得在代码里硬编码两个DataSource,然后手动在每个查询方法里选择从库,维护起来非常痛苦。
用@DS("slave")就能在查询方法上直接指定走从库,主库只留给写方法。
1.3 什么时候不要上多数据源
这里必须泼一盆冷水。多数据源适合"库数量少、路由规则简单、切库点稳定"的项目,不适合大型复杂的分布式数据方案。
如果你已经有几十个分表分库,或者需要跨库跨节点复杂查询、分布式事务、全局路由规则,那你需要的其实是ShardingSphere这类中间件。dynamic-datasource和ShardingSphere两者不是同一个定位,前者是数据源路由,后者是数据中间件,不要搞混。
还有一类情况:你其实只是有两个库,但两个库几乎不会在同一个接口里互相访问,那我建议你直接拆成两个微服务,比在单服务里搞多数据源更干净。多数据源是缓解问题的手段,不是为了炫技而存在。
2. 多数据源到底多在哪里:核心组件与路由执行原理
2.1 DataSource、SqlSessionFactory、连接池之间的关系
先理一下Spring Boot访问数据库的完整链路:DataSource是数据库连接的来源,连接池管理着多个物理连接;SqlSessionFactory负责创建SqlSession,也就是一次数据库会话;Mapper通过SqlSession拿到数据库连接去执行SQL。
单数据源下,Spring容器里只需要一个DataSource、一个SqlSessionFactory,所有Mapper都走同一个连接池。
多数据源的情况下,每个数据源理论上都要有自己的一套连接池和SqlSessionFactory。如果完全手写,就需要这样配置:
- 两个独立的DataSource Bean。
- 两个独立的SqlSessionFactory Bean。
- 两个独立的事务管理器。
- Mapper分包,分别用MapperScan指定不同的SessionFactory。
这样一来Spring容器里的Bean数量翻倍,后续维护各种麻烦。而dynamic-datasource的做法是:容器里只保留一个主DataSource,它是一个路由数据源(RoutingDataSource),内部管理着多个真实数据源,在运行时根据上下文动态路由到目标数据源。
简单说就是:你看到的还是一个DataSource,实际背后有多个库的连接池在待命。
2.2 ThreadLocal与动态路由:@DS注解是如何生效的
dynamic-datasource的核心机制基于Spring的AbstractRoutingDataSource扩展而来。每次SQL操作前,框架通过ThreadLocal记录当前线程要使用的数据源标识,然后从内部维护的一个Map里取出对应数据源,建立连接并执行SQL。方法执行完毕后,ThreadLocal里的数据源标识会被清理掉。
整个链路大致是:
- 调用带@DS("slave")的方法时,拦截器把"slave"写入ThreadLocal。
- 从DataSource池中查找key为"slave"的数据源。
- 建立连接,执行SQL,方法结束后清理ThreadLocal。
我用"回到家门口"来类比:你家门口有两个门,一个通往书房一个通往库房。平时你不说话,默认去书房,一旦你说"去库房",门就会帮你切换到通往库房的那个。这个"说话"的过程就是在方法上加@DS注解。
需要特别注意的是,ThreadLocal是线程隔离的,所以在异步方法、线程池内部调用时,数据源标识不会自动传递。这是后面实战阶段最容易踩的一个坑。
2.3 MyBatis-Plus在多数据源下扮演的角色
MyBatis-Plus在这个场景里是"增强器",它不负责路由,也不负责多数据源管理。它更擅长的是帮你省掉90%的单表CRUD SQL,比如分页插件、逻辑删除、自动填充、条件构造器这些能力。
在多数据源项目中,MyBatis-Plus的分页插件能在路由后的数据源上正常工作。但有一点需要留意:分页插件需要和路由框架配合,如果你的数据源切换失效,分页生成的limit语句可能跑到错误的库里去执行,结果就是SQL报错或者数据查空。
3. 方案选型实录:手写AbstractRoutingDataSource、dynamic-datasource还是ShardingSphere
3.1 为什么没有选择手写AbstractRoutingDataSource
Spring自己带了一个路由数据源的抽象类AbstractRoutingDataSource,很多人也基于它实现过自定义多数据源方案。手写方案的核心思路是:
- 继承AbstractRoutingDataSource。
- 实现determineCurrentLookupKey方法,从ThreadLocal里拿当前库的key。
- 通过AOP切面解析自定义注解,把库名写入ThreadLocal。
这个思路本身没问题,在项目很简单、只有两个数据源的时候也够用。但它的短板在实际落地中会暴露得比较明显:
- 连接池参数、数据源切换、事务传播的边界都要自己写。
- 没有现成的@Component来处理@DS等注解的自动解析逻辑。
- 对配置文件的格式不友好,很多人最后是把数据源配置写死在代码里的。
- 切换点控制不好容易泄漏ThreadLocal里的key,导致后续请求路由到错误的数据源。
手写方案适合学习原理,不适合在真实业务项目中长期维护。我更推荐把路由机制的实现交给成熟的开源组件,把精力放在业务代码上。
3.2 dynamic-datasource与ShardingSphere的定位差异
ShardingSphere是Apache下的分布式数据库中间件,它的强项是分库分表、数据分片、分布式事务、读写分离编排。如果你需要做分片规则、分布式事务,ShardingSphere确实是更正统的选择。但它的配置复杂度和学习成本明显偏高。
dynamic-datasource定位要轻得多,解决的问题更聚焦:
- 多个数据库之间切换。
- 注解式声明,代码侵入性低。
- 内置连接池适配,支持HikariCP、Druid、BeeCP等。
- 不需要规则引擎,没有分片逻辑。
我把选型标准总结成一个经验:如果你的需求是"不同业务访问不同库",用dynamic-datasource;如果你的需求是"一张表数据量巨大需要分片存储",就得考虑ShardingSphere。
3.3 缺点,还是要说一说
dynamic-datasource也有明显的边界。比如它不支持跨库事务,同一Service方法里如果先操作了订单库再操作用户库,本地事务是管不了两个库的一致性的。你必须在业务层面接受"做不到强一致",或者使用分布式事务框架,比如Seata,但这又会把架构复杂度拉高一个台阶。
另外,它在路由切换的时候,存在一库多连、连接池叠加的情况,如果你配置的数据库很多,每个库的连接池都会占一定的内存和连接数。在选择连接池参数时要合理估算,不然很容易到高峰期连不上库。
综合下来,我的结论是:大部分中后台系统、多个库独立使用、偶尔跨库查询的场景,dynamic-datasource是性价比最高的方案。
4. 从零配置一套Spring Boot多数据源项目:依赖、YAML、代码三步走
4.1 依赖引入与版本选择
我用的是当前比较稳定的Spring Boot 2.x版本范围。如果你是Spring Boot 3.x,需要注意javax到jakarta的包名迁移问题,以及MyBatis-Plus对Spring Boot 3的支持是否到位,建议用比较新的版本。
最小依赖如下:
xml复制<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>3.6.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.20</version>
</dependency>
</dependencies>
dynamic-datasource-spring-boot-starter是这个方案的核心依赖。它的版本和MyBatis-Plus版本要匹配,一般来说选比较新的就行。我遇到过老版本搭配新版MySQL驱动时,自动识别数据库类型失败的情况,升级版本基本能解决。
4.2 application.yml配置:主库、从库、报表库的完整示例
数据源配置统一放在spring.datasource.dynamic下面。
yaml复制spring:
datasource:
dynamic:
primary: master
strict: false
datasource:
master:
url: jdbc:mysql://localhost:3306/order_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
druid:
initial-size: 5
max-active: 20
min-idle: 5
slave:
url: jdbc:mysql://localhost:3306/order_slave?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
report:
url: jdbc:mysql://localhost:3306/report_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
几个配置参数要理解清楚:
- primary表示默认数据源,你在代码里如果没有加@DS注解,所有SQL都会走master这个默认库。
- strict表示严格模式。strict为false时,如果你在@DS里写了一个不存在的库名,框架不会报错,还是使用primary数据源;strict为true时,直接抛出异常。我个人建议开发环境把strict开成true,能更早发现切库写错的低级问题。
- 每个数据源可以单独配置连接池参数。这里用的是druid,因为druid的监控和SQL拦截功能在排查多数据源问题时非常有用。
4.3 启动类排除DataSourceAutoConfiguration的关键原因
使用dynamic-datasource时,启动类上有一步很多人会忽略:
java复制@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class MultiDataSourceApplication {
public static void main(String[] args) {
SpringApplication.run(MultiDataSourceApplication.class, args);
}
}
为什么排除?因为dynamic-datasource会自己创建数据源并注册到容器中,如果你不排除Spring Boot的自动配置类,它也会尝试创建一套默认的DataSource,容易导致DataSourceBean冲突,或者更隐晦的问题是health indicator检查时连不上库。
注意,排除的是DataSourceAutoConfiguration,而不是DynamicDataSourceAutoConfiguration。后者是dynamic-datasource自身的自动配置,必须保留。
4.4 Mapper的配置与MyBatis-Plus分页插件注册
MyBatis-Plus的分页插件需要自己注入。在多数据源环境下,分页插件和动态数据源可以共存,不需要为每个数据源单独注册一份插件。
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
需要注意的点是DbType必须是MYSQL,不能写成OTHER,否则分页的count语句和limit生成都会出问题。
MapperScan的路径不需要按多数据源拆分。因为dynamic-datasource在运行时已经把数据源路由好了,所有Mapper都是同一个SqlSessionFactory持有。MapperScan写在启动类上就行:
java复制@MapperScan("com.example.multids.mapper")
4.5 Service层的@DS使用实例与常见位置
@DS注解可以加在Service方法上,也可以加在Mapper接口或者Mapper方法上。实际业务中我建议加在Service方法上,因为一个Service方法可能调用多个Mapper,方法级别控制数据源的粒度刚好合适。只加在Mapper上会导致一个Service方法里如果要切库,每个Mapper方法都要写一遍注解,维护起来容易看花眼。
java复制@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserMapper userMapper;
@DS("master")
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order);
}
@DS("slave")
public List<Order> listOrders() {
return orderMapper.selectAll();
}
@DS("report")
public ReportData getReportData() {
return reportMapper.queryReport();
}
}
这就是日常使用中最基础且最频繁的写法。方法执行前切到对应库,方法结束后路由清除,下一个请求再根据自己的注解重新切换。
5. 多数据源下的经典困境:事务、回滚与跨库一致性
5.1 @Transactional和@DS在一起会怎样
这是评论区最常被问的问题。直接说结论:dynamic-datasource支持在事务方法内切换数据源,因为它内部基于Spring事务管理器做了一层数据源的动态转移。你写一个@Transactional方法,方法内部先操作master数据源,再操作slave数据源,框架能够感知@DS的变化,在事务上下文内切换连接。
但是——这里有个很大的但是——两个库的事务仍然是独立的。假设master库操作成功了,slave库操作抛异常,Spring事务管理器只能回滚当前线程绑定的那个数据源上的事务。如果你绑定到master,slave的操作就回滚不了;如果你绑定到slave,master又已经提交了。然后你会遇到一个更头疼的问题:由于事务边界和连接绑定的问题,切库时机不当甚至会导致事务手里拿着的还是旧连接,SQL虽然没报错,但走到了错误的库上。
为了避免给人挖坑,我把自己的实践原则分享出来:
- 单库内的多个写操作,放在一个方法内,用@DS指定库,配合@Transactional,这是最安全的。
- 涉及跨库写操作的场景,必须把两个库的操作拆到两个Service方法里,每个方法各自管理自己的事务,再在业务层用最终一致性或者本地消息表去弥合一致性问题。
- 绝对不要在一个@Transactional方法里,先写A库,再写B库,然后期待失败时两个库能同时回滚,这不是dynamic-datasource的职责范围。
5.2 一个真实的事务丢失事故
我在项目里遇到过这么一起事故。有一个订单重新统计功能,顺序是:先把统计库的某张状态表更新为"处理中",然后调用远程接口获取数据,再把结果写入主库的统计结果表。最初我是这么写的:
java复制@DS("master")
@Transactional
public void reprocess() {
reportMapper.updateStatus(1); // 实际是report库
// 调用远程接口...
reportMapper.updateResult(orderData); // 实际是report库
}
因为reprocess方法上写的是@DS("master"),reportMapper虽然自己配了@DS("report"),但在外层已经开启事务的情况下,事务管理器拿到的连接是master的,内部的@DS切换并没有真正把连接切到report库。于是updateStatus最终在master库执行,master库压根没有report对应的表,跑了一段时间后才发现报表数据一直不对。
排查起来很快,一旦遇到"事务方法的连接与我指定的数据源不一致"这种情况,第一时间就要怀疑外层事务绑定了数据源。解决办法其实简单:更新状态的逻辑单独拆一个方法,让reportMapper和status更新各自在一个独立的事务和独立的数据源中执行。这也正好呼应了前面说的"跨库别放在一个事务里"。
5.3 为什么说跨库强一致不适合用这个方案
如果你真的需要跨库强一致,比如支付系统和订单系统分别建库,一个下单动作需要在两个库同时写入。动态数据源解决不了这种问题,你需要的是Seata的AT模式或者TCC模式,甚至更进一步引入消息队列做最终一致性。
多数据源方案的定位始终是"降低单服务访问多库的复杂度",它不是分布式事务中间件。大家在设计阶段就要有这种预判,不要等到上线后出了问题再去补事务方案,那就被动了。
6. 实测排查记:切库失效、循环依赖、连接池耗尽这些坑
6.1 @DS注解失效的三种隐蔽情况
先说不生效时的现象。最常见的是:代码里写了@DS("slave"),但最终SQL还是跑到了master库。如果看日志,能发现当前路由的key一直为空或者是primary。
第一类问题是@Component和@DS的组合。dynamic-datasource的切库是基于Spring AOP代理实现的,只有通过代理对象调用的方法上,@DS才会被拦截。如果你在同一个类的内部方法之间互相调用,比如:
java复制@Service
public class OrderService {
public void methodA() {
methodB();
}
@DS("slave")
public void methodB() {
...
}
}
methodA调用methodB时,由于是this调用,走的不是代理对象,而@DS由代理对象拦截,因此methodB上的注解完全不会生效。解决办法是把methodB放进另一个独立的Service类里,或者在methodA上也加上同样的@DS注解。
第二类问题是异步线程池。ThreadLocal的数据源标识不会跨线程传递。如果使用@Async或者自建的ThreadPool,在子线程里执行SQL时,数据源标识丢失,路由会回到primary。常规做法是把目标数据源名称手动传入子线程,在子线程方法内再次声明@DS。
第三类问题是Spring的@Transactional先于@DS拦截顺序。如果先开启事务再切数据源,事务管理器可能已经把连接绑定到旧的数据源上了。这一点和第5部分提到的事务问题其实是同一个根源。记住一个原则:@DS要和@Transactional的控制层次尽可能保持一致,且数据源切换发生在事务开启之前。
6.2 数据源Bean循环依赖怎么解决
dynamic-datasource在实际运行时需要注入多个数据源,自定义数据源又依赖DataSourceProperties,在某些特殊配置下,可能出现循环依赖:
text复制The dependencies of some of the beans in the application context form a cycle
解决方案最直接的是确保数据源配置里的url、username、password等属性名称没有拼错,避免生成空的DataSource。排除了配置问题后,可以尝试把连接池参数统一放到dynamic-group下,而不是每个库单独写一遍。如果项目里引入了自定义的DataSource配置类,检查是否和dynamic-datasource的自动配置重复初始化了DataSource。
6.3 大流量下连接池耗尽:参数调优实录
线上出过一个druid连接池耗尽的问题。场景是主库有20个连接,从库10个,高峰期大量线程在切换数据源。由于连接池是按数据源独立管理的,每个切换点都会从目标数据源的连接池里申请连接。如果业务代码里存在"一次请求内频繁切换数据源"的行为,比如循环遍历调用带@DS的方法,每个循环都切一次库,连接申请和释放的次数会非常高。
排查时我先打开druid的监控页面,看到主库的活跃连接数长期维持在20左右,从库几乎一直满连接。Druid的workThread字段大小也接近上限。这说明连接释放速率跟不上申请速率,服务端的waitCount在持续增加。
调整策略是分两步:
- 从代码层面减少频繁切库:将在循环体内的单条查询改成批量查询,或提前把需要关联的数据fetch出来,在内存中组装。
- 从配置层面调大连接池上限:把主库max-active从20提到50,从库从10提到30,同时调小min-idle避免空闲时连接占用太多。此外,给druid加了max-wait为5秒,防止无限期等待。
调整后的效果很明显,接口的TP99从900ms降到400ms左右,活跃连接数也稳定在可用范围内。
6.4 druid的SQL监控在多数据源排查中的作用
多数据源问题不好查,很大程度上是因为你没法直观看到当前SQL到底在哪一个库上执行。如果用的连接池是druid,天然有SQL监控能力,通过Druid的StatViewServlet可以看到每个数据源的具体SQL执行列表。
但注意,动态数据源是多DataSource并存的,druid监控页面上会显示多个数据源各自的统计,你需要先看清楚当前关注的库是哪个。这一点比想象中重要。我第一次排查切库问题时,盯了半天master的监控数据,结果SQL其实是从slave执行的,方向完全错了。
开启druid监控页面的配置很简单:
yaml复制spring:
datasource:
druid:
stat-view-servlet:
enabled: true
url-pattern: /druid/*
login-username: admin
login-password: admin123
web-stat-filter:
enabled: true
生产环境建议务必加权限控制,不要裸奔对外开放。
7. 多数据源下的MyBatis-Plus增强能力怎么配合
7.1 分页插件、逻辑删除、自动填充在切库后是否正常
很多人在多数据源项目里担心MyBatis-Plus的分页、逻辑删除、自动填充这些能力会不会在切库后失效或者错乱。实测结果是:这些能力和数据源路由是解耦的,它们依赖的是SqlSessionFactory与MybatisPlusInterceptor,只要你的数据源路由正常,分页和逻辑删除都能正常工作。
有个细节需要留意:逻辑删除字段的插入、更新映射是MyBatis-Plus在解析实体类时定好的,和数据源无关;自动填充呢,同样走的是MetaObjectHandler接口,和数据库连接无直接关联。所以只要你的Mapper按照正常的MyBatis-Plus方式写,多数据源不会干扰这些特性的行为。
但分页插件要注意DbType的识别。如果你配置了不正确的DbType,在Oracle库和MySQL库之间来回切换,分页方言就会出问题。一般建议把所有库的方言统一,多数据源项目尽量使用同类型的数据库。
7.2 自定义拦截器在多数据源下的顺序问题
如果你在项目里注册了自己的MyBatis拦截器,比如每次自动在SQL后面追加某个查询条件,或者统一分析SQL性能,这里有一个顺序问题:你的拦截器执行时可能还没有拿到真正的数据源连接,也就拿不到真正的Connection对象。这种情况下,如果你想在拦截器里通过Connection做元数据操作,拿到的是代理连接,而不是目标库的直连。
解决办法是把需要真实数据库连接的操作延迟到StatementHandler处理阶段,或者从事务管理中显式获取当前线程绑定的DataSourceConnectionHolder中的连接。实际开发中,这个需求不常见,但遇到过一次之后,教训很深刻:不要自定义拦截器去判断数据库类型,让插件框架处理更安全。
7.3 代码生成器与多数据源的兼容
小组里新来同事第一天用MyBatis-Plus的代码生成器生成了一堆代码,结果跑起来所有Mapper都匹配到了default库,业务上已经写好了@DS,但生成的XML文件里还带着当年单库的表前缀。这其实不是生成器的问题,是业务设计时没统一库与库之间的表命名约定。多库环境下,强烈建议不同库的表名要有明确区分,比如master库的表带tb_order,report库的表带rpt_xxx,这样即使有人忘了加@DS,SQL在日志里也能一眼看出来是哪个库的表。
代码生成器本身不需要做什么特殊适配,生成的实体、Mapper都是独立文件,你只需要在Service层正确放置@DS即可。
8. 进阶:从多数据源到动态租户库与灰度发布
8.1 按租户动态切库的实现思路
多数据源还有一个很常见的进阶方向:多租户系统。不同租户对应不同数据库,登录用户的上下文里带着租户id,每次请求根据租户id动态路由到对应的库,而不是在方法上写死库名。
dynamic-datasource支持通过自定义注解和SpEL表达式动态指定数据源。比如在Service的方法上写:
java复制@DS("#dataSourceName")
public void handle(String dataSourceName) {
...
}
这里的dataSourceName参数可以是动态传入的租户库标识。你也可以通过继承DynamicDataSourceContextHolder来实现自己的策略,在请求经过拦截器时,把租户id写入ThreadLocal,然后在执行SQL前去匹配数据源。
我在一个SaaS项目上做过类似的事情,大约有20多个租户库,库数量是动态增加的,这时候用静态的@DS("master")注解就不合适了,必须改成"启动时从数据库或者配置中心动态注册数据源"。
8.2 停机零影响:如何不停机增加一个数据库
项目上线后要增加一个分库,不能因为多数据源配置变更就全部重启。dynamic-datasource提供了DataSourceProperty和DynamicRoutingDataSource,允许在运行期动态添加数据源。
java复制@Autowired
private DynamicRoutingDataSource dynamicRoutingDataSource;
public void addDataSource(String dsName, DataSourceProperty dataSourceProperty) {
DataSource dataSource = dynamicRoutingDataSource.augmentDatasource(dsName, dataSourceProperty);
dynamicRoutingDataSource.addDataSource(dsName, dataSource);
}
这样就能在接口层面接收一个"新增数据源"的请求,把配置信息传递给应用,在运行期动态加入连接池。不过要提醒的是,运行期加数据源只适合初始化不复杂的场景。如果数据源要绑定特殊连接参数、初始化SQL或者缓存预热,还是建议走配置中心发布、服务优雅滚动的方案。
8.3 多数据源结合配置中心的实践建议
生产环境不建议把数据源连接信息写死在application.yml里。当有多个环境(dev、test、prod)时,每个环境的数据源地址和账号都不同,维护成本很高。我目前使用的做法是:
- 使用Nacos或者Apollo作为配置中心。
- 数据源配置放在配置中心,同时和application.yml的spring.datasource.dynamic节点保持一致的结构。
- 通过配置中心的灰度发布,先在其中一台机器上生效,观察日志和监控指标,再全量推送。
配置中心的加载场景示例
yaml复制# Nacos配置示例
spring:
datasource:
dynamic:
primary: master
strict: true
datasource:
master:
url: ${MASTER_DB_URL}
username: ${MASTER_DB_USER}
password: ${MASTER_DB_PASSWORD}
使用环境变量占位符,而不是把所有机器的数据库密码都放在同一个配置文件里。这样一来,即使配置中心内容被拉取泄露,也没法直接用。
9. 一个完整的最小可运行Demo:从数据表到接口
9.1 建表与测试数据的准备
为了这个demo,简单建两张表,一张在master库,一张在slave库,但是表结构长得一样。
sql复制-- master库
CREATE TABLE `order_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) DEFAULT NULL,
`amount` decimal(10,2) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- slave库
CREATE TABLE `order_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) DEFAULT NULL,
`amount` decimal(10,2) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
两个库都有这张表,只是为了方便验证数据源的路由结果,看同样的接口是从哪个库里读出的数据。
9.2 Controller与Service层代码
OrderService接口:
java复制public interface OrderService {
List<Order> listFromMaster();
List<Order> listFromSlave();
void insertOrder(Order order);
}
OrderServiceImpl实现类:
java复制@Service
public class OrderServiceImpl implements OrderService {
@Resource
private OrderMapper orderMapper;
@Override
@DS("master")
public List<Order> listFromMaster() {
return orderMapper.selectList(null);
}
@Override
@DS("slave")
public List<Order> listFromSlave() {
return orderMapper.selectList(null);
}
@Override
@DS("master")
public void insertOrder(Order order) {
orderMapper.insert(order);
}
}
Controller:
java复制@RestController
@RequestMapping("/order")
public class OrderController {
@Resource
private OrderService orderService;
@GetMapping("/master")
public List<Order> masterList() {
return orderService.listFromMaster();
}
@GetMapping("/slave")
public List<Order> slaveList() {
return orderService.listFromSlave();
}
@PostMapping("/insert")
public String insert(@RequestBody Order order) {
orderService.insertOrder(order);
return "success";
}
}
通过访问两个不同的Get接口,分别往master库和slave库里写入不同的数据,再返回结果就能直观看到路由生效了。比如在master表里插一条amount为100的数据,在slave表里插一条amount为200的数据,访问/master接口返回100,访问/slave接口返回200,基本说明切库没问题。
9.3 验证路由是否生效的三种手段
第一种是直接看返回数据,如果两个库里插入了不同的数据,返回不同结果说明路由正常。第二种是打开日志,看SQL日志里的JDBC连接编号。dynamic-datasource在debug级别下会打印当前数据源名称。配置日志级别:
yaml复制logging:
level:
com.baomidou.dynamic.datasource: debug
这样控制台中能看到类似:
text复制dynamic-datasource switch to the datasource [slave]
第三种是配合druid监控,点击到对应数据源的SQL列表去核对执行的语句。三种方式我日常都会用,优先看日志最简单直接。
10. 最后再分享几条实战心得
多数据源配置本身不复杂,多数项目的复杂点在于切库边界不清、事务和数据源纠缠、异步线程丢失路由。我在实际项目里反复撞过几次坑之后,逐渐形成了一套自己的使用习惯:
- 默认情况下能不加@DS的地方就不要加,让代码无脑走primary,只有明确要切库的逻辑才声明注解。
- 写SQL时强制带上表名前缀的注释,比如在XML里写注释,方便后期排查时定位SQL来自哪个模块。
- 生产环境一定要开监控,至少把druid的StatViewServlet打开,不然出了问题都不知道从哪个数据源开始查。
- 多数据源和分布式事务不是一回事,判断好自己的业务边界再动手。
- 每次改数据库账号密码、扩容连接池、切换数据源,都要做一遍主从两个库的验证,不要只看着接口返回200就觉得稳了。
另外一个容易忽略的细节是:在Service方法里从master切到slave后,MyBatis一级缓存和Spring事务管理的Session持有机制是否清干净。如果不清干净,同一个方法中第二次访问的同一Mapper方法有可能拿到第一条SQL的缓存结果,造成数据就是"没切过来"的错觉。这种情况在每次变更数据源后,如果有缓存需要手动清理或改用二级缓存管理,否则会平白多浪费很多排查时间。
这篇内容基本覆盖了Spring Boot整合MyBatis-Plus做多数据源的完整链路,从原理、选型、配置、踩坑到进阶方向都有了。希望你们在落地的时候能少走几段弯路。
