资讯动态

SpringBoot多数据源配置与跨库事务方案选型实践

发布时间:2026/10/26 21:27:50 来源:尧图企业网站定制
「一个订单库、一个报表库、一个历史归档库,三套连接,还得保证下单扣库存的时候不出岔子」——这是我接一个后台项目时,第一个礼拜就撞上的问题。当时我的第一反应是给每套库配一套 Mapper,写三个 Service,结果代码量翻倍不说,只要业务上出现一个查报表顺便校验订单的接口,整套设计就崩了。后来折腾了几年,从自己手写 AbstractRoutingDataSource,到用现成的 starter,再到上分布式事务中间件,踩的坑能装满一个文档。这篇就围绕SpringBoot 多数据源和多数据源下的事务解决方案这两个核心,把我这些年验证过的思路、代码和踩坑记录完整摊开。如果你现在正面对要连多个库读写分离分库之后事务不知道怎么处理这类问题,不管你是刚接触 SpringBoot 一两年的新手,还是已经写过几套 CRUD 的老手,这篇应该都能让你少走点弯路。我不打算从什么是数据源讲起,而是直接从什么情况下该拆、拆了之后用什么方案、方案落地时哪里会炸这条线走一遍。所有代码都是能直接抄过去改改就用的,配置参数我会说明为什么是这个数,而不是拍脑袋。1. 多数据源这件事,先想清楚要不要做很多人一上来就问SpringBoot 多数据源怎么配,这个问题本身就有问题。配置是最后一步,前面还有两件事要判断:你的场景到底需不需要多数据源,以及你需要的哪一种多数据源。这两件事判断错了,后面写多少代码都是白费。1.1 三个把我逼到多数据源的真实场景我遇到过的需求,归纳下来基本就三类,每一类的解法完全不一样,混为一谈是最容易出事的地方。第一类是物理上就是多个库。比如一个后台项目,交易数据在一个库,报表统计在另一个库,历史数据归档在第三个库。这种场景下,库和库之间是平等的业务域,没有主从关系,也不存在读哪个都行的说法,查报表就必须走报表库,写订单就必须走交易库。这时候需要的是运行时按需切换,也就是动态数据源。第二类是读写分离。一个主库负责写,一到三个从库负责读。这种场景表面上看也是多数据源,但它的本质是同一份数据的多个副本,切换规则很固定:写走主,读走从。它的难点不在切换,而在主从延迟——你刚写完立刻去从库查,可能查不到。这种场景我一般不建议用通用的动态数据源注解去标,而是用专门的读写分离组件或者在 Mapper 层做约定,原因后面会讲。第三类是分库分表之后的逻辑多数据源。用户表按 ID 取模拆成 16 个库,业务代码里看到的是一个逻辑表,实际路由到哪个物理库由分片算法决定。这种就完全不该用DS这类注解手写了,得上分片框架,手写路由的错误率太高。我这里重点讲第一类,因为它最普遍,也最能体现多数据源方案的核心矛盾。第二类和第三类我会在选型对比里说明为什么不推荐用同一套方法处理。提醒:先花十分钟把你手上的需求归类。如果是读写分离,直接去找读写分离的成熟方案;如果是分库分表,直接上分片;只有多个独立的业务库这一种,才值得你自己动手搭建动态数据源。搞混了类型,后面事务、连接池、监控全都会跟着乱。1.2 四条技术路线,我实际的排序把需求归类清楚之后,实现路径其实有四条,复杂度从低到高,我按自己实际用过的顺序说一下。路线一:多套 DataSource 多套 SqlSessionFactory 分包扫描。这是最原始也最稳的做法。给每个库配一个DataSource、一个SqlSessionFactoryBean、一个DataSourceTransactionManager,然后用MapperScan(basePackages xxx.mapper.order, sqlSessionFactoryRef orderSqlSessionFactory)把不同包的 Mapper 绑到不同工厂上。好处是边界极其清晰,谁也不会串库,IDE 里搜一下包名就知道这段代码连的是哪。坏处是同一个业务方法里想同时操作两个库就非常别扭,你要么在 Service 层手动注入两个 Mapper 分别调用,要么就得跨越工厂边界。项目里库的数量固定、业务域划分清楚的时候,我到现在还是首选这条路。路线二:继承 AbstractRoutingDataSource 做动态路由。这是最主流的方案,也是各类 starter 的底层原理。核心就一个determineCurrentLookupKey()方法,返回一个 key,框架去 Map 里找对应的 DataSource。配合自定义注解和 AOP 切面,可以在方法上加DS(report)就完成切换。好处是灵活,一个 Service 方法里调不同 Mapper 可以走不同库;坏处是隐式,代码上看不出这次调用走的是哪个库,出了串库问题排查起来很痛苦。路线三:直接用成熟的 dynamic-datasource starter。就是大家常说的那个 baomidou 的组件。它把路线二全部封装好了,注解、切面、嵌套切换、事务联动都做了,还支持strict严格模式和seata集成。中小项目我基本直接用这个,省下来的时间够写好几天业务。问题在于版本升级和自定义扩展的时候,你要读懂它的源码,不然遇到为什么我的切面没生效就只能干瞪眼。路线四:分布式事务中间件。严格说这不是多数据源的实现方案,而是多数据源之上的事务方案。只有当你确实需要在一个业务操作里同时写两个库并且保持一致性的时候,才需要引入。绝大多数项目其实压根不需要,靠业务设计就能规避。我的排序很简单:能不做多数据源就不做;非做不可,库固定的走路线一,库会动态增加或者要频繁切换的走路线二或三;路线四放到最后,先问自己三遍真的必须跨库写吗。1.3 一张选型对照表,先定方向再动手光讲道理不直观,我把这几条路线按实际关心的维度列了个表,你对着自己的项目对号入座就行。维度多套 SqlSessionFactory 分包动态数据源 AOP 注解动态数据源 starter分片框架上手成本中,配置繁琐但直白高,要自己写切面和上下文低,加依赖加配置即可很高串库风险极低,编译期就绑死中,依赖切面顺序中,但 strict 模式可兜底低单方法跨库不友好支持支持支持嵌套切换不需要要自己处理栈内置支持不适用事务支持每库独立事务管理器需自己配内置多事务管理器由框架处理适合场景业务域清晰、库数量固定想完全掌控细节大多数中小项目数据量必须拆表我的推荐度高中高按需表格里串库风险这一栏是我最看重的。做动态数据源最怕的不是配不出来,而是配出来了但某条链路悄无声息地连错了库,结果把报表数据写进了交易库。这种事故往往几个月后对账的时候才发现,追起来极其麻烦。所以不管你选哪条路,一定要有兜底机制——starter 里的strict: true就是干这个的,它会让找不到对应数据源的请求直接抛异常,而不是默默用主库。自己手写的话,determineCurrentLookupKey()返回 null 时也要主动抛异常,别让框架给你兜底成默认库。2. 动态数据源的核心原理拆解选定方向之后就进入实现。这一节我拆一下动态数据源的骨架,搞懂了这三四百行核心代码,后面不管是自己写还是用 starter,你都能看明白它在干什么、什么时候会失灵。2.1 AbstractRoutingDataSource 只做了一件事:查表换连接很多人觉得这个类很神秘,其实它的实现思路朴素到不行:内部维护两个 Map,一个存目标数据源(targetDataSources),一个存默认数据源(defaultTargetDataSource),重写getConnection()的时候先调一下抽象方法determineCurrentLookupKey()拿到 key,再用 key 去targetDataSources里查,查到就返回那个数据源的连接,查不到就用默认的。就这么多。它没有做任何连接管理、没有做任何上下文传递,甚至没有做线程安全之外的任何保护。所谓切换数据源,本质上是每一次拿连接的时候,都根据当前线程里存的一个字符串,去选一个连接池。理解了这一点,很多问题就自解释了:为什么事务内切换无效?因为事务一开,连接就已经被拿走了并绑定在事务上下文里,后面再改那个字符串,已经拿到的连接不会变。为什么异步线程里切换无效?因为ThreadLocal不跨线程传递。public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { String key DataSourceContextHolder.peek(); if (key null) { // 不要返回 null 让框架兜底成默认库, 宁可报错 throw new IllegalStateException(当前线程没有指定数据源, 请检查 DS 注解或上下文设置); } return key; } }这段代码里那个主动抛异常的选择,是我踩过坑之后加的。早期我让它返回 null,框架自动用主库,结果某个定时任务忘了加注解,默默把数据写进了主库,过了一周才发现。宁可让它在测试环境就炸,也别让它在生产上悄悄跑。2.2 ThreadLocal 承载上下文,栈式切换比覆盖更安全上下文用ThreadLocal存是常识,但存什么结构,决定了你嵌套调用时会不会出问题。最早我用的是最简单的ThreadLocalString,set 完在 finally 里 remove。这个写法在单层调用下没问题,一遇到嵌套就完蛋:外层方法标注了DS(order),内部调用了一个标注DS(report)的方法,内层执行完把上下文清掉了,外层剩下的代码再去拿连接,发现上下文是空的,直接抛异常或者走默认库。改成栈结构就没这个问题了:public final class DataSourceContextHolder { private static final ThreadLocalDequeString HOLDER ThreadLocal.withInitial(ArrayDeque::new); private DataSourceContextHolder() { } public static void push(String key) { HOLDER.get().push(key); } public static String peek() { DequeString deque HOLDER.get(); return deque.isEmpty() ? null : deque.peek(); } public static void poll() { DequeString deque HOLDER.get(); if (!deque.isEmpty()) { deque.poll(); } if (deque.isEmpty()) { HOLDER.remove(); } } public static void clear() { HOLDER.remove(); } }用ArrayDeque而不是Stack,是因为后者是同步的,在高频切换的场景下没必要付出那把锁的代价。poll()里判断空了就remove(),是为了避免线程池复用线程时残留上下文——这一点在 Tomcat 这种线程池模型里极其重要,一个残留的 key 会让下一个请求莫名其妙地连到别的库上。同样,如果你用Async或者线程池,务必在提交任务前把上下文快照出来,在任务里重新 push,否则子线程根本拿不到父线程的ThreadLocal。2.3 AOP 切面必须比事务切面更靠外这是整个动态数据源方案里最关键、也最容易配错的一个点。Transactional在 Spring 里是由一个 Advisor 处理的,它的默认 order 是Ordered.LOWEST_PRECEDENCE,也就是优先级最低、处在调用链最内层。而你自己写的DS切面,如果没有显式指定Order,默认也是LOWEST_PRECEDENCE,两者的相对顺序就不确定了。一旦你的DS切面排在了事务切面里面,执行顺序就变成:先开事务(此时上下文还是空的,拿到的是默认库连接),再切数据源(改了个字符串,但连接已经绑好了)。结果就是——注解看起来生效了,实际上所有操作都跑在主库上。Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) // 必须比事务切面更靠外 public class DataSourceAspect { Around(annotation(ds) || within(ds)) public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { DataSourceContextHolder.push(ds.value()); try { return pjp.proceed(); } finally { DataSourceContextHolder.poll(); } } }代码里within(ds)是为了支持标在类上的注解,annotation(ds)支持标在方法上的。两个都写上,方法级注解会优先匹配,符合方法覆盖类的直觉。关于Order的取值,还有个细节值得说。Ordered.HIGHEST_PRECEDENCE是Integer.MIN_VALUE,理论上你还可以写更小的数字,但没必要。只要是负数,就排在事务切面外面了。不过如果你项目里还有其他切面(比如日志、权限),建议给它们各自留出档位,比如 DS 用-100,日志用-50,别全挤在MIN_VALUE上,不然它们之间的顺序又会变得不确定。2.4 连接池要按库独立计算,不能一把梭这一节很多人跳过,但它是最容易在生产上出事故的地方。多数据源意味着多个连接池同时存在,每个池子都会占着数据库的连接名额。我第一次上线就栽在这上面:四个应用实例,每个实例配了三个数据源,每个数据源默认 10 个连接,算下来 4 × 3 × 10 120。听起来还好,可数据库那台机器的max_connections只开到了 150,还应部署了一批定时任务和运维连接,直接把库顶到too many connections。正确的算法分两步。第一步算出单个库需要多少连接,用排队论里那个 Little 定律就够了:所需连接数 ≈ 单库峰值 QPS × 平均 SQL 执行时间(秒)举个例子,交易库峰值 800 QPS,平均 SQL 耗时 15 毫秒,那800 × 0.015 12,再留 1.5 到 2 倍余量应对毛刺,配 20 到 24 比较合适。这里的关键是平均耗时要算真实的,你要是有一条慢 SQL 跑到 500 毫秒,它一个人就能占住一个连接半秒钟,算出来的需求就会翻好几倍。所以配连接池之前,先把慢 SQL 日志翻一遍。第二步算全局上限:总连接数 应用实例数 × 每实例每库池大小 × 涉及的库数量还是上面那个例子,4 个实例 × 20 × 3 个库 240,已经超过 150 了。解决办法要么是把max_connections调上去(得看机器内存,MySQL 每个连接都有额外开销),要么是把池子调小(比如每实例每库 12,总 144,卡着上限也不健康),要么是拆实例。我一般建议控制在数据库上限的 60% 到 70%,留出空间给运维、备份和临时的排查连接。注意:报表库和历史库的连接池可以配得更小一些,因为它们通常是低频重查询,单条 SQL 耗时长但并发低。读写分离场景下,从库的池子要按读 QPS 算,往往比主库大不少。别三套配置复制粘贴,一定要分开算。3. 从零落地一套可复用的多数据源配置原理讲完,这一节直接上可以跑起来的配置和代码。我按配置文件 → 数据源注册 → 注解切面 → 验证的顺序走,每一步都说清楚为什么这么写。假设的场景是一个后台项目,主库叫master存交易数据,副库叫report存报表数据。3.1 依赖与 YAML 配置的正确写法依赖上其实很简单,动态数据源本身不需要额外 jar,spring-boot-starter-jdbc加上你要用的 ORM 就够了。连接池我习惯用 HikariCP,SpringBoot 默认就带着,不用自己引。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency配置文件的写法,关键点是不要用spring.datasource.url这套默认键,因为它只能配一个。我习惯自定义一个前缀,把多个库的配置平铺成 Map,再用ConfigurationProperties绑到实体上。app: datasource: primary: master strict: true pool: max-pool-size: 20 min-idle: 5 connection-timeout: 3000 validation-timeout: 2000 idle-timeout: 600000 max-lifetime: 1800000 sources: master: url: jdbc:mysql://127.0.0.1:3306/db_trade?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: app_rw password: ${DB_MASTER_PWD} driver-class-name: com.mysql.cj.jdbc.Driver max-pool-size: 24 report: url: jdbc:mysql://127.0.0.1:3307:3306/db_report?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: app_ro password: ${DB_REPORT_PWD} driver-class-name: com.mysql.cj.jdbc.Driver max-pool-size: 12几个配置项的取值理由我说一下。max-lifetime设成 30 分钟,是因为大部分云数据库的中间件会把空闲超过一定时间的连接掐掉,池子里的连接如果活得太久,拿起来就是一条已经死掉的连接,报错信息通常是connection is not available或者Communications link failure,特别难查。设得比数据库那边的超时短一点,让池子主动回收,就不会碰到。connection-timeout设 3 秒而不是默认的 30 秒,是因为在多数据源环境下,某个库挂了不应该让整个请求挂 30 秒,3 秒拿不到连接就快速失败,让熔断逻辑接管。validation-timeout配合connection-test-query(MySQL 可以用SELECT 1)或者 JDBC4 的isValid(),在取连接时做一次轻量校验,代价很小但能挡掉一批僵尸连接。strict: true我再强调一次。它对应的是上下文里的 key 在配置里不存在时怎么办,开着就直接抛异常。没有这个开关的时候,一个拼错的DS(reprot)会让所有请求默默跑到主库去,而且功能完全正常,你要等到业务方说报表数据怎么和交易库一样才会发现。这个开关是我认为动态数据源方案里性价比最高的一个配置项。3.2 数据源注册与启动期校验配置绑定的实体大概长这样,用 Map 结构承接多个库:Data ConfigurationProperties(prefix app.datasource) public class MultiDataSourceProperties { private String primary master; private boolean strict true; private Pool pool new Pool(); private MapString, Source sources new LinkedHashMap(); Data public static class Pool { private int maxPoolSize 20; private int minIdle 5; private long connectionTimeout 3000; private long validationTimeout 2000; private long idleTimeout 600000; private long maxLifetime 1800000; } Data public static class Source { private String url; private String username; private String password; private String driverClassName; private Integer maxPoolSize; } }然后构建动态数据源。这里有个细节:每个库单独 new 一个HikariDataSource,而不是共享一个。共享是绝对不行的,池子里的连接和 URL 是绑死的。另外要把每个HikariDataSource的poolName设成库名,这样出现连接泄漏或者池子打满的时候,日志里一眼就能看出是哪个库。Configuration EnableConfigurationProperties(MultiDataSourceProperties.class) public class DynamicDataSourceConfig { Bean Primary public DataSource dynamicDataSource(MultiDataSourceProperties props) { MapObject, Object targets new HashMap(); props.getSources().forEach((name, src) - { HikariConfig cfg new HikariConfig(); cfg.setJdbcUrl(src.getUrl()); cfg.setUsername(src.getUsername()); cfg.setPassword(src.getPassword()); cfg.setDriverClassName(src.getDriverClassName()); cfg.setPoolName(pool- name); // 日志里能直接看出是哪个库 cfg.setMaximumPoolSize( src.getMaxPoolSize() ! null ? src.getMaxPoolSize() : props.getPool().getMaxPoolSize()); cfg.setMinimumIdle(props.getPool().getMinIdle()); cfg.setConnectionTimeout(props.getPool().getConnectionTimeout()); cfg.setValidationTimeout(props.getPool().getValidationTimeout()); cfg.setIdleTimeout(props.getPool().getIdleTimeout()); cfg.setMaxLifetime(props.getPool().getMaxLifetime()); cfg.setConnectionTestQuery(SELECT 1); targets.put(name, new HikariDataSource(cfg)); }); DynamicDataSource ds new DynamicDataSource(); ds.setTargetDataSources(targets); Object primary targets.get(props.getPrimary()); if (primary null) { throw new IllegalStateException(主数据源 props.getPrimary() 未配置); } ds.setDefaultTargetDataSource(primary); ds.setStrict(props.isStrict()); ds.afterPropertiesSet(); return ds; } }afterPropertiesSet()这行千万别漏。AbstractRoutingDataSource的targetDataSources是在这个方法里被解析成resolvedDataSources的,不调的话运行时会报找不到数据源,而且报错信息很不直观,你会以为是 key 拼错了。这个坑我调了小半天。另外建议在启动时加一个连通性自检。用ApplicationRunner遍历所有数据源,各执行一次SELECT 1,失败就打印明确的库名。这个成本几乎为零,但能挡掉一大堆上线才发现某个库密码配错的问题。自检里记得把上下文压栈和出栈配对,别把自己的启动校验搞成串库。3.3 注解与切面的完整代码注解本身很简单,关键是默认值要有意义。我把默认值设成空字符串,切面里判断为空就用主库,这样不写注解和写了空注解行为一致,减少歧义。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) Documented public interface DS { String value() default ; }切面里还有一个容易被忽略的点:同一个类内部方法自调用,切面不生效。这是 Spring AOP 的代理机制决定的,跟多数据源没关系,但在多数据源场景下后果更严重——你以为是切到了报表库,实际还在主库上。排查办法是打开 AOP 的 debug 日志,或者干脆在切面里打一行log.debug(switch to {}, ds.value()),上线前压测一次,把每个接口实际走的数据源打出来核对一遍。还有一个清尾的习惯:如果某个方法是通过TransactionSynchronizationManager手动绑定连接的,或者用了Async,切面的 try-finally 保不住跨线程的上下文,必须在提交任务的地方显式处理。我一般在封装线程池的地方统一加一层包装:public class ContextAwareTask implements Runnable { private final String dsKey; private final Runnable task; public ContextAwareTask(Runnable task) { this.dsKey DataSourceContextHolder.peek(); this.task task; } Override public void run() { if (dsKey ! null) { DataSourceContextHolder.push(dsKey); } try { task.run(); } finally { if (dsKey ! null) { DataSourceContextHolder.poll(); } } } }这个包装类看着很土,但它能解决异步任务连错库这个非常隐蔽的问题。而且它顺带解决了ThreadLocal在线程池里的残留问题——每个任务开始前压栈、结束后出栈,线程复用也不会串。3.4 多 SqlSessionFactory 与动态数据源的混用边界经常有人问:能不能既用DS注解切库,又用多套SqlSessionFactory?我的建议是别混。两套机制的作用点完全不同:DS是在拿连接的时候路由,SqlSessionFactory是在绑 Mapper 的时候路由。混在一起的时候,你很难判断一个 Mapper 调用到底走的是哪条路,出了串库问题基本没法排查。如果非要混,必须定一条清晰的规则,比如所有跨库的公共查询走DS,单库专属的 Mapper 走独立工厂,并且在团队里强制这个约定,写进 Code Review 检查项。规则一旦被破坏,后果往往是好几个月的对账灾难才暴露出来。还有个小细节值得一提:如果你用 MyBatis-Plus,它的SqlSessionFactory会从容器里拿DataSource,而容器里现在是一个动态数据源(带Primary),这是正常的。但如果你同时又手动定义了另一个SqlSessionFactoryBean,记得给它显式指定dataSource,别让它自动注入到那个动态数据源上,那样两套工厂就都指向同一个动态路由了,分包扫描失去意义。4. 事务方案:从本地事务管理器到分布式一致性到这里数据源能切了,真正头疼的才刚开始。事务这一块,我的核心观点就一句:能不跨库写,就别跨库写。把这句话刻在脑子里,后面的方案你只需要按需选,而不是全都堆上去。4.1 一个事务管理器只认一个 DataSourceDataSourceTransactionManager在构造的时候会绑定一个DataSource,开启事务时调getConnection()拿连接,并把连接通过TransactionSynchronizationManager绑定到当前线程上,后续所有同一个事务内的操作都复用这个连接。这就是它事务内切库无效的根本原因——连接已经在开启事务的那一刻定死了,后面无论你怎么改上下文里的 key,拿到的还是那个连接。所以在动态数据源 单一事务管理器这套配置下,正确的用法是:一个Transactional方法内只操作一个库。你要跨库,就得拆成两个方法、两个事务。这一点必须在团队里明确,否则一定会有人写一个方法里既写订单库又写报表库,然后问为什么报表库的数据回滚了订单库没回滚。如果你想给每个库配独立的事务管理器,大概是这样:Configuration public class TxConfig { Bean public PlatformTransactionManager masterTxManager( Qualifier(masterDataSource) DataSource ds) { return new DataSourceTransactionManager(ds); } Bean public PlatformTransactionManager reportTxManager( Qualifier(reportDataSource) DataSource ds) { return new DataSourceTransactionManager(ds); } }然后在使用的地方显式指定:Transactional(transactionManager reportTxManager)。这里有个坑——如果不指定,Spring 会注入那个带Primary的,也就是主库的事务管理器。你在报表 Service 上写了Transactional却没指定管理器,结果事务开在主库连接上,而实际的操作走的是报表库,报表库那边的操作压根不在事务里,回滚时一点用没有。这个错误在测试环境很难发现,因为一切看起来都正常,只有真出异常的时候才暴露。我的做法是:在报表库的 Service 基类上统一用Transactional(transactionManager reportTxManager)标在类上,子类方法就不用每个都写,减少遗漏。同时写一条自定义的静态检查规则,凡是report包下的类出现不带transactionManager的Transactional,直接 CI 报错。4.2 链式事务管理器的尽力而为和它的死穴有段时间ChainedTransactionManager很火,思路是把多个事务管理器串起来,提交时按相反顺序依次提交,回滚时先回滚后加入的、再回滚先加入的。听起来像是个不错的折中方案,但它有几个硬伤,我必须说清楚。第一,它只能做到尽量都提交、尽量都回滚,没有任何一致性保证。如果第一个库提交成功、第二个库提交失败,它不会把第一个库回滚掉——已经提交的事务回不去了。结果就是一个库有数据、一个库没数据,业务上是矛盾的。这个方案的本质是把失败概率降低,而不是保证一致。第二,它已经被标记为废弃,在新版本的 Spring 里被移除了,升级的时候会直接编译不过。如果你现在还在用,迁移成本要提前评估。第三,它把事务范围拉长了。多个库的连接在整个业务执行期间都被占着,并发一上来连接池就吃紧,死锁的概率也高了不少。所以我对它的定位是:过渡方案,只在两个库的写操作频率极低、且业务上允许极小的不一致窗口时勉强能用。真正要保证一致性,还是得往下走。4.3 Seata AT 模式落地时的三个硬约束到分布式事务这一层,可选的主流就是 Seata 的 AT 模式、TCC 模式,或者消息最终一致。AT 模式因为对业务代码侵入最小,用得最多,但它有几个约束,不理解清楚上线就是灾难。约束一:每个参与的表必须有主键,而且得是单一主键或者能被正确识别的联合主键。AT 模式靠拦截 SQL 解析出前镜像和后镜像,没有主键就没法定位行,直接报错。我见过一个历史遗留表用自增 ID 业务编码做复合主键,字段类型还不太规范,AT 模式死活跑不通,最后只能改成 TCC。约束二:每个业务库都要建undo_log表。这个表是 AT 模式回滚的依据,忘了建会在第一次回滚时炸出来,而且报错信息不一定指向它。上线前的检查清单里一定要有这一项,包括索引和字段长度都要按官方脚本建,自己手改容易出问题。约束三:全局锁会带来写冲突。两个全局事务同时改同一行,后一个会等待全局锁,默认超时时间不长,热点行上很容易抛global lock acquire failed。这个不是 bug,是这个模式的固有代价。热点账户、秒杀库存这类场景,AT 模式基本不适合,得用别的思路。还有个现实问题:版本要对齐。Boot 3 之后,Seata 的版本必须跟着升到 2.x,老版本的自动配置类和新的 starter 机制冲突,启动就报错。升级之前先在你的测试环境跑一遍完整的跨库事务用例,别只验证能启动。从我的经验看,中小项目里真正需要 AT 模式的场景不到十分之一。大部分跨库需求,其实是可以用业务设计绕开的——比如把该放一起的数据放同一个库,或者用下面这种更土但更可靠的办法。4.4 本地消息表:牺牲实时性换确定性这是我个人最偏爱、也最常推荐的方案。核心思路很朴素:业务操作和消息记录写在同一个库的同一个本地事务里,这样业务成功和消息落库必然同时成功或同时失败。然后由后台任务扫消息表,把消息投递到目标系统,投递成功就标记完成,失败就重试。CREATE TABLE t_local_message ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, biz_type VARCHAR(64) NOT NULL COMMENT 业务类型, biz_id VARCHAR(64) NOT NULL COMMENT 业务单号, payload TEXT NOT NULL COMMENT 消息体 JSON, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待投递 1已投递 2失败, retry_count INT NOT NULL DEFAULT 0 COMMENT 重试次数, next_retry_at DATETIME NULL COMMENT 下次重试时间, 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_id), KEY idx_status_retry (status, next_retry_at) ) ENGINE InnoDB COMMENT 本地消息表;这里有三个设计要点值得说。第一是uk_biz唯一索引,它保证了同一个业务单号不会重复投递,是幂等的第一道防线。第二是idx_status_retry联合索引,扫描任务用WHERE status 0 AND next_retry_at NOW()走这个索引,数据量涨到千万级也不会拖慢。第三是next_retry_at,用指数退避而不是固定间隔重试——失败一次等 10 秒、两次等 30 秒、三次等 2 分钟,避免下游刚恢复就被重试流量打垮。它的好处是不依赖任何中间件、链路简单、出问题好排查,只需要一个定时任务和一张表。坏处是不是强一致,中间有秒级的延迟窗口,而且下游必须做幂等。凡是业务上能接受最终一致的场景,我都首选这个。真正必须强一致、不能有任何中间态的,才轮到上分布式事务框架。4.5 事务不生效的场景清单多数据源环境里,事务失效的花样比单库多得多。我把这些年遇到的整理成一张表,写完代码对一遍,能省掉很多次为什么没回滚的深夜排查。现象根因处理方式跨库操作只有一边回滚单事务管理器只能管一个连接拆事务,或用本地消息表/TCC切库后事务开在主库Transactional未指定事务管理器显式写transactionManager注解标了但没生效方法自调用,绕过代理抽到另一个 Bean,或注入自身代理异常了但没回滚抛的是受检异常rollbackFor Exception.class部分更新生效部分没有方法非 public,代理不拦改成 public异步任务里事务无效事务绑定在线程上异步内部单独开事务切面顺序颠倒导致串库DS 切面在事务切面内侧加Order提到外层多线程环境数据源乱跳ThreadLocal未传递上下文快照 重新压栈这张表里,异常了但没回滚是最常见的一类。Spring 默认只在抛出RuntimeException和Error时回滚,受检异常不回滚。很多人习惯了业务里抛自定义的受检异常,结果事务压根没回滚,数据留在了半截状态。我的做法是统一在基类上写Transactional(rollbackFor Exception.class),一次性解决。方法非 public这一条也常被忽略。Spring 的代理是基于接口或者 CGLIB 的,非 public 方法代理拦不住,事务和DS都会失效。写 Service 的时候老老实实全用 public,别图省事写个包级私有。5. 踩坑实录:那些文档里不会写的问题前面讲的是应该怎么做,这一节讲做完了之后哪里会炸。这些问题我在文档里基本没见过,都是线上真金白银换来的。5.1 数据源串库:最隐蔽的一类事故串库的表现形式有很多种,最典型的是本该写报表库的数据写进了主库,第二种是本该读主库的请求读到了旧数据,第三种更隐蔽——读到了别的租户的数据。它们的共同点是:功能完全正常,日志也不报错,只有对账或者用户投诉的时候才会暴露。排查串库我有一套固定流程。第一步,打开数据源的 debug 日志,把每个请求实际用到的poolName打出来,和预期对照。第二步,在切面里加一行 traceId 相关的日志,把进入方法时的 key和离开方法时的 key都记下来,如果是栈不平衡,一眼就能看出来。第三步,检查所有异步链路——Async、CompletableFuture、消息消费、定时任务,这些地方是串库的重灾区,因为它们没有经过 HTTP 请求那条主线程。有一个特别隐蔽的坑:ThreadLocal在 Tomcat 线程池里的残留。假设 A 请求设置了report,执行过程中抛了个没被 catch 的Error(比如OutOfMemoryError或者线程被中断),finally 没跑到,上下文就残留在这个线程上了。这个线程被复用去处理 B 请求,B 请求本来该走主库,结果走了报表库。这种问题的特征是偶发、无法复现、换个时间点就好了,极难查。我的应对是在过滤器的最外层做一次clear(),请求进来先清、出去再清,双保险。5.2 连接池被打满与连接泄漏多数据源下连接池打满的原因,除了前面算过的总数超限,还有一个很隐蔽的:跨库的长事务。一个方法里先查主库、再调一个耗时 3 秒的外部接口、最后写报表库,如果中间主库的连接一直没释放(比如事务还没提交),那这 3 秒里它就白占着一个连接。并发一上来,主库池子瞬间见底,报connection is not available, request timed out after 3000ms。解决办法有两个方向。第一个方向是把外部调用挪出事务,先查数据、提交事务、再调外部接口、最后开新事务写。第二个方向是缩短事务粒度,把一个大事务拆成几个小事务,只在真正需要原子性的那几步开事务。这两个方向本质上是一件事:让连接被持有的时间尽可能短。连接泄漏的排查,我一般直接看 Hikari 的指标。它有个HikariPool-1 - Pool stats的日志,会定期打当前活跃连接数、空闲连接数、等待线程数。如果活跃连接数持续上涨、空闲数持续为 0、等待线程数不为 0,基本可以确定是泄漏。定位到具体位置靠leakDetectionThreshold,设成 20 秒,超过这个时间没归还的连接会打一条带堆栈的警告日志,一眼就能看出是谁借了不还。提醒:leakDetectionThreshold只在排查阶段开,生产环境常开会带来额外的开销(每次借还都要记时间戳)。查完问题记得关掉,或者设成一个明显高于正常耗时的值,比如 60 秒。5.3 问题速查表最后把高频问题整理成一张表,贴在手边,遇到直接对号入座。报错或现象大概率原因快速验证方式处理方向No qualifying bean of type DataSource多个 DataSource 未标Primary看启动日志的注入信息给动态数据源加PrimaryCannot determine target DataSource上下文 key 不在配置里打印 key 和配置的库名列表开 strict,校验注解值Connection is not available, request timed out池子太小或连接泄漏看池活跃数、开泄漏检测调池大小或修泄漏点切库注解完全没反应切面顺序在事务内侧打印DataSourceContextHolder.peek()加Order提到外层事务提交了但另一个库没数据跨库操作不在同一事务看两边的事务管理器拆事务或上最终一致方案定时任务连错库线程池未传递上下文定时任务里打印 key上下文快照包装从库读到旧数据主从延迟写后立刻读,看是否命中关键读强制走主库启动报undo_log相关错误分布式事务表未建查目标库的表结构按脚本建表这张表里我想额外说一句从库读到旧数据。读写分离场景下,这个问题的标准解法不是等一会儿,而是在写操作之后的一段时间内,把该用户的读请求强制打到主库。实现方式可以是在写操作的返回值里带一个标记,后续几次读带上这个标记走主;也可以简单粗暴一点,把写后立即读的那几个接口直接标注走主库。前者精细但复杂,后者简单但影响面可控。我一般先上后者,只有在读压力确实大的时候才做前者。再补充一个概率很低但确实会遇到的情况:多个数据源的driverClassName不一致。比如主库是 MySQL 8,历史库还是 MySQL 5.7 的老驱动。这种时候一个driverClassName配错,启动不一定报错,但跑到某个库的特定语法上就炸。上线前用自检脚本把每个库的SELECT version打出来核对,成本几秒钟,能省掉不少事。洋洋洒洒写了这么多,最后说个我自己的判断标准:如果一个需求让我必须在两个库之间保证强一致,我第一反应不是上什么框架,而是这两个库能不能合成一个。绝大多数所谓跨库需求,拆解下来都是历史包袱造成的,能合库就合库,能挪表就挪表。真的合不了,那就老老实实按业务上能不能接受最终一致分流:能接受,本地消息表就够了;不能接受,再上分布式事务框架,并且接受它带来的性能和复杂度代价。这套判断顺序,比任何一套技术方案都管用。

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

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

免费获取报价 →
↑