0. 开场:如果项目里只有一个数据库,这篇文章可以直接关掉
先说个直白的判断:如果你的业务系统只连一个库,那多数据源配置对你来说就是纯粹的增加复杂度,别被“轻松实现”这四个字忽悠着盲上。但如果你遇到的是这些场景,那这篇文章大概率能帮你省下几个晚上的加班时间:
- 业务库和报表库分开,报表查询不能压到线上交易库
- 老系统和新系统并存,新功能走新库,历史数据还在旧库里查
- 读写分离,主库写、从库读,不想自己写一堆切库的底层代码
- 分库分表还没上 ORM 那样重,只是按业务模块分库存储
我用 MyBatis-Plus 的多数据源方案折腾过几个项目,从最开始的手写DataSource切换 AOP,到后面用dynamic-datasource这套开源方案,中间踩过的坑不算少。这篇文章会把核心思路、配置过程、源码层面的原理拆解、以及我实测遇到过的疑难杂症一次说完。适合的人群是:已经能熟练使用 Spring Boot 和 MyBatis-Plus 做单库 CRUD,想搞明白多数据源到底是怎么“动起来”的开发者。
1. 多数据源的刚需场景与方案选型:为什么我最终选了动态数据源
1.1 没有多数据源的时候,你是怎么熬过来的
先还原一下没有多数据源时的日常。业务刚开始时一切都很美好,一个库、一个数据源、一套Mapper扫描完事。等系统跑起来半年,报表需求多了,你发现报表的统计 SQL 都是大范围扫描,跑一次能把线上交易库的 CPU 拉高不少。然后老板说,把统计类的查询挪到另一个库去。
这时候你面临两条路:第一,新建一套SqlSessionFactory和连接池,给每个数据源单独配一套Mapper映射,A 库的 Mapper 扫com.xx.dao.a,B 库的 Mapper 扫com.xx.dao.b。能跑,但每次加一个 Mapper 就要想清楚它属于哪个库,而且如果同一个业务方法里要交替查两个库,代码会变得非常难维护。第二,就是用动态数据源,通过注解或方法级配置,在一个代码库里自由切换。
我早期用的就是第一种方式,相当于给项目手工焊了两个独立的“数据库连接通道”。这种方案初期还好,一旦模块增多,Mapper 归属混乱、事务边界模糊、排查问题时脑子里要装两张映射表,非常痛苦。
1.2 主流的几种动态数据源实现路径
先说清楚,动态数据源切换这件事,Spring 框架本身就有支持。核心抽象是AbstractRoutingDataSource,这个类允许你维护一组目标数据源,再通过一个determineCurrentLookupKey()方法决定当前线程用哪个数据源。思路就是:数据源注册进一个 Map,切库的时候切换一个 Key。
基于这个底层能力,业界有几种做法:
| 实现路径 | 优点 | 缺点 | 适用程度 |
|---|---|---|---|
手写AbstractRoutingDataSource+ThreadLocal | 无额外依赖,底层可控 | 要自己处理事务、切面、配置,工作量大 | 适合学习原理,不适合快速落地 |
用dynamic-datasource开源库 | 开箱即用,注解即可切库,社区活跃 | 多一个依赖,极端场景仍需自定义 | 大多数项目的务实选择 |
| 分库分表中间件 | 支持更复杂的路由规则 | 产品重、运维要求高 | 金融级或分片规则极复杂的场景 |
我自己在线上项目里用的是dynamic-datasource。它不是把底层原理包装得黑不溜秋的那种库,核心机制非常清晰:重写了数据源选择逻辑,用 AOP 切方法,把注解里声明的数据源标识放到ThreadLocal里,真正执行 SQL 时由抽象路由数据源去选择具体的物理数据源。
1.3 为什么这个方案能“轻松”切库
切库的本质难点在于:一个方法内部可能先查订单库,再查商品库,最后写入日志库。如果数据源切换不是“跟随线程上下文”的,那就没法做到这种在同一方法里随意切换的效果。
dynamic-datasource对这件事的处理是:通过 AOP 拦截被@DS注解标记的方法,在方法进入时将目标数据源 Key 压入一个栈结构的上下文里,方法结束后自动恢复切换前的数据源。这就意味着你不必关心切来切去之后状态污染的问题,框架帮你做了清理。
一句话总结这个方案的优点:配置量极小,对业务代码侵入性低,几乎只靠一个注解就能完成库切换,同时保留了手动编程式切换的口子。
2. 核心原理拆解:从 Spring 数据源抽象说起
2.1AbstractRoutingDataSource是地基
要理解动态数据源,先得理解 Spring 的AbstractRoutingDataSource干了什么。它的本质是一个DataSource实现,但它自己不真正持有数据库连接。它内部维护了一个targetDataSources的 Map,里面存着很多个真实的数据源,然后提供determineCurrentLookupKey()抽象方法返回当前应该用哪个 Key。当需要获取连接时,它会用这个 Key 从 Map 里取出对应的真实数据源,再返回该数据源的连接。
这就像是一个总机室接线员:你拨一个内部分机号,总机帮你转到对应的人。dynamic-datasource的核心DynamicRoutingDataSource正是继承了这个抽象类,只不过它把“如何决定当前用哪个 Key”这件事做得更聪明。
2.2ThreadLocal和栈结构:为什么切换能跟着线程走
先问一个问题:方法 A 调方法 B,方法 A 切到了数据源 X,方法 B 也写了@DS切到了数据源 Y,那方法 A 里 B 执行完之后的数据源是哪个?
如果只是用一个ThreadLocal存一个 Key,那 B 执行完之后把 Key 改成了 Y,A 后续操作如果还想用 X,就会出问题。所以dynamic-datasource用了Deque(双端队列)作为栈,进入方法时 push 新的 Key,方法结束时 pop 恢复上一个 Key。这样嵌套切换也能正确恢复。
这也是为什么我建议你去读一下源码里DynamicRoutingDataSource和DynamicDataSourceContextHolder的实现,理解了栈这个设计,你就能解释很多稀奇古怪的切库异常。
2.3 AOP 切面和顺序控制:切库发生在事务之前而不是之后
配置过 Spring 事务的开发者应该清楚事务的传播机制。MyBatis 的SqlSession是与事务绑定的,而事务管理器在开启事务时就会从DataSource获取连接。如果数据源切换发生在事务开启之后,那切换就失效了。
所以@DS注解切面的执行顺序必须比@Transactional要早,也就是切库动作必须在事务创建连接之前完成。这一点框架已经处理好了,它通过@Order(0)之类的顺序控制在事务切面之前切入。但如果你自己在项目中再叠加别的切面,就得小心别把顺序打乱。
2.4 什么场景不能直接切库
既然数据源切换靠的是线程局部变量,那最简单的边界条件就是:跨线程不能用注解切库。
比如你写了个定时任务,多个线程并行执行,每个线程跑不同的业务,这时候在主线程里设置了数据源 Key,子线程里的操作却感知不到。dynamic-datasource也提供了一些扩展思路,你可以把数据源 Key 作为参数传递到子线程内再设置,或者用它的钩子机制去处理异步线程的数据源传递。这块我后面会具体讲。
3. 完整配置实战:从 pom 到第一个注解切库的落地全过程
3.1 依赖引入和版本匹配
多数据源配置不复杂,但版本坑非常多。我建议遵循一个原则:mybatis-plus-boot-starter和dynamic-datasource-spring-boot-starter的版本不要跨代太远。以下是一组实测较为稳定的组合(具体版本以你项目为准,但思路通用):
<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.5.2</version> </dependency>这里有第一个容易踩的坑:dynamic-datasource是独立的开源项目,版本号不一定和 MyBatis-Plus 保持同步。如果你的项目里 MyBatis-Plus 是 3.5.x,动态数据源用 3.5.x 基本没问题;如果你用的是更老的 MyBatis-Plus 版本(比如 3.2.0),我建议选 3.3.x 左右的动态数据源,防止接口签名不兼容。
3.2 application.yml 的配置长什么样
直接上一份完整的多数据源配置,这是我在模拟项目 X 里用的模板,主库负责业务写入,副库负责人事统计查询,还有个只读的历史归档库:
spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://localhost:3306/business_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 report: url: jdbc:mysql://localhost:3306/report_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: report_user password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 3 maximum-pool-size: 10 archive: url: jdbc:mysql://localhost:3306/archive_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: archive_user password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 2 maximum-pool-size: 5要注意几个细节:
primary: master表示没有标注任何@DS的时候,默认走 master 这个数据源。这个配置不写默认是master,但显式写出来更清晰。strict: false表示如果指定的数据源不存在,不报错,走默认 primary。如果你希望搞错数据源名就直接报错,好尽早暴露问题,那就设成true。我线上项目是设成true的,因为静默降级会掩盖配置错误。- 每个数据源内部可以独立配置 Hikari 连接池参数,这一点很有用。报表库并发低、查询重,连接数没必要跟主库一样大,防止资源浪费。
3.3 第一次用@DS注解切换数据源
配置好了之后,写一个简单的场景:查询部门列表走人事报表库。
@Service public class DepartmentReportService { private final DepartmentMapper departmentMapper; public DepartmentReportService(DepartmentMapper departmentMapper) { this.departmentMapper = departmentMapper; } @DS("report") public List<Department> listAllDepartments() { return departmentMapper.selectList(null); } }对,就是这么简单。@DS("report")标注之后,这个方法执行过程中的全部 SQL 操作都会走report这个库。Mapper 接口都不需要额外处理。
这个注解还能标在类上,表示这个类下的所有方法默认都走某个数据源。如果类级别和方法级别同时有标注,方法级别的优先级更高。这种设计很实用,比如说定义一个ReportService,类上标@DS("report"),但其中个别方法比如某个实时查询需要走 master,方法上再单独标@DS("master")覆盖即可。
3.4 在 Service 内部动态切换:摆脱注解的限制
注解确实方便,但业务里总有一些特别的需求。比如同一个方法里,要根据传入参数判断该查哪个库——可能是按日期分库,可能是按租户分库,或者切换只对某段逻辑生效。这种场景注解就有点死板了。
dynamic-datasource提供了编程式切换的接口:
@Autowired private DynamicRoutingDataSource dynamicRoutingDataSource; public void handleByTenant(String tenantId) { // 手动切换到对应租户的库 DynamicDataSourceContextHolder.push("tenant_" + tenantId); try { // 这里执行查询和写入 orderMapper.selectById(1L); } finally { // 使用完毕必须恢复 DynamicDataSourceContextHolder.poll(); } }这里有个非常重要的心得:push之后必须在finally里poll。push和poll成对出现,相当于栈的压入和弹出。如果你只push不poll,虽然当前线程用完之后可能会被 Spring 进行线程复用,不小心就把上一个请求的数据源状态带到了下一个请求。
我之前在项目里救过的一次线上事故就是因为这个:某个定时任务线程把数据源切到了报表库,任务结束时没有poll,恰逢这个线程被池化复用,下一次请求本来应该走 master,结果还是路由到了报表库,导致查询报错。排查这种问题非常费精力,因为报错信息和数据源毫无关系。
3.5 批量数据源切换的另外几种姿势
除了注解和手动push/poll,还有一个常用的方式:@DS支持 SpEL 表达式。你可以这样做:
@DS("#tenantId") public List<Order> listByTenant(String tenantId) { return orderMapper.selectList(null); }这样调用时传入的tenantId会作为数据源 Key 的一部分去解析。不过这个功能有个前提,Spring 的 SpEL 表达式需要能解析到入参名,编译时没有-parameters参数的话,Spring Boot 一般没问题,但如果是普通 Spring MVC 项目,可能需要配置参数名保留。
还要提醒一点:@DS支持 SpEL 是把双刃剑。如果你把一个外部可传入的字符串直接拼接进数据源 Key,又开了strict: false,一旦传入了非法值,静默降级成主库执行,那可能造成越权查数据的问题。安全角度上,我建议对口径做好白名单校验,或者在配置里直接开strict: true。
4. 实操过程与关键环节实现:事务、嵌套、多参数一个都不能少
4.1 事务与多数据源:最容易翻车的地方
先说一个硬道理:Spring 的声明式事务(@Transactional)只能在单一数据源上保证事务。你可以在主库上开事务,也可以在报表库上开事务,但没有一个事务能同时覆盖两个库。
我来展示一个大家很容易误用的场景:
@Transactional public void doSomething() { orderMapper.insert(order); // 假设走 master reportMapper.updateReport(report); // 假设走 report }这个代码表面上看没啥问题,但如果orderMapper.insert走的是 master,reportMapper.updateReport走的是 report,那么实际上这里有两个物理事务。事务管理器会绑定到一个数据源上(一般是默认的主库),另一个数据源的操作走的是独立连接上的自动提交。
也就是说,如果reportMapper.updateReport执行报错,整个方法抛出异常,Spring 会回滚主事务,但 report 库里的那条更新可能已经提交了。反过来也一样。这个现象,很多刚接触多数据源的朋友都会踩到。
所以在设计服务时要注意:
- 不要把跨库写操作放进同一个宣称事务保护的方法里。如果业务上必须两个库同时写成功或失败,那一般要引入分布式事务中间件,比如 Seata,但这是重方案,权衡后再用。
- 如果你只需要“主库写入具备事务性,报表写入允许最终一致”,那完全可以把报表写入放到事务外操作,接受短暂的不一致。
dynamic-datasource提供了@DSTransactional注解,可以基于本地消息表等方式实现多数据源事务,但它不是真正意义的两阶段提交,而是通过事务消息和重试补偿来尽量保持一致。业务要求极高一致性的场景还是要用专业的分布式事务方案。
4.2 服务嵌套调用时的数据源切换:栈的妙处
上面说到了栈结构,我再展开演示一个嵌套场景:
@Service public class OrderService { public void createOrder() { // 默认走 master orderMapper.insert(order); // 调用统计服务,该服务类上标注 @DS("report") reportService.incrementOrderCount(); } }这个方法本身没有@DS注解,所以进入时是默认主库。进入reportService.incrementOrderCount()时,AOP 拦截到@DS("report"),把report压入栈。方法结束后弹出栈顶,恢复成主库。整个切换过程是无感的,而且不会影响外层方法后续继续走主库。
这个机制也解释了为什么你在一个事务方法里调用另一个数据源的方法,不要让内层方法的@DS一直生效到外层结束。框架的栈机制已经处理了这个问题,你不需要手动在内外层之间做清理。如果你自己写切库逻辑,就一定要考虑嵌套的入栈出栈,这也是我建议直接用现成方案的原因。
4.3@DS和@Transactional的标注顺序问题
虽然框架把切面的优先级调好了,但使用姿势不对,照样翻车。比如这样:
@Transactional @DS("report") public void queryReportData() { // ... }看起来是先开启事务,再切数据源?实际上 AOP 的执行顺序取决于切面优先级,@DS切面优先级高于@Transactional,所以进入方法时会先切数据源,再开启事务。这个顺序是符合预期的:先拿到目标数据源的连接,再开启事务。
但如果你用了自定义切面,在@Transactional之后才做切库,那事务已经在错误的连接上开启了,后面切库只对事务内的后续操作生效,且很容易出乱子。所以排查类似问题时,先检查切面执行顺序。
4.4 多库相同表结构时的 Mapper 复用
很多时候多个库的表结构是一样的,比如每个租户一个库,表结构完全相同。这种情况下 Mapper 接口只需要一套,对应的 XML 和实体类也共用,切换数据源后直接用同一个 Mapper 就能查不同库的数据。
public List<Order> queryByDb(String dbKey) { DynamicDataSourceContextHolder.push(dbKey); try { return orderMapper.selectList(...); } finally { DynamicDataSourceContextHolder.poll(); } }这个模式在 SaaS 多租户系统里非常常见。最关键的好处是业务代码不用为每个租户写一份 Mapper,数据库连接层帮你顶住了所有差异。
不过要注意,如果两个库的表结构不同,比如一个字段叫order_amount,另一个叫amount,那就不能复用同一套 Mapper 映射了。这种情况我通常建议分成两个 Mapper,不要让一个 Mapper 里塞两套结果映射,会非常影响可读性和维护性。
4.5 分页插件与多数据源:PageHelper 的救赎
MyBatis-Plus 自带分页插件,这个插件在多数据源下有些讲究。我们知道 MyBatis-Plus 的分页插件是对Executor进行拦截的,而Executor是绑定到SqlSessionFactory上的。在单数据源下,一切正常;多数据源下,如果你用动态数据源方案,实际上多个物理数据源共享了一个SqlSessionFactory(或者说一套 Mapper 代理),所以 MyBatis-Plus 的分页插件也是共享的。
这意味着你不需要为每个数据源单独配置分页插件。之前我在手写多套SqlSessionFactory的时候,分页插件就要给每个库配一份,否则某些库的分页就不生效,这个操作极为繁琐。用了动态数据源之后,这个头疼的问题直接消失了。
如果你使用的是 MyBatis 原生分页插件 PageHelper,在多数据源场景下要特别注意:PageHelper 的本质是拦截器,通过ThreadLocal保存分页参数,这一点和动态数据源的ThreadLocal有一点类似。因为 PageHelper 的分页参数会跟随当前线程,如果使用不当,线程池复用后会导致分页串台。这个和动态数据源本身的维护是同一种思维模式:线程局部变量用完了要清理。
5. 常见问题与排查技巧实录
5.1 问题一:切换数据源没生效,查询还是走了主库
这种情况我在群里帮人排查过很多次。最常见的几个原因:
@DS注解标在了 private 方法上。Spring AOP 基于代理,private 方法根本无法被代理拦截,自然不生效。- 同类内部调用绕过代理。例如 OrderService 中一个方法调用同类里的另一个带
@DS的方法,这时候走的是this调用,不是代理对象,AOP 拦截不到。解决方法是把带注解的方法拆到另一个 Service 里,或者通过AopContext.currentProxy()获取代理对象再调用,不过后者需要在启动类配置@EnableAspectJAutoProxy(exposeProxy = true)。 - 方法没有被 Spring 管理。比如在一个
new出来的普通类里调用带@DS的方法,肯定无效。只有 Spring Bean 的方法才能被切面拦截。 - 数据源名称配置错了。如果
strict为 false,找不到指定 Key 时会默认走 primary,看起来就是“切换没生效”。排查时建议先开strict: true,强制暴露配置错误。
排查思路很简单:在方法入口打日志,确认DynamicDataSourceContextHolder里当前栈顶的 Key 是什么,再看实际执行的连接来自哪个数据源。也可以打开 MyBatis 的 SQL 日志,结合连接 URL 来判断实际走了哪个库。
5.2 问题二:多数据源配置后启动报错,提示无法确定合适的 DataSource
这个报错经常在 Spring Boot 自动配置 DataSource 时发生。原因通常是:项目里同时出现了多个DataSourcebean,Spring 不知道该注入哪个。
如果你使用了动态数据源 starter,它会自动提供一个DynamicRoutingDataSource作为主要数据源,并配置@Primary。但如果你手动又定义了一个DataSource的@Bean,又没有标明@Primary,启动时依赖注入就会报错。
解决方案是:尽量别在项目里手动创建DataSourcebean,把数据源统一交给动态数据源管理。如果确实需要单独建一个连接池做特殊用途,注意用@Qualifier精确注入,并且不要和动态数据源混在一起。
5.3 问题三:连接池报错Connection is not available
多数据源场景下连接池报错,首先要区分是哪个库的连接池满了。常规排查步骤:
- 看错误日志里有没有
HikariPool-1、HikariPool-2这种标识,它对应的是配置里哪个数据源。 - 检查该库的连接池最大连接数是否合理。有时候一个报表查询 SQL 写得很烂,把某张表锁住,请求一多,连接池就被占满。
- 检查连接池泄漏。如果某些跨库操作没有释放连接,长期占用,连接池就会耗尽。这时候要开 Hikari 的泄漏检测:
spring: datasource: dynamic: hikari: leak-detection-threshold: 60000超过 60 秒未归还的连接会打警告日志,帮助定位。
5.4 问题四:同线程之间数据源串了,上一个请求的数据源污染下一个请求
这个问题的本质就是没有清理ThreadLocal。如果你用的是注解方式,框架会自动清理;但如果你是push手动切换后没有poll,或者用了线程池但未传递上下文,都会出现这种串库情况。
我的经验法则是:凡是手动调用DynamicDataSourceContextHolder.push()的地方,必须配套try/finally执行poll()。这个规则要像写成代码规范一样定下来,code review 时重点检查。
至于线程池场景,比如你在一个任务里把数据源切到了报表库,然后 submit 到线程池执行子任务,子任务里的@DS注解如果没标注,它会默认走 primary,而不是继承父线程的数据源。如果需要传递,可以在提交任务时把当前数据源 Key 作为一个参数传给子线程,或者手动在子线程任务里push指定 Key。
5.5 问题五:MyBatis-Plus 自动填充功能在多数据源下失效
有些项目用 MyBatis-Plus 的元对象处理器(MetaObjectHandler)自动填充创建时间、更新人这样的字段。这个处理器是全局级别的,跟数据源没直接关系,所以一般不会因为切换数据源而失效。
但如果你的自动填充逻辑里有某种数据源依赖(比如根据当前登录用户所属分库去查一些配置),那在切换数据源后填充的上下文就不一样了。这个场景需要你自己设计好上下文传递,和动态数据源本身的关系不大。
5.6 问题六:明显卡顿,某个库的连接池配置不合适
多数据源的一个隐性好处是你可以在每个数据源上独立配置连接池参数。如果之前用单库硬扛所有请求,某个重查询拖垮了连接池,那拆分多库之后,报表库可以用较小的连接池配合较长的查询超时,主库用较大的连接池保证写入吞吐。连接池配多大没有标准答案,我给出一个参考区间:
| 场景 | 连接数参考 | 说明 |
|---|---|---|
| 主库写入型 | 20-50 | 视并发写入量和数据库性能而定 |
| 报表查询型 | 5-15 | 查询多为只读,连接释放快 |
| 归档历史库 | 3-8 | 低频访问,小连接池更节约资源 |
最终还是要压测后调整,别照搬别人的参数。
6. 进阶扩展:多数据源与读写分离、Seata 的配合经验
6.1 主从读写分离能不能用这个方案
可以,而且非常顺手。你可以配置两个数据源,一个主库,一个从库:
spring: datasource: dynamic: primary: master datasource: master: url: jdbc:mysql://localhost:3306/main_db username: root password: 123456 slave: url: jdbc:mysql://localhost:3306/slave_db username: root password: 123456然后在查询方法上标注@DS("slave"),写方法不标注,默认走主库。当从库延迟严重或者查询要求强一致时,可以临时在方法上加@DS("master")。
不过我要提醒:这类方案只能解决“读写走的连接不同”这个问题,它不解决从库数据同步延迟。强一致场景不能无脑走从库。
6.2 多数据库类型混合,比如 MySQL 和 PostgreSQL 共存
如果你的业务用了 MySQL 做主库,PG 做报表库,这种配置也完全可行。数据源层面只需要配置各自的driver-class-name和 URL,MyBatis 方言的差异通过 SQL 写法来控制。
不过要注意:MyBatis-Plus 的某些自动 SQL 功能针对不同数据库生成的 SQL 略有差别。MyBatis-Plus 本身有数据库类型识别能力,但多数据源混合时,要确保你的 SQL 和实体注解不与方言冲突。比如 MySQL 支持反引号包裹字段名,PG 则通常不需要,混合使用时 SQL 里的方言特性要收敛一些。
6.3 什么时候该上 Seata 这类分布式事务方案
如果你遇到的是这种情况:用户下单,订单库写入成功,库存库扣减失败,业务上的数据就对不上了。这已经不是多数据源本身能解决的问题,需要引入分布式事务。
我这里只给一个经验判断:绝大多数中小项目的“伪跨库事务”,可以通过重构调整为先写主库、异步同步到辅库来规避,没必要直接上重量级中间件。只有确实无法避免两个库之间的强一致更新,才需要考虑 Seata AT 模式的全局事务。
6.4 动态数据源和 Flyway 的配合
如果项目用 Flyway 管理数据库版本,在多数据源下要注意:Flyway 默认只跑在一个数据源上,你需要为每个数据源单独配置迁移脚本的执行入口,或者把 Flyway 的执行目标限定为某个特定库。
我在项目里的做法是,把 Flyway 只跑在master上,其他库的表结构通过复制基线脚本+手工执行变更脚本来同步。如果你的团队分工允许,也可以给每个数据源配置独立的 Flyway。
7. 结语:多数据源不是银弹,但它解决了真实痛点
我踩过多数据源的坑,也用它解决过线上故障。说到底,动态数据源的核心价值不是让你把代码写得花里胡哨,而是把一个原本需要手工管理连接和切面的复杂问题,收敛成一个注解、一个配置、一套可预期的机制。
几个我用真金白银换来的体会,放最后给你当提醒:
- 默认走主库,辅库靠注解。别把主库的 Mapper 全部加上
@DS("master"),那是没必要的冗余。 - 手动静默降级是最难排查的问题。
strict: true一定要开,宁可启动时炸出来,也不要线上静默走错库。 push和poll必须成对。这个规则要在 code review 里死守。- 连接池参数要分开调。别让报表查询把主库的连接池拖垮。
- 事务边界画清楚,跨库强一致要谨慎。必要时就用异步化和本地消息表来换取可接受的最终一致。
如果你要拿去用在项目里,最稳妥的落地路径是:先在 Demo 项目里把单库 CRUD 跑通,加上@DS切库验证生效,再逐步迁移真实业务方法。等这套机制跑顺了,你会发现所谓“数据库切换”其实早就不是问题,真正值钱的是你对事务边界、线程状态和连接池管理的理解。这些才是每天线上系统稳定运行背后的真功夫。