线上对账任务在一个周五晚上跑挂了,日志里干干净净,一条报错都没有,但数据就是没对上。翻代码才发现,那个加了@Transactional的方法被同一个类里的另一个方法直接调用,注解形同虚设;中间某一步抛出的异常又被上层catch掉,前面写进去的半截数据就那样留在了库里。这个坑我在不同团队里见过不止五次,每次都要重新给新人讲一遍Java 事务到底是怎么跑起来的。很多人对事务的理解停留在“加个注解就完事了”,一旦出了问题,就只能靠猜。这篇东西想干的事很具体:把 Java 事务从数据库底层到 Spring 代理层拆开讲清楚,把传播行为、隔离级别、回滚规则这几个天天用却常常配错的开关说明白,再把事务失效的十几种现场和分布式事务的选型逻辑一次性摊开。不管你是刚写完第一个 CRUD 接口的新人,还是已经在处理跨库一致性问题的老手,应该都能从里面挑到能直接抄走的东西。
1. 先把事务这件事说透:从 ACID 到 Java 里的三条落地路径
1.1 四个字母背后到底是谁在兜底
很多人背 ACID 背得很熟,但一问“原子性是谁保证的”就卡住了。原子性不是 Java 给的,也不是 Spring 给的,是数据库的 undo log 给的。InnoDB 在修改数据之前,会先把旧值记到 undo log 里,事务回滚时按相反的顺序把旧值写回去,这就是原子性的物理实现。持久性是 redo log 在扛,事务提交时先把变更写进 redo log 并刷盘(innodb_flush_log_at_trx_commit=1时每次提交都刷),哪怕此时数据页还没落盘,崩溃重启后也能从 redo log 重放出来。隔离性靠的是锁加 MVCC,一致性其实不是单独实现的,它是前三个加上业务约束共同作用的结果。
把这个链路想清楚,很多困惑就自动消失了。比如为什么“事务里发了 MQ 消息,回滚了消息却发出去了”——因为 MQ 不在数据库的事务边界里,它根本不受 undo log 管。再比如为什么大事务会让主从延迟变得很难看——redo log 一路涨,从库回放要时间。我在排查问题时有个习惯:先判断问题出在“数据库事务边界内”还是“边界外”,这一步能砍掉一半的排查方向。
提示:
innodb_flush_log_at_trx_commit和sync_binlog这两个参数决定了“提交”到底有多“实”。前者为 1、后者为 1 是最安全的组合,代价是每次提交至少两次刷盘;如果业务能容忍极端情况下丢少量最近事务,把它们调成 2 或 0 能换来明显的吞吐提升,但这个决定必须由业务方拍板,不能由开发顺手改。
1.2 手写、模板、注解:三条路径的取舍
Java 里操作事务,本质上就三种写法。最原始的是直接玩 JDBC:conn.setAutoCommit(false),然后commit()或rollback(),finally里把连接还回去。这种写法现在基本只在老代码或者极特殊的场景里出现,因为它的模板代码太多,而且一旦中间return或者抛异常没处理好,连接就会带着未提交的事务回到池子里,后果很难看。
往上一层是编程式事务,Spring 提供的TransactionTemplate是代表。它把“开事务、执行、提交/回滚、释放资源”这套流程包成了一个回调,你只需要关心execute里那段业务逻辑。我个人的经验是:凡是事务边界需要动态决定的场景,优先用TransactionTemplate。比如“根据参数决定这段逻辑要不要单独一个事务”“在一个循环里分批提交”,用注解反而别扭,用模板几行代码就说清楚了。
最上层就是声明式事务,也就是@Transactional。它的好处是零侵入、可读性好,坏处是它依赖代理,一旦调用路径绕开了代理,注解就是一行注释。三种方式没有高下之分,只有场景之分。我的实际分布大概是:业务主流程 90% 用注解,批处理和数据修复脚本用模板,剩下极少数跟框架底层打交道的地方才手写。
1.3 为什么绝大多数业务代码只该用注解
有人会问,既然模板更可控,为什么不干脆全用模板?因为事务边界本质上是业务语义的一部分,不是技术细节。“下单扣库存”这件事要么全成要么全败,这个边界应该写在方法签名上让人一眼看到,而不是埋在十几行代码中间。注解的价值就在这里:它把事务边界声明化了,代码评审的时候扫一眼就能判断这个边界划得对不对。
但声明化是有代价的,代价就是“看不见”。你看不到连接什么时候获取、什么时候提交,出问题时就没有抓手。所以用注解的前提是:你得知道它背后发生了什么。这也是为什么我坚持让团队里的新人在用@Transactional之前,先用模板写一遍带事务的批量插入,感受一下连接复用和回滚的时机。感受过一次,后面看注解就不会再当成魔法。
2. @Transactional 背后的机制拆解:代理、连接、同步器
2.1 一个注解是怎么变成一次 commit 的
@Transactional生效的前提是当前类被 Spring 管理,并且调用方拿到的是代理对象。Spring 在启动时扫描到这个注解,会给对应的 Bean 创建一个代理(有接口时默认 JDK 动态代理,没有接口用 CGLIB),代理里织入一个TransactionInterceptor。调用进来时,拦截器先根据方法上的注解解析出一个TransactionDefinition(包含传播行为、隔离级别、超时、是否只读、回滚规则),再交给PlatformTransactionManager去开启事务。
DataSourceTransactionManager的处理逻辑很朴素:从DataSource拿一个连接,setAutoCommit(false),把这个连接通过TransactionSynchronizationManager绑定到当前线程的ThreadLocal上,然后放行执行业务方法。方法正常返回就commit,抛异常就按回滚规则判断要不要rollback,最后清理ThreadLocal、恢复连接的autoCommit、归还连接池。整个过程记住了这一条链路,后面所有“为什么失效”的问题都能自己推出来。
注意:代理意味着“调用必须从外面进来”。
this.method()这种自调用走的是原始对象,压根不经过代理,拦截器不会被触发,注解写得再全也没用。这是事务失效里出现频率最高的一种,没有之一。
2.2 连接绑定与事务同步器:同一事务里为什么能拿到同一个 Connection
TransactionSynchronizationManager这个类值得单独拎出来说。它内部维护了一组ThreadLocal,分别存当前线程绑定的DataSource到ConnectionHolder的映射、当前事务是否活跃、隔离级别、只读标志、以及一堆同步回调。JdbcTemplate、MyBatis 的SqlSessionTemplate在拿连接时,都不是直接向数据源要,而是先问这个管理器:“当前线程有没有已绑定的连接?”有就直接复用,没有才去池子里取。
这就是为什么在同一个事务方法里连续调十个 DAO,用的还是同一个物理连接,也才会有“事务里改了数据,同事务内查询能看到”的现象。同样是因为这个机制,@Transactional方法里新起一个线程去查库,会拿不到外层事务的连接,自然也就看不到未提交的数据——不是数据库不给你看,是你换了条线程,ThreadLocal是空的。
// 事务提交后再执行某个动作,避免"消息发出去但事务回滚了" TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { // 这里执行缓存删除、消息投递等"事务后"动作 cacheEvict(orderId); } });这段代码在实际项目里非常有用。缓存删除、异步通知这类动作,如果放在事务方法体里直接执行,一旦事务回滚,动作已经生效了,数据和缓存就不一致。挂到afterCommit上,才能保证“事务真的提交了才执行”。
2.3 事务的挂起与恢复:REQUIRES_NEW 到底干了什么
当内层方法的传播行为是REQUIRES_NEW时,AbstractPlatformTransactionManager会发现当前线程已经有一个活跃事务,于是执行“挂起”:把当前的ConnectionHolder从ThreadLocal里解绑,暂存到一个SuspendedResourcesHolder对象里,然后向数据源要一个新的连接开启新事务。内层执行完提交或回滚,再把之前暂存的那个 holder 重新绑回ThreadLocal,恢复外层事务。
这里有两个容易忽略的后果。第一,挂起期间外层事务的连接一直被占着不放,内层又占一个,一次嵌套就同时占用两个连接。如果外层方法在一个循环里调用内层REQUIRES_NEW方法,连接池的maximumPoolSize又设得比较小,很容易在并发稍高的时候直接把池子打满,表现为请求全部卡在获取连接上,日志里一堆获取连接超时。第二,两个事务是两个独立的连接、独立的事务,内层提交了外层回滚,内层的数据是回不来的。所以REQUIRES_NEW最适合的场景是“记录操作日志”这类“不管主流程成不成功都要留下痕迹”的旁路逻辑,不适合用来做业务数据的分离。
NESTED则是另一套机制:它不换连接,而是在同一个连接上用Savepoint打一个点。内层回滚只回滚到这个点,外层不受影响;但外层一旦回滚,内层所有的变更都会跟着没。它依赖 JDBC 驱动的 savepoint 支持,用之前最好确认一下驱动和数据库都支持,别想当然。
3. 七种传播行为与四种隔离级别:选错一个就是事故
3.1 传播行为速查与真实取舍
传播行为决定了“当调用发生时,已经存在一个事务该怎么办”。七种取值我按使用频率排一下,顺便把容易混淆的点标出来。
| 传播行为 | 有外层事务时 | 无外层事务时 | 典型用途 | 坑点 |
|---|---|---|---|---|
REQUIRED(默认) | 加入外层 | 新建 | 99% 的业务方法 | 内层异常会导致整个外层回滚 |
REQUIRES_NEW | 挂起外层,新建 | 新建 | 日志、审计、独立进度记录 | 双连接占用,内层提交不可撤销 |
NESTED | 建 savepoint | 新建 | 可部分回滚的子流程 | 依赖驱动支持,外层回滚会带走内层 |
SUPPORTS | 加入外层 | 不加事务 | 纯查询方法 | 单独调用时无事务,行为不一致 |
NOT_SUPPORTED | 挂起外层 | 不加事务 | 耗时查询、大批量导出 | 挂起期间连接被占着 |
MANDATORY | 加入外层 | 抛异常 | 强制要求上游开事务的底层方法 | 上游忘加就炸 |
NEVER | 抛异常 | 不加事务 | 明确禁止在事务中执行的逻辑 | 用得极少 |
这里最需要警惕的是REQUIRED的“连坐”效应。内层方法抛了异常,如果这个异常一路往外抛,外层事务会被标记为 rollback-only,最后提交时抛UnexpectedRollbackException,提示“事务被标记为只回滚”。很多人第一次遇到这个异常会一头雾水,明明代码里已经catch住了为什么还回滚。原因是内层事务拦截器在捕获异常时已经把整个事务标记成了 rollback-only,外层即使吞掉异常,最后也提交不了。要解决只有两条路:要么让内层用REQUIRES_NEW独立出去,要么别把可能失败的子逻辑放在同一个事务边界里。
3.2 隔离级别和数据库实现的差异
隔离级别这块,Spring 的默认值是ISOLATION_DEFAULT,意思是不指定,跟随数据库。MySQL InnoDB 默认是REPEATABLE_READ,Oracle 和 PostgreSQL 默认是READ_COMMITTED。这个差异带来的行为区别非常大,同一个应用换了个数据库,脏读、不可重复读、幻读的表现可能完全不同。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现手段 |
|---|---|---|---|---|
READ_UNCOMMITTED | 可能 | 可能 | 可能 | 基本无隔离 |
READ_COMMITTED | 否 | 可能 | 可能 | MVCC 快照按语句生成 |
REPEATABLE_READ | 否 | 否 | InnoDB 下基本避免 | MVCC 快照按事务生成 + 间隙锁 |
SERIALIZABLE | 否 | 否 | 否 | 全部读加锁 |
有个细节值得说:在REPEATABLE_READ下,事务开始后第一次读建立快照,后续所有普通查询都读这个快照,所以别的会话刚提交的数据你是看不到的。这会导致一种很典型的线上现象——运营在后台改了配置,应用读到的还是旧值,重启或者事务结束后才生效。看到这种“改了不生效”的反馈,先想想是不是被 RR 的快照挡住了。
另外,@Transactional(isolation = ...)一旦设置了非DEFAULT的值,Spring 在判断能否复用已有连接时会额外检查隔离级别是否一致,不一致就触发挂起、重新取连接。所以不要随手在方法上写一个isolation,它可能在你没意识到的地方多消耗一个连接。
3.3 只读事务、超时、回滚规则这三个开关
readOnly = true不是摆设。对 MySQL 来说,它会提示驱动和数据库这是个只读事务,InnoDB 可以减少一些内部开销;对某些数据库(比如加了只读路由的主从架构),它还能帮你把请求路由到从库。但要注意,readOnly只是提示,不是强制,在只读事务里执行写操作,多数数据库不会报错,只会默默写进去。所以它带来的是性能优化,不是安全约束,别指望用它来防止误写。
timeout的单位是秒,默认是数据库的innodb_lock_wait_timeout(通常 50 秒)。这个默认值在生产里往往太长,一个被慢 SQL 卡住的事务能占着连接和锁将近一分钟,连锁反应很严重。我给核心业务方法设的超时通常是 3 到 10 秒,具体看这条链路最长能容忍多久。
rollbackFor是另一个高频坑。Spring 默认只在遇到RuntimeException和Error时回滚,遇到受检异常(Exception及其子类)默认不回滚。如果你的方法签名上写着throws IOException,然后内部真的抛了,事务会照常提交,脏数据就这么留下了。所以我的习惯是统一写@Transactional(rollbackFor = Exception.class),宁可多回滚也不要少回滚。
@Transactional( rollbackFor = Exception.class, // 受检异常也回滚 timeout = 5, // 秒,防止慢 SQL 长期占锁 propagation = Propagation.REQUIRED ) public void settleOrder(Long orderId) throws IOException { // 业务逻辑 }顺便提一句事务里不该做的事:不要在事务方法里调用远程接口、发消息、做文件上传。这些操作耗时不可控,一次网络抖动就能让事务从几十毫秒变成几秒,锁的持有时间跟着成倍上涨。我见过一个事务里嵌了三次外部 HTTP 调用,高峰期直接把数据库连接池打满,最后是靠把远程调用全挪出事务边界才解决的。
4. 事务失效的十种现场与排查手法
4.1 代码层面的坑:自调用、权限、异常被吞
自调用排第一,前面已经说过。判断方法很简单:看调用方是不是从代理对象发起的。快速验证的办法是在方法里打印this.getClass(),如果是XxxServiceImpl而不是XxxServiceImpl$$EnhancerBySpringCGLIB,说明你拿到的根本不是代理。解决办法有三种:把方法挪到另一个 Bean 里(最干净)、注入自身代理(@Lazy注入自己或者AopContext.currentProxy(),需要开exposeProxy)、或者干脆用TransactionTemplate。
第二个是方法可见性。Spring 5 之后,protected和包级可见的方法在某些代理模式下也能被拦截,但强烈建议统一用public,别去赌代理实现细节。private方法一定不生效,final方法在 CGLIB 代理下也不生效,因为没法覆写。
第三个是异常被吞。这段代码你一定见过:
@Transactional public void doSomething() { try { repository.save(entity); otherService.remoteCall(); // 这里抛了异常 } catch (Exception e) { log.error("记录一下", e); // 吞掉了,事务正常提交 } }异常被catch住没有重新抛出,拦截器感知不到失败,自然就提交了。要么在 catch 里重新抛一个运行时异常,要么显式调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。后者我一般只在“确实需要吞掉异常但要回滚”的场景用,因为它可读性比较差,新人看了容易迷惑。
第四个是对象压根不归 Spring 管。new XxxService().doSomething()这种写法,代理根本不存在,注解等于注释。第五个是调用发生在异步线程里,ThreadLocal不传递,事务上下文丢失。这两个都属于“看起来在 Spring 里,实际上不在”。
4.2 数据库与连接池层面的坑:引擎、隐式提交、连接超时
代码没问题,事务还是不生效,那就要往数据库层看。最常见的是存储引擎不支持事务。MySQL 5.5 之前的默认引擎是 MyISAM,它不支持事务,注解加得再标准也白搭。现在虽然默认 InnoDB,但历史迁移过来的表、手写建表语句忘了指定引擎的表,还是可能踩到。执行show table status where name = 'xxx'看一眼Engine字段,两秒钟的事。
第二种是DDL 隐式提交。MySQL 里执行CREATE TABLE、ALTER TABLE、TRUNCATE这类 DDL 语句,会自动提交当前事务,不管你在不在事务里。有些数据修复脚本里混着 DDL 和 DML,跑完发现部分数据没回滚,就是这个原因。
第三种是连接池配置与事务超时不匹配。事务超时设了 30 秒,但连接池的maxLifetime只有 20 秒,连接可能在事务中途被回收,表现是偶发的“连接已关闭”异常。反过来,connectionTimeout设得太短,并发高的时候获取连接直接失败,业务方法根本没进去,日志里看像“事务没生效”,实际是连接没拿到。
第四种是手动改了 autoCommit。有些老代码或者工具类会在方法里执行conn.setAutoCommit(true),把事务默默提交掉。这类问题排查起来最费劲,建议在代码规范里直接禁止业务代码操作autoCommit。
4.3 一张排查速查表
我把这些年遇到的情况整理成一张表,出问题时按顺序过一遍,基本都能定位。
| 现象 | 最可能的原因 | 快速验证方式 |
|---|---|---|
| 抛异常了但数据还在 | 受检异常未配rollbackFor,或异常被 catch | 检查注解和 catch 块 |
| 某个方法完全不起作用 | 自调用、private/final 方法、对象不是 Bean | 打印getClass()看是否代理 |
| 外层正常返回却报回滚异常 | 内层事务被标记 rollback-only | 搜索UnexpectedRollbackException栈 |
| 批量插入部分成功部分失败 | 循环里用了REQUIRES_NEW或分了批提交 | 看循环里的传播行为配置 |
| 事务里查不到刚插入的数据 | 查询走了另一个线程或另一个数据源 | 检查线程与数据源路由 |
| 偶发连接超时、请求卡住 | 事务过长占连接,或嵌套传播导致连接翻倍 | 看连接池活跃数与慢 SQL |
| 特定表的事务不生效 | 存储引擎是 MyISAM | show table status看 Engine |
| 主从延迟越来越严重 | 大事务导致 binlog 堆积 | 看主库事务大小与从库延迟曲线 |
提示:排查事务问题,第一步永远是打开事务相关的 debug 日志(把
org.springframework.transaction的日志级别调成 DEBUG),它会明确告诉你事务是在哪里开启的、是新建还是加入、提交还是回滚。比在代码里到处打断点快得多。
5. 分布式事务:先想清楚是不是真的需要
5.1 落地之前必须先问的三个问题
一上来就选型分布式事务方案,是很多团队的通病。我通常会先问三个问题。第一个:这两个操作能不能放进同一个库?很多所谓的跨库需求,其实是早期分库分表拍脑袋导致的,业务上根本没必要拆。第二个:能不能接受最终一致?绝大多数业务(发积分、发通知、更新统计)都能容忍秒级延迟,只要能保证最终对得上,就没必要上强一致的方案。第三个:失败之后能不能人工兜底或者自动重试?如果补偿逻辑清晰、有对账任务,那用最轻的方案就够了。
这三个问题问完,真正需要强一致分布式事务的场景其实少之又少。我参与过的项目里,最后落到 2PC/XA 的只有一个资金类的核心链路,剩下的全是用最终一致解决的。这个顺序很重要,别让技术选型倒推业务设计。
5.2 主流方案的对比与适用边界
| 方案 | 一致性 | 性能 | 业务侵入 | 适用场景 |
|---|---|---|---|---|
| XA / 2PC | 强一致 | 差(同步阻塞、锁到二阶段) | 低 | 短事务、强一致要求、参与方少 |
| TCC | 最终一致 | 较好 | 高(要写三个方法) | 资金、库存等需要显式预留的资源 |
| Saga | 最终一致 | 好 | 中(要写补偿) | 长流程、跨多个服务 |
| 本地消息表 | 最终一致 | 好 | 中 | 有可靠数据库的消息投递 |
| 事务消息 | 最终一致 | 好 | 低 | 已有消息中间件且支持半消息 |
选型的核心矛盾不在“哪个更先进”,而在“你愿意为一致性付出多少可用性和复杂度”。XA 的问题不在于理论不好,而在于它在两阶段之间锁着资源不放,参与方一多,整体可用性就是乘积下降。TCC 看起来美好,但空回滚、悬挂、幂等这三个问题不处理干净,线上一定会出事:Try 没到就来了 Cancel 叫空回滚,Cancel 比 Try 先到叫悬挂,重复调用就是幂等没做好。这三个点必须在设计阶段就落到接口约定里,事后补非常痛苦。
5.3 本地消息表:我这几年用得最多的落地方式
如果要在“足够可靠”和“足够简单”之间选一个平衡点,我最推荐本地消息表。核心思路一句话:把要发的消息和业务数据写在同一个本地事务里,再由一个独立的任务把消息捞出来投递。
@Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { // 1. 业务数据落库 orderMapper.insert(dto.toEntity()); // 2. 消息和业务在同一个本地事务里落库 outboxMapper.insert(OutboxMessage.pending("ORDER_CREATED", dto.getOrderId())); // 事务提交,两条记录要么都在,要么都不在 }然后由一个定时任务扫描outbox表里status = PENDING的记录,投递成功后置为SENT,失败则按退避策略重试。这里有几个实操细节值得强调:扫描要用select ... for update skip locked或者按状态加索引 + 分片,避免多个实例重复捞同一批;投递必须做幂等,消费方用业务唯一键做去重;outbox表要定期归档,否则数据量涨起来扫描会越来越慢。
这套方案的缺点也很明显:依赖数据库,消息延迟取决于扫描间隔,通常是秒级。但对绝大多数场景来说,秒级的最终一致完全够用,而且它的可观测性特别好——消息状态就摆在那张表里,出了问题直接查表,比翻中间件的日志快多了。
6. 大事务、批量操作与性能调优
6.1 大事务到底伤在哪,怎么拆
“大事务”没有严格的字数定义,我的判断标准是:执行时间超过 1 秒,或者涉及行数超过几千行,或者内部有远程调用,三条命中任意一条就该拆。它的危害是多维的:锁持有时间长,其他事务排队;连接被长时间占用,并发能力被卡死;undo log 持续增长,回滚时可能比正向执行还慢;binlog 一次性写入量过大,从库回放出现明显延迟。
拆的方向有四个。第一,把远程调用移出事务边界,先落库再调用,或者先调用再落库,用状态机记录中间态。第二,批量化改成小批次提交,一次 500 行而不是一次性 5 万行。第三,把非核心的从属操作异步化,主流程只做必须同步完成的部分。第四,用状态机替代长事务,把一个跨多步的业务拆成多个短事务,每一步用状态字段标记进度,失败后从断点继续。
第四点最容易被忽略,但它往往是治本的。比如“导入 10 万条数据”,如果做成一个大事务,中途失败全部回滚,重来一遍还是从零开始。改成“先建任务记录,然后分批处理,每批独立事务 + 更新进度”,失败后可以从上次的进度继续,用户体验和系统压力都会好很多。
6.2 批量写入的批次大小该怎么算
批次大小不是拍脑袋定的,它受几个硬约束限制。第一是max_allowed_packet,单条 SQL 加上所有参数的总大小不能超过它,默认值常见是 4MB 或 16MB。第二是单次事务的 redo log 写入量,写得太多会导致一次刷盘时间过长,出现明显的毛刺。第三是锁的范围,批次太大容易和别的业务产生锁冲突。
一个粗略的估算方法:假设一行数据序列化后约 1KB,批次 500 行就是 500KB 左右的 SQL 负载,距离 4MB 有足够余量,同时单次事务的执行时间通常在百毫秒级,是个比较稳的起点。实际调优时我会用 200、500、1000 三个档位压测,看吞吐和 P99 延迟的拐点,通常拐点出现在 500 到 1000 之间。
// 分批提交:每 500 条一个独立事务,避免一个巨大事务 public void batchImport(List<Item> items) { int batchSize = 500; for (int i = 0; i < items.size(); i += batchSize) { List<Item> batch = items.subList(i, Math.min(i + batchSize, items.size())); transactionTemplate.executeWithoutResult(status -> itemMapper.batchInsert(batch)); } }顺带说一个容易被忘的点:批量插入时如果表上有唯一索引,冲突会导致整批失败。这时候要么用insert ignore(注意它会把其他错误也一起忽略),要么用on duplicate key update,要么在应用层先查一遍。三种方式各有代价,得看业务对“部分成功”的容忍度。
6.3 事务与缓存、消息、异步的先后顺序
这几个东西和事务凑在一起,顺序错了就是数据不一致的源头。缓存这块,公认的做法是先更新数据库,事务提交后再删缓存,而不是更新缓存。删缓存要挂到afterCommit上,避免“缓存删了但事务回滚了”导致缓存被回填成旧值。更稳妥的还有延迟双删和订阅 binlog 变更,前者简单但只能降低概率,后者最可靠但需要额外组件。
消息这块,核心原则是不要在事务提交前投递。事务没提交就发消息,消费者可能查不到数据,出现“消息到了但数据还没有”的经典问题。要么用本地消息表,要么用支持事务消息的中间件,要么至少在afterCommit里发。异步任务同理,@Async方法如果在事务方法内部被调用,它可能在外层事务提交之前就开始执行,读到的还是旧数据。这类问题不会稳定复现,往往是压测或者低峰期才偶尔冒出来,排查成本极高。
注意:
@Async和@Transactional写在同一个方法上,异步生效但事务不生效——因为异步执行时已经换了线程,事务拦截器的顺序也可能让注解失效。这两个注解要分开到两个方法上,由外层方法调用内层的异步方法。
7. 几个我踩过的坑和几条硬经验
说几个印象深的。有一次线上出现偶发的“订单状态更新丢失”,日志完全正常,重试一次就好了。查了两天才发现,更新语句用的是乐观锁版本号,而事务的隔离级别是REPEATABLE_READ,两个并发请求在同一个快照下读到相同的版本号,其中一个更新失败后被上层 catch 掉当成“无需处理”忽略了。修法很简单,把更新失败当成异常抛出并触发重试,但根因是对隔离级别和乐观锁的交互理解不到位。
还有一次是连接池被打满,查下来是某个查询方法上标了NOT_SUPPORTED,而它被一个循环调用了几百次,每次都要挂起外层事务、取新连接、执行、归还。看起来每次都很短,但架不住次数多,而且挂起期间外层连接一直占着。后来把循环挪到事务外面,问题立刻消失。
几条我现在的固定习惯:注解统一写rollbackFor = Exception.class;核心方法一定设timeout;远程调用一律移出事务;批量操作统一用模板分批提交;REQUIRES_NEW用之前先数一下连接池够不够;任何“事务后”的动作都挂afterCommit;出问题先开org.springframework.transaction的 DEBUG 日志再动手查。
最后分享一个定位事务问题的笨办法但很管用:在事务方法的第一行和最后一行各打一条带线程名和时间的日志,同时在数据库侧开general_log或者慢查询日志,两边时间一对,就能看出事务到底开了多久、期间执行了哪些 SQL、卡在哪一步。比对着代码空想快十倍不止。事务这东西,原理就那么多,剩下的全是场景经验,踩过一次、想明白一次,下次就能一眼认出来。