资讯详情

资讯详情

行业资讯

SpringBoot多数据源路由与跨库事务一致性实践

SpringBoot 多数据源这个事说简单也简单——配两套 DataSource、两个 SqlSessionFactory代码里加个注解切一下半小时能跑起来。说难也真难难在事务上单库那套Transactional的思维惯性一旦带到多库场景就会出现“主库回滚了、从库已经写进去了”这种最难查的问题日志上还看不出来只能靠对账慢慢捞。我前后在几个内部系统里做过读写分离、业务库加报表库、订单库加库存库这类改造踩过的坑基本都集中在两个地方数据源切换的时机以及事务管理器到底绑的是哪个连接。这篇就把这两件事拆开讲从场景判断、路由实现、事务边界到跨库一致性方案选型把能直接抄的部分和必须自己判断的部分分开写清楚。适合已经写过一两个 SpringBoot 项目、正准备给系统接第二套库的同学也适合正在排查“事务不回滚”问题的同学对照着看。1. 先判断场景多数据源到底难在哪很多人一上来就问“多数据源怎么配”其实配置是整个环节里最不值钱的部分。真正决定后续维护成本的是你属于哪一种场景因为不同场景对事务的要求差着量级。配置错了可以改架构选错了就是推倒重来。1.1 读写分离和多个业务库是两件完全不同的事读写分离的特点是所有库的表结构一致写主库、读从库业务代码本身不关心连的是哪台机器。这种场景下绝大多数业务操作仍然是单库事务只是把只读查询分流出去。它的问题主要是主从延迟导致“写完立刻读不到”以及事务内读请求被错误地路由到从库。多个业务库的特点则完全相反库与库之间表结构不同、职责不同一个业务动作天然要跨库完成比如下单要写订单库、扣库存要写库存库。这种场景下事务是真问题你没法靠“延迟几毫秒再读”糊弄过去。把这两类混为一谈是新手最容易犯的错。读写分离用一个路由数据源就够了多个业务库如果也要强一致就必须引入分布式事务或者补偿机制。对比维度读写分离多业务库表结构完全一致各自独立切换粒度按 SQL 读写属性按业务模块或注解事务范围通常单库天然跨库主要风险主从延迟、事务内误路由部分提交、数据不一致典型方案路由数据源 强制走主库多数据源 分布式事务/补偿1.2 先问一句这个跨库操作真的需要强一致吗我见过不少项目明明是两个关联度很低的模块非要上分布式事务中间件结果为了那点一致性把整个链路拖慢一倍。判断标准其实很朴素如果跨库的两步操作中间失败业务上能不能接受“一边成功一边待补偿”的短暂状态能接受的用本地消息表加定时补偿就够了实现简单、可控性强不能接受的比如资金类操作才值得上两阶段提交或成熟的分布式事务框架。这个判断必须在动手写代码前做完因为后面的技术选型完全取决于它。我的经验是一个系统里真正需要强一致的跨库操作不会超过三五个剩下的都可以用最终一致性兜住。把所有跨库操作都当成强一致来设计最后一定是性能和复杂度双输。1.3 一张选型对照表先定方向再动手场景推荐方案代价适用规模只读查询分流动态路由数据源极低任意少量跨库写、可容忍短暂不一致本地消息表 定时补偿低中小型系统跨库写、需要自动回滚分布式事务框架AT 模式中中大型系统强一致、低并发核心链路XA 两阶段提交高少量关键操作跨库但业务可拆分拆服务改用接口调用中高长期演进方向注意这张表只是方向性参考实际选型还要看团队对中间件的运维能力。一个没人会排查的分布式事务框架比没有框架更危险。2. 数据源路由的核心原理与实现路径搞清楚场景之后才是技术实现。这里先讲清楚原理因为不理解原理后面所有“为什么事务不生效”的问题都解释不通。2.1 AbstractRoutingDataSource 到底做了什么Spring 提供的AbstractRoutingDataSource本质上是一个“数据源容器加一次查找”。它内部维护一个 Map键是路由键、值是真实的 DataSource。当你调用它的getConnection()时它会先调用抽象方法determineCurrentLookupKey()拿到当前的路由键再从 Map 里取出对应的真实数据源最后把连接请求转给它。关键点在于路由发生在获取连接的那一刻而不是在调用 Mapper 方法的那一刻。这个细节决定了后面所有事务相关的坑。如果连接已经被事务管理器拿走并绑定在线程上了你再改路由键也没用因为根本没有第二次获取连接的动作。还有一点容易被忽略如果determineCurrentLookupKey()返回 null它会回退到defaultTargetDataSource。这个默认值一定要显式设置否则在某些初始化顺序下会直接抛异常而且报错信息非常不友好。2.2 用 AOP 加 ThreadLocal 实现注解切换最主流的做法是自定义一个注解配上 AOP 切面在方法执行前把路由键写进 ThreadLocal方法结束后清理。这样业务代码只需要在 Service 方法上打个注解完全不用感知底层数据源。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { String value() default master; } public class DataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void set(String key) { CONTEXT.set(key); } public static String get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } } Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { Around(annotation(ds)) public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { String previous DataSourceContextHolder.get(); DataSourceContextHolder.set(ds.value()); try { return pjp.proceed(); } finally { if (previous null) { DataSourceContextHolder.clear(); } else { DataSourceContextHolder.set(previous); } } } }三个细节值得单独说。第一用Order(Ordered.HIGHEST_PRECEDENCE)把切面优先级提到最高确保它在事务拦截器之前执行否则事务已经开启并拿到连接了切数据源毫无意义。第二finally 里要恢复“上一个值”而不是直接清空因为方法可能存在嵌套调用直接清空会让外层方法丢失路由键。第三ThreadLocal 必须有 remove异步线程复用场景下不清理就是内存泄漏加串数据源。2.3 显式配置多套 SqlSessionFactory 的适用场合另一种做法是给每个库单独配一套 DataSource、SqlSessionFactory、DataSourceTransactionManager然后用MapperScan按包路径分别绑定。这种方式的好处是隔离彻底每个库的事务管理器互相独立能实现真正的“每库一个本地事务”。坏处是配置量大且 Mapper 必须按库分包业务上稍微耦合一点就得挪代码。Configuration MapperScan(basePackages com.example.mapper.order, sqlSessionFactoryRef orderSqlSessionFactory) public class OrderDataSourceConfig { Bean(orderDataSource) ConfigurationProperties(spring.datasource.order) public DataSource orderDataSource() { return DataSourceBuilder.create().build(); } Bean(orderSqlSessionFactory) public SqlSessionFactory orderSqlSessionFactory( Qualifier(orderDataSource) DataSource ds) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(ds); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources(classpath*:mapper/order/*.xml)); return bean.getObject(); } Bean(orderTransactionManager) public DataSourceTransactionManager orderTransactionManager( Qualifier(orderDataSource) DataSource ds) { return new DataSourceTransactionManager(ds); } }我的经验是如果两个库的业务边界清晰、几乎不跨库用这种分包方式最省心如果跨库调用频繁还是用路由数据源加注解切换更灵活但事务就得另外想办法。2.4 现成组件能不能直接用市面上有成熟的多数据源组件支持注解切换、分组、负载均衡用起来确实快。我不反对用但要清楚它做了什么、没做什么。这类组件大多基于路由数据源实现事务方面通常提供的是一个“伪全局事务”注解靠记录回滚日志在应用层模拟提交和回滚本质上是补偿而不是真正的两阶段提交。如果只是内部管理系统这种程度够用。如果是核心交易链路还是要自己评估清楚它能不能处理数据库连接中断、应用崩溃这类异常场景答案往往是不能。3. 从零实现路由、线程传递与常见配置坑这一节把完整的实现链路串起来包括那些文档里不会写、但线上一定会遇到的细节。3.1 数据源注册与路由键的命名约定路由键建议直接用业务语义命名比如order、report而不是ds1、ds2。原因很简单三个月后你回头看代码DS(ds2)完全不知道是哪个库。命名统一之后配一个枚举或者常量类IDE 还能自动补全。Configuration public class DynamicDataSourceConfig { Bean ConfigurationProperties(spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(spring.datasource.report) public DataSource reportDataSource() { return DataSourceBuilder.create().build(); } Bean Primary public DataSource dynamicDataSource( Qualifier(masterDataSource) DataSource master, Qualifier(reportDataSource) DataSource report) { MapObject, Object targets new HashMap(); targets.put(master, master); targets.put(report, report); DynamicRoutingDataSource routing new DynamicRoutingDataSource(); routing.setTargetDataSources(targets); routing.setDefaultTargetDataSource(master); routing.afterPropertiesSet(); return routing; } }afterPropertiesSet()这一步别漏它负责把 targetDataSources 转成内部的 resolvedDataSources。漏了的话启动可能不报错但一调用就空指针排查起来很费时间。3.2 连接池参数要按库单独算不能共用一套这是我在一次压测里发现的主库 QPS 五千、报表库 QPS 五十但两个数据源用了同一套连接池配置最大连接数都是 20。结果报表库那边连接长期闲置浪费主库这边在高峰期频繁等待连接超时。正确做法是按每个库的实际并发单独设置。估算方式是单库最大连接数 ≈ QPS × 平均 SQL 耗时秒× 安全系数1.5 到 2。比如主库峰值 QPS 800、平均耗时 20 毫秒那么 800 × 0.02 16乘以 1.5 约 24配置 25 到 30 是比较稳妥的区间。3.3 异步线程里数据源丢失的处理只要用了线程池ThreadLocal 一定丢。这不是 Spring 的问题是线程隔离的本质决定的。表现是主线程里标了DS(report)用CompletableFuture提交一个查询任务结果这个任务跑到了主库上报表查询把主库拖慢。解决办法是包装任务提交逻辑把当前的上下文传过去。public class DataSourceTaskDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { String key DataSourceContextHolder.get(); return () - { DataSourceContextHolder.set(key); try { runnable.run(); } finally { DataSourceContextHolder.clear(); } }; } } Bean(bizExecutor) public Executor bizExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setTaskDecorator(new DataSourceTaskDecorator()); executor.initialize(); return executor; }如果项目里已经用了线程上下文传递的组件直接复用它也行但一定要验证子线程里DataSourceContextHolder.get()拿到的值是正确的而不是想当然。3.4 分页插件和 SQL 拦截器的方言问题多数据源下如果两个库的产品不同比如一个 MySQL 一个 PostgreSQL分页插件自动探测方言就可能探错。表现是分页 SQL 生成出来语法不对或者干脆不分页全量返回。稳妥的做法是给每个 SqlSessionFactory 显式指定方言或者统一数据库产品降低复杂度。3.5 一个完整的方法级调用示例Service public class ReportService { Autowired private ReportMapper reportMapper; DS(report) public ListReportVO queryDailyReport(LocalDate date) { return reportMapper.selectByDate(date); } DS(master) Transactional(rollbackFor Exception.class) public void syncReport(LocalDate date) { ListReportVO data reportMapper.selectByDate(date); // 写回主库 reportMapper.batchSave(data); } }注意syncReport这个方法是有问题的方法上的DS(master)在事务开启后生效但方法内部调用的reportMapper.selectByDate也走主库而不是报表库。想读报表库再写主库必须把这个方法拆成两段读和写分别在各自的事务或非事务上下文中完成。这个坑下面还会详细展开。4. 事务为什么在多数据源下会失灵这是整篇最核心的部分。几乎所有“多数据源事务不生效”的问题根因都能归到下面两三条里。4.1 事务和连接是绑死的路由只发生一次Spring 的DataSourceTransactionManager在开启事务时会通过DataSourceUtils.getConnection()拿到一个连接然后把这个连接放进ConnectionHolder绑定到当前线程的TransactionSynchronizationManager上。整个事务期间所有的 JDBC 操作都复用这一个连接。所以路由只发生一次。你在这个事务里再怎么改路由键后续的 SQL 仍然走原来那个连接。这不是 Bug是设计如此——事务的语义就要求全程同一个连接。理解这一点之后很多现象就顺了为什么DS加在事务方法内部的调用上没反应因为连接早就定了。为什么跨库写只回滚了一半因为两个库各自有自己的连接而事务管理器只管得住它绑定的那一个。4.2 单个事务管理器管不住第二个数据源如果所有数据源都包装在同一个路由数据源实例里那么全局只有一个DataSourceBean也就只有一个事务管理器。这个管理器在开启事务时拿到的连接来自路由结果事务的回滚只对这个连接有效。另一个库的操作如果没有独立的连接和独立的事务就是自动提交模式写进去就不会回来了。要验证这一点很简单在跨库方法上开一个事务故意在第二个库的写入之后抛异常然后看第二个库的数据在不在。大概率是在的。4.3 传播行为和路由切换组合出的怪现象Transactional(propagation REQUIRES_NEW)的语义是挂起当前事务、开一个新事务。在多数据源场景下如果新事务仍然走同一个事务管理器它挂起的是同一个数据源的连接然后重新获取连接——但路由键如果没变拿到的还是同一个库。真正想要“换一个库开新事务”必须满足两个条件事务管理器不是同一个且数据源不是同一个。这也是为什么我在 2.3 节强调显式配置多套事务管理器的原因——只有那样才能写出Transactional(transactionManager orderTransactionManager)这种精确控制。现象根因处理方向事务内DS切换无效连接已绑定路由不再触发把切换提到事务外层跨库写入只回滚一半事务只覆盖一个连接引入分布式事务或补偿REQUIRES_NEW没换库事务管理器仍是同一个配多个事务管理器并显式指定方法内 this 调用注解失效走了代理内部调用注入自身或拆类4.4 自调用导致的注解失效在多数据源里更隐蔽DS和Transactional都依赖代理。类内部用this.xxx()调用另一个带注解的方法注解完全不生效。单库场景下这个问题的表现是“事务没开”还比较容易发现多数据源场景下的表现是“数据源没切”数据悄无声息地写到了错的库里往往要到数据核对时才发现。我的处理习惯是把需要切换数据源的方法放到独立的类里用注入的方式调用。实在不想拆就注入自身代理Lazy注入自己但这种方式可读性差不推荐在核心链路上用。5. 跨库一致性四条路各自能扛什么前面把问题讲透了这节讲方案。没有银弹只有取舍。5.1 XA 两阶段提交强一致但代价明确XA 的思路是让数据库自己参与分布式事务第一阶段各库预提交并锁住资源第二阶段协调者统一决定提交还是回滚。Java 侧通常通过 JTA 事务管理器实现配合支持 XA 的数据源。优点是语义严格应用层几乎不用改代码。缺点是性能损耗大两次网络往返加长时间持锁而且对数据库连接池、驱动版本都有要求配置稍有不慎就会出现“事务悬挂”——连接断了但锁没释放。我的建议是只在极少数必须强一致、且并发不高的操作上使用比如月度结算这类批量任务。千万别用在用户请求的同步链路上。5.2 AT 模式自动补偿但要知道它没做什么AT 模式的思路是一阶段各库直接提交本地事务同时把数据的前后镜像记到一张 undo 日志表里如果全局回滚就根据镜像生成反向 SQL 去补偿。它的好处是对业务代码侵入小一个注解就能开全局事务。但有几个必须清楚的限制表必须有主键否则镜像没法定位行不支持所有 SQL 类型复杂的批量更新可能出问题全局锁会带来额外开销热点行容易冲突默认隔离级别下存在中间态可见的问题读操作要配合全局锁查询才能拿到正确结果。还有个运维层面的现实它需要一个独立的协调服务多了一套要部署、要监控、要处理故障的组件。小团队上之前想清楚谁来维护。5.3 本地消息表最土但最稳的方案这是我最常用的方案。核心思路是把跨库操作拆成“本地事务 消息投递”两步消息表和业务数据在同一个库、同一个本地事务里写入保证原子性然后用定时任务扫表投递下游消费时做幂等。Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { orderMapper.insert(dto.toOrder()); // 消息表和订单同库同一个本地事务 messageMapper.insert(MessageDO.builder() .bizId(dto.getOrderNo()) .topic(stock-deduct) .payload(JSON.toJSONString(dto)) .status(PENDING) .retryCount(0) .nextRetryTime(LocalDateTime.now()) .build()); }投递端用定时任务扫 PENDING 状态的消息按退避策略重试。消费端必须做幂等用业务单号做唯一索引重复消费直接忽略。这个方案的好处是完全不依赖额外中间件出问题能靠查表定位最坏情况也就是延迟高一点。代价是要自己写扫描、重试、告警这套基础设施大概两三百行代码一次性投入。5.4 对账兜底任何方案都得配一层不管你选了上面哪种最后一层必须是定时对账。因为分布式环境下网络分区、进程被杀、消息丢失这些情况永远存在再好的方案也只能保证“大概率一致”。对账的逻辑很朴素按业务日期跑批比对各库的关键字段发现差异就告警并生成补偿任务。关键是要设计成可重复执行、结果可追溯别做成一次性脚本。5.5 怎么选三个判断维度维度倾向 XA倾向 AT 模式倾向消息表一致性要求强一致强一致可容忍短暂中间态最终一致并发量低中高团队运维能力有中间件经验有中间件经验无额外依赖我个人的排序是能拆服务就拆服务拆不了优先消息表消息表扛不住再考虑 ATXA 放最后。6. 排查速查表与踩坑实录线上问题不会按教科书出。下面这些是我实际遇到过的按现象归类方便对照。6.1 常见问题速查现象可能原因排查动作数据写到了错误的库路由键未设置或线程丢失打印DataSourceContextHolder.get()注解切换完全无效自调用绕过代理检查调用链路是否经过代理事务内切换不生效连接已绑定看事务是否先于切换开启启动报数据源循环依赖路由 Bean 与事务管理器互相引用用Lazy或调整注入顺序分页 SQL 语法错误方言探测错误显式指定数据库类型连接池耗尽多库共用池配置按库分别设置最大连接数定时任务数据源串了线程复用导致 ThreadLocal 未清理在 finally 中强制 remove6.2 一个真实的排查过程有次内部系统反馈“报表页面的查询偶尔特别慢把整个服务拖住”。第一反应是报表库慢查询结果一看慢日志啥也没有。后来在切面里加了日志才发现一部分请求的路由键是 null走了默认数据源也就是主库。原因是那个查询在异步线程里执行ThreadLocal没传过去于是报表查询全压在主库上。这个问题最坑的地方在于它是“偶发”的——同步调用时能正确切到报表库只有走异步分支才出问题测试环境根本压不出来。后来加了TaskDecorator才彻底解决。这件事之后我养成了一个习惯所有异步任务提交的地方都加一行断言日志确认路由键不为空。6.3 三条来自实践的建议第一给路由切面加上“命中日志”但只在 debug 级别输出生产环境按需开启。出问题时能第一时间确认路由对不对比翻源码快得多。第二事务方法里禁止出现数据源切换注解。这条可以做成代码扫描规则在 CI 阶段拦住。人为靠 code review 守不住尤其是人员流动快的时候。第三跨库写操作单独建一个包统一走同一套模板方法方便后续替换实现方案。如果一开始就散落在各个 Service 里将来换方案要改几十个地方。注意多数据源配置一旦引入务必在项目文档里写清楚“哪些方法在哪个库上执行”。三个月后接手的人可能是你自己会非常感谢这份说明。7. 上线前的验证清单方案跑通不等于能上线。这部分是我每次做多数据源改造必做的验证项按顺序走一遍基本能覆盖主要风险。7.1 功能验证先验证路由正确性最基本的一条随便找一个带DS的方法在切面里打印实际使用的数据源标识确认和预期一致。然后针对每一个注解位置跑一遍包括类级别注解、方法级别注解、以及方法注解覆盖类注解的情况。再验证嵌套调用A 方法标了DS(report)内部调用 B 方法标了DS(master)看 B 执行完之后回到 A路由键有没有正确恢复。这个场景特别容易出问题因为很多人 finally 里直接 clear 了。7.2 事务验证用异常主动触发回滚这一步必须做而且要在真实的两个库上做。写一个跨库方法在第二个库操作之后抛运行时异常然后去两个库里分别查数据。预期结果应该是两个库都没有数据如果用了分布式事务或者第一个库回滚、第二个库有数据如果没做跨库事务你需要明确知道这个行为并接受它。这个测试的价值在于它会强制你把“我的系统在跨库失败时到底会发生什么”这件事想清楚而不是停留在“应该没问题吧”的模糊认知上。7.3 异常场景验证断网、超时、连接池打满把其中一个库的网络断开或者把连接池最大连接数调到 1 然后并发压测观察系统的行为。重点看三件事异常是否能被正确抛出、连接是否能被正确释放、有没有出现线程卡死。多数据源场景下连接泄漏是最难查的问题之一因为泄漏点可能在另一个库的连接池上而监控大盘往往只盯主库。7.4 我个人的一条经验做这类改造我习惯先把“数据源切换”和“事务”这两件事分开上线。第一版只做路由切换不涉及跨库事务跑一周观察有没有路由错误、连接池指标是否正常。确认稳定之后再引入跨库一致性的方案。一次性全上出了问题根本分不清是哪一层的锅。另外改造期间一定要保留一个开关能随时切回单数据源的模式。这个开关不需要多优雅一个配置项控制路由数据源是返回真实数据源还是永远返回主库就够了。回滚能力比任何设计方案都重要这是我这几年最深的体会。

准备好开启您的数字化征程了吗?

专业团队为您量身定制,让您的企业网站脱颖而出

立即咨询