
做后端几年的人迟早会遇到多数据源的破事。要么是业务库按模块拆成了MySQL、Oracle、PostgreSQL好几套要么是同一套MySQL做了读写分离读库写库各一个连接串再要么是SaaS系统里每个租户一个库切库逻辑散落在各种Service里。刚接手时多数人会直接new两个DataSource丢给MyBatis用结果发现要么连错库要么事务管不住数据对不上最后只能靠人肉保证“这段代码别乱动”。直到项目里用上dynamic-datasource这套方案DS切数据源、DSTransactional管跨库事务才算把多数据源真正捋顺。这篇文章想把这些心得完整写下来DS怎么用、底层怎么路由、DSTransactional到底能管多广、它和Seata是什么关系、生产上有什么坑。适合正在做多数据源选型、或者已经在用DS但碰到事务问题的人看包括对源码原理感兴趣但没时间逐行啃的同学。读完之后你至少能回答三个问题注解为什么会失效、跨库事务的边界在哪、什么场景必须上Seata。1. 多数据源需求与方案选型为什么不能用“多套DataSource”硬怼1.1 业务场景读写分离、多租户隔离与业务拆库先复盘一下哪几类场景一定会逼你碰多数据源。第一种是读写分离。主库负责写一个或多个从库负责读。从库可以挂权重可以按地区分片读流量大时还要横向扩展。这种场景下业务方法里经常要指定这个方法是读操作走从库那个方法是写操作走主库。第二种是多租户隔离。每个租户拥有独立数据库应用层根据当前登录用户的租户ID动态决定访问哪个库。隔离级别最高、互不影响但代价是应用代码必须有一套可靠的“运行时选库”机制。最怕的是两个租户的请求在同一个线程里被线程池复用时数据源key串了把A租户的数据写到B租户的库里这是重大事故。第三种是业务拆库。订单库、商品库、用户库、日志库各自独立部署一个业务流程可能要跨两三个库读写。最典型的就是“下单同时扣库存、扣余额”订单在订单库库存可能在商品库余额在账户库三库之间的数据一致性靠本地事务是管不住的。这些场景落到一个共同的技术诉求上一套代码体系内方法级别声明式切换数据源最好还带跨库事务能力。1.2 三种常见方案的对比手动注入、AbstractRoutingDataSource、dynamic-datasource大多数人一开始都会选择最朴素的做法在Spring容器里定义多个DataSource用Primary指定一个主库其余用Qualifier手动注入到Service。这种做法在小项目里能跑但问题很明显——每个Service要和具体的数据源Bean耦合切库逻辑靠程序员自觉SQL要是落在错误的库上排查起来跟大海捞针一样。Spring其实提供了一个路由数据源AbstractRoutingDataSource它可以维护一组目标数据源映射在运行时通过determineCurrentLookupKey()决定当前该用哪个。但它只是个“零件”路由key怎么来、怎么保证线程隔离、怎么和事务切面配合全部要自己实现。没有配套的注解、没有SpEL动态解析、没有跨库事务方案生产上用起来还差得很远。dynamic-datasourceMaven坐标为com.baomidou:dynamic-datasource-spring-boot-starter就是把上面这些零件组装成完整方案的开源框架它们的对比差异见下维度手动多DataSourceAbstractRoutingDataSourcedynamic-datasource切库方式代码里Qualifier注入自己实现路由keyDS注解声明式动态key不支持自己写SpEL表达式线程隔离无自己维护ThreadLocal栈跨库事务不支持不支持DSTransactional/Seata运维复杂度高高低这套框架解决的核心问题就是让业务代码只关心“我现在要操作哪个逻辑库”完全不关心这个库的连接串、连接池、事务生命周期。1.3 最小化配置长什么样我直接把项目里最常用的YAML配置贴出来先说结论再拆解spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://localhost:3306/master username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://localhost:3306/slave username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver oracle_report: url: jdbc:oracle:thin:localhost:1521:xe username: report password: report driver-class-name: oracle.jdbc.OracleDriverprimary表示默认数据源没加DS的方法一律走这个库strict为false时如果DS指定了一个不存在的key会回落到primary而不是直接报错strict为true时找不到key直接抛异常。我个人的习惯是开发环境必须stricttrue早点暴露写错的key生产环境如果确定所有key都覆盖到了也建议保持true防止配置漂移把请求悄悄带到了错误库。2. DS注解使用指南从基本用法到动态参数2.1 类级注解与方法级注解的优先级DS最简单的用法就是直接在类或方法上标一个数据源key。下面这段是典型代码Service DS(slave) public class UserQueryService { public ListUser listUser() { // 走 slave return userMapper.selectList(null); } DS(master) public void updateUser(User user) { // 方法上的注解覆盖类上的注解这个方法走 master userMapper.updateById(user); } }规则很简单方法上的DS优先级高于类上的DS类上没有标注、方法上标注了只有标注的方法才切换两个都没标注走primary默认库。实际开发里我的建议是读多写少的服务在类上标注从库个别写方法单独标注主库这样代码量最少也最直观。2.2 SpEL表达式从参数、上下文动态决定数据源3.x版本开始支持SpEL表达式这是多租户场景的刚需。最常见的写法是从方法参数取租户IDService public class TenantOrderService { DS(#tenantId) public ListOrder listOrders(Long tenantId) { // tenantId 对应一个数据源key比如 t001 } }也可以从上下文对象里取比如根据HTTP请求头动态路由DS(#request.getHeader(x-tenant-id)) public ListOrder listOrders(HttpServletRequest request) { }这里要注意SpEL表达式解析出来的key如果不存在行为受strict开关控制。stricttrue直接抛DynamicDataSourceExceptionstrictfalse才落到primary。有的团队用false当“容错开关”我的经验是这容易掩盖配置错误一旦线上出现“凭什么这个租户的数据写到了主库”的诡异问题第一反应要去查数据源key是否配置齐全。2.3 高频坑自调用失效、事务拦截顺序、线程隔离第一个坑同类内方法自调用DS失效。Service public class OrderService { public void outer() { // 这里走的是 this 调用不经过代理DS 不生效 this.inner(); } DS(slave) public void inner() { } }这是Spring AOP的通病。DS本身靠切面实现只有代理对象外部的调用才会触发切面类内部this调用会绕过代理。解决办法有三个把inner方法拆到另一个Bean里注入自身代理用AopContext.currentProxy()。最推荐第一个职责也更清晰。第二个坑DS和Transactional一起用时顺序错了切换不生效。Spring事务管理器在事务开始时就把物理连接绑到了当前线程如果事务已经开启再切数据源DataSourceUtils.getConnection会直接返回已绑定的连接根本不会重新路由。所以正确姿势是先切数据源后进事务。把DS放到事务方法的外层方法上或者保证DS对应切面的执行顺序在事务切面之前这个后面原理部分会展开。第三个坑事务方法内部多次切换数据源切了等于白切。同一个事务内部始终复用同一个Connection即使你在方法里写了DS切换第二次操作拿到的还是第一次绑定好的连接。这不是框架的bug是Spring事务资源复用的设计使然。如果确实需要在一个方法里先写主库再从从库读我建议拆成两个独立事务方法让读操作在另一个事务里走从库。3. DS的原理拆解ThreadLocal栈、AOP切面和动态路由3.1 DynamicRoutingDataSource路由的最终决策点要理解DS绕不开DynamicRoutingDataSource。它继承了Spring的AbstractRoutingDataSource核心逻辑就一个方法Override protected String determineCurrentLookupKey() { return DynamicDataSourceContextHolder.peek(); }AbstractRoutingDataSource在getConnection时会调用determineCurrentLookupKey拿到当前key然后从目标数据源Map里取出对应的DataSource返回连接。dynamic-datasource做的最重要的一件事就是把这个“当前key”放进了线程安全的栈里维护。这里要特别说下为什么是栈而不是一个普通变量。因为切面允许嵌套方法A切到了slave方法A内部又调用了方法B切到reportB执行完不管有没有异常都要恢复到slaveA执行完再恢复到默认的主库。只有后进先出的栈结构能够天然支持这种任意层级的嵌套恢复。如果只是一个变量内层切库后外层就恢复不回去了线程A留下的数据源key还可能被线程池里的下一个任务读到。3.2 DynamicDataSourceContextHolder内部ThreadLocal加DequeDynamicDataSourceContextHolder内部维护的核心数据结构本质上是这样private static final ThreadLocalDequeString LOOKUP_KEY_HOLDER new ThreadLocal();对外暴露三个关键方法push(String key)当前key入栈完成切换peek()看一眼栈顶决定当前用哪个库poll()出栈恢复为切换前的keypush和poll必须成对出现。框架在切面的finally里做了poll兜底保证方法抛出异常时也能把key弹出去避免线程池线程复用时残留垃圾key。我见过团队自己手写切库逻辑时忘了在finally里恢复结果一个请求抛异常之后后面几十个请求全跑到了错误库上。这种问题很难排查因为它是偶发的只在异常路径出现。另外要注意ThreadLocal具备线程隔离能力但不具备线程传递能力。用了DS的方法切到从库后如果方法里又往线程池里丢了个异步任务异步线程拿不到主线程的数据源key它默认走primary。想在异步任务里延续数据源上下文需要自己把key传过去比如把key作为参数显式传入或者在提交任务时手动push。这个细节在多数据源场景里极其容易踩线上表现就是异步导出的数据一会儿对一会儿不对。3.3 切面怎么完成切换和恢复DS对应的切面是DynamicDataSourceAnnotationInterceptor核心逻辑用伪代码表示public Object invoke(MethodInvocation invocation) throws Throwable { String dsKey resolveDataSourceKey(invocation); DynamicDataSourceContextHolder.push(dsKey); try { return invocation.proceed(); } finally { DynamicDataSourceContextHolder.poll(); } }resolveDataSourceKey要做两件事先看方法上有没有标注DS再看类上有没有标注DS方法优先如果注解值里包含#开头的SpEL表达式就对该表达式求值。整体就是一个简单可靠的try/finally结构。这里最重要的编程范式是无论业务代码执行成功还是抛出异常finally里的poll都必须执行。这也是dynamic-datasource框架本身做得比较扎实的地方——它把“切库”这个动作做成了完全对称的入栈/出栈操作。3.4 切面顺序与事务时机的配合Spring事务管理器开启事务时会调用DataSourceUtils.getConnection从当前数据源拿物理连接。这个拿连接的时机就是路由生效的唯一时机。只要在这个时机之前确定好数据源key事务就能开在正确库上。所以DS的切面order必须小于Transactional切面的order也就是必须先执行数据源切换再开启事务。dynamic-datasource内部给DynamicDataSourceAnnotationAdvisor配置的order是最高的正常情况下它一定在事务切面之前执行。但这里有个隐含的小陷阱如果你在同一个方法上同时标注了DS和Transactional事务切面的顺序依然在数据源切面之后所以连接能正确获取。但如果通过外部队列、定时任务、或者某些绕过Spring代理的方式调用了这个方法代理链都不存在一切切面自然都不生效。3.5 从源码视角总结路由链路把整个链路口算一下一次带DS(slave)的方法调用会经过这样的路径Spring容器动态代理拦截方法调用DynamicDataSourceAnnotationInterceptor拿到DS注解值slave执行push(slave)将key放入当前线程的栈顶业务方法执行内部触发MyBatis/JdbcTemplate操作数据库框架从连接池获取连接时调用determineCurrentLookupKeypeek()得到slave从数据源Map取出slave对应的DataSource业务方法结束finally执行poll()栈恢复到之前的状态。整个过程业务代码无感知也不需要手动释放连接、关闭事务框架把这些细节全部收拢了。4. 本地事务管不住多数据源DSTransactional的正确使用姿势4.1 本地事务为什么在多数据源下失效先想清楚一个基础问题MySQL的本地事务是建立在单条物理连接之上的。一个库一个连接两个库两套独立事务。Spring自带的Transactional只能绑定一个DataSource比如你只给method加了Transactional底层事务管理器只会把当前方法的事务资源绑定到primary数据源对应的连接上。其他数据源上的操作呢各自独立提交、独立回滚出了问题是没人管的。举一个我真实做过的电商项目场景订单库和账户库。下单动作要往订单库插入订单记录往账户库扣减余额。如果两笔写操作分散在两个库又没有全局事务兜底订单库提交成功、账户库提交失败最终就是一笔“订单已生成但钱没扣”的脏数据。业务对账查出来都是大麻烦。4.2 注解的正确写法dynamic-datasource提供的DSTransactional注解可以把这个过程包起来形成一个跨库的本地聚合事务。DSTransactional public void createOrderAndDeduct(Order order, Account account) { orderMapper.insert(order); // 默认主库订单库 accountMapper.deduct(account); // 内部通过DS切换到账户库 }从形式上看DSTransactional和Transactional非常像都是方法级注解。但有几个区别要记清楚它只能用于被Spring管理的Bean方法上和DS一样依赖AOP它默认情况下对RuntimeException和Error回滚checked exception不回滚这个和Spring的Transactional默认行为一致但你要清楚这一点不要在事务方法里吞异常嵌套调用时内层方法再标DSTransactional会复用已有的事务上下文类似Spring事务传播级别里的REQUIRED它没有Transactional那些rollbackFor、propagation、isolation的完整配置项它要解决的是“多个数据源连接统一提交/回滚”不是Spring事务管理器的替代品。4.3 拼写提醒DSTransactional还是DSTranscation这里要专门说一个很多人会遇到的编译问题。网上铺天盖地的文章、尤其是搜索引擎里排名靠前的博客把注解写作DSTranscation导致很多人照着抄结果编译直接报错。实际上dynamic-datasource里的注解全限定名是com.baomidou.dynamic.datasource.annotation.DSTransactional单词是Transactional中间是有a的。这个备注特别重要你看到“DSTranscation”的写法基本可以断定那篇资料没经过代码验证。本篇标题里我沿用了大家搜索时习惯的写法代码里请务必使用正确的DSTransactional。5. DSTransactional原理分析本地聚合与Seata模式5.1 注解背后其实是两种处理器DSTransactional的AOP切面本身不直接干活它把真正的事务管理工作委托给处理器。根据项目里是否启用了Seata会有两条完全不同的实现路径没有启用Seata走LocalTransactionProcessor也叫本地事务聚合方案启用了Seata走SeataTransactionProcessor通过Seata的GlobalTransaction做真实的分布式事务。也就是说DSTransactional本质上是个“门面注解”。它承诺的是“帮你把事务管理接管过来”但接管的方式既可以是轻量级的本地聚合也可以是重量级的Seata AT模式。这一点很多人不清楚以为DSTransactional内部就是强一致分布式事务用出问题之后才发现跟预期不符。5.2 本地聚合方案的提交与回滚顺序先看非Seata场景下本地聚合内部到底做了什么。当DSTransactional方法启动时框架会为当前线程创建一个事务上下文后续方法执行过程中每用到一个新的数据源就把这个数据源和它对应的连接绑定到上下文中。这里有个细节第一个绑定进来的数据源被视为主数据源其余数据源被放进一个集合里。主数据源通常是事务入口处第一个执行操作的那个库这个顺序取决于方法内部第一行SQL用的是哪个数据源。等到方法执行完毕进入提交阶段策略是先提交所有非主数据源从数据源最后提交主数据源如果任一个从数据源提交失败立即回滚主数据源此时主数据源还没有提交还能完整回滚如果主数据源提交失败尝试回滚所有已经提交成功的从数据源。这个策略是有讲究的。它把“主数据源”放在最后提交是因为主数据源是业务的核心库最可能成为瓶颈或失败点。前面先把不那么关键的从库事务提交掉最后再提交主库一旦主库失败虽然要去补偿回滚从库但至少把主库这个核心库的一致性保住了。不过要认识到这个方案做不到真正的强一致。主数据源提交失败后去回滚从数据源这个补偿动作本身也可能失败而且业务代码看不到这个失败结果。它属于“尽力而为”的弱一致性方案。回滚阶段就简单了遍历事务上下文里所有数据源对应的连接逐个rollback。因为都没有提交所以此时回滚是可靠的。整个过程中框架通过自定义事务同步机制把多个数据源纳入了同一套提交/回滚流程而不是简单地在代码里捕获异常后手动调一次rollback。5.3 什么时候必须上Seata如果你面对的是金融账户、核心库存、订单金额这类强一致场景本地聚合的弱一致性是过不了关的。比如主库提交失败后从库补偿回滚失败账就平不了对账规则再严也救不回这种偶发数据错乱。此时必须启用Seata让DSTransactional走全局事务。启用Seata时配置如下spring: datasource: dynamic: seata: enable: true同时项目需要引入Seata相关依赖并在每个业务库里创建Seata AT模式需要的undo_log表CREATE TABLE undo_log ( id bigint(20) NOT NULL AUTO_INCREMENT, branch_id bigint(20) NOT NULL, xid varchar(100) NOT NULL, context varchar(128) NOT NULL, rollback_info longblob NOT NULL, log_status int(11) NOT NULL, log_created datetime NOT NULL, log_modified datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;Seata AT模式的工作链路大致是全局事务开启后生成XID并把每个数据源包装成DataSourceProxy业务SQL执行时AT模式截获SQL执行前后的数据快照beforeImage/afterImage写入undo_log全局提交时各分支事务执行本地提交随后异步清理undo_log全局回滚时根据undo_log反向生成补偿SQL把数据恢复到执行前的状态。这种方案的优点是业务代码侵入极小SQL语法和表结构基本不用改多一张表而已。代价是引入了全局事务协调器链路变长、锁时间变长、性能下降对高并发核心链路要有预热和容量规划。我的建议是能用单库事务解决的绝不跨库必须跨库且是弱一致场景用本地聚合强一致场景才把Seata请出来。6. 生产环境落地建议连接池、监控与事务边界设计6.1 每个数据源独立配置连接池多数据源模式下每个数据源本质上还是独立连接池默认走HikariCP。同一个业务系统里不同库的负载差异很大绝不能共用一套连接池参数。我见过一次线上事故报表服务依赖的从库响应慢连接池被慢查询占满结果所有走该从库的接口全部超时其他正常库也因为线程被占满而拖垮。所以生产环境一定要给每个数据源单独分配参数最小化配置大致是这样spring: datasource: dynamic: datasource: master: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 slave: hikari: maximum-pool-size: 40 minimum-idle: 10 connection-timeout: 30000这里有个容易忽略的点连接池参数是压在数据源级别的不是框架统一管理的。你给master配了20给slave配了40连接池就会各自维护独立连接压测时别只看总数要看具体到哪个库被耗尽。6.2 事务边界设计能用单库就别碰跨库做多数据源时间越长越明白一个道理多数据源的核心不是“能不能切”而是“事务边界怎么画”。我的个人原则有三条都是踩坑踩出来的第一读写分离场景下写操作走主库、读操作走从库主从尽量不要放在同一个事务方法里。从库读操作加事务后缀没有意义反而引入跨库事务的复杂度如果“写完主库立即读主库”是刚需就让它走主库别为了负载均衡把一致性牺牲掉。第二多租户场景下一个租户的所有操作尽量在同一个库内完成。平时DS(#tenantId)切过去之后就不应该再随便切到其他库否则两个租户间的关联查询会变成跨库查询事务也管不住。第三真正需要跨库更新的API单独抽方法用DSTransactional包起来并且方法内部避免长链路调用。长链路意味着锁时间长、连接占用多、本地聚合的回滚风险更大一个慢方法会把整个连接池拖垮。6.3 线上排错怎么看当前线程落在哪个库多数据源最痛苦的排查场景是用户报告“数据怎么跑到另一个库去了”而你已经忘了那段逻辑该走哪个库。这时一个顺手的小工具比啥都强。可以临时加个接口输出当前线程的数据源keyRestController public class DatasourceController { GetMapping(/ds/current) public String currentDs() { return DynamicDataSourceContextHolder.peek(); } }再配合traceId在切面里打印切换日志只打方法入口的切库动作不要每行SQL都打否则日志量爆炸。这样线上排查时可以明确回答“这条链路在哪个节点切到了哪个库”。我用这个方式处理过好几次诡异问题最后定位出来的原因都不是DS本身而是事务方法里连接被提前绑定、或者异步线程丢了key但如果你连“当前跑了哪个库”都不知道就没法往更深层排查。6.4 一个容易忽视的细节从库延迟与strict开关多数据源场景里从库切换一定会遇到主从复制延迟问题。写完主库马上查从库读到旧数据这跟DS没关系是MySQL复制原理决定的。我的处理方式是写后立即读的场景强制走主库其余读操作才走从库。在代码层面明确标注不要指望框架帮你解决复制延迟。strict开关的选择上开发环境我坚持开true让切库key写错时第一时间报错生产环境如果确实有兜底需求可以用false但必须清楚这意味着“找不到库时静默走primary”可能把错误路由掩盖成正常主库流量。除非团队已经养成了严格的key审核机制否则我建议生产也保持true。7. 最后再分享几个我当时踩过的冷门坑关于DS和DSTransactional网上教程讲用法的一大把但冷门坑很少有人系统整理。我把自己实际项目里踩过的几条列在下面每一条都对应过一次线上事故希望你别再重复交学费。第一不要在Mapper接口上滥用DS。理论上DS可以标在Mapper上但Mapper往往被多个Service调用不同Service对数据源的要求不一样标在Mapper上等于写死了路由规则后续扩展非常痛苦。正确做法是把DS放在Service层方法上按业务场景切库。第二事务方法里调用DS方法要特别当心事务传播。比如外层方法加了Transactional内层方法再标DS因为外层事务已经绑定了连接内层DS实际上不会重新路由。很多人误以为“外层管事务、内层管数据源”很优雅其实内层切库全程没生效。你需要的不是嵌套调用的巧妙设计而是把切库方法放到事务方法之外。这个代码结构上的纪律比理解原理更重要。第三连接池配置不要无限调大。多数据源场景下各个库的连接池加起来总量可能非常惊人。默认HikariCP的maximum-pool-size是10生产库一多20个库就是200条连接数据库侧eca的max_connections往往先被撑爆。所以每个库的连接池大小要根据真实QPS和数据库规格单独评估而不是抄别人的配置。第四DSTransactional本地聚合方案不要在性能敏感的核心写链路里滥用。它要同时持有多个数据库连接直到方法结束连接占用时间是单库事务的数倍高并发下连接池很容易被打满。有测试数据说这种方案相比单库事务相同并发下数据库连接数占用会高出一截。这不是否定它而是提醒你它是“轻量级的跨库事务方案”不是“无代价的分布式事务方案”。第五如果决定上Seata先想清楚运维成本。它会引入TCTransaction Coordinator组件业务侧的每个数据源都要建undo_log表高并发下全局事务锁也会拉长事务时间。我见过团队上了Seata之后因为没对长事务做限流导致连接池被占满的。Seata是好工具但要把它当做一个独立的基础设施来运维不能作为业务代码里的一个开关随手打开。多数据源管理这件事做到最后拼的不是会用几个注解而是对事务边界、线程模型、连接生命周期的理解。DS把数据源切换从“手写切库”变成了“声明式切库”但它没改变ThreadLocal和AOP的底层规律DSTransactional给你提供了一层事务聚合能力但强一致始终是它的边界。希望这篇文章能帮你把这些边界看清楚而不是在出问题时才去翻源码。