资讯动态

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

发布时间:2026/10/26 5:59:19 来源:尧图企业网站定制
先把结论摆出来在 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?useSSLfalseserverTimezoneAsia/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?useSSLfalseserverTimezoneAsia/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) { MapObject, 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 ThreadLocalDataSourceKey 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 threadhttp-nio-8080-exec-12 [req-8821] routing keyMASTER threadhttp-nio-8080-exec-12 [req-8821] routing keyREPORT threadhttp-nio-8080-exec-12 -- 异常点 [req-8821] exit threadhttp-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.xjavax.*全部换成jakarta.*如果你用了自定义的Filter、HandlerInterceptor包名要跟着改。另外 Boot 3 默认的连接池版本也升级了HikariCP 的一些配置项行为有微调比如minimum-idle的默认值策略变了。二是 MyBatis 或 MyBatis-Plus 升级SqlSessionFactory的装配方式可能变化。多数据源下如果你手动配了SqlSessionFactoryBean升级后要检查dataSource的注入是否还指向动态数据源。我就遇到过一次升级后Primary失效所有查询都走了默认库原因是新版本把DataSource的自动装配逻辑调整了需要显式指定Qualifier。注意升级前一定要在预发环境跑一遍全量回归尤其是那些带DS注解的方法。这类问题不会在启动时暴露只有在实际调用时才显现。我个人在实际操作中的体会是多数据源这套东西配好只是起点真正决定它稳不稳的是三件事上下文清理是否无死角、切面顺序是否被钉死、跨库写入是否有兜底对账。前两件靠代码规范约束第三件靠流程保障。把这三件做到位剩下的就是常规的容量评估和监控了。至于那些更复杂的分布式事务场景能改业务模型避开就避开实在避不开再上方案顺序别反了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑