Spring Boot多数据源主从分离:动态路由与事务一致性实战
1. 先说清楚多数据源到底在解决什么问题早几年我在给一个订单查询模块做主从分离改造的时候第一天就卡在“数据源到底该配几个”这个问题上。我当时以为只要在 application.yml 里把主库和从库的连接地址都写上再随便定义一个 DataSource 交给 MyBatis 使用读写流量自然就会分开。结果项目一启动Spring Boot 直接抛异常提示存在多个 DataSource自动配置不知道选哪一个。我盯着那行报错看了很久才意识到Spring Boot 多数据源、主从分离、动态切换与事务一致性根本不是一个“多配一个连接”就能解决的问题。这篇文章就沿着这条线完整讲一遍。我用实际项目里跑过的方案把主从分离的路由设计、Spring Boot 自动配置的冲突、事务管理器的绑定逻辑、跨库事务的一致性问题以及日常最容易翻车的一批细节都写清楚。适合正在做读写分离改造、或者需要在同一个 Spring Boot 服务里同时访问多个数据库的同学参考读完可以直接拿着这套思路和代码去落地。1.1 主从分离的典型组织方式主从分离的落点通常是“一个写库加一到多个只读从库”。写操作也就是 insert、update、delete必须走主库因为复制链路是单向的读操作也就是 select可以按规则走到任意一个从库也可以在主库和从库之间做负载均衡。ORM 框架本身并不知道某条 SQL 应该去哪个数据库MyBatis 也好JPA 也好真正决定路由的还是我们自己的代码。我在项目里最常用的一套划分方式是在 service 层方法粒度上标注路由意图。比如方法名以 get、query、select 开头默认走从库以 save、update、delete、add 开头强制走主库个别需要强一致读的方法手动标注走主库。这套规则比“解析 SQL 判断是 select 还是 insert”可靠得多因为很多复杂 SQL 是写操作却以 select 开头比如 select 里嵌入了更新或调用存储过程的情况硬用 SQL 解析判断很容易翻车。1.2 动态切换和“配置多个 DataSource”是两个层面的问题这里必须先把概念分清。在 Spring Boot 里定义两个 DataSource bean那只是“配置了多个数据源”至于请求到底用哪一个需要自己做一套路由逻辑。常见的实现是继承 AbstractRoutingDataSource这是一个代理型 DataSource内部维护一个 targetDataSources 映射。每次调用 getConnection 的时候它都会调用 determineCurrentLookupKey() 拿到当前线程应该使用的 key然后从映射里取出真正的 DataSource返回对应的连接。这个机制最巧妙的地方在于业务代码拿到的始终是一个 RoutingDataSource底层连接池管理、真实建连这些都交给具体 DataSource 处理路由层只负责决定 key完全不干预连接内容。所以动态切换本质上就是在“合适的时机”往当前线程的 ThreadLocal 里写入一个路由 key其余的事情交给框架。1.3 为什么事务一致性在这个场景里特别敏感主从分离之后事务一致性的压力不会消失只是换了一种形式。原来单库时一个 Transactional 就能保证原子性拆分之后主库事务只能保证主库自己的操作一致如果系统里还涉及多个业务库的写入那本地事务根本管不了另一个库。再往下一层主从复制存在延迟写完主库立刻去从库读可能读到旧数据这又是一种“数据暂时不一致”。所以标题里把主从分离、动态切换和事务一致性放在一起本质上是一个链路完整的问题路由怎么切、事务怎么管、切完之后一致性怎么兜住。下面从动手设计开始逐步拆开讲。2. 动手前的设计路由规则与 Spring Boot 自动配置的冲突代码怎么写很大程度取决于路由规则怎么定。很多项目最后出问题不是代码写不出来而是规则本身含糊或者规则和框架的默认行为互相打架。2.1 路由规则的优先级设计路由规则不能只是一个简单的 if else。我结合线上问题总结过一份优先级显式注解标注优先于方法名前缀推断事务已经存在时以事务开启时确定的数据源为准不能在中间切换读操作默认走从库写操作强制走主库强一致读操作比如用户注册后立即回显身份信息显式标注 Master强制走主库。这份优先级最好写进公共的 AOP 模块里而不是散落在各个业务方法里。否则后期排查问题的时候你根本不知道某个查询到底为什么走到了主库或者从库。我见过最崩溃的一次排查就是线上一个报表查询时快时慢最后发现是因为某位同事在一个私有方法里悄悄把路由 key 写死了而且这个方法被十几个接口间接调用根本看不出来。2.2 Spring Boot 自动配置会怎样干预Spring Boot 对 DataSource 有自动配置只要 classpath 下只有一个 DataSource bean它就会自动创建连接池并且把唯一的 DataSource 交给 MyBatis、JdbcTemplate、事务管理器等组件使用。可一旦我们自己定义了多个 DataSource bean自动配置的 ConditionalOnMissingBean 条件就不成立它不会替你选择一个但 MyBatis 的自动配置又可能继续尝试获取唯一的 DataSource结果就是启动时报错或者选错数据源。我踩到的第一个坑就在这里。排查思路其实不复杂在启动类上把 DataSourceAutoConfiguration 排除掉然后所有的 DataSource、SqlSessionFactory、PlatformTransactionManager 全部由自己声明让每个组件都明确指向具体的数据源。这样虽然代码看起来多了一点但消除了所有隐式选择报错时一眼就能看清对象关系。2.3 组件到底应该指向谁SqlSessionFactory 绑定哪个 DataSource就决定了 MyBatis 默认把所有 mapper 语句下发到哪个库。这里有一个很容易做歪的设计如果分别给主库和从库各建一个 SqlSessionFactory然后按 mapper 接口拆分比如读的 mapper 走从库、写的 mapper 走主库这种方案适合真正按业务分库的场景却非常不适合主从分离。原因很简单主从分离场景下同一个订单 mapper 可能既要有 queryOrder 走从库又要有 updateOrder 走主库如果业务层为了区分两个 SqlSessionFactory硬生生把 mapper 拆开service 层的代码会变得非常扭曲。我更推荐的做法是只建一个 SqlSessionFactory绑定到 RoutingDataSource 上让所有 SQL 都先经过路由层至于走哪个库由路由规则决定。还有一个细节不能漏RoutingDataSource 的 defaultTargetDataSource 一定要设置为主库这样所有未知路由、未标注的方法都兜底到主库。宁可让一部分读请求多扛在主库上也不能让任何写操作落进从库。3. 一份可以直接落地的配置与路由代码设计清楚了下面就是具体的配置和代码。我这里给出一份我在实际项目里运行过的精简版包含数据源定义、路由数据源、路由上下文和 AOP 切换四个部分。3.1 主从数据源的基础配置我习惯用 HikariCP 作为连接池。配置写在 application.yml 里然后定义一个配置类手动创建 DataSource。下面这份配置是核心骨架。spring: datasource: master: jdbc-url: jdbc:mysql://192.168.1.10:3306/order?useUnicodetruecharacterEncodingutf8 username: root password: changeMe driver-class-name: com.mysql.cj.jdbc.Driver hikari: pool-name: MasterPool maximum-pool-size: 20 minimum-idle: 5 slave: jdbc-url: jdbc:mysql://192.168.1.11:3306/order?useUnicodetruecharacterEncodingutf8 username: root password: changeMe driver-class-name: com.mysql.cj.jdbc.Driver hikari: pool-name: SlavePool maximum-pool-size: 40 minimum-idle: 10注意这里用到的是 jdbc-url 而不是 url。手动创建 DataSource 时Spring Boot 的 DataSourceProperties 在解析配置时对 url 和 jdbc-url 的处理有差异直接统一用 jdbc-url 更干净也不容易踩到“我明明配置了 url 但连接池没读到”的怪问题。3.2 注册路由数据源配置类里需要创建主库、从库两个真实数据源以及一个路由数据源。大致代码如下。Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } Bean public DataSource routingDataSource( Qualifier(masterDataSource) DataSource master, Qualifier(slaveDataSource) DataSource slave) { MapObject, Object target new HashMap(); target.put(DataSourceType.MASTER, master); target.put(DataSourceType.SLAVE, slave); MasterSlaveRoutingDataSource routing new MasterSlaveRoutingDataSource(); routing.setDefaultTargetDataSource(master); routing.setTargetDataSources(target); return routing; } }这里有一个非常重要的细节不要把 Bean 方法在另一个方法里直接再调用一次比如在 routingDataSource() 里写 masterDataSource()。虽然 Spring 的 Bean 方法默认返回单例但如果你在配置类里反复调用某些情况下会产生新的连接池实例等于初始化了两套连接池。正确做法是使用方法参数注入也就是我上面写的形式从容器里拿已经创建好的单例。3.3 路由上下文与 AOP 切换路由数据源的核心是重写 determineCurrentLookupKey()把当前线程的路由 key 返回给框架。public class MasterSlaveRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.get(); } }DataSourceContextHolder 的内部就是一个 ThreadLocal用来存放当前请求的路由标识。关键是必须有清理逻辑否则线程池复用线程时会留下上一次请求的路由 key导致后续请求串库。我通常在 finally 块里调用 clear()。再定义两个注解 Master 和 Slave然后用 AOP 拦截 service 层的方法根据注解和方法名设置路由 key。AOP 的优先级设置为最高之一确保路由在事务拦截器之前执行。用一个 Around 通知就能完成但有一点要写死业务方法执行完无论成功还是异常finally 里都要清理 ThreadLocal。3.4 为什么事务开始之后再切换路由是无效的这个话题几乎每次分享都会被反复问。很多人配置好路由之后在 service 方法里先设置了 SLAVE然后执行 SQL发现所有 SQL 还是跑到了主库。原因在于连接获取的时机Spring 的事务管理逻辑是在进入 Transactional 方法时通过 DataSourceUtils 从当前数据源获取连接并把连接绑定到当前线程的事务资源里。后续同一个事务内的所有 SQL 都复用的是这条连接路由数据源的 determineCurrentLookupKey 只在第一次 getConnection 时被执行一次。所以只要事务先于路由生效后面再改 ThreadLocal 已经晚了。这也是为什么我在 AOP 设计里反复强调执行顺序路由 AOP 必须包在事务外层且同一个业务方法内不要既开启事务又在中途尝试切换路由。如果一个业务方法同时需要访问多个数据源那说明它已经跨库了要放到后面的一致性方案里去处理而不是靠切换路由硬撑。4. 事务管理器只配一个还是配多个多数据源项目里事务管理器是最容易让人混乱的部分。很多人以为只要有一个事务管理器就够了Spring 会自动帮我们协调数据源实际上完全不是这么回事。4.1 一个 DataSource 对应一个事务管理器Spring 的 Transactional 注解默认会找名为 transactionManager 的 PlatformTransactionManager bean。当你声明了两个 DataSource 时如果只注册一个事务管理器就必须明确告诉 Spring 它管理的是哪一个数据源。很多项目的做法是把事务管理器绑定到 RoutingDataSource 上这样一来事务内第一次获取连接时会触发一次路由后续所有 SQL 继续使用这条连接。表面上看是“路由数据源的事务”实际上同一个事务内的全部操作最终仍然打向同一个库。我给项目定的规矩是事务管理器只绑定主库。理由非常直接——事务是给写操作设计的读操作原本就不应该开事务。在典型的读多写少场景里核心事务全部落在主库从库完全走非事务的只读连接。这样做的另一个好处是Transactional 默认就是主库事务语义非常直观团队新人也好理解。Bean public PlatformTransactionManager transactionManager( Qualifier(masterDataSource) DataSource master) { return new DataSourceTransactionManager(master); }4.2 只读事务最容易打穿读写分离既然事务管理器绑定主库那些加了 Transactional(readOnly true) 的查询方法反而会走主库因为单例事务管理器管理的连接来自主库。这个坑特别隐蔽。我见过一个项目业务代码为了“规范”给所有 service 方法都加了 Transactional(readOnly true)结果从库流量一直上不去主库的查询压力把连接池顶到了上限。读写分离配置得再漂亮到了事务这层一样会失效。如果你确实希望带 Transactional(readOnly true) 的方法走从库就得给从库单独建一个事务管理器然后在方法上显式指定 Transactional(slaveTransactionManager)。这样做的问题也很明显代码必须写死事务管理器名称业务方法被框架细节污染而且事务模式下连接要持有到方法结束读并发一高从库连接池很容易被打满。在绝大多数读多写少且能接受轻微从库延迟的场景里我的建议是所有读方法不加 Transactional直接让 SQL 走从库连接只有写方法和跨库场景才显式加事务。这样最干净也最能发挥多数据源的价值。4.3 多事务管理器时的命名策略一旦真的需要多个事务管理器命名就必须非常讲究。Spring 启动时会检查名为 transactionManager 的 bean如果找不到直接报错。我建议主库事务管理器使用默认名 transactionManager从库事务管理器命名为 slaveTransactionManager。这样绝大多数写方法不需要显式指定事务管理器名只有真正想走从库事务的方法才写 Transactional(slaveTransactionManager)。命名清晰比注释有用得多因为异常日志里看到 transactionManagerName 就能直接定位到代码路径。5. 跨库事务一致性本地事务、XA 与补偿主从分离本身不要求跨库事务因为写只发生在主库。可一旦业务场景变成“一个服务同时读写两个业务库”事务一致性就变成了绕不开的坎。比如扣库存和写订单是分别针对两个库的操作任何一个库失败另一个库不能留下脏数据。5.1 主从延迟带来的“写后读不到”先处理一个看起来不明显但很高频的问题写完主库后立刻去从库读可能读到旧数据。复制是异步的延迟可能只有几毫秒但在跨机房部署时可能到几百毫秒。用户刚刚提交订单紧接着查询订单列表从库里还没有这条记录这种体验非常糟糕。我的处理办法是给读操作分级别。用户操作同一会话内的回显查询强制走主库对账、定时任务、数据导出这类能接受延迟的放心走从库拿不准的查询默认走主库宁可让主库多扛一点压力也不能让用户看到自相矛盾的数据。5.2 本地事务解决不了跨库写把跨库的写入放在同一个本地事务里是不可能的。即便在一个方法里依次操作两个不同的 DataSourceSpring 的本地事务管理器也只能先挂起第一个事务再启动第二个事务任何一步失败都无法让另一个库回滚。更危险的做法是“在 catch 里手工补偿”——比如先删订单再补删库存。这种代码看着像实现了回滚实际上补偿动作自己也可能失败而且执行时机不可控最终留下的脏数据反而更难排查。5.3 XA 两阶段提交的适用边界如果系统确实无法接受任何中间态两个数据库都支持 XA可以用 Atomikos 或 Narayana 这类符合 JTA 规范的事务管理器。Spring Boot 接入后一个 Transactional 可以同时覆盖两个 XA 数据源两阶段提交保证要么都提交要么都回滚。但真实项目里我不拿它当默认方案原因是它要长期持有两个库的连接锁事务跨界之后锁范围明显变大很多云数据库和中间件对全局事务的支持并不理想接入之后会有各种奇怪延迟和连接占用问题。吞吐量要求不高的核心账户类操作可以考虑普通业务系统就不建议直接上了。5.4 工程上更实用的最终一致性方案最落地的方案是“小事务加最终一致性”通常也叫本地消息表或 outbox 模式。具体步骤是这样的第一个库里开启本地事务写入业务数据同时写入一张待补偿消息表也就是 outbox 表业务数据和 outbox 记录在同一个本地事务里完成原子性由本地事务保证事务提交后后台任务或消息队列读取 outbox 表把消息投递给第二个库所在的服务第二个库执行业务写入成功之后回调确认把 outbox 状态更新为已完成如果第二个库执行失败后台任务根据状态做重试重试多次仍失败就进入人工补偿队列由专门页面或定时任务处理。这套方案最大的优点是不引入分布式事务基础设施也不依赖数据库的 XA 支持最终一致性经过重试和监控兜底可以做到极高可靠。缺点是需要接受“短暂不一致”的瞬间下单后可能订单库已经有记录积分库还没加上但几秒后通过重试最终对上。工程上要做的不是消灭短暂不一致而是把不一致的时间窗口压缩到可控范围并让失败可观测。我在实际项目里还配了一个补偿监控页面专门列出重试超过三次的消息人工介入时能直接看到业务上下文省去了翻日志的麻烦。5.5 outbox 写入顺序必须严格写 outbox 最容易被忽略的一个点是顺序先写业务数据和 outbox提交本地事务再向消息队列投递顺序绝不能反过来。如果先投递消息再提交本地事务下游服务可能已经收到消息开始处理而上游事务却在提交时失败回滚下游读到的就是一条不存在的上游数据。这个错误我见过不止一次而且单元测试很难发现往往要等线上出现“幽灵订单”才能回头看明白。为了让 outbox 和业务数据保持在同一个事务里我建议把 outbox 表放在业务主库中而不是单独放到一个公共库。它的作用是充当跨库操作的记账本本身并不承担业务唯一性验证所以和业务表同库代价很低。每次跨库写操作时先 UPDATE 业务表再 INSERT outbox 记录最后一次提交这个本地事务成功了后续重试和投递才有前提。6. 动态切换后最容易翻车的四个场景最后这部分是真正的经验复盘。很多问题不是说配置不对而是运行时的细节把配置绕过去了。6.1 ThreadLocal 没清理导致的路由串台应用服务器普遍用线程池复用工作线程。假如请求 A 在处理过程中设置了 SLAVE 路由但没有在 finally 里清理 ThreadLocal请求结束后线程归还池中路由 key 仍然残留。下一个请求 B 被分配到同一个线程时即使 B 是写请求路由也直接指向 SLAVE于是写入操作全部跑到从库。如果从库配置了 read-only写入会直接报错如果从库允许写入但不会同步回主库那造成的后果更严重整个主从数据直接分裂。解决方案是 AOP 通知里必须用 try/finally无论业务方法正常返回还是抛出异常最后都要清理 ThreadLocal。这行代码不能省也不要用 Spring 的 RequestContextFilter 之类的机制代替因为跨池的线程复用场景它们覆盖不到。6.2 强一致读被主从延迟坑主从复制延迟在正常情况下只有几毫秒但一旦主库有大事务、大批量更新或者从库负载较高延迟就会明显上升。用户感知最明显的是“刚提交的数据查不到”。我把策略总结成三条用户操作后的回显查询强制主库对账、导出、报表类异步处理走从库不确定的查询默认主库。另外还要注意一次 HTTP 请求内如果已经执行过写操作后面再跟读操作这个读操作在同一线程内顺手走主库也能减少很多不一致问题。6.3 连接池翻倍与循环依赖我亲眼见过一个配置类写法看起来没什么问题但启动日志里出现了两次 MasterPool 初始化。排查后发现原因是配置类里在 routingDataSource() 中直接调用了 masterDataSource() 方法而该方法所属的 Configuration 类本身又被其他配置类依赖结果 Spring 容器在两个阶段分别初始化了同一个名称但不是同一个实例的连接池。生产环境启动后连接数是预期的两倍数据库重启时会产生非常夸张的连接接踵请求。正确做法是我在前面代码里写的通过 Qualifier 把容器中的 DataSource 作为参数注入不要在一个配置类里反复调用另一个 Bean 方法。这条规矩在绝大多数 Spring 配置问题里都适用不只是数据源场景。6.4 事务与路由的 AOP 顺序前面的章节已经详细说过事务在路由之后会失效这里补充一个实际排查经验。我曾经在日志里看到 set 路由 key 的日志出现但 SQL 仍然走了主库。后来加了事务开启日志才发现事务拦截器先拿到了连接路由 key 才设置上去顺序完全反了。判断顺序是否正确很简单比较一条路由日志和一条事务开启日志的打印顺序路由必须先于事务。如果在一个类里路由 AOP 和 Transactional 同时存在要通过 Order 类注解或 Ordered 接口显式控制顺序。我给团队的铁律是路由切换放在 service 层方法入口不要在一个已开启事务的私有方法里修改路由如果某个方法前半段需要读从库、后半段需要写主库那这个方法本身就不适合用读写分离的模型应该拆成两次独立的 service 调用把数据变更交给主库事务处理。如果上面的经验能帮你少折腾一个晚上那这篇文章就没白写。最后再分享一个小技巧所有连接池都起一个独立且可识别的名字就像我配置里的 MasterPool 和 SlavePool并且把异常日志中的 poolName 打出来。这样线上排查路由问题的时候一眼就能看出执行 SQL 的到底是你预期的那个库而不是靠猜。这一点做到了后续遇到任何多数据源问题你都能快速定位到真正的根因。