SpringBoot4系列05 - 多数据源配置
第二篇那个订单工程,数据源是启动时自动配好的那一个:yml 里写一句 spring.datasource.url,Boot 就照着它建好 HikariDataSource、SqlSessionFactory 和事务管理器,一行配置类都不用写。
这一篇我把那个工程拆开了:交易库放订单和订单明细,运营库放商品主数据,两个库在同一个应用里共存。难的从来不是「怎么配两个数据源」,难的是配完之后那串莫名其妙的报错:
注入 JdbcTemplate,报 NoSuchBeanDefinitionException。
注入 PlatformTransactionManager,同样的报错。
最离谱的是注入 OrderMapper,还是同样的报错,而报错信息指向 mapper 自己,跟数据源看不出任何关系。
这些都不是 bug,是 Boot 4 主动退让的结果,所有退让条件里都有同一个注解 @ConditionalOnSingleCandidate,它检查的是「候选人是不是只有一个」。想明白这一条,多数据源就从「照抄配置」变成了「知道每个 bean 为什么存在」。
还有一个坑更隐蔽,我单独留了一节:事务管理器绑错数据源的时候,异常照抛、事务照回滚,数据却留在库里。没有报错,没有 WARN,日志上还写着「事务回滚成功」。
工程在 springboot4-multisource 目录下,结论全部来自真实运行,包括那些像段子的反例。

先把两个库的关系摆清楚
多数据源要回答的问题就三个:两个库是什么关系(主从、分库还是多租户)、选库发生在什么时候、事务要不要跨库。最后一问属于 JTA 或者最终一致性的范畴,这篇只指路,两套代码对应前两问:
| 方案 | 选库时机 | 库的关系 | 典型场景 |
|---|---|---|---|
| 静态多数据源 | 依赖注入时,编译期就定了 | 平级,各管一摊 | 报表库、运营库、读写分离的从库 |
| 动态路由数据源 | 运行时,按注解 | 平级,但可以随时换 | 分库、按业务切库、多租户 |
演示用的两个库是这么分工的:
| 库 | 装什么 | 谁在用 |
|---|---|---|
springboot4_pos | t_order、t_order_item | OrderMapper |
springboot4_biz | t_product | ProductMapper |
两个库各有一张结构完全一样的 t_user,id 都是 1,只有数据不同:交易库里叫 admin,运营库里叫 admin1。我造这张重复的表,是因为切错库时只会报「表不存在」,分不清是路由配错了还是脚本没跑;同名表加不同数据,把问题压缩成一次字段值的比对。
探针 SQL 里我还带了 DATABASE() 和 CONNECTION_ID():
| |
这两个是连接的属性,不是数据的属性,哪怕有人把两个库的数据改成一样也骗不了人。下面所有「切没切对」的判断,我认的都是 DATABASE()。
4.1.1 的版本事实
版本先查清楚,spring-boot-starter-parent:4.1.1 锁的是这些:
| 组件 | 版本 |
|---|---|
| Spring Framework | 7.0.9 |
spring-boot-jdbc | 4.1.1 |
spring-boot-transaction | 4.1.1 |
| HikariCP | 7.0.2 |
| MyBatis-Plus | 3.5.17(mybatis-spring 4.0.0、mybatis 3.5.19) |
| MySQL | 8.0+(本工程在 8.4 上验证) |
JDBC 自动配置的包名
spring-boot-jdbc-4.1.1.jar 里的 AutoConfiguration.imports 一共 9 项,全部落在 org.springframework.boot.jdbc.autoconfigure,关键是这几条:
| |
import 里写 org.springframework.boot.autoconfigure.jdbc.* 会直接编译失败,我试过。事务那边同理,三项都在 org.springframework.boot.transaction.autoconfigure 和 .jta.autoconfigure 下面:Boot 4 把自动配置按技术模块拆成独立的 jar,包名跟着模块走。排错时还有一条,@AutoConfigureAfter 指到不存在的类,编译直接失败。
spring.datasource.* 下 12 个 error 级废弃属性
翻开 spring-boot-jdbc 的配置元数据,spring.datasource 命名空间下有 12 个标着 level: error 的废弃项:
| 废弃属性 | 换成 |
|---|---|
spring.datasource.continue-on-error | spring.sql.init.continue-on-error |
spring.datasource.data | spring.sql.init.data-locations |
spring.datasource.data-password | spring.sql.init.password |
spring.datasource.data-username | spring.sql.init.username |
spring.datasource.initialization-mode | spring.sql.init.mode |
spring.datasource.jmx-enabled | spring.datasource.tomcat.jmx-enabled |
spring.datasource.platform | spring.sql.init.platform |
spring.datasource.schema | spring.sql.init.schema-locations |
spring.datasource.schema-password | spring.sql.init.password |
spring.datasource.schema-username | spring.sql.init.username |
spring.datasource.separator | spring.sql.init.separator |
spring.datasource.sql-script-encoding | spring.sql.init.encoding |
level: error 不是「打个 WARN 还能用」,是绑定时直接抛异常、启动即失败,多数据源建表的常见错误写法正好撞在这上面:
| |
这样写启动第一步就过不去,改成 spring.sql.init.* 也还是不行,原因在建表那一节说。另外记一笔,这个命名空间下没有任何 multi、primary 之类的多数据源属性,多数据源从头到尾都要手工搭。
spring.datasource.connection-fetch 管不了时序问题
Boot 4 提供 spring.datasource.connection-fetch,取值 eager(默认)和 lazy:
| 值 | 官方说明 |
|---|---|
eager | 直连,立刻取连接 |
lazy | 尽可能晚取,没有语句执行就完全不取 |
lazy 看起来像能解决「事务里切换不生效」,不行:它作用于自动配置出来的那个 DataSource,本篇全部手工声明数据源,配了也不生效,而且不能按请求切换,详见坑十四。
依赖和配置
依赖六项:spring-boot-starter-webmvc、spring-boot-starter-aspectj、spring-boot-starter-jdbc、mybatis-plus-spring-boot4-starter、mysql-connector-j(runtime)、spring-boot-starter-actuator,完整 pom 在工程里,说我的取舍。
Web starter 用 spring-boot-starter-webmvc。AOP starter 用 spring-boot-starter-aspectj,spring-boot-starter-aop 这个名字在 Boot 4 里不存在;这个依赖不是可选的,没有它 @Aspect 不会被识别,我吃过这个亏,@DataSource 在那儿躺着,切库就是不起作用。
spring-boot-starter-jdbc 非常薄:starter + spring-boot-jdbc + HikariCP,里面没有 spring-tx 的显式坐标,它是 spring-boot-jdbc 和 mybatis-plus 传递进来的,本篇所有自动配置结论都出自 spring-boot-jdbc。MyBatis-Plus 必须用带 boot4 后缀的 starter,第二篇定过纪律。actuator 也不是摆设:里面有一个专门给路由数据源写的健康检查贡献者,/actuator/health 会把每个 target 分别展开,是「两个库到底连上没有」最省事的证据源。
然后看 yml,biz 段和 pos 段结构完全相同,只是库名换成 springboot4_biz、连接池名换成 biz-pool:
| |
数据源属性是两层的写法:app.datasource.pos.* 绑 DataSourceProperties 装连接信息,app.datasource.pos.hikari.* 绑 HikariDataSource 自己装连接池参数,这是 Boot 官方文档给多数据源的推荐拆法,pool-name 能自己取名字,下面排查日志全靠它。
spring.datasource.url 我故意没写:容器里只要出现两个 DataSource,默认数据源那个自动配置就退让了,连接信息压根不会被读;而且那族属性里有一半已是 error 级废弃,写了直接启动失败。
方案 A:静态多数据源
先讲这个方案,理解原理的必经之路。
为什么必须全部手工写
注册第二个 DataSource 之后,下面这些 bean 全都不会出现,这张表是我对着容器一个一个验出来的:
| 自动配置 | 退让条件 | 多数据源下的后果 |
|---|---|---|
DataSourceAutoConfiguration | @ConditionalOnMissingBean(DataSource, XADataSource) | 默认数据源不建(这个在预期内) |
JdbcTemplateAutoConfiguration | @ConditionalOnSingleCandidate(DataSource) | JdbcTemplate、NamedParameterJdbcTemplate 都没了 |
JdbcClientAutoConfiguration | @ConditionalOnSingleCandidate(NamedParameterJdbcTemplate) | JdbcClient 跟着没了,连锁 |
DataSourceTransactionManagerAutoConfiguration | @ConditionalOnSingleCandidate(DataSource) | 没有 PlatformTransactionManager |
DataSourceInitializationAutoConfiguration | @ConditionalOnSingleCandidate(DataSource) | schema.sql / data.sql 不执行 |
MybatisPlusAutoConfiguration | @ConditionalOnSingleCandidate(DataSource) | mapper 扫描整体失效 |
TransactionAutoConfiguration$TransactionTemplateConfiguration | @ConditionalOnSingleCandidate(PlatformTransactionManager) | TransactionTemplate 没了 |

MyBatis-Plus 那条最坑:mapper 扫描没了,所有 @Mapper 接口都不会注册成 bean,启动时报 NoSuchBeanDefinitionException,异常信息里出现的是 OrderMapper。我第一次就栽在这里,把包名、编译输出目录查了个遍,根因却在数据源那边。
退让全是静默的:不报错、不警告,只是 bean 没了,排查这类问题看日志没用,得看容器里到底有什么。@ConditionalOnSingleCandidate 的完整语义是「只有一个候选,或者有多个但恰好一个标了 @Primary」,两个平级的数据源让条件不成立,一串东西集体消失。这个「或者」很重要,坑十拿它做了对照实验。
一个库一套 bean
静态方案的核心就一句话:一个 SqlSessionFactory 是「数据源 + MyBatis 配置 + 映射文件」的整包,库不同这个整包就得新建一套。

交易库的配置类长这样(建表用的 DataSourceInitializer 见下一节):
| |
posDataSource 注入属性类,initializeDataSourceBuilder() 拿它推断 url 和驱动。@MapperScan 指向的 posSqlSessionFactory 是工厂生产出来的那个 SqlSessionFactory,不是 MybatisSqlSessionFactoryBean 本身:后者是 FactoryBean,容器里注册的是它 getObject() 的产物。事务管理器我用子类 JdbcTransactionManager:父类 commit() 时数据库报错抛原生 SQLException,子类翻成 Spring 的 DataAccessException。### 建表归谁管
建表不能指望自动配置:DataSourceInitializationAutoConfiguration 同样有 @ConditionalOnSingleCandidate(DataSource),而且 spring.sql.init.schema-locations 只能给一个值,回答不了「这段脚本发给哪个库」。所以每个库一个 DataSourceInitializer,各喂各的 schema 和 data 脚本。工程里那五份 SQL 脚本还有个副产品:单独执行就能重置数据,不用重启应用。
MybatisSqlSessionFactoryBean 的包名
这是本篇第三个大坑,说实话是个纯粹的「抄教程会死」的坑:3.5.17 里这个类在 com.baomidou.mybatisplus.spring.,而网上讲「多数据源 + MyBatis-Plus」的文章基本都还写着 extension.spring 旧包名。我照抄过来第一个动作就是编译不过,照抄编译不过的,九成是这个原因。
这个类用来替代 MyBatis 原生 SqlSessionFactoryBean,三个工厂共用这段构造逻辑,我写成包私有的静态方法:
| |
setGlobalConfig() 那两行不能省:BaseMapper 那套 CRUD 靠 GlobalConfig 里的 SqlInjector 注入,不设的话只有空接口,表现是「找不到 statement」,报错方向又指歪了。
跑起来看
先把两个库建好灌好数据,起服务。
| |
浏览器里打开这个接口,看到的就是这么一行,posDatabase 是 springboot4_pos、3 条订单,bizDatabase 是 springboot4_biz、4 个商品:

它是这次运行的真实取值,不是截完再拼的;计数由种子脚本写死,反复跑都是这个结果。
交易库的 join 查询验证另一件事:两张表在同一个库里。
| |
databaseName 是 SELECT DATABASE() 来的,专门给排查用。运营库那边换个 controller、换个 mapper,/api/static/products 返回的就是 t_product 的数据。
到这里静态方案就跑通了,没有任何技术难度,就是啰嗦。
一条反例:事务管理器绑错数据源
这一段是我最想单独拎出来说的。
| |
事务管理器绑的是运营库,写入的是交易库,然后抛异常。会发生两件事:@Transactional("bizTransactionManager") 让 Spring 去运营库拿连接、开事务、绑在当前线程上;而 orderMapper.insert(...) 用的是交易库自己的连接,那条连接不在任何事务里(Hikari 默认自动提交),方法返回前就已经落库了。紧接着抛异常,Spring 老老实实把运营库上那个空事务回滚了。

我跑了一遍:
| |
ordersBefore 是 3,ordersAfter 是 4。换成 manager=pos,同样的代码、同样的异常,ordersAfter 就回到 3 了:
这个 bug 不报错、不打 WARN、日志上还显示「事务回滚成功」。 要发现它,只能把调用前后的行数摆出来比,所以这个接口把两个计数一起返回。我直接查库确认过,那笔「回滚了但还在」的数据就躺在交易库里,id 是 5 不是 4:正确的那次演示也 INSERT 过一次、拿到了 id=4,随后被回滚了,MySQL 的 AUTO_INCREMENT 不会因为回滚而退回去,别拿自增空洞当「有没有丢数据」的依据。
结论:静态方案下每个 @Transactional 都得显式写事务管理器名字,而写错名字不会有任何提示。工程里三个事务管理器谁都没标 @Primary,写裸的 @Transactional 会直接抛 NoUniqueBeanDefinitionException,这是故意的,「这个事务管的是哪个库」本来就该被显式回答。
方案 B:动态路由数据源
静态方案太死了:加一个库动四个地方,切换发生在依赖注入阶段,运行时想换库做不到。动态方案换个思路:让 MyBatis 眼里始终只有一个数据源,库的切换发生在更下面一层。
AbstractRoutingDataSource 没有废弃
这个类能用,而且是官方认可的一等用法。我把 spring-jdbc-7.0.9.jar 的字节码全量扫了一遍,AbstractRoutingDataSource 没有任何 Deprecated 标记,签名也没变。更直接的证据在 Boot 4 这边,spring-boot-jdbc 里有一个专门为它写的东西:
| |
而且 DataSourceHealthContributorAutoConfiguration 的条件里显式列出了这个类:
| |
一个已经被废弃的类是不会被写进自动配置条件里的,这就是我敢放心用它的铁证。对比之下,跨库链式事务的 ChainedTransactionManager 在 Boot 4 里位于 spring-data-commons,字节码里有 4 处 Deprecated 标记,别拿它当选型依据。
上下文用栈,不用单值
先定义逻辑库的枚举:
| |
用枚举不用字符串,是因为路由数据源内部拿这个值当 Map 的 key,拼错了编译期查不出来。REPORT 的物理库是 null,故意没注册,是给反例留的入口。然后是这个方案的核心,一个 ThreadLocal:
| |
三个设计决定。用 ThreadLocal,不用 InheritableThreadLocal:可继承版本只在线程被创建那一刻拷一次父线程的值,Web 容器的线程池早就创建完了,表现出来就是「有时候对、有时候错、重启一下又好了」这种最难查的毛病。用栈,不存单个值:@DataSource 允许嵌套,外层标 BIZ 里层标 POS,里层跑完必须能回得来。弹干净之后把 ThreadLocal 本身也 remove 掉:池化线程反复复用,用完必须清。
路由数据源本体
| |
父类的 determineTargetDataSource() 是 protected 且不是 final,Framework 7 里实测可以覆写;我把泛泛的 IllegalStateException 翻成专用异常,全局异常处理器放心只接 DataSourceRoutingException。注册时有几个参数必须显式设置:
| |
方法参数用 @Qualifier,容器里已经有两个 DataSource,按类型注入会歧义报错。setLenientFallback(false) 是最要紧的一行:默认值 true 的含义是「标识取不到就悄悄用默认库」,关掉之后,一个非 null 但没注册的 key 会直接抛异常。

切面的顺序不能随手写
切数据源和开事务是两条互不知情的切面,谁先执行决定了结果对不对。Spring 的事务拿到连接就绑在当前线程上,所以「挑哪个库」必须在取连接之前做完。顺序反了的表现是:事务先开、连接从默认库借出来并绑定,切面再改标识,之后的 SQL 走的还是那条已经绑好的连接,注解看起来生效了,实际没生效。
Spring 的 advice 按 order 从小到大往外包,order 越小越靠外。Boot 4 里 @EnableTransactionManagement 的 advisor 用默认值 Ordered.LOWEST_PRECEDENCE,而 TransactionAutoConfiguration 没有暴露任何调整它的属性,所以把顺序写在事务注解这一头是常见错误写法:
| |
Boot 4 里这个 order 属性没有对应支持,写上去不报错但也不起作用,只能从切面这一头压下去:
| |
写 0 不写 Ordered.HIGHEST_PRECEDENCE,是给别的切面留位置:以后日志切面、幂等切面想包在外面,还能用负数插进去。

取注解的方式有一处细节:不能用 joinPoint.getSignature().getDeclaringType(),那是接口或者代理声明的方法,类上的 @DataSource 有可能取不到;要沿 getTarget().getClass() 的真实方法找,先方法注解、再类注解,类上标默认库、个别方法单独覆盖。
跑起来看:基本切换
| |
| |
同一句 SQL、同一个 mapper、同一个 SqlSessionFactory,靠一个注解落到两个库上。两个 connectionId 不一样,说明这两次用的是两条不同的物理连接。一次请求里先后切两个库也行,/api/dynamic/probe/both 会按顺序返回 POS、BIZ 两条探针结果。
四个反例
正例谁写都能对,有价值的是下面四个「应该失败」的场景,全部做成了可以访问的接口,我一个一个跑的。
反例一,标了一个没注册的库。 REPORT 在 targetDataSources 里没注册,标上它请求直接 500,Content-Type 是 application/problem+json,detail 里带着 Cannot determine target DataSource for lookup key [REPORT]:
| |

抛异常,不是静默回退到默认库,这就是 setLenientFallback(false) 换来的东西:默认的 true 会让这里一声不吭地返回交易库的数据,响应 200。不过这条还有个故事,坑十三讲。
反例二,同类内部调用,注解被 AOP 吃掉。 外层方法标 BIZ,中间插一步标了 @DataSource(POS) 的 this.probePos(),三步探针的结果:
@Transactional、@Cacheable、@Async 全是同一个毛病,修法是把方法挪到另一个 bean 去,工程里那个 ProbeInnerService 就是为这个拆出来的。
反例三,事务里面才切换,切不动。 事务方法内部手动 push(BIZ) 再查询,两步都在 POS:
两步的 connectionId 完全一样,actualDatabase 都是 POS:事务在方法进入时就绑好连接了,之后再改标识,改的是一个不会再被读的变量。对应的正例是两个注解标在同一个方法上,让切面顺序自己决定先后,落库是 springboot4_biz、connectionId 8608。
这一对正反例是动态方案能不能用的分水岭。 要切换,注解必须标在事务外面。
反例四,换了个线程。 方法里用 CompletableFuture.supplyAsync(...) 把第 2 步查询单独扔给另一个线程,结果:
第 2 步落回了默认库,而且没有报错,接口返回 200。这一条推翻一个流传很广的说法:设了 setLenientFallback(false) 就不会有静默回退。它对「非 null 但没注册的 key」成立,换线程不一样,父类的源码是这么写的:
| |
后半截那个 lookupKey == null 是独立的一条路,跟 lenientFallback 一点关系都没有:标识为 null 时无条件回退到默认数据源。换线程正好走这条路径,ThreadLocal 不过线程边界,新线程上 peek() 返回 null。结论:想在新线程里用对库,只能把标识显式传进去,上下文不会自己过去。
还有个细节:我本想覆写 toString() 让 Hikari 日志带上库名,javap 查下来父类链上没有现成的重写可用,最后靠切面的 DEBUG 日志代替。
切面日志和实际执行的 SQL
开了 DEBUG,每次切换都有记录:
| |
注意第 3 行,目标方法是 ProbeInnerService.probePos(..) 而不是 ProbeService,这就是「跨 bean 调用才生效」的证据;反例二那次 selfInvocation 只切了一次,内层的 probePos() 没有留下记录。
另一半证据在 SQL 这边:
| |
t_order 和 t_product 的语句交替出现,说明在用两个库。但光看 SQL 日志判断不了连的是哪个库,同一条 SELECT COUNT(*) FROM t_order 在两个库上都成立,所以工程里把连接池名字分开取了,日志里 biz-pool 和 pos-pool 各自打出了 Starting 和 Start completed。
切没切对只认三样东西:DATABASE()、CONNECTION_ID(),还有切面那行 DEBUG。整轮验证下来应用日志 ERROR 0 行、WARN 0 行,四个反例全跑了一遍,一行 ERROR 都没打:这类 bug 最难缠的地方就是它们不制造噪音。
健康检查:两个库都连上了最省事的证明
最省心的一个发现是健康检查:/actuator/health 的 components.db 下面有四个条目,普通数据源 posDataSource、bizDataSource 平级并列,dynamicDataSource 下面自己展开了 BIZ 和 POS 两个独立条目,全部 UP:
| |

上面这张是完整响应,还有磁盘、SSL、liveness 这些系统自带的检查项。没有为这个展开写任何代码,就是前面那个 RoutingDataSourceHealthContributor 干的活,键名直接用 targetDataSources 里的 key。它是「两个库都连上了」最省事的证明,还能发现 SQL 日志发现不了的问题:某个库的连接池配得不对,但业务上还没走到那条路径。
这一篇的测试怎么写
| |
| |
| 测试类 | 用例数 | 覆盖 |
|---|---|---|
DynamicRoutingTest | 9 | 路由切换、四个「应该失败」的反例 |
StaticDataSourceTest | 6 | 静态绑定、join、事务管理器正反例 |
AutoConfigurationBackoffTest | 5 | 断言一串自动配置的 bean 不存在 |
AdvisorOrderTest | 2 | advisor 链上数据源切面排在事务切面之前 |
ExceptionHandlerTest | 2 | 被包装过的路由失败仍然返回带明细的 problem+json |
PrimaryAutoConfigurationProbeTest | 1 | 对照实验,一个 @Primary 让自动配置复活 |
AutoConfigurationBackoffTest 全是「某个 bean 不存在」的断言,写成断言比写成注释有用:注释会被忽略,测试会红。AdvisorOrderTest 锁的是一件「改了也不会报错」的事:它断言数据源切面的 order 必须小于事务 advisor、且在代理链上排在更外层,两个断言都写,是防哪天有人改了排序方式。取断言值时我踩到一个 API 细节:Advisor 接口本身没有 getOrder(),是 AspectJPointcutAdvisor 这些具体实现各自实现了 Ordered,得先 instanceof 再调用。
另外,第四篇的切片测试靠 @Transactional 回滚保证用例互不干扰,这一篇没法这么做:事务管理器有三个,注入会歧义,而且要验证的恰恰是「事务有没有真的回滚」,用事务去清理事务等于把要测的东西当工具用。所以 StaticDataSourceTest 的清理放在 @AfterEach 里,用例数据统一用 TEST-MS- 前缀,跑完按前缀删掉。
避坑指南
坑一,@ConditionalOnSingleCandidate 是这个主题的总开关。 它检查的是「候选人是不是只有一个」,不是「有没有」。注册第二个数据源之后,JdbcTemplate、JdbcClient、事务管理器、schema.sql 初始化、整条 MyBatis-Plus 自动配置全线退让,全是静默的。排查别猜,直接写测试断言 bean 存不存在,比翻日志高效。
坑二,spring.datasource.* 里有 12 个属性是 error 级废弃。 绑定时直接抛异常、启动即失败,把 spring.datasource.schema 写进多数据源工程第一步就挂。改成 spring.sql.init.* 也没用,那个自动配置同样有 @ConditionalOnSingleCandidate,而且回答不了「这段脚本发给哪个库」。
坑三,报错方向指错的时候要怀疑数据源。 「找不到 XxxMapper」的第一反应是查 mapper 包名和编译输出目录;项目里配了多个数据源时,先确认 MyBatis-Plus 的自动配置生效了没有,它退让了,mapper 扫描是一起消失的。
坑四,MybatisSqlSessionFactoryBean 的包名在 3.5.17 是 com.baomidou.mybatisplus.spring。 写成 extension.spring 编译不过。另外 setGlobalConfig() 不能省,不设它 BaseMapper 那套 CRUD 注入不进去,报的是「找不到 statement」,同样指错方向。
坑五,@MapperScan 是类级注解,一个类只能声明一组规则。 所以每个库一个配置类,数据源、工厂、事务管理器、扫描规则管齐,三个库三个类,躲不掉。
坑六,数据源属性要拆成两层前缀。 连接信息绑 DataSourceProperties,连接池参数绑 HikariDataSource。DataSourceProperties 可以直接复用,initializeDataSourceBuilder() 里驱动推断那堆细节都是现成的。
坑七,切面顺序错了,注解会「看起来生效了」。 事务拿到连接就绑到线程上,挑库必须在取连接之前。Boot 4 里 @EnableTransactionManagement(order = 2) 这个属性没有对应支持,只能从切面这头压 @Order,工程里给 0 留出负数位置。这个顺序 Spring 不打印,工程里用测试把它固定住了。
坑八,@DataSource 和 @Transactional 一样,自调用不生效。 同类内部 this.方法() 不走代理,修法是把方法挪到另一个 bean,或者开 @EnableAspectJAutoProxy(exposeProxy = true) 后用 AopContext.currentProxy()。它比事务版更容易被忽略:数据源不对不报错,只是数据不对。
坑九,lenientFallback=false 挡不住 null 标识。 这一条和很多文章的说法相反。父类判断里的 lookupKey == null 是独立的一条路,标识为 null 时无条件回退到默认库,开关管不着。换线程正是这条路径:ThreadLocal 不过线程边界,新线程上 peek() 返回 null,查询静默落到默认库,接口返回 200。这个开关只管「非 null 但没注册」的情况。
坑十,一个 @Primary 能让整串自动配置复活。 @ConditionalOnSingleCandidate 的完整语义是「只有一个候选,或者有多个但恰好一个标了 @Primary」。工程里有对照实验:PrimaryAutoConfigurationProbeTest 单独起一个只声明 @Primary 数据源的上下文,断言 JdbcTemplate、JdbcClient、PlatformTransactionManager、SqlSessionFactory、TransactionTemplate 全部复活。但要想清楚:自动配置只绑那一个数据源,第二个库它照样拿不到,只有动态方案下「那一个」本来就是路由数据源时才刚好成立。工程里两套方案并存,我选了「全部手工、谁都不标 @Primary」这条更直白的路。
坑十一,三个事务管理器,@Transactional 不写名字直接报错。 这是故意的,多数据源下「这个事务管的是哪个库」本来就该被显式回答。但静默的反面才可怕,看坑十二。
坑十二,事务管理器绑错数据源,异常抛了、事务回滚了、数据还在。 Spring 去运营库拿连接、开事务、绑线程;insert 走交易库自己的连接,自动提交,返回前就落库了;异常触发的是运营库那个空事务的回滚。不报错、不打 WARN、日志上还写着「事务回滚成功」。 只有把调用前后的行数摆出来比才能发现,所以那个接口把 ordersBefore 和 ordersAfter 一起返回。残留数据那条 id 是 5 不是 4,自增主键不会因为回滚而退回去,别拿自增空洞当「丢没丢数据」的依据。
坑十三,异常被中间层重新包装,@ExceptionHandler 接不住。 这条是被一个失败的测试带出来的:按类型接 DataSourceRoutingException 接不到,路由失败发生在 mapper 取连接时,SqlSessionTemplate 把异常交给 MyBatisExceptionTranslator 翻成 MyBatisSystemException,原始异常进了 cause 链,请求落到通用兜底,那句最关键的 Cannot determine target DataSource for lookup key [REPORT] 被吃掉,而且状态码还是 500,从响应上完全看不出有问题,只断言状态码的测试会把这个 bug 放过去。修法是沿 cause 链找:
| |
在 @ExceptionHandler(DataAccessException.class) 里先 findCause(ex, DataSourceRoutingException.class),找到再走专用处理。我没用 getMostSpecificCause():它一路走到链尾,而这个 case 的链尾正好是最不需要的 IllegalStateException。更通用的教训:按异常类型写 @ExceptionHandler 之前,先确认异常穿过多层框架之后类型还在不在。 修复前后的差别有测试盯着,ExceptionHandlerTest 断言的是 type 和 detail,不只是状态码。
坑十四,spring.datasource.connection-fetch 不是用来解决时序问题的。 lazy 只作用于自动配置出来的那个 DataSource,手工声明数据源配了也不生效,而且它是数据源级开关,不能按请求切换。管用的还是顺序,把 @DataSource 标在事务外面。
坑十五,跨库事务这块,ChainedTransactionManager 别再用了。 它在 Boot 4 里的位置是 spring-data-commons(不在 spring-tx 里),字节码里有 4 处 Deprecated 标记,是 Spring Data 内部用的东西。跨库这件事的正确边界是:
| 需求 | 做法 |
|---|---|
| 单库事务 | 每个数据源一个 JdbcTransactionManager,@Transactional("xxx") 指明名字 |
| 跨库强一致 | JTA / XA。Boot 4 有 XADataSourceAutoConfiguration 和 JtaTransactionManager,但需要 XA 版数据源和事务协调器 |
| 跨库最终一致 | 消息队列 + 本地消息表,超出本篇范围 |
动态方案还有一条自己的边界:一个事务只能落一个库,因为事务开在路由数据源上,取连接时问一次当前标识就绑死了。想在一个事务里同时改两个库,这个方案做不到,得上 XA。
Boot 4 多数据源事实清单
这个主题在 Boot 4 上的关键事实,类包位置都以 4.1.1 实测为准。
| 项目 | 当前事实 |
|---|---|
| JDBC 自动配置包名 | org.springframework.boot.jdbc.autoconfigure.* |
| 事务自动配置包名 | org.springframework.boot.transaction.autoconfigure.* |
| 建表脚本 | spring.datasource.schema / .data 是 error 级废弃,改 spring.sql.init.*;多数据源下要手工喂 |
| 连接池 | Boot 默认 HikariCP,DruidDataSource 仍可用但不再是默认 |
| AOP starter | spring-boot-starter-aspectj |
| Web starter | spring-boot-starter-webmvc |
MybatisSqlSessionFactoryBean | 3.5.17 起在 com.baomidou.mybatisplus.spring. |
| 事务 advisor 顺序 | 没有对应配置属性,只能从切面这头压 @Order |
| 跨库链式事务 | ChainedTransactionManager 在 spring-data-commons,带 Deprecated,别用 |
| 连接获取模式 | spring.datasource.connection-fetch(eager / lazy) |
小结
这一篇的难点从头到尾都藏在「不报错的隐式行为」里:自动配置静默退让、注解静默失效、事务静默不生效、异常静默降级,四条路全都不制造噪音,只会让结果错。关系型那几篇的坑多数会留下异常或者错日志,这一篇这些连痕迹都没有,只能靠对比和断言,那四个反例接口和专门断言「bean 不存在」的测试类就是这么来的。
选型建议一句话:库的角色固定就用静态方案,业务代码里看不到「切库」,啰嗦但直白;库的选择依赖运行时状态才上动态路由,代价是 @DataSource 会渗进 service,记得把它标在事务外面。没解决的事也有:一个事务管理器身上还有哪些东西是配置不出来的,比如超时、只读、隔离级别,以及那个没暴露的 advisor 顺序,这篇没展开,等我踩够了坑再单独写。两套方案、25 个用例、可以单独重置的数据脚本都在 springboot4-multisource 工程里。
署名 Xiong Neng,许可 MIT。