Java事务底层机制与实战:@Transactional失效排查和传播行为选型

发布时间:2026/11/3 23:51:25
Java事务底层机制与实战:@Transactional失效排查和传播行为选型 “加个Transactional就完事了”——这句话我在代码评审里见过太多次每次都得花半小时解释为什么它可能是错的。Java 事务这套东西表面上看是框架帮你包了一层实际上从数据库连接、AOP 代理、线程绑定到异常类型判断每一环都能让事务悄无声息地失效而且失效的时候往往不报错数据该脏还是脏等到对账出问题才被发现。这篇文章我想把 Java 事务从底往上捋一遍事务到底是怎么落到一根数据库连接上的注解是怎么被翻译成代理调用的七种传播行为各自对应什么样的真实业务以及那些让人抓狂的“注解加了但没生效”该怎么一步步排查。不管你是刚接触 Spring Boot 不久还是写了几年业务代码但一直没深究过这块应该都能从中找到能直接抄作业的部分。1. 一个注解背后的三层机制为什么Transactional经常“假生效”1.1 事务的物理载体同一根连接上的连续操作先把框架放一边想想数据库层面事务是什么。你打开一个客户端执行BEGIN然后 update、insert、delete最后COMMIT。这一串操作之所以能作为一个整体是因为它们跑在同一根 TCP 连接上数据库服务端为这根连接维护了一个会话级的事务上下文。换一根连接就是另一个事务彼此看不见对方未提交的修改也谈不上一起回滚。这个认知特别重要。Java 里所有事务框架干的事本质上都是在解决“怎么保证这一批操作复用同一根连接并且在合适的时机 commit 或者 rollback”。声明式事务也不例外它做的事情是把“取连接、绑连接、开事务、执行、提交/回滚、释放连接”这一套模板代码用 AOP 包起来让你只写业务。所以当你怀疑事务没生效时第一个该问的问题永远是这几条 SQL 真的跑在同一根连接上吗大部分疑难杂症答案都藏在这个问题里。框架内部靠TransactionSynchronizationManager这个类做连接与线程的绑定底层是一个ThreadLocalMapDataSource, ConnectionHolder。方法进入事务时把连接塞进当前线程方法执行期间所有 DAO 调用都从这个 ThreadLocal 里取取不到才去连接池新拿。这就解释了为什么事务天然不跨线程——你在事务方法里new Thread()去写库子线程的 ThreadLocal 是空的它会去连接池另拿一根跟主线程的事务毫无关系。异步任务、线程池、CompletableFuture里做写操作全部踩这个坑。1.2 AOP 代理注解只有落在代理对象上才算数Spring 声明式事务的实现基础是代理。容器启动时它会扫描所有 Bean发现有方法或类标了Transactional就给这个 Bean 生成一个代理对象放到容器里你Autowired注入拿到的是代理不是原始对象。调用代理方法时代理先开事务再反射调用原始对象的目标方法最后提交或回滚。代理有两种生成方式目标类实现了接口走 JDK 动态代理没实现接口走 CGLIB 生成子类。两种方式有个共同点——代理对象只拦截从外部进来的调用。这句话是理解所有失效场景的钥匙。代理的invoke方法里写的是“开事务 → 调目标方法 → 提交”如果你在目标方法内部用this.b()调另一个带注解的方法b()这个调用根本不经过代理b()上的注解就是一行注释事务自然不存在。我见过一个真实案例某订单服务在createOrder()里调this.deductStock()两个方法都标了Transactional开发以为扣库存是独立事务结果库存扣减失败时整个订单一起回滚跟预期完全相反。后来把deductStock挪到另一个 Bean 里才解决。这类问题不会报错日志里也看不出异常只能靠对机制的理解去发现。1.3 自调用失效的三种绕法及其代价知道原因之后解法就有得选了但每种都有代价得看场景挑。最直接的是拆类把需要独立事务的方法挪到另一个 Service 里注入进来调用。这种方式最干净不依赖任何框架特性缺点是要多维护一个类有时候纯粹为了事务拆类会让代码组织显得别扭。第二种是注入自身Autowired private OrderService self;然后self.deductStock()。Spring 允许自注入默认允许循环依赖的情况下拿到的就是代理对象。这种写法在 Spring Boot 2.6 之后要注意循环依赖默认被禁了得开spring.main.allow-circular-references不太优雅。第三种是AopContext在启动类或配置类上加EnableAspectJAutoProxy(exposeProxy true)然后代码里写((OrderService) AopContext.currentProxy()).deductStock()。这种方式不用拆类也不用自注入但代码里出现了对 AOP 基础设施的强依赖单元测试时如果没有代理上下文会直接抛异常可测性变差。我的经验是短期救火用 AopContext长期重构拆类。自注入这种写法我一般不推荐它把循环依赖这个隐患埋进了容器启动流程里出问题时排查成本很高。另外还有个偏门解法是把方法声明成public并且通过ApplicationContext拿 Bean 再调本质和自注入一样没必要。2. 传播行为不是背出来的七个值对应哪些真实业务2.1 REQUIRED 与 REQUIRES_NEW一个退款场景的推演传播行为这块面试题里背七个值谁都会但落到代码里能不能选对完全是另一回事。我用一个退款场景把最常用的两个值讲透。假设退款流程分三步写退款单、调外部渠道退款、更新订单状态。如果整个方法标REQUIRED默认值三步在同一个事务里渠道调用失败抛异常退款单和订单状态一起回滚干净利落。但渠道调用是远程 HTTP可能超时几十秒这段时间数据库事务一直开着行锁不释放并发高的时候能把连接池耗干。这时候有人会想把“调用渠道”这一步改成REQUIRES_NEW让它独立事务。问题来了——REQUIRES_NEW会挂起当前事务从连接池另拿一根连接开新事务。如果外层事务已经持有某行的锁内层新事务再去更新同一行就会自己把自己锁死等锁超时。这个坑我在一个支付对账系统里亲眼见过日志里全是Lock wait timeout exceeded查了两天才定位到是嵌套事务导致的死锁。所以REQUIRES_NEW的正确用法是内外层操作的数据不重叠且内层失败不应该影响外层已完成的动作。比如“记录操作审计日志”这种日志写失败不该让主业务回滚用REQUIRES_NEW就合适——前提是日志表和业务表没有行级冲突。2.2 NESTED 的保存点机制与它容易翻车的地方NESTED是最容易被误解的一个。它不像REQUIRES_NEW那样另开连接而是在当前事务里打一个保存点savepoint内层失败时回滚到保存点外层还能继续。听起来很美好但它有两个硬性前提数据库支持保存点MySQL InnoDB 支持Oracle 支持某些数据库不支持以及数据源必须配JdbcTransactionManager或DataSourceTransactionManager用 JTA 的话行为会不一样。还有个细节NESTED内层抛异常被外层 catch 住之后如果不显式处理外层事务在提交时可能仍然报UnexpectedRollbackException。原因是内层回滚到保存点这个动作在某些驱动实现里会把连接的“只回滚”标记打上外层提交时发现标记还在就抛异常。稳妥的做法是内层方法用REQUIRES_NEW隔离或者在外层捕获异常后确认事务状态。我把七个传播行为整理成一张表方便对照传播行为当前有事务当前无事务典型场景REQUIRED加入新建绝大多数业务方法SUPPORTS加入非事务执行只读查询可有可无MANDATORY加入抛异常强制要求上游开事务的内部方法REQUIRES_NEW挂起后新建新建审计日志、独立补偿记录NOT_SUPPORTED挂起后非事务执行非事务执行大批量导入避免长事务NEVER抛异常非事务执行明确不允许事务的场景NESTED保存点嵌套等同 REQUIRED可部分回滚的子流程2.3 只读事务不是摆设但很多人用错了Transactional(readOnly true)这个配置我观察下来两极分化一类人从不用一类人到处加。它的实际作用有三层一是给数据库驱动一个提示MySQL 会把连接设为只读模式某些情况下能触发优化二是 Hibernate 这类 ORM 会跳过脏检查dirty checking不再为实体维护快照查询大结果集时内存和 CPU 都能省一截三是告诉团队“这个方法不该写库”是一种契约。但它有个容易踩的点只读事务里如果真写了库MySQL 默认不会报错只是把写操作当成普通语句执行事务语义就乱了。所以别指望它当保险用。另外readOnly只在方法级别生效类级别标了之后方法级别可以覆盖但很多团队不知道这个优先级导致类上全局只读、某个写方法忘了覆盖线上出现“数据莫名写不进去”的怪现象。3. 事务失效的排查链路从现象到根因的完整复盘3.1 第一步永远是确认代理到底生成了没有遇到“事务没生效”别急着改代码先确认代理存在。最土但最有效的办法是在启动日志里开logging.level.org.springframework.transactionDEBUG看方法调用时有没有打印Creating new transaction或者Participating in existing transaction。一行日志就能区分是“代理没生成”还是“代理生成了但事务没开”。如果没有这行日志往下查三类原因类没被 Spring 管理忘了Service或者被new出来的、方法是private或finalCGLIB 无法覆写 final 方法private 方法代理拦不到、类本身是final的CGLIB 无法继承。这几种情况有个共同特征——不报错静默失效。我建议在 CI 里加一条静态检查规则扫出“标了Transactional但不是 public 的方法”能提前拦掉一批问题。还有一种隐蔽情况类实现了接口注入时用的是接口类型但方法注解只标在实现类上。JDK 动态代理是基于接口的接口方法上没有注解某些旧版本 Spring 会读不到。现在的版本处理得更智能了但为了稳妥注解统一标在实现类的方法上最保险。3.2 异常被吞掉事务自然不知道该回滚事务回滚的触发条件是方法向外抛出未捕获的异常。代理在invoke里包了 try-catch只有异常冒泡到代理层它才会执行 rollback。所以下面这段代码的事务是无效的Transactional public void createOrder(Order order) { try { orderMapper.insert(order); inventoryService.deduct(order.getSkuId()); } catch (Exception e) { log.error(下单失败, e); // 异常被吞了代理层感知不到 } }deduct失败后异常被 catch 住方法正常返回代理认为一切顺利直接 commit。订单插进去了库存没扣数据不一致。这个坑的可怕之处在于它看起来“很健壮”——有 try-catch有日志代码评审时很容易被放过。正确做法是要么不 catch 让它抛出去要么 catch 之后重新抛一个运行时异常。如果确实需要记录日志捕获后throw new BizException(...)是标准姿势。第二个坑是异常类型不匹配。Transactional默认只对RuntimeException和Error回滚受检异常Exception的直接子类里非运行时的那部分不回滚。你写了个自定义的CheckedBizException extends Exception抛出去之后事务照样提交。解决办法是显式声明Transactional(rollbackFor Exception.class)这条我建议作为团队规范写进代码模板无脑加上成本为零收益很大。3.3 多数据源和连接不一致导致的静默失效微服务架构下多数据源很常见DS注解、动态数据源、AbstractRoutingDataSource这些方案用起来方便但跟事务配合时容易翻车。核心矛盾在于事务开启时连接已经绑定到 ThreadLocal 了而路由数据源是在取连接的那一刻根据上下文决定用哪个库。如果事务先开、数据源后切事务里的所有操作都会跑在第一个库里切库动作形同虚设。反之如果先切库再开事务倒是能对上但代码里顺序稍微写错就出问题。我遇到过的典型症状是读主库写从库事务里读到了旧数据或者两个库的操作本该各自回滚结果一个回滚一个提交。排查方法是在每个数据源操作前后打印DataSourceContextHolder.get()的值看它跟实际连接的库是否一致。更彻底的做法是让事务管理器和数据源绑定关系在配置层面就明确别在业务代码里动态切。还有一种情况是用了 MyBatis-Plus 的多数据源插件但事务管理器还是单数据源的这种配置下事务只对默认库生效其他库的操作是自动提交的。这个坑藏得深配置类里两行代码的差异行为完全不同。3.4 一张速查清单贴在手边随时用排查事务问题我习惯按这个顺序过一遍命中率很高检查项命令/方法命中症状代理是否生成开 DEBUG 日志看Creating new transaction无日志 代理没生效是否自调用检查this.xxx()调用注解在内部方法上失效方法是否 public看方法签名private/final 无法代理异常是否被吞搜 try-catch 块不回滚异常类型看rollbackFor配置受检异常不回滚引擎是否支持show table status看 EngineMyISAM 无事务数据源切换时机打印路由上下文操作跑错库提示MySQL 的 MyISAM 引擎不支持事务建表时如果没指定ENGINEInnoDB默认在某些老版本配置下会是 MyISAM这时候注解加得再对也没用。上线前统一检查一遍表的引擎ALTER TABLE xxx ENGINEInnoDB能补上。4. 隔离级别与锁并发扣库存时你到底在防什么4.1 四种隔离级别分别挡住什么问题隔离级别这个概念教科书上讲得抽象落到“扣库存”这种具体场景就好理解了。两个事务同时扣同一件商品的库存stock 10各自执行update stock set num num - 1 where id 1如果不做任何隔离控制可能卖出 11 件。隔离级别和锁机制就是干这个的。四种级别对应的问题隔离级别脏读不可重复读幻读MySQL 默认READ UNCOMMITTED可能可能可能否READ COMMITTED不会可能可能否REPEATABLE READ不会不会InnoDB 通过间隙锁基本避免是SERIALIZABLE不会不会不会否MySQL 的 InnoDB 默认用REPEATABLE READOracle 和 PostgreSQL 默认READ COMMITTED。这个差异在跨库迁移或者团队技术栈切换时经常引发问题同样的代码在 MySQL 上跑得好好的迁到 Oracle 上出现不可重复读同一个事务里查两次余额不一样。REPEATABLE READ的实现靠 MVCC多版本并发控制事务开始时拍一个快照后续读都基于这个快照所以看不到别的事务的提交。但快照读只对普通 SELECT 生效update、delete、select ... for update走的是当前读会去读最新版本这就引出了间隙锁和死锁那一堆事。4.2 乐观锁与悲观锁在事务里的分工光靠数据库隔离级别防并发超卖性能代价太大。实际业务里一般是悲观锁和乐观锁二选一配合事务使用。悲观锁就是select ... for update在事务里锁住目标行别人想改得排队。优点是简单可靠缺点是并发度低、容易死锁。用的时候有个细节一定要走索引如果where条件没命中索引InnoDB 会锁全表准确说是锁所有扫描到的行加上间隙并发场景下直接雪崩。我见过一个秒杀接口因为for update的条件字段没建索引压测时 QPS 掉到个位数。乐观锁是加版本号update ... where id ? and version ?更新影响行数为 0 就说明被人抢先了重试或者报错。优点是并发度高、不占用数据库锁缺点是要处理重试逻辑高冲突场景下重试率会很高。选型上我的经验是读多写少、冲突概率低用乐观锁写多冲突高、短事务用悲观锁。库存扣减这种场景如果用了 Redis 预扣减数据库层的乐观锁就够如果是直接怼数据库悲观锁更稳妥。两种方式都必须放在事务里for update离开事务就失效了乐观锁的“查询版本号 更新”如果不在一个事务里中间可能被别人改掉。4.3 长事务的隐性代价比你想的严重一个事务从BEGIN到COMMIT的时长直接决定了它持有锁的时长。长事务的危害不止是锁等待还有三个容易被忽略的副作用。第一是undo log 膨胀。InnoDB 的 MVCC 靠 undo log 保存历史版本长事务会让 undo log 无法清理磁盘占用持续增长严重时触发ibdata空间告警。第二是主从延迟。大事务在主库上执行很久binlog 一次性同步到从库从库回放需要同样长的时间读写分离的读库可能读到落后几分钟的数据。第三是连接池耗尽。每个长事务占一根连接并发一上来连接池见底后续请求全部阻塞在获取连接那一步表现为接口整体超时。避免长事务的实操手段把远程调用、文件 IO、大批量循环这些操作从事务里挪出去批量插入分批提交每批 500 到 1000 条能拆成多个短事务的绝不合成一个。监控上可以在数据库侧查information_schema.innodb_trx看trx_started超过一定阈值的事务提前预警。5. 编程式事务注解搞不定的那些场景5.1 TransactionTemplate 比注解更可控的地方声明式事务方便但它有个结构性限制事务边界等于方法边界。一个方法里如果只有中间三行需要事务前后还有别的操作注解就没法精确控制。这时候该上TransactionTemplate。Autowired private TransactionTemplate transactionTemplate; public void processBatch(ListOrder orders) { for (Order order : orders) { // 每条订单一个独立事务一条失败不影响其他 transactionTemplate.execute(status - { orderMapper.insert(order); inventoryService.deduct(order.getSkuId()); return null; }); } }这段代码如果用Transactional标在processBatch上整批是一个事务一条失败全批回滚。用TransactionTemplate包在循环里粒度就精确到单条。批量导入、批量对账这类场景这个差别很关键。TransactionTemplate还能灵活地设置回滚条件。比如你想根据返回值决定是否回滚transactionTemplate.execute(status - { int affected orderMapper.updateStatus(orderId, PAID); if (affected 0) { status.setRollbackOnly(); // 手动标记回滚 } return affected; });这种“业务判断决定回滚”的逻辑注解方式很难表达setRollbackOnly是编程式事务的杀手锏。5.2 什么时候该放弃声明式事务除了粒度和回滚条件还有几种场景声明式事务力不从心。需要精细控制传播行为又不想污染注解的比如一个方法在 90% 的调用里用REQUIRED在 10% 的调用里需要REQUIRES_NEW注解写死了改不了编程式可以按调用方传参决定。事务里要执行回调或者事件发布的Spring 的TransactionalEventListener能解决一部分但它依赖事务同步机制遇到非事务上下文就失效。编程式事务里可以手动控制提交后再发事件逻辑更清晰。需要在一个方法里开多个独立事务的比如“先写主表主表写成功后异步写日志表”异步那部分用REQUIRES_NEW会挂起主事务用TransactionTemplate新开一个更直观。5.3 分布式事务的边界什么时候该停下来想想单库事务靠数据库跨库跨服务就得上分布式事务方案了。这块内容能写一本书我这里只讲判断标准——什么时候真的需要分布式事务什么时候是过度设计。我见过太多团队在压根不需要强一致的场景上硬上分布式事务框架结果复杂度飙升、性能暴跌最后业务方还抱怨慢。判断标准其实很简单这笔数据不一致能不能靠事后对账 补偿修回来能修回来的就别上强一致用本地消息表 定时补偿这套轻量方案成本低、可观测性好。修不回来、且不一致会造成资金损失或者合规问题的才值得上两阶段提交或者 TCC 这类方案。还有一点分布式事务的复杂度跟参与方数量呈指数关系。两个服务之间的事务还好控制五个以上基本就是灾难现场。设计阶段就该尽量把事务边界收敛在一个服务内跨服务的操作靠状态机 幂等来保证最终一致这比事后救火省事得多。还有个小技巧值得分享不管用哪种分布式事务方案每个参与方的接口都要做成幂等。网络抖动导致的重试是常态幂等做不好补偿逻辑会把你拖进更深的坑。幂等的实现方式唯一索引、状态机校验、Redis 去重都行选一种顺手的一直用下去。我个人在实际项目里的体会是Java 事务这块最难的从来不是记住七个传播行为或者四种隔离级别而是建立“事务边界”这个意识。每写一个写库的方法先问自己三件事这个方法的事务边界在哪、失败了该回滚哪些数据、并发时会不会互相踩。这三问答清楚了注解怎么写、传播行为怎么选都是水到渠成的事。反过来边界没想清楚注解加得再花哨也只是心理安慰。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询