尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring @Transactional 事务原理与失效场景排查指南

Spring @Transactional 事务原理与失效场景排查指南 前一阵同事跑来找我说给一个 service 方法加了 Transactional代码里明明手动抛了 RuntimeException数据库里的数据却还是被改了。他第一反应是“Spring 的 bug”后来查了半天才发现问题出在方法内部 self-invocation 上——事务注解压根没触发代理。这种场景在刚接触 Spring 的人里太常见了。这其实说明一个很核心的问题很多人把 Transactional 当成一段“魔法”加上了就以为数据库操作会自动进入事务。但它背后的机制不是魔法而是Spring AOP 的代理模型 事务同步管理器的调度。如果你没搞清楚这条链路遇到“注解不生效”“事务没回滚”“嵌套事务死锁”这类问题就只能靠瞎猜。这篇文章我想用一个完整链路把 Transactional 从注解到数据库提交的整个工作原理拆开包括代理怎么生成、事务怎么开启、回滚怎么判定、传播行为怎么决定嵌套事务关系、哪些场景会导致失效。最后也会给出一些我在实际项目里验证过的排查思路和工程化建议尽量做到看完能直接解决你手上的问题。1. 先理解一个基本前提Transactional 到底“管”的是哪一段代码1.1 注解的对象不是数据库而是方法执行边界很多人的误区在于把 Transactional 想象成“给数据库操作加事务”。准确地说Transactional 管理的是目标方法执行期间的数据库事务边界。比如你有一个方法Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { orderDao.insert(dto.getOrder()); itemDao.batchInsert(dto.getItems()); } }这里的事务边界就是createOrder方法的整个执行过程。方法开始之前Spring 会去拿一个数据库连接把 autocommit 关掉方法正常执行完就提交方法抛出了满足回滚条件的异常就回滚。所以本质上一个 Transactional 方法对应了一个事务单元。方法内部不管调用了多少个 DAO、操作了多少张表只要都在同一个数据库连接上执行就处于同一个事务中。这也是为什么我们经常说“事务应该放在 service 层而不是 dao 层”——因为 service 方法才能合理划定“多个操作一起成功、一起失败”的业务边界。1.2 类级别注解与方法级别注解的组合规则Transactional 既可以标注在类上也可以标注在方法上。实际生效的规则是标注在类上表示该类的所有 public 方法都参与事务。标注在方法上表示仅该方法参与事务。方法级注解优先级高于类级注解。这个规则在源码层面由 Spring 的SpringTransactionAnnotationParser处理。它先解析方法上的注解如果方法上没有再回退到类上的注解。这里有一个关键点Transactional 只对 public 方法生效。这不是文档随便写的而是因为代理机制的局限性决定的后面我会详细说。但你要记住一个原则写事务方法时统一用 public别为了“封装”去标 private否则你看到的将是静默失效。2. 代理模型Spring 是怎么在方法前后“悄悄插入”事务逻辑的2.1 容器里的 Bean 已经不是你以为的那个 Bean要理解 Transactional必须先理解 Spring 的 AOP 代理。Spring 容器在初始化单例 Bean 的时候如果检测到该类需要被事务增强会创建一个代理对象覆盖到容器中而你注入到其他类里的引用实际是代理对象的引用。可以用一个不那么严谨但非常好懂的类比代理对象就像一个“替身演员”。你原本的方法是一个演员本人代理对象是替他走位、喊话、处理杂务的替身。调用方以为自己在和演员本人对话其实每一句话都是替身先接住替身处理完事务逻辑再转交给演员本人执行。对于事务场景这个代理对象执行的逻辑是根据方法上的事务属性决定是否开启事务、如何传播。真正调用你的业务方法。根据业务方法抛出的异常决定事务提交还是回滚。这一段逻辑在 Spring 里的核心实现类是TransactionInterceptor它实现了MethodInterceptor接口里面的invoke方法就是用模板方法模式把上述流程串起来的。如果你看过它的源码会发现核心流程并不难理解难的是它和事务同步管理器之间的协作。2.2 JDK 动态代理与 CGLIB两种“造替身”的方式Spring 生成事务代理有两种方式代理方式实现原理适用条件默认情况JDK 动态代理基于接口生成代理类目标类至少实现一个接口Spring Boot 2.x 之前常用CGLIB通过字节码技术生成目标类的子类不要求接口可代理普通类Spring Boot 2.x 之后默认你平时用 Spring Boot 写业务默认就是 CGLIB所以即使你的 Service 类没有实现接口Transactional 也能生效。但理解这两者的区别依然重要因为很多老项目改造时会遇到“代理类型不同导致的行为差异”。JDK 动态代理生成的代理对象只能挂在接口上方法调用会通过InvocationHandler分发。CGLIB 则是生成目标类的子类改写方法逻辑。两者的一个共同限制是private 方法、final 方法、static 方法都无法被代理。因为 private 方法不能被子类重写final 方法也不能static 方法根本不属于实例调用链。这就是为什么 Transactional 标在这些方法上会静默失效。2.3 验证代理是否生成一个很直接的现场确认法如果你怀疑某个 Bean 真的没被事务增强最直接的办法是调试或打印看看对象类型。在 Spring Boot 里你可以临时写个 CommandLineRunner 或测试类SpringBootTest public class ProxyCheckTest { Autowired private OrderService orderService; Test void checkProxy() { System.out.println(orderService.getClass()); } }如果打印出来类名里带着$$EnhancerBySpringCGLIB$$或者$Proxy说明代理已经生效。如果直接打印出原类说明这个 Bean 压根没被 Spring 的 AOP 机制处理——那 Transactional 当然不生效。这一招在排查“事务失效”问题时特别有用。它能快速把问题定性为“代理没生成”还是“代理生成但没走事务路径”避免你在错误的方向上浪费时间。3. 事务生命周期一个事务从开始到结束到底走了几步3.1 事务开启前Spring 在做什么准备工作当请求通过代理调用到TransactionInterceptor.invoke时Spring 会经历一个比较复杂但很有条理的决策流程。我把它拆成几个关键动作从目标方法上解析事务属性包括传播行为、隔离级别、超时时间、回滚规则。检查当前是否存在事务。判断依据存放在TransactionSynchronizationManager中这个类使用 ThreadLocal 保存当前线程的事务资源。根据传播行为决定“加入当前事务”还是“新建事务”。真正开启事务的动作是拿到一个数据库连接然后执行connection.setAutoCommit(false)。从这一步开始后续在这个连接上执行的 SQL 都不会立即持久化而是进入了未提交状态。这里我需要额外提一点Spring 的事务管理是基于“连接”的不是基于“会话”或“请求”的。一个事务绑定了一个数据库连接而这个连接被绑定到当前线程上。多个 DAO 方法在同一个事务线程内执行时通过DataSourceUtils.getConnection拿到的必须是同一个连接这样才能让多个 SQL 共享同一个事务。这也是为什么 Transactional 方法里如果有异步线程参与事务就会失效——异步线程拿到的连接已经不是同一个了。3.2 显式提交、回滚和隐藏的清理动作业务方法执行完毕后如果一切正常事务管理器会执行 commit如果抛出了满足回滚规则的异常事务管理器会执行 rollback。但你以为这就结束了吗并没有。Spring 在提交或回滚之后还要做清理工作恢复连接的 autocommit 状态、把连接归还给连接池、清理 ThreadLocal 里的事务资源。如果这个清理动作没做好线程池复用时就会出现“上一个事务的状态污染了下一个请求”的问题。这个设计被称为事务同步Transaction Synchronization。TransactionSynchronizationManager里注册了一批同步回调在事务提交后、回滚后、资源清理前等节点都会触发。Spring 内置的一些组件比如缓存同步、事件发布也是基于这套机制实现的。所以你平时看到“事务方法里发个事件结果事件在事务提交前就被消费了”之类的问题本质就是因为对事务生命周期的理解少了一层——事务不仅仅是 SQL 的 commit/rollback它还关联了同步逻辑。3.3 传播行为嵌套调用时多个事务方法如何相处现场最常见的问题之一A 方法调用 B 方法A 和 B 都标了 Transactional那这俩到底是同一个事务还是两个事务答案由传播行为决定。Spring 定义了 7 种传播行为其中最常用的几个传播行为含义实际场景REQUIRED有事务就加入没有就新建默认值绝大多数业务场景REQUIRES_NEW无论如何都新建事务挂起已有事务日志记录、审计、独立小事务NESTED有事务就嵌套子事务没有就新建部分失败可回滚的场景依赖数据库 savepointSUPPORTS有事务就加入没有就在无事务状态执行只读查询方法MANDATORY必须有事务否则抛异常强制事务上下文的场景NEVER禁止事务有事务就抛异常明明是查询却误加了事务的场景NOT_SUPPORTED挂起当前事务以无事务方式执行事务里执行长时间外部调用理解传播行为的关键不是背定义而是要明白底层动作REQUIRED 不会新建事务而是把当前线程的事务资源继续沿用。REQUIRES_NEW 会挂起当前事务的连接再从连接池拿一个新连接所以这两个事务互不干扰。NESTED 不是真正的新事务而是基于当前事务设置一个 savepoint内层事务回滚时可以只回滚到 savepoint 位置。我在项目中看到很多“嵌套事务死锁”的问题根本原因就是 REQUIRES_NEW 用得太多。A 事务持有连接 1B 方法开 REQUIRES_NEW 又去拿连接 2如果两个连接同时操作同一条记录就可能出现互相等待锁的情况。这类问题单看代码很难发现必须结合数据库连接池的活跃连接和锁等待状态来排查。4. 回滚判定规则异常类型不等于回滚逻辑4.1 为什么受检异常默认不会触发回滚这是 Transactional 最反直觉的地方。很多人以为只要方法抛出异常事务就会回滚其实 Spring 默认的回滚规则是只回滚 RuntimeException 和 Error。受检异常checked exception默认不回滚。为什么这么设计因为 Spring 设计者认为受检异常通常代表“业务层面可处理的异常”比如库存不足、余额不够这些情况也许业务代码已经兜底处理不需要强制回滚整个事务。而 RuntimeException 代表系统级的、不可预期的错误比如空指针、类型转换异常这种情况下数据很可能处于未知状态必须回滚保护一致性。但这个设计在实际项目中经常导致事故。最典型的就是业务代码里抛了一个自定义的受检异常比如BusinessException extends Exception事务居然正常提交了数据全写进去了。等测试发现问题只能手动补数据。4.2 rollbackFor 和 noRollbackFor 的正确打开方式为了避免上面的问题我在团队里定的规矩很简单所有业务异常统一继承 RuntimeException或者在 Transactional 上显式声明 rollbackFor。如果你要处理受检异常必须在注解里明确指定回滚规则Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) throws BusinessException { // 业务代码 }这样无论是 RuntimeException 还是受检异常只要是 Exception 的子类都会触发回滚。如果你有某些异常不希望回滚可以用noRollbackForTransactional(noRollbackFor CustomerNotifyException.class)注意这里的判定逻辑Spring 是用异常类型做匹配不是精确匹配而是isAssignableFrom关系。也就是说rollbackFor Exception.class能匹配所有 Exception 子类。4.3 事务方法内部 try-catch 吞掉异常等于亲手关闭回滚开关这一节要单独提醒try-catch 是事务回滚的头号杀手。看一段很典型的错误写法Transactional public void createOrder(OrderDTO dto) { try { orderDao.insert(dto.getOrder()); itemDao.batchInsert(dto.getItems()); } catch (Exception e) { log.error(订单创建失败, e); } }这个方法永远不会回滚。因为异常在业务方法内部被捕获了没有传播到代理层。TransactionInterceptor 只在目标方法抛出的异常到达代理层时才做回滚判断。你在方法里把异常吞掉Spring 看不到任何异常自然认为“一切正常”最终 commit。这是排查事务问题时第二大概率的原因仅次于自调用。排查时看到一个异常日志但数据还是落库了第一反应就应该是异常是不是在方法内部控制住了5. 事务为什么会失效一份完整的失效场景排查清单5.1 自调用同一个类里 this 调 this代理绕过了你先看失效场景排行榜第一位自调用。Service public class OrderService { Transactional public void outerMethod() { innerMethod(); } Transactional public void innerMethod() { orderDao.insert(...); } }当外部调用outerMethod时外层确实经过代理事务开启。但外层的 this 是代理对象吗注意outerMethod内部调用innerMethod()时这里的 this 指向的是目标对象本身不是代理对象。也就是说内部调用是直接调真实对象的方法而不是通过代理调用。所以内层方法上的 Transactional 完全不会执行。这种情况下有一个更隐蔽的后果即使外层没有加 Transactional内层加了也不生效。因为你从外部进入的是外层方法外层没有事务增强代理对象直接调用目标对象的 outerMethodouterMethod 内部再调用 innerMethod 时走的完全是 this 调用链事务注解被无视。这也解答了很多人的困惑“为什么我把 Transactional 加到私有方法上或者加到非事务方法调用的方法上完全没反应”因为你根本没走代理。解决方法有几种把内部方法拆到另一个 Service Bean 中通过注入的代理对象调用。在当前类注入自身代理或者使用AopContext.currentProxy()需要在配置里开启 exposeProxy。最简单也最彻底的做法从设计上避免同类内的事务方法互相调用把事务边界放到最外层方法上。5.2 方法可见性与 final 修饰符的坑前面已经说过Spring Boot 2.x 默认 CGLIB代理类通过继承目标类实现。CGLIB 无法覆盖 private 方法没有继承关系的默认方法也不行。final 方法同样无法重写。所以一旦你把 Transactional 加到 private 方法上Spring 不会报错连日志都不会有一句。它的表现就是没有任何事务甚至方法执行中抛异常也不会回滚。解决办法事务方法必须 public。如果确实需要“内部私有逻辑也参与事务”可以把这部分逻辑提取到一个内部类或者独立 Service 中。5.3 类没有被 Spring 管理时的静默状态还有一种很隐蔽的情况你的事务方法类上忘了标注Service等其他组件注解Spring 容器里根本没有这个 Bean。如果直接用new OrderService()手动创建对象然后调用带 Transactional 的方法Spring 的 AOP 完全不会介入。因为事务代理的生成发生在 Spring 容器初始化 Bean 的过程中手动 new 的对象不会经过 BeanPostProcessor自然不会有任何增强。排查思路很简单检查这个类有没有被Service、Component等注解标注检查 IDEA 的 Spring 视图里能不能看到这个 Bean。5.4 多线程与事务的天然冲突事务绑定的核心资源存在 ThreadLocal 里线程之间互不可见。如果你在事务方法里 new 了一个线程去执行数据库操作那个新线程会开启自己的连接甚至可能没有事务最终导致数据不一致。这不是 Transactional 的问题而是事务模型的天然边界。任何涉及线程池、异步调用的场景都需要重新设计事务方案比如把异步操作放到事务提交后的监听器里执行或者用编程式事务在子线程中手动控制。5.5 一个完整的现场排查链路最后我整理一套排查链路项目里遇到事务问题时按顺序走基本能定位 90% 的问题确认类是否被 Spring 管理确认方法是否是 public。打印 Bean 的实际类型确认代理是否生成。查看方法是否被同类内其他方法直接调用自调用。检查方法内部是否 try-catch 吞掉了异常。查看抛出的异常类型确认是否属于默认回滚范围如果不是确认有没有 rollbackFor。如果事务有嵌套确认传播行为检查是否用了 REQUIRES_NEW 导致多个连接。查看数据库连接池的活跃连接数排除连接耗尽问题。每次都按这套流程走从不靠猜。很多你觉得是“Spring 灵异事件”的问题最后都是上面某一条。6. 隔离级别与锁别只在面试题里背概念真实业务真的会被它坑6.1 从脏读、不可重复读到幻读Transactional 还有一个容易被忽略的属性隔离级别。它控制的是一个事务在执行过程中能看到其他事务的哪些“中间状态”。READ_UNCOMMITTED能读到其他事务未提交的数据会出现脏读。业务场景中几乎不用除非你明确知道自己在做什么。READ_COMMITTED只能读到已提交的数据解决脏读但不可重复读仍然存在。REPEATABLE_READ同一事务内多次读取同一行结果是相同的解决不可重复读。SERIALIZABLE最强隔离级别事务串行执行幻读也不会出现但并发性能极差。MySQL InnoDB 默认是REPEATABLE_READ所以你在 Spring Boot 项目里即使不配置隔离级别实际上也是这个级别。听起来很简单但真实的坑在于隔离级别必须结合数据库本身的锁机制来理解。比如 InnoDB 在 REPEATABLE_READ 下通过 MVCC多版本并发控制解决了普通读的不可重复读问题但在SELECT ... FOR UPDATE等当前读场景下它会靠记录锁和间隙锁来实现。如果你在事务里写了FOR UPDATE并发请求可能会被卡住很多“偶尔超时”“偶尔死锁”的问题源头都在这里。6.2 事务里喂给别人的“脏数据”往往先发生在自己身上我见过一个很典型的业务事故一个下单流程里先插入订单再在同一个事务里调用库存系统扣减库存。库存系统是另一个服务这个调用在事务中间。因为事务还没提交下游服务如果反查订单信息看到的还是旧数据。这类问题就是“事务中间状态泄漏到外部”。解决方案不外乎两种思路要么把外部调用挪到事务提交之后要么把整个流程重新设计成异步事件驱动。在事务方法里做网络调用这件事本身就值得警惕——你不光要担心外部系统失败导致事务回滚还要担心外部系统在事务未提交时就读取到了不完整的数据。关于这一点我在团队里的规矩就是事务方法里尽量不碰远程调用如果一定要碰必须放在最后且做好超时控制。6.3 超时时间一个被很多人遗忘的属性Transactional 的timeout属性非常实用但很少有人配置。它指定事务的最大执行秒数默认值取决于底层事务管理器比如使用 Spring Boot 默认的DataSourceTransactionManager时如果是 MySQL最终由 JDBC 驱动和数据库层面决定实际效果并不统一。生产环境建议显式设置合理的超时时间避免某些异常情况下事务长期持有连接不放把连接池拖垮。设置方式Transactional(timeout 5) public void syncData() { // 长时间任务 }如果事务执行超过 5 秒会抛出事务超时异常并触发回滚。这个属性在日志系统、数据同步、批量处理等场景特别有用。有一点值得说明Spring 的事务超时不完全等同于数据库层面的锁等待超时。它更多是指整个事务从开始到提交的耗时上限底层通过定时检查实现。数据库锁等待超时则是由数据库自身控制的比如 InnoDB 的innodb_lock_wait_timeout。两者要配合使用单靠任何一个都不足以覆盖所有问题。7. 编程式事务当注解满足不了需求时的兜底方案Transactional 很好用但它是一个“静态”的事务边界。有些场景需要在代码中动态控制事务粒度比如一个循环里每条数据处理都需要独立提交一条失败不影响其他数据。这时候注解反而成了负担因为整个方法是一个大事务任何一条失败都会全部回滚。这种场景我一般直接用TransactionTemplate。它的核心思路是把要执行的业务逻辑封装成一个回调对象然后由事务模板统一管理事务。Service public class ImportService { private final TransactionTemplate transactionTemplate; public ImportService(TransactionTemplate transactionTemplate) { this.transactionTemplate transactionTemplate; } public void importData(ListRow rows) { for (Row row : rows) { transactionTemplate.execute(status - { try { saveOne(row); return null; } catch (Exception e) { status.setRollbackOnly(); throw e; } }); } } }用setRollbackOnly显式标记回滚比依赖异常类型判断更可控。如果想实现“单条失败不影响后续数据”在 catch 里记录日志后继续循环即可。为什么在聊 Transactional 的文章里非要提 TransactionTemplate因为我在不少项目里看到“事务边界不清晰”导致的长事务问题一个批量导入接口被 Transactional 包住一次性处理几万条数据事务长时间不提交连接池被耗尽数据库锁范围极大。这类问题用注解非常难解用 TransactionTemplate 就可以精确控制每条数据的提交时机。另外注解事务对动态事务属性比如根据参数决定超时时间、传播行为的表达能力很弱程序化事务则灵活得多。如果你正在设计一个对事务要求很精细的系统建议把两种方式都掌握按场景选择。8. 工程化的几条经验让 Transactional 从“魔法”变成可控工具8.1 事务方法要短、小、内聚这是我反复强调的一条。事务方法的时间越长持有的连接时间越长锁的范围越大并发能力越差。一个事务方法里如果既有数据库操作又有远程调用、文件上传、消息发送那这个事务基本就是个隐形炸弹。最理想的事务方法应该是从数据库读取必要数据做业务判断写数据库然后结束。我见过最夸张的一个案例是一个事务方法里循环调用了 50 次第三方接口平均响应时间 8 秒。整个流程下来数据库连接被占用将近 30 秒并发稍微一高连接池直接耗尽。后来重构先把外部接口全部挪出事务事务内只剩两次数据库写操作问题当场消失。8.2 事务粒度与锁粒度同步收紧很多人在优化事务的时候只关注“缩短事务时间”忽略了一个更直接的因素事务里操作的数据行数。同样是 1 秒的事务如果只锁 1 行并发没问题如果锁了 1 万行那就是灾难。所以写事务方法时SQL 一定要带精确的查询条件避免使用SELECT *或缺少索引的过滤条件导致锁的范围扩大。InnoDB 的锁是加在索引上的没有索引的查询会锁更多行这是一个很容易被忽视却又影响巨大的细节。8.3 把“事务是否生效”当作测试断言的一部分最后分享一个我个人的习惯事务相关的代码不能只跑功能测试还应该加上“回滚验证”。最省事的做法是写一个测试方法故意让事务方法抛异常然后断言数据库里没有新增数据。如果这条测试没过说明事务没有生效。这个用例看起来有点笨但它能捕捉到自调用、异常被吞、代理未生成等一大批问题。我每次重构完事务相关代码都会跑一遍这个测试心里才踏实。Test void createOrder_shouldRollbackWhenExceptionThrown() { assertThatThrownBy(() - orderService.createOrderWithException()) .isInstanceOf(RuntimeException.class); assertThat(orderDao.count()).isZero(); }这个习惯帮我挡过至少三次事故。项目里真正危险的不是你不懂原理而是你自以为懂了原理代码却在你没注意的角落悄悄绕过了代理、吞掉了异常。在我自己的经验里Transactional 学习曲线最陡的地方其实不是它本身的语法而是它背后那一整套“代理 事务同步 异常映射”的运行框架。一旦把这些底层机制弄明白遇到问题就不再是“翻资料碰运气”而是可以顺着代理链路一步步定位。希望这篇文章也能帮你建立这个思维框架下次再碰到事务相关的诡异问题能比我先一步搞定。
返回列表