news 2026/10/26 5:59:18

SpringBoot多数据源与事务:动态路由、切面顺序和跨库一致性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot多数据源与事务:动态路由、切面顺序和跨库一致性

先把结论摆出来:在 SpringBoot 里把多数据源跑通,代码量撑死一百多行,一天时间足够;真正让人掉头发的是跑通之后的两三个月。某个凌晨你会收到告警,说某张业务表的数据跑到了另一个库里,或者主事务回滚了,但另一边的写入已经落盘。我手上带过多数据源的项目不算少,几乎没有哪个能躲过这类问题——它们不会在启动时报错,也不会在测试环境暴露,只会在并发上来之后,以一种极低频、极难复现的方式出现。

这篇内容想聊的是SpringBoot 多数据源及事务解决方案这件事里,那些文档通常一笔带过、但实际会咬人的部分:动态数据源怎么装配才不会和事务拦截器打架、@Transactional为什么会在切换数据源时把连接绑到错误的库上、跨库写入到底该选本地消息表还是 AT 模式、以及一个线程池复用导致串库的真实排查链路。适合已经会用 SpringBoot 写业务、正在或即将接手多数据源改造的开发者,也适合被“事务看着生效其实没生效”折腾过的同学。代码和配置都是可以直接抄的,但我更希望你把每一处的“为什么这么写”看明白。

1. 先判断是真需求还是伪需求:多数据源的三类诉求与代价

我见过太多项目,一开始只是想让报表查询别拖慢主业务,最后演变成三套数据源、两套事务管理器、一堆@DS注解满天飞。所以在动手写配置类之前,先花二十分钟想清楚你属于哪一类诉求,这一步能省掉后面大部分的返工。

1.1 读写分离、业务分库、外部系统库:三类诉求的边界在哪

第一类是读写分离。主库扛写,从库扛读,两边表结构完全一致,只是流量分配不同。这类诉求的本质是“同一个逻辑库的多份副本”,它对事务的要求很明确:写操作必须在主库,读操作可以走从库,而且必须容忍主从延迟。

第二类是业务分库。订单一个库、用户一个库、商品一个库,表结构不同,业务边界清晰。这类诉求的本质是“多个独立的逻辑库”,它的难点在于跨库的关联查询和跨库的写入一致性,也就是后面要重点讲的分布式事务问题。

第三类是接入外部系统库。比如要读一个历史遗留系统的数据,或者要往一个第三方对账库写数据。这类库你通常没有 DDL 权限,连接配置也不受你控制,连接池参数、超时时间都得按对方的规矩来。

这三类诉求看起来都是“配多个 DataSource”,但它们的解法完全不同。读写分离适合用路由键 + 强制主库标记;业务分库适合按业务模块拆 Service,每个 Service 固定用一个数据源,尽量减少动态切换;外部系统库最好单独封一个 Repository 层,别和主业务的数据源混在一个路由容器里。

我个人的经验是:能按业务模块静态绑定数据源的,就别做动态路由。动态路由的灵活性是用可维护性换来的,一旦注解用错位置,排查成本极高。只有在同一个 Service 里确实需要按条件访问不同库时(比如多租户按 tenantId 分库),动态路由才是必要的。

1.2 用一张表算清楚多数据源的隐性成本

很多人评估多数据源只看“配起来麻不麻烦”,这是最表面的成本。下面这张表是我自己项目里总结出来的实际开销,你可以对照着看:

成本项具体表现影响程度
连接数叠加每个数据源一个连接池,连接总数相乘高,直接打爆库的连接上限
事务边界模糊跨库操作无法用单个本地事务保证高,需要额外一致性方案
监控割裂慢 SQL、连接池指标要分数据源采集中,出问题定位慢一倍
切面顺序敏感自定义切面与事务拦截器的执行顺序高,错了就静默失效
上下文传递异步线程、消息消费线程丢失数据源标记高,典型串库来源
测试成本单元测试要 mock 多套连接中,集成测试复杂度上升

重点看第一行。假设单个数据源配 10 个连接,你有 3 个数据源,单实例就是 30 个连接;如果服务部署 4 个实例,总连接数就是 120。而很多数据库实例的max_connections默认值在 150 上下,再加个运维连接、备份连接,基本就贴边了。所以连接池的大小不是拍脑袋定的,得按“实例数 × 数据源数 × 单池连接数 < 数据库上限 × 0.8”这个不等式倒推。

1.3 什么时候应该果断放弃多数据源

有三种情况,我建议你直接放弃多数据源方案,换个思路。

第一种,只是想隔离报表查询。那不如做一个只读从库,在应用层用两套 Mapper(一套指主库、一套指从库),而不是搞动态路由。或者更简单,报表走独立的数据同步服务,把数据抽到分析型存储里,业务库一点压力都不受影响。

第二种,库与库之间强关联查询很多。比如订单列表要 join 用户表、商品表,那就别拆库了,用同库不同表前缀的方式逻辑隔离,等业务量真到瓶颈再拆。拆库的前提是业务边界足够清晰,而不是库名不同。

第三种,跨库写入要求强一致。如果你的业务场景确实需要“A 库扣款、B 库加余额”这种原子性,那本地事务方案根本解决不了,要么上分布式事务中间件,要么改业务模型把两个操作合并到一个库。后者往往才是正解——很多所谓的跨库需求,其实是领域划分没做好造成的。

2. 动态数据源的装配:从配置绑定到切面时序

决定要做动态数据源之后,第一个要解决的问题是装配。这一块网上的示例代码大同小异,但细节差异决定了它能不能在真实项目里稳定运行。

2.1 目标数据源的注册方式与 @Primary 的必要性

先看配置文件的写法。我强烈建议不要用spring.datasource这个前缀去配多个数据源,理由是 SpringBoot 的数据源自动配置会读取这个前缀,虽然它带@ConditionalOnMissingBean,但在某些版本下仍可能造成属性覆盖和日志误导。用自定义前缀,语义清楚,也不会互相干扰:

spring: ds: master: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/biz_order?useSSL=false&serverTimezone=Asia/Shanghai username: app_user password: ****** maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 report: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/biz_report?useSSL=false&serverTimezone=Asia/Shanghai username: report_user password: ****** maximum-pool-size: 5

注意这里用的是jdbc-url而不是url。HikariCP 认的是jdbcUrl属性,如果你直接写url,在某些绑定场景下会绑定不上,启动时报“driverClassName is required”之类的错。这个坑我踩过一次,排查了快一个小时才发现是属性名的问题。

然后是配置类。关键点是给动态数据源加@Primary,并且把目标数据源注册进去:

@Configuration public class DataSourceConfig { @Bean("masterDataSource") @ConfigurationProperties(prefix = "spring.ds.master") public DataSource masterDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } @Bean("reportDataSource") @ConfigurationProperties(prefix = "spring.ds.report") public DataSource reportDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } @Bean("dynamicDataSource") @Primary public DataSource dynamicDataSource( @Qualifier("masterDataSource") DataSource master, @Qualifier("reportDataSource") DataSource report) { Map<Object, Object> targets = new HashMap<>(4); targets.put(DataSourceKey.MASTER, master); targets.put(DataSourceKey.REPORT, report); DynamicDataSource ds = new DynamicDataSource(); ds.setTargetDataSources(targets); ds.setDefaultTargetDataSource(master); ds.afterPropertiesSet(); return ds; } }

@Primary的作用不只是“优先注入”。MyBatis 的SqlSessionFactory、JdbcTemplate、事务管理器在装配时都需要一个唯一的DataSource,如果你不指定主数据源,容器会因为候选 Bean 过多直接启动失败。加了@Primary之后,所有没显式指定@Qualifier的地方都会拿到动态数据源,这样注解切换才有意义。

还有一个容易忽略的点:afterPropertiesSet()必须调用。AbstractRoutingDataSource的目标数据源解析逻辑放在这个方法里,你不调它,resolvedDataSources就是空的,运行时直接抛“Cannot determine target DataSource for lookup key”。

2.2 上下文持有者的设计:ThreadLocal 的清理时机

数据源键的持有者看起来简单,但它是串库问题的高发区。标准的写法大概是这样:

public final class DataSourceContextHolder { private static final ThreadLocal<DataSourceKey> HOLDER = new ThreadLocal<>(); private DataSourceContextHolder() {} public static void set(DataSourceKey key) { HOLDER.set(key); } public static DataSourceKey get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }

看起来没问题,问题出在谁来调用 clear。如果你的切面只在@Around的finally里remove,那在同步调用链里是安全的。但只要有一个地方漏了清理——比如某段代码手动set之后走了异常分支、或者用了@AfterThrowing忘记兜底——这个线程回到 Tomcat 线程池时,ThreadLocal里还残留着上一次的键,下一个请求进来如果没有主动set,就会默默用上一个请求的数据源。这就是典型的“偶发查错库”。

我的做法是双重保险:切面finally里清理,同时在determineCurrentLookupKey里对null做显式兜底,返回默认键。

public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { DataSourceKey key = DataSourceContextHolder.get(); return key == null ? DataSourceKey.MASTER : key; } }

兜底不能解决根本问题,但能把“随机串库”降级成“读到主库”,故障影响面立刻小一个量级。这个设计上的小取舍,我在后面第五节的排查案例里会再展开。

2.3 切面顺序:为什么数据源切换必须早于事务开启

这是多数据源最容易出问题的地方,没有之一。

Spring 的@Transactional本质是一个 AOP 拦截器,它在方法执行之前就从 DataSource 拿一个连接,并把连接绑定到当前线程(TransactionSynchronizationManager)。而数据源路由是在getConnection()被调用时才通过determineCurrentLookupKey()决定走哪个库。

推论很直接:如果事务拦截器先执行,那么连接在数据源键被设置之前就已经拿到了。你后面在方法里再怎么切,都只影响新获取的连接,不影响已经绑定到事务的那个。

所以数据源切面的优先级必须高于事务拦截器:

@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { @Around("@annotation(ds)") public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { DataSourceKey previous = DataSourceContextHolder.get(); DataSourceContextHolder.set(ds.value()); try { return pjp.proceed(); } finally { if (previous == null) { DataSourceContextHolder.clear(); } else { DataSourceContextHolder.set(previous); } } } }

这里我用previous做了现场恢复而不是直接清空,是为了支持嵌套切换——内层方法切到报表库,执行完恢复到外层的主库,而不是把外层的键也一并清掉。

Ordered.HIGHEST_PRECEDENCE是最保险的值。默认情况下@Aspect的切面顺序和事务拦截器都是LOWEST_PRECEDENCE,此时谁先谁后取决于 Bean 的注册顺序,属于不可控因素。你可以在本地跑一百次都是对的,换个环境就反过来了。

提示:即使切面顺序配对了,也只在“注解打在事务方法外层同一个方法上”时成立。如果@DS打在 Service 的 A 方法上,而 A 方法内部调用了另一个类的事务方法 B,那 B 的事务开启时数据源键是在的,没问题;但如果 A 方法本身没有事务、内部通过this.自调用了一个带事务的方法,代理不生效,事务压根不会开启,这时候数据源切换是生效的,但事务保护没了。

3. 事务绑错库的根因:@Transactional 与动态数据源的三处冲突

第二节讲了切面顺序,这是第一处冲突。但真正让事务“看起来生效、实际上没生效”的原因还有两处,都跟事务管理器的工作机制有关。

3.1 连接获取与事务绑定的先后关系

DataSourceTransactionManager的doBegin方法大致做三件事:从 DataSource 拿连接、关闭自动提交、把连接包装成ConnectionHolder绑定到TransactionSynchronizationManager。注意,拿连接这一步调用的就是动态数据源的getConnection(),此时路由键已经被切面设置好了,所以拿到的是正确的库。

问题出在同一事务内的第二次切换。假设事务方法内部调用了另一次@DS(REPORT)的方法,路由键变了,但DataSourceTransactionManager判断当前线程已经有绑定的ConnectionHolder,会直接复用,不会重新getConnection()。结果就是:你以为切到了报表库,实际用的还是主库连接,SQL 跑在错误的库上,报“表不存在”或者更糟——同名表存在,数据写错地方。

这个行为在读写分离场景下特别隐蔽。你写了@Transactional(readOnly = true)想着走从库,结果因为外层还有个写事务,读请求全落在主库上。压测的时候主库 CPU 打满,你以为是读写分离没配好,其实是路由根本没切过去。

所以一条硬规则:动态切换只在一个事务的最外层生效,事务内部的切换是无效的。业务上要么把跨库操作拆成两个独立事务(那就进入分布式事务范畴),要么老老实实用一个库。

3.2 传播行为、挂起与只读标志在多数据源下的表现

REQUIRES_NEW这个传播行为在多数据源场景下值得单独说。它的语义是挂起当前事务、新开一个事务。挂起的实现是把当前的ConnectionHolder从线程上解绑,新事务重新走一遍doBegin。

这意味着REQUIRES_NEW会重新触发一次getConnection(),也就重新走一次路由判断。如果你在外层是主库、在REQUIRES_NEW方法里用@DS(REPORT),它确实能拿到报表库的连接,这是少数能在事务内切库的合法姿势。

代价是什么?两个连接同时被占用。一个线程在REQUIRES_NEW期间持有两个连接,如果这个路径的并发高,连接池会被迅速耗尽,出现“连接获取超时”。我在一个对账服务里就遇到过:单线程只占 1 个连接时 QPS 能到 800,改成嵌套REQUIRES_NEW之后,连接池 10 个连接平均每个线程占 2 个,有效并发腰斩,QPS 掉到 400 出头,还时不时报超时。

readOnly标志也值得注意。@Transactional(readOnly = true)会给连接设置只读标记,如果你的动态路由是根据readOnly标志来决定走主库还是从库的(这是一种常见做法,通过TransactionSynchronizationManager.isCurrentTransactionReadOnly()判断),那判断发生的时间点必须在事务开启之前。而事务拦截器本身就是开启事务的地方,这里是存在时序矛盾的。稳妥的做法是显式用注解指定,别依赖readOnly自动推断。

3.3 连接池叠加带来的容量风险

前面提过连接数计算,这里展开说具体的评估方法。假设你的服务有 3 个数据源,配置如下:

数据源maximum-pool-size用途
master10订单写、订单读
report5报表查询
archive3历史归档查询

单实例峰值连接数是 18。部署 6 个实例,理论峰值 108。数据库max_connections假设是 200,看起来还有余量,但要扣掉:主从复制连接、监控采集连接、运维手工连接、慢查询会话,实际可用大概在 160 到 170 之间。108 占 65% 左右,属于可以接受但需要监控的区间。

如果这时候有人给 report 库加了 5 个连接,总峰值就变成 138,占比 83%,已经很危险了。所以连接池参数变更必须走评审,不能随手调。

还有一个隐性风险是连接泄漏。动态数据源把连接交给事务管理器之后,如果某个分支抛异常没走正常的close,连接就回不到池里。HikariCP 有个leak-detection-threshold,建议设成 20000 毫秒(20 秒),超过这个时间没归还就打警告日志。日志里会带上获取连接的堆栈,定位泄漏点非常有用。默认是 0(不检测),很多人不知道要开。

4. 跨库写入的一致性方案:四种落地路径与取舍

到了这一节,前提已经明确了:跨库的写操作,用两个@Transactional是绝对不可能保证原子性的。两段代码写在同一个方法里,第一段提交成功、第二段抛异常,第一段是不会回滚的。这是数据库层面的硬约束,不是框架能绕过去的。

那怎么办?四条路,各有适用边界。

4.1 为什么两个本地事务拼不成一个分布式事务

先说清楚为什么不成立,这决定了后面方案的设计方向。

本地事务的原子性依赖数据库的 redo/undo 日志,它的边界就是单个连接、单个库。事务管理器把连接绑定在线程上,提交时调connection.commit(),这个动作只对当前连接的库有效。你开两个事务管理器、绑两个连接,本质上是两个独立的本地事务,中间没有任何协调者。第一个提交之后,它的变更就对其他会话可见了,第二个再怎么回滚也撤不回第一个。

要做到真正的原子提交,必须有外部协调者记录全局状态,并具备“如果第二步失败,能反向补偿第一步”的能力。这就是所有分布式事务方案的共同内核:要么两阶段提交(准备 + 提交,强一致但阻塞),要么补偿式(先做后补,最终一致)。

方案一致性性能损耗侵入性适用场景
XA / JTA 两阶段提交强一致高,锁持有到全局提交低,配置层面短事务、低并发
AT 模式(自动补偿)最终一致中,需额外回滚表低,加注解中小规模业务
TCC 模式最终一致低高,三个方法都要写资金类、高并发
本地消息表最终一致极低中,要写表异步通知场景

我的默认选择是本地消息表。原因很实际:它不依赖任何中间件,出问题可以用 SQL 直接查、直接修,运维同学看得懂。AT 模式虽然注解就能用,但一旦出现悬挂事务或者脏回滚,排查难度很高,需要理解全局锁的持有逻辑。

4.2 本地消息表:表结构、投递循环与幂等消费

思路很朴素:把“跨库写”拆成“本地写 + 消息投递”,本地写和消息落表在同一个事务里,保证要么都成功要么都失败;投递由独立的调度器异步执行,失败就重试。

CREATE TABLE t_message_outbox ( id BIGINT NOT NULL AUTO_INCREMENT, biz_type VARCHAR(64) NOT NULL COMMENT '业务类型', biz_no VARCHAR(64) NOT NULL COMMENT '业务单号', payload TEXT NOT NULL COMMENT '消息体,JSON', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待投递 1已投递 2已消费 3失败', retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz (biz_type, biz_no), KEY idx_status_retry (status, next_retry_at) );

业务侧的写法:

@Transactional(rollbackFor = Exception.class) public void createOrderAndNotify(OrderCmd cmd) { orderMapper.insert(cmd.toOrder()); MessageOutbox msg = new MessageOutbox(); msg.setBizType("ORDER_CREATED"); msg.setBizNo(cmd.getOrderNo()); msg.setPayload(JsonUtil.toJson(cmd)); msg.setStatus(0); msg.setNextRetryAt(LocalDateTime.now()); outboxMapper.insert(msg); }

投递端是一个独立的定时任务,扫status = 0且next_retry_at <= now()的记录,调用下游接口。重试间隔我一般设为 1s、5s、30s、2min、10min、30min、1h、3h,共 8 次,超过就直接置为失败并告警。这个退避序列的依据是:前几次重试覆盖网络抖动和下游短暂不可用,后面几次覆盖下游发版或重启,再往后就基本是逻辑问题,重试也没用,需要人工介入。

下游必须做幂等。最稳的做法是用biz_no建唯一索引,重复投递时插入直接冲突,捕获DuplicateKeyException当作成功处理。别用“先查再插”,高并发下这个判断不可靠。

4.3 AT 与 TCC:全局锁、回滚表与适用规模

AT 模式的原理是在业务 SQL 执行前后采集数据镜像,写入一张回滚表(不同实现里表名不同,通常是undo_log),全局事务回滚时用镜像反向生成补偿 SQL。它对业务代码零侵入,一个注解就能跑起来,看起来很美。

但有几个成本要清楚:每次写操作多一次镜像插入,延迟大概增加 10% 到 30%;全局事务期间会持有行锁,并发冲突高的时候会造成大量回滚重试;回滚表需要定期清理,不清理会一直膨胀。我见过一个日订单几十万的系统,回滚表三个月涨到上亿行,磁盘告警之后才想起来要加清理任务。

TCC 是手动版的三阶段:Try 预留资源、Confirm 确认、Cancel 取消。它把资源预留的逻辑交给业务自己实现,因此没有全局锁,性能最好,但侵入性也最大——每个参与方都要写三个方法,还得处理空回滚、悬挂、幂等这三个经典问题。

我的判断标准很简单:资金、库存这类不允许出错的场景用 TCC,普通业务流程用本地消息表,只有在不想改代码又需要一定一致性保障时才考虑 AT。判断错了代价不小,因为换方案等于重写。

4.4 最大努力通知与对账兜底

最后这条是兜底思维:前面所有方案都可能失败,那么失败之后靠什么发现?

答案是对账。每天跑一次比对任务,把两个库中应该一致的数据拉出来做差集,不一致的生成异常单,由人工或半自动流程处理。这个机制看起来原始,但它是唯一能在“消息丢了”“补偿失败了”之后把数据拉回正轨的手段。

对账任务的实现要点:按业务日期分片,避免一次扫全表;比对用摘要字段(比如金额 + 状态 + 版本号)而不是全字段;异常单要有处理状态流转,别生成一堆没人管。我们线上的对账任务每天凌晨跑,正常情况差异条数是 0,一旦非 0 就立刻推送到告警群。上线两年,靠它捞回过七八笔问题数据,都是重试链路断掉但日志没暴露的情况。

5. 一次“偶发查错库”的完整排查链路

理论讲完,讲个真实案例。问题现象是:订单查询接口偶发返回了报表库的数据,两张表同名同结构,字段略有差异,导致前端展示的金额出现明显异常。频率大概是每天两三次,压测环境完全复现不了。

5.1 现象特征与第一轮取证

第一件事不是改代码,是加日志。我在DynamicDataSource.determineCurrentLookupKey()里打了一行:

@Override protected Object determineCurrentLookupKey() { DataSourceKey key = DataSourceContextHolder.get(); if (log.isDebugEnabled()) { log.debug("routing key={}, thread={}, stack={}", key, Thread.currentThread().getName(), Thread.currentThread().getStackTrace()[2]); } return key == null ? DataSourceKey.MASTER : key; }

同时记录每次请求进入时的线程名、请求 ID、路由键。跑了两天,抓到一次异常,日志长这样:

[req-8821] enter thread=http-nio-8080-exec-12 [req-8821] routing key=MASTER thread=http-nio-8080-exec-12 [req-8821] routing key=REPORT thread=http-nio-8080-exec-12 <-- 异常点 [req-8821] exit thread=http-nio-8080-exec-12

同一个请求内,路由键从 MASTER 变成了 REPORT。但请求里根本没有调过任何带@DS(REPORT)的方法。这说明 REPORT 这个键是上一个请求残留下来的。

5.2 从上下文残留到线程池复用的逐步验证

顺着线索往下查,分三步验证。

第一步,确认残留来源。我在切面的finally里加了计数日志,统计clear()的调用次数与set()的调用次数是否相等。跑了一天,发现set调用 12 万次,clear只有 11.9 万次,差了几百次。差异集中在几个特定的异常路径上——那些路径里,业务代码在切面之外手动调用了DataSourceContextHolder.set()。

第二步,确认线程复用关系。Tomcat 线程池默认 200 个线程,http-nio-8080-exec-12这个线程会轮流处理不同请求,所以上一个请求残留的键会被下一个请求读到。这就解释了为什么是“偶发”——只有当“残留请求”和“查询请求”恰好落在同一个线程上时才会触发。

第三步,验证兜底逻辑为什么没挡住。因为残留的值不是null,是REPORT,兜底只处理null的情况,所以完全没起作用。

到这里根因就清楚了:上下文清理存在遗漏路径 + 线程复用 + 兜底范围不足,三个条件叠加才出问题。任何一个环节断开,都不会有这个故障。

5.3 修复与回归验证清单

修复分三层,缺一不可。

第一层,切面统一收口。禁止业务代码直接调用DataSourceContextHolder.set(),把方法改为包级可见,只允许切面调用。同时把清理逻辑改成保存现场 + 恢复现场,而不是简单清空。

第二层,请求入口兜底清理。加一个Filter,在请求进入和退出时各清一次:

@Component public class DataSourceContextFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { try { chain.doFilter(req, resp); } finally { DataSourceContextHolder.clear(); } } }

请求进来先清一次,防止继承上一条请求的脏值;出去再清一次,防止污染下一条请求。这个成本几乎为零,收益极大。

第三层,异步线程显式传递。所有跨线程的执行都要手动带上下文,或者用可传递的ThreadLocal实现。这点下一节展开。

回归验证我列了个清单,每次都照着跑:

验证项方法通过标准
同步调用切库单测覆盖 3 种注解组合路由键与注解一致
异常路径清理人为抛异常后查 ThreadLocal值为 null
线程池复用连续 1000 次混合请求无跨请求污染
异步任务切库提交到线程池后执行查询路由键正确
事务内切库验证不会切换(符合预期)日志有明显提示

6. 上线前的自检清单:配置、日志与压测

最后聊落地。多数据源的代码写完之后,有一些检查项是必须在上线前过一遍的,漏一项都可能在线上还债。

6.1 必须打印的四类日志

第一类是路由决策日志。每次determineCurrentLookupKey的返回值,至少在 INFO 级别打一次(可以按请求 ID 去重,避免刷屏)。这是排查一切串库问题的第一手材料。

第二类是事务开启日志。可以打开org.springframework.transaction包的 DEBUG,看事务开始、提交、回滚的先后顺序,确认数据源切换发生在事务开启之前。

第三类是连接获取日志。HikariCP 的leak-detection-threshold打开后,超过阈值会打印获取连接的完整堆栈,这是定位连接泄漏的唯一有效手段。

第四类是跨库调用日志。所有跨库的写操作都要记录业务单号、目标库、执行结果,方便对账时回溯。

6.2 压测时重点观察的指标

压测不是只看 QPS 和 RT。多数据源场景下,要额外盯这几个:

  • 各数据源的活跃连接数。如果某个库的活跃连接长期贴着上限,说明连接池配小了或者有泄漏。
  • 连接获取等待时间。HikariCP 的connection-acquire-millis指标,正常应该在个位数毫秒,超过 100 毫秒就要警惕。
  • 事务回滚率。回滚率突然升高,可能是锁冲突,也可能是数据源切错导致的“表不存在”异常。
  • 跨库调用的成功率与耗时分布。P99 耗时突然拉长,通常是下游库慢查询或者网络抖动。

压测的流量模型要尽量模拟真实比例。如果真实场景是 90% 读 10% 写,压测就不能按 50/50 来,否则连接池的分配比例会失真。

6.3 版本升级时的兼容性注意点

多数据源对版本比较敏感,主要是两块。

一是 SpringBoot 从 2.x 升到 3.x,javax.*全部换成jakarta.*,如果你用了自定义的Filter、HandlerInterceptor,包名要跟着改。另外 Boot 3 默认的连接池版本也升级了,HikariCP 的一些配置项行为有微调,比如minimum-idle的默认值策略变了。

二是 MyBatis 或 MyBatis-Plus 升级,SqlSessionFactory的装配方式可能变化。多数据源下如果你手动配了SqlSessionFactoryBean,升级后要检查dataSource的注入是否还指向动态数据源。我就遇到过一次升级后@Primary失效,所有查询都走了默认库,原因是新版本把DataSource的自动装配逻辑调整了,需要显式指定@Qualifier。

注意:升级前一定要在预发环境跑一遍全量回归,尤其是那些带@DS注解的方法。这类问题不会在启动时暴露,只有在实际调用时才显现。

我个人在实际操作中的体会是,多数据源这套东西,配好只是起点,真正决定它稳不稳的是三件事:上下文清理是否无死角、切面顺序是否被钉死、跨库写入是否有兜底对账。前两件靠代码规范约束,第三件靠流程保障。把这三件做到位,剩下的就是常规的容量评估和监控了。至于那些更复杂的分布式事务场景,能改业务模型避开就避开,实在避不开再上方案,顺序别反了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 5:59:16

Harness:面向生产环境的Agent可回放日志与插件化工程实践

1. 项目概述&#xff1a;这不是一个“框架”&#xff0c;而是一套可拆解、可验证、可归档的Agent工程实践范式你有没有遇到过这样的情况&#xff1a;花三天搭好一个Agent流程&#xff0c;跑通了demo&#xff0c;结果上线后一出问题就抓瞎——日志里只有“LLM返回了奇怪的JSON”…

作者头像 李华
网站建设 2026/10/26 5:59:08

Spring Security整合JWT登录认证:过滤器链、401排查与双令牌实践

上周有个做后台系统的朋友把一段 401 日志甩给我&#xff0c;说自己照着教程把 Spring Security 和 JWT 拼到了一起&#xff0c;登录接口能正常吐出 token&#xff0c;可拿着 token 去访问业务接口&#xff0c;照样被拦在门外。这种"能登录、不能访问"的状态&#xf…

作者头像 李华
网站建设 2026/10/26 5:58:07

Pandas缺失值处理:按数据类型定制化方案

1. 项目概述&#xff1a;为什么Pandas里“空”比你想象的更复杂“数据分析-Pandas的类型数据的缺失处理”——这标题看着平平无奇&#xff0c;但我在带某高校数据科学实训课时发现&#xff0c;超过七成的学员卡在同一个地方&#xff1a;不是不会写df.dropna()&#xff0c;而是根…

作者头像 李华
网站建设 2026/10/26 5:58:01

Java技术栈下企业级Agent Harness的设计与实践

我们团队花了大半年时间&#xff0c;把 AI Agent 的前期原型从 Python 搬到 Java&#xff0c;最后沉淀出了一个内部代号叫 BizBuddy 的 Agent Harness 平台。整个过程踩过的坑不少&#xff0c;但最核心的收获是&#xff1a;企业级 Agent 应用真正缺的不是“能调用大模型”的脚手…

作者头像 李华
网站建设 2026/10/26 5:58:01

MySQL索引原理与B+树数据结构详解

1. 为什么数据库需要索引&#xff1f;在深入探讨MySQL索引的数据结构之前&#xff0c;我们需要先理解索引存在的根本意义。想象一下&#xff0c;你面前有一本500页的电话簿&#xff0c;里面记录了100万条联系人信息&#xff0c;现在需要找到"张三"的电话号码。如果没…

作者头像 李华
网站建设 2026/10/26 5:58:01

多模态对比学习避坑指南:数据、训练与评估实战经验

多模态对比学习这个方向&#xff0c;我从两年前开始断断续续地做&#xff0c;中间换过三个数据集、两套训练框架&#xff0c;踩过的坑如果全部写下来大概能出一本小册子。今天挑几个印象最深、也最容易让后来者重复踩的坑展开聊聊。需要先说明的是&#xff0c;这篇不是教程&…

作者头像 李华