资讯动态

Spring事务与传播机制详解:七种传播行为与事务失效场景

发布时间:2026/9/26 8:02:00 来源:尧图企业网站定制
做Java开发这么多年面试别人或者被面试Spring事务和传播机制永远是绕不开的一道坎。很多人张口就能背出七种传播行为什么REQUIRED、REQUIRES_NEW、NESTED但真到线上排查问题或者让你亲手设计一个涉及多数据源、嵌套调用的业务时往往就露馅了。所以这期Java外功精要第6篇我准备把Spring事务及其传播机制掰开揉碎讲一遍。不讲那些贴在你永远没看过的源码注释里的废话而是结合我这些年真金白银踩过的坑把事务从概念、原理到实战、排查一次性讲透。无论你是刚准备校招的应届生还是工作了几年想系统性梳理知识点的老手这篇文章都值得你花十五分钟认真读完。1. 事务的本质与Spring为什么要管这件事先搞清楚最底层的问题事务到底是什么说白了事务就是一组要么全部成功、要么全部失败的操作集合。你去银行转账从A账户扣钱和往B账户加钱这两个操作必须同时成功或同时失败中间任何一步出了岔子钱不能平白无故消失。数据库的ACID属性——原子性、一致性、隔离性、持久性——就是对事务的完整定义。其中原子性靠undo log保证持久性靠redo log保证隔离性靠锁机制或MVCC保证而一致性是前三者协同工作后的最终结果。这是MySQL、Oracle、PostgreSQL等关系型数据库的通用逻辑。那问题来了既然数据库自己就支持事务为什么我们还要折腾Spring事务想象一个用户注册的经典场景。业务代码里你要做三件事写入用户主表、写入用户角色关系表、发送一条注册成功后的初始化消息。这三件事分散在三个不同的Mapper方法里任何一个失败前面已经执行的SQL就必须一起回滚。如果每个Mapper方法各自默默提交了自己那个事务那注册到一半系统崩了你就得到一条幽灵用户——主表有记录角色表没有。这时候就需要一个总指挥官来掌控全局。Spring最聪明的地方就是把这件脏活累活从业务代码里抽离出来。你不需要在Service层每个方法里手动写connection.setAutoCommit(false)也不需要自己管理成功了commit、失败了rollback这一大堆跟业务八竿子打不着的样板代码。一个Transactional注解甩上去剩下的交给Spring。我见过太多人把Spring事务当成某种魔法好像加个注解就能自动保证正确性。事实上Spring事务管理的本质是基于AOP动态代理实现的声明式事务拦截。它做的事情用一句话概括就是在目标方法执行前开启事务执行成功提交事务执行抛出异常则回滚事务。打个不恰当的比方Spring事务就像考场里的监考老师。它不帮你答题只负责按规则发卷开启事务、收卷提交事务、发现作弊就判零分回滚。你自己的业务逻辑该怎么写还怎么写但整个考试秩序由它维护。Spring事务管理的三大核心组件很多初学者一上来就抱住Transactional啃这是舍本逐末。真正理解Spring事务框架绕不开它的核心抽象层——三个顶梁柱级别的接口。1.1 PlatformTransactionManager事务总指挥这个接口是整个Spring事务框架的中枢定义了事务管理的标准动作public interface PlatformTransactionManager { TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException; }看到没三个方法获取事务、提交事务、回滚事务。干净利落。Spring根据不同的数据访问技术提供了多种实现类。最常用的是DataSourceTransactionManager适用于JDBC和MyBatis这类基于java.sql.Connection的技术栈。另外还有JpaTransactionManager适用于Spring Data JPA / Hibernate和JtaTransactionManager适用于跨多个数据源的全局事务。实际使用中你引入spring-boot-starter-jdbc后Spring Boot会自动配置好DataSourceTransactionManager。这也是为什么我们用Transactional时从来没手动配置过事务管理器——背后Spring Boot已经悄悄把一切都安排好了。1.2 TransactionDefinition事务规则说明书这个接口描述了事务的一些行为特性最重要的三个属性隔离级别Isolation事务之间互相隔离的程度对应数据库的四种隔离级别。传播行为Propagation事务方法之间互相调用时事务如何传播。这是本文的重头戏。超时时间Timeout事务允许运行的最长时间超时则自动回滚默认-1表示不超时。另外还有两个容易被忽略的属性只读状态Read-only和回滚规则Rollback Rules。只读事务可以引导数据库引擎做性能优化比如MySQL的START TRANSACTION READ ONLY回滚规则决定了哪些异常会触发回滚。1.3 TransactionStatus事务运行状态机TransactionStatus是当前事务的实时心电图代表了事务当前运行的状态和你与事务交互的接口。它主要提供这些信息当前事务是否是一个新开启的事务还是参与了一个已有的事务当前事务是否可以回滚比如某些场景下事务已被标记为rollback-only手动设置回滚标记status.setRollbackOnly()提醒Spring这个事务最终只能回滚顺带提一句编程式事务。虽然声明式事务Transactional是绝对的主流但编程式事务在一些复杂业务中依然有用武之地。比如你需要在同一个方法里先做一批操作根据中间结果决定是继续还是回滚声明式事务的粒度不好控制编程式事务就能顺手解决。Spring提供了一个模板类TransactionTemplate用法非常简洁Autowired private TransactionTemplate transactionTemplate; public void transfer() { transactionTemplate.execute(status - { try { userMapper.reduceBalance(uid, 100); accountMapper.increaseBalance(sid, 100); return Boolean.TRUE; } catch (Exception e) { status.setRollbackOnly(); return Boolean.FALSE; } }); }这种方式的优点是可以精确控制事务边界缺点是侵入业务代码。大多数场景下Transactional依然是首选。2. Transactional注解声明式事务的魔法与真相Transactional是Spring声明式事务的门面。它的魔力来自于Spring AOP。当你把一个方法标上TransactionalSpring容器在启动时会用CGLIB或JDK动态代理生成一个代理对象代理对象会在目标方法前后织入事务逻辑。业务流程是代理对象拦截方法调用在方法执行前通过PlatformTransactionManager.getTransaction()开启或参与一个事务执行业务方法方法正常返回则commit提交事务方法抛出异常则rollback回滚事务理解了这个代理机制很多事务失效的问题就迎刃而解了。我后面会专门用一节来盘点事务失效的经典场景。先看看Transactional有哪些我们可以调整的参数。很多人只会用光秃秃的Transactional其实它的属性控制着事务的各种边界行为。2.1 隔离级别isolation属性控制事务的隔离级别。Spring定义了五个常量与数据库隔离级别一一对应Spring隔离级别数据库含义脏读不可重复读幻读DEFAULT使用数据库默认隔离级别MySQL默认为REPEATABLE_READ取决于数据库取决于数据库取决于数据库READ_UNCOMMITTED读未提交可能可能可能READ_COMMITTED读已提交避免可能可能REPEATABLE_READ可重复读避免避免可能SERIALIZABLE串行化避免避免避免Transactional(isolation Isolation.REPEATABLE_READ) public void doSomething() { // ... }关于隔离级别有一件事必须说清楚Spring的isolation参数最终会转换为数据库连接的connection.setTransactionIsolation()调用但如果你用的是MySQL的InnoDB引擎在事务已开启的情况下切换隔离级别会被自动隐式提交所以实际使用中这个配置必须在事务开启前就设置好。Spring由代理实现通常在执行getTransaction()时就会设置这一点Spring处理得比较妥当。2.2 回滚规则这是Spring事务中最重要也最容易踩坑的属性。很多人以为事务只要抛出任何异常就会自动回滚其实Spring的默认回滚规则是运行时异常RuntimeException和Error才会回滚检查异常Checked Exception默认不回滚。老实说这个默认规则曾经坑了无数人。因为很多Java程序员习惯用检查异常来表达业务失败。比如Transactional public void createOrder(Order order) throws BusinessException { orderMapper.insert(order); inventoryMapper.deduct(order.getProductId(), order.getQuantity()); throw new BusinessException(库存不足); }当BusinessException是检查异常extends Exception时这个方法抛出异常后事务不会回滚订单照样入库库存照样扣减。你会得到一个没有库存、没有订单完整性的烂摊子。解决方案是指定rollbackForTransactional(rollbackFor Exception.class) public void createOrder(Order order) throws BusinessException { // ... }这里强烈建议统一使用rollbackFor Exception.class。虽然你可以写成更精确的rollbackFor BusinessException.class但从工程实践的健壮性角度Exception.class可以兜底所有未知异常。我见过太多项目因为漏配rollbackFor导致线上数据错乱的事故了。还有一个容易被忽视的参数是noRollbackFor。如果你明确知道某些业务异常不应该回滚整个事务——比如发消息失败但是主业务已经完成不想让发消息的失败导致核心数据回滚——可以显式排除Transactional(rollbackFor Exception.class, noRollbackFor MessageSendException.class) public void createOrder(Order order) { // 订单写入 // 扣库存 // 发消息如果失败不影响主事务 }2.3 只读事务与超时readOnly true是一个很有迷惑性的参数。它并不是严格意义上的不允许写——而是给底层驱动和数据库一个这个事务只读的提示让数据库可以做优化。在MySQL上Spring会把连接的connection.setReadOnly(true)MySQL会自动把事务标记为只读事务只读事务内部仍然可以执行写操作但会丢弃部分undo日志以节省开销。在JPA/Hibernate中readOnly会跳过脏检查flush mode设为MANUAL提升性能。适合readOnly true的场景纯粹的查询方法。比如报表统计、详情查询。这样做的两个好处一是数据库可以针对性优化二是代码语义上明确告知其他开发者这里不该有写操作。关于超时timeout属性单位是秒。它通过TransactionDefinition传给事务管理器底层调用java.sql.Statement.setQueryTimeout()等方式让JDBC层在执行SQL时遵守这个超时时间。适合对执行时间非常敏感的场景比如秒杀系统的库存扣减。2.4 事务管理器transactionManager参数用于指定使用哪个事务管理器。当你的项目里只有一个DataSourceTransactionManager时Spring会自动装配你不需要填。但当一个项目里有多个数据源比如主库从库、业务库日志库或者一个配了Druid一个配了HikariCP就必须显式指定Transactional(transactionManager orderTransactionManager) public void doOrder() { // ... }最典型的坑就是项目里有两个数据源Spring Boot会自动创建两个PlatformTransactionManager如果不加transactionManager限定Spring在启动时会因为找不到唯一的PlatformTransactionManager而报错。即使不报错有时也会莫名其妙选择了错误的事务管理器导致事务根本没作用到目标数据源上。遇到这种问题去检查这个参数准没错。3. 传播机制Spring事务的灵魂设计传播机制绝对是Spring事务的最高频考点也是日常开发中区分会用和理解的分水岭。我先用一句话概括传播机制的本质当Transactional标注的方法被另一个事务方法调用时当前的事务要如何与已存在的事务协同工作——是共用一个大事务还是另起炉灶是要求必须有事务还是禁止有事务。Spring定义了七种传播行为我先用一张表给出全貌再逐个说透。传播行为英文枚举当前无事务时当前有事务时实际场景1. 必须事务REQUIRED新建事务加入当前事务默认值绝大多数业务场景2. 支持事务SUPPORTS无事务执行加入当前事务查询方法有事务就用没有就算了3. 强制事务MANDATORY抛异常加入当前事务不允许无事务调用4. 新建事务REQUIRES_NEW新建事务挂起当前事务新建独立事务日志记录、审计、发送消息独立成败5. 不支持事务NOT_SUPPORTED无事务执行挂起当前事务无事务执行对事务不敏感的大批量操作6. 禁止事务NEVER无事务执行抛异常不允许在事务中执行7. 嵌套事务NESTED新建事务创建Savepoint嵌套事务子事务回滚不影响父事务已提交部分但要求JDBC 3.0以上下面逐个展开。3.1 REQUIRED最常用的默认传播行为REQUIRED是Spring默认的传播行为也是使用频率最高的。它的规则是外层没有事务当前方法开启一个新事务。外层已有事务当前方法直接加入这个事务成为同一个物理事务的一部分。这个加入的语义很重要。因为外层事务和加入它的事务共用一个数据库连接、同一个事务边界。内层方法的任何异常如果最终向外抛出会把外层事务也一起标记回滚。如果出现Self-invocation问题后面事务失效章节会详细讲REQUIRED也会失效方法会以无事务状态执行。我们日常写的业务Service几乎全部都是REQUIRED。用户下单时需要写订单表、扣库存表、更新优惠券状态这三步用一个事务任何一个失败全部回滚这正是REQUIRED的标准用法。3.2 SUPPORTS佛系事务锦上添花SUPPORTS的规则是外层有事务就加入外层没事务就以非事务方式运行。这个传播行为在事务性的查询场景中很有用。比如一个查询方法在事务里调用它就能利用事务的隔离特性做一致性读取在无事务环境下调用它也不会报错照常查询。什么业务适合SUPPORTS我一般用来标记那些可有可无的辅助查询方法。比如在readOnly true的报告生成方法里调用某个统计方法如果这个统计方法本身没有标注它会在无事务下执行如果标注了SUPPORTS且外层是只读事务它就会在外层事务里执行。不过说实话这个传播行为比较鸡肋实际业务中用得很少见到它主要是在面试题里。3.3 MANDATORY强制已有事务不允许裸奔MANDATORY表示当前方法必须运行在事务中。如果调用方没有开启事务直接抛出IllegalTransactionStateException。这种设计用来强制约束方法的调用上下文。比如你有一个内部金额变更方法这个方法绝不能在没有事务的环境下执行——因为如果调用方代码不考虑事务这个金额变更就可能出现只改了一半就提交的脏状态。用MANDATORY相当于告诉所有调用者你必须在事务里调用我否则我直接罢工。MANDATORY在业务代码中同样少见因为几乎所有方法都默认在REQUIRED事务里。它最大的价值是作为一种制度性约束在团队协作中防止低级错误。3.4 REQUIRES_NEW关键时刻必须另起炉灶REQUIRES_NEW的规则不管外层有没有事务当前方法直接挂起外层事务开启一个全新的独立事务。新事务的提交/回滚完全独立于外层事务。这是分布式系统和异步消息场景中最常用的传播行为。单独说起来有点抽象我举一个真实的例子。假设你要处理一笔订单业务主流程是下单扣库存这属于一个事务。下单成功后需要记录一条操作日志到日志表。如果日志写入失败你不希望整个下单流程一起回滚——毕竟订单已经创建成功了日志记录丢了是小事但把订单回滚掉是灾难。用REQUIRES_NEW就能完美解决Transactional public void createOrder(Order order) { orderMapper.insert(order); inventoryMapper.deduct(order.getProductId(), order.getQuantity()); writeLog(order); // 这个方法的传播行为是REQUIRES_NEW } Transactional(propagation Propagation.REQUIRES_NEW) public void writeLog(Order order) { logMapper.insert(new OpLog(CREATE_ORDER, order.getId())); }writeLog在独立事务中执行即使日志插入失败也只会回滚writeLog自己的事务createOrder的订单和库存操作不受影响。这里有个非常重要的注意点REQUIRES_NEW会挂起外层事务意味着外层事务在此期间的数据库操作还处于未提交状态新事务如果去读这些未提交的数据可能读到临时的、最终可能被回滚的中间状态。比如上面例子中如果writeLog里需要读取订单号来写日志而订单号是createOrder里刚生成还未提交的就会遇到问题。解决方式通常是让订单号在开启事务前就生成用UUID或者提前从序列取号而不是依赖数据库的auto_increment的自增值。另外需要警惕的是REQUIRES_NEW如果被滥用会导致数据库连接池快速耗尽。因为在挂起外层事务的同时连接池仍然占有外层事务的连接REQUIRES_NEW方法又要占用一个新连接。极端情况下一个外层事务里嵌套10个REQUIRES_NEW就意味着同时持有11个数据库连接。并发高时很容易Connection is not available, request timed out。所以REQUIRES_NEW要用在真正意义上必须独立提交/回滚的场景不能把REQUIRES_NEW当成避免大事务的银弹来用。3.5 NOT_SUPPORTED暂时把事务晾在一边NOT_SUPPORTED规则外层有事务时先把外层事务挂起当前方法以无事务方式运行。什么时候用得上对于某些耗时特别长的操作比如大批量数据导入一次处理十万条数据、生成报表、发送批量通知等如果它们运行在事务里会导致连接长时间持有、锁长时间不释放、undo log疯狂累积。这种场景适合把事务挂起来让当前方法痛快地无事务执行。比如你有一个负责报表生成的方法因为数据量太大一条条处理需要十分钟。如果这个方法也在一个事务里那么这个事务要运行十分钟连接被占住不说MySQL的undo log要膨胀到好几个G严重影响数据库性能。这时就可以用NOT_SUPPORTEDTransactional public void doDailyReport() { // 准备业务数据 prepareData(); // 事务操作 // 生成报表不需要事务 generateReportFile(); // Transactional(propagation Propagation.NOT_SUPPORTED) }需要非常明确的是NOT_SUPPORTED执行期间其他线程对数据的修改都能被你看到因为当前没有事务隔离你做的大批量修改每一条都会立即自动提交无法整体回滚。所以这个传播行为只适合结果可容忍部分失败或者操作本身就是幂等重试的场景。3.6 NEVER明确拒绝事务NEVER的规则外层有事务就直接抛异常当前方法只允许在无事务环境中运行。坦白讲NEVER是我觉得最有性格的传播行为。它用来强制一个方法不能出现在事务调用链上。比如你有一段代码无论如何都不能跟其他数据库操作放在一个事务里它可能会执行某些DDL操作比如建临时表MySQL中DDL操作会导致隐式提交放在事务里会造成连接状态混乱。正常业务中极少见到NEVER。了解它主要是为了面试八股文的完整性。不过观念上它很有价值它提醒我们传播机制不仅仅是可以有事务的选择题也是必须不能有事务的约束工具。3.7 NESTED保存点式的子事务NESTED的规则外层无事务时新建事务等价于REQUIRED外层有事务时在当前事务内创建一个保存点Savepoint后续代码在保存点之后的子事务中执行。NESTED最核心的价值在于局部回滚如果子事务内部抛出异常只回滚到保存点不影响父事务已执行的操作。父事务的最终提交仍要等所有子事务都成功父事务回滚时子事务也随之回滚。这么说可能有点抽象。想象一下你在上传一份批量文件一个事务里循环处理文件中的每一行。如果某一行处理失败你不希望整个文件所有的处理全部回滚——那样太浪费了。但你又不能提前提交因为后面某一行失败可能导致整个批次数据不一致。NESTED正好解决这个问题每一行开一个NESTED子事务这一行出错就回滚到这一行开始时的保存点其他行不受影响如果整个批次整体失败所有子事务一起回滚。要特别注意NESTED和REQUIRES_NEW的核心区别NESTED的回滚是部分回滚即回滚到Savepoint外层事务的先前操作不受影响。REQUIRES_NEW的回滚是独立回滚新事务的回滚与外层事务完全无关。NESTED依赖JDBC的Savepoint特性JDBC 3.0MySQL的InnoDB引擎支持良好。NESTED的提交依赖外层事务提交如果外层事务最终回滚即使内层NESTED子事务已经结束了它的数据也会跟着回滚。所以在既想隔离单个操作失败的影响又想让所有操作成为一个最终整体提交/回滚的整体时NESTED是比REQUIRES_NEW更贴合需求的方案。3.8 一个经典的事务在传播中失效场景很多人容易误解传播机制在同类之间嵌套时的行为。简单说如果两个方法都是REQUIRED那么它们会共享同一个物理事务。内层方法标注REQUIRED不是命名一个新事务而是加入已有事务。这个结论的一个直接后果是如果你在REQUIRED方法A内调用了同样标注REQUIRED的方法BB不管怎么抛异常、怎么被捕获只要最终异常没从A抛出去Spring就不知道这个异常事务照样会提交。很多人在这里栽跟头以为B如果有校验不通过B内部捕获后不继续抛A就会自动感知并回滚——实际上A和B是同一个事务B的小动作Spring一无所知。这也是为什么我把传播机制放在解释为什么的章节里讲而不是丢一堆枚举值了事。4. 事务失效的8个经典场景从原理到实战理解了AOP代理的机制后事务失效的问题其实是最好排查的逻辑题。这里我把实际开发中遇到的失效场景做一个系统盘点每一个都是真实踩过的坑。4.1 自调用导致事务完全失效这是排名第一的坑。Service public class OrderService { public void createOrder(Order order) { // 业务逻辑 this.innerMethod(order); } Transactional public void innerMethod(Order order) { // 事务相关操作 } }createOrder调用this.innerMethod()表面看起来innerMethod标了Transactional应该开启事务。但事实是this是当前对象本身不是Spring的代理对象。通过this直接调用的方法不走代理不触发事务拦截器Transactional完全没生效。解决方案有三种把innerMethod拆分到另一个Service类中通过Spring注入代理对象调用。自己注入自己在OrderService里注入OrderService注意循环依赖的坑Spring 4.3已经可以通过Autowired循环依赖注入自己但工程上不推荐。通过AopContext.currentProxy()获取代理对象((OrderService) AopContext.currentProxy()).innerMethod(order);需要显式开启EnableAspectJAutoProxy(exposeProxy true)才能获得原始线程绑定的代理对象。4.2 方法不是public导致事务失效Spring的Transactional是基于Spring AOP实现的Spring AOP默认只对public方法生效。如果方法是private或protectedSpring不会为其创建代理事务也就不会开启。这里有坑中坑上面说private不行但实际上Spring会为protected方法创建代理吗Spring的AOP实现中只拦截public方法。如果想在非public方法上启用事务直接宣告失败。不仅是TransactionalSpring的Async、Cacheable也一样都要求public方法。这是AOP代理机制决定的。4.3 异常被try-catch吞掉经典中的经典Transactional public void doSomething() { try { int result userMapper.update(...); throw new RuntimeException(模拟失败); } catch (Exception e) { // 记录日志什么都不做 } }因为异常被捕获住没有向外抛出Spring根本感知不到异常所以事务正常提交。你在数据库里看到的结果是方法里已经执行的更新操作竟然生效了。这是我在代码审查中反复强调的问题。事务边界之内要么不捕获异常要么捕获后重新抛出并确保它能被事务拦截器感知。解决方案Transactional public void doSomething() { try { int result userMapper.update(...); throw new RuntimeException(模拟失败); } catch (Exception e) { // 记录日志 throw new RuntimeException(e); // 重新抛出 } }4.4 抛出检查异常但未指定rollbackFor前文已经详细说过。默认情况下Spring只对RuntimeException和Error回滚。抛出Exception的子类非RuntimeException不会回滚。这就是为什么我建议所有Transactional都写rollbackFor Exception.class。4.5 类没有被Spring管理这是最基础的失效场景类上没有Service、Component等注解Spring没有扫描到它没有创建bean那代理也不会存在Transactional当然无效。有些时候你手动new了一个Service对象OrderService service new OrderService(); service.createOrder(order);这样也是无效的。Spring的依赖注入和控制反转要求所有bean都必须通过容器管理由Spring创建代理并把代理注入到使用方。不能用new生成。4.6 方法内部调用了多线程异步操作Transactional public void createOrder() { orderMapper.insert(order); new Thread(() - { // 这里的事务操作不会和外部方法共享同一个事务 logMapper.insert(log); }).start(); }新开的线程和当前请求线程不是同一个线程Spring的事务是基于ThreadLocal实现的新线程拿不到外层事务的事务上下文。在新线程中执行的数据库操作完全在独立事务中运行出现异常也不会让主线程的事务回滚。所以凡是涉及多线程、异步、消息队列的场景事务边界一定要设计清楚独立的事务方法必须单独标注好传播行为不能在子线程里默认自动延续父线程的事务。4.7 方法虽然是public但被final修饰Spring默认使用CGLIB代理生成子类来拦截方法调用。如果方法被final修饰CGLIB无法重写这个方法来织入事务拦截逻辑所以事务同样失效。Java代理机制有个限制final方法无法被覆盖。Transactional标注在final方法上或标注在会被继承重写覆盖的私有方法上都会导致事务失效。4.8 数据库表引擎不支持事务最后一种情况发生在数据库层面。比如MySQL的MyISAM引擎就不支持事务现在新版本MySQL默认InnoDB但历史遗留表仍然可能出现MyISAM。如果操作的表是MyISAM事务管理器无论如何设置底层都不会真正执行事务。你看到的表象是异常没回滚、数据部分更新。排查这类问题最快的方式是执行SHOW TABLE STATUS LIKE your_table_name;看Engine字段是否为InnoDB。4.9 一个可能被忽略的坑事务中使用了synchronized热词里提到了Transactional和synchronized的组合问题这个实践经验值得单独讲一下。Transactional public synchronized void createOrder(Order order) { // 检查库存 // 扣减库存 }你以为synchronized加了锁、事务也加了并发一定安全。其实隐患很大。为什么因为synchronized锁的是当前对象而Spring注入的是代理对象。当一个请求进入方法后方法内部的this是代理对象还是被代理的目标对象注意synchronized在方法上锁的是this这里的this指的是代理对象。多个代理对象实例是不同的所以this锁不住多个请求可能同时进入方法体此时事务虽然开启但select检查和update操作之间没有数据库锁保护或者即使有也存在检查完后另一个事务已提交修改的竞态窗口。更安全的并发扣库存方案应该是加SELECT ... FOR UPDATE悲观锁或者用UPDATE ... SET stock stock - n WHERE stock n这种原子条件更新乐观锁靠数据库层面的约束来保证正确性事务负责保证操作的原子性。5. 隔离级别、锁与事务的协同隔离级别本质上解决的是并发事务之间的数据可见性问题。面试问起隔离级别时大多数人都能背出四种级别和三个问题但是要和Spring事务结合起来理解就没那么简单了。先说说最容易混淆的不可重复读和幻读。不可重复读同一事务内两次读取同一行数据得到不同结果。这是因为另一并发事务修改了这条数据并提交了。幻读同一事务内两次执行同样的范围查询结果行数不同。这是因为另一并发事务插入了新数据并提交了。MySQL的默认隔离级别是REPEATABLE_READ。它通过MVCC多版本并发控制机制解决了不可重复读问题在一个事务内每次快照读普通的SELECT看到的是事务开始时的同一种快照。但是要注意对于当前读SELECT ... FOR UPDATE、UPDATE、DELETE读到的是最新已提交数据。因此MySQL在REPEATABLE_READ下仍然可能出现幻读需要间隙锁来避免InnoDB引入了next-key lock间隙锁记录锁的结合来最大程度避免幻读。Spring事务中指定隔离级别时要考虑两点第一隔离级别和锁的关系。SERIALIZABLE会对读取的行加共享锁读锁写加排他锁写锁并发度急剧下降。READ_UNCOMMITTED几乎不加锁性能好但脏读风险极高反正生产环境我不会主动用。第二隔离级别与事务并发度的取舍。网上很多答案都是能不用就别用太高隔离级别我会说这其实要结合具体业务。账务类核心系统宁可牺牲并发也要保证正确性报表类查询用readOnly REPEATABLE_READ尽量保证结果一致性。另外有一个Spring事务里很常见的问题事务内部多个查询方法之间如果想做到可重复读就必须它们共享同一个数据库连接。通过REQUIRED传播行为内外层方法共享同一个事务连接所以第二次查询能看到第一次查询提交前的结果。如果使用了REQUIRES_NEW第二个方法是一个全新事务、新连接、新快照很可能看到完全不同的数据。这也是为什么我在设计事务内多次查询结果必须一致的业务时会刻意避免在内部使用REQUIRES_NEW。6. 分布式服务下的事务困境与现实解法跟着热词讨论很多人提到订单和库存这种跨库跨服务的场景时会理所当然地想到分布式事务。先说清楚一个问题Spring的Transactional管理的是本地事务它只能保证一个数据源上的一组操作要么全部成功、要么全部回滚。一旦业务涉及多个数据库订单库库存库甚至跨多个微服务Transactional就完全没有用武之地了——因为每个数据库连接各自管理自己的事务Spring的PlatformTransactionManager管理不了跨连接的事务。生产环境的分布式事务方案有很多这里不展开代码只讲讲我在这条路上摸索出来的路线图以及各方案的核心权衡。2PC两阶段提交是经典方案所有参与者先prepare再由协调者决定commit或abort。但它有一个致命弱点协调者单点故障时参与者会长期阻塞而且prepare阶段锁资源高并发下性能很差。现在的业务系统很少有直接用裸2PC的。TCCTry-Confirm-Cancel补偿方案是目前互联网公司用得最多的柔性事务方案。核心思想是把一个事务拆成三个动作Try阶段先做资源预留比如锁定库存但暂不扣减Confirm阶段执行真正扣减Cancel阶段释放预留。TCC需要每个业务方法都写三套逻辑对侵入性要求很高。比如订单和库存两个服务下单订单服务调用库存服务做try库存如果有异常则订单服务去做cancel释放库存。服务间的状态同步和幂等设计极其关键。**消息最终一致性本地消息表 / 事务消息**是另一种思路。核心是把本地业务操作和发送消息放在同一个本地事务里然后通过可靠的消息中间件异步通知下游服务。比如下单操作在本库事务里插入订单记录的同时插入一条扣库存消息记录事务提交后由后台任务把消息发送到MQ库存服务消费消息执行扣减。如果扣减失败MQ重试。这种方案最终一致但简单可靠能够容忍秒级延迟。Seata的AT模式是一种无侵入的分布式事务方案。它通过代理数据库操作自动记录undo_log在全局事务提交时逐分支提交失败时通过undo_log反向补偿。这种方案胜在开发成本低但缺点是需要独立的事务协调器且对数据库的高并发写性能有一定损耗。我的建议是服务内部用本地事务落库没有问题跨服务的写操作优先考虑设计成最终一致性的模型能用MQ异步就绝不硬搞同步强一致如果业务必须要强一致才考虑TCC或Seata。关键原则是不要一上来就引入分布式事务框架因为分布式事务的复杂度呈几何级增加系统越重越难排查。回到Spring事务本身我想强调一个容易被忽略的观点代码层面的本地事务和分布式事务其实遵循完全不同的设计哲学。本地事务追求要么成功要么回滚分布式事务追求最终一致失败可补偿。不能用本地事务的思维去设计分布式系统这是很多架构事故的根源。7. 常见问题排查实录与脚本化的排障方法最后这部分我把自己排查Spring事务问题的经验沉淀成一套可执行的checklist和速查表。代码出问题不可怕可怕的是没有方法和步骤地乱猜。7.1 事务不生效的排查五步法第一步确认类是否被Spring管理看类上有没有Service、Component等注解或者XML/JavaConfig里有没有正确注册bean。第二步确认调用方式这个方法是被外部Spring代理对象调用的还是通过this内部调用如果有this调用拆出去或者用代理调用。第三步确认方法修饰符是否public如果没有改成public。第四步确认异常抛出与回滚规则异常是否被捕获抛出的是运行时异常还是检查异常rollbackFor是否匹配第五步排除数据库层问题表引擎是否支持事务连接是否委托给了DataSourceTransactionManager遵循这个顺序95%的事务失效问题能在三分钟内定位。7.2 事务相关日志怎么看传统排查事务问题时可以通过开启HikariCP连接池和Spring事务的debug日志来观察logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManagerDEBUG logging.level.org.springframework.transaction.support.TransactionSynchronizationManagerDEBUG开启后你会看到类似Initiating transaction commit、Rolling back JDBC transaction的日志输出这能帮你快速确认事务是否按预期被开启和结束。对于MyBatis也可以打开logging.level.你的Mapper包名DEBUG观察每次SQL执行是否在一个事务内。注意如果代码中出现了JDBC Connection will not be managed by Spring这种日志说明当前执行上下文里没有事务——排查的重点就要转移到代理和传播机制上。7.3 经典问题速查表现象原因处理方式事务未开启方法非public / 类未被Spring管理 / 自调用按排查五步法逐项核对异常未回滚异常被捕获吞掉事务内不吞异常或捕获后重抛检查异常未回滚未指定rollbackFor统一rollbackFor Exception.class事务回滚了但数据没恢复表引擎MyISAM / 多数据源切换错事务管理器改用InnoDB、检查transactionManager参数连接池耗尽大事务内执行慢SQL / REQUIRES_NEW嵌套过多拆分事务、缩短事务时间、按业务场景重新设计传播行为只看到SQL没看到事务日志级别没开 / 实际没有事务管理器被激活开启DataSourceTransactionManager调试日志并发超卖事务应用层锁没生效改用数据库悲观锁/乐观锁事务内保证原子操作7.4 一个真实的压测排除案例我记得某次做促销活动压测时发现库存扣减偶尔超卖。当时代码是Transactional public void deductStock(Long productId, int quantity) { Stock stock stockMapper.selectByProductId(productId); if (stock.getQuantity() quantity) { stock.setQuantity(stock.getQuantity() - quantity); stockMapper.updateById(stock); } }这段代码在高并发下确实存在严重的竞态条件两个请求同时selectByProductId都看到库存足够都执行了扣减结果库存被扣了两次。虽然方法加了Transactional但事务只能保证原子性不能保证隔离性和并发安全。问题根源在于先select再update不是原子操作必须锁定资源。最终修复方案改成了条件更新Transactional public void deductStock(Long productId, int quantity) { int updated stockMapper.deductStockWithCondition(productId, quantity); if (updated 0) { throw new BusinessException(库存不足); } }对应的SQL用原子条件更新UPDATE stock SET quantity quantity - #{quantity} WHERE product_id #{productId} AND quantity #{quantity}数据库行锁保证只有一行更新成功事务保证这条语句的原子性。虽然只是简单一行改法但背后映射的正是锁机制事务的协同关系。这是一个非常典型的案例希望大家碰到并发扣减的场景时第一时间想到条件更新而不是盲目加synchronized。8. 最后谈谈我的事务实践心法这篇从Spring事务的本质讲到传播机制的七种行为从失效场景盘点讲到分布式事务的选型内容确实不少。但如果你问我这么多内容里最想强调哪一句我会说别把Spring事务当黑盒要把它当白盒。所谓白盒就是当你看到Transactional时脑海里能自动浮现出那三件套PlatformTransactionManager负责开和关TransactionDefinition负责定规则TransactionStatus负责记录状态。知道方法被调用时代理对象会先做一个前置通知方法结束时做一个后置通知。有了这套底层的运行模型任何失效场景都不过是代理没触发、规则不匹配、异常没感知这三种原因的排列组合。再叮嘱一句关于传播机制的心法REQUIRED是默认REQUIRES_NEW是隔离NESTED是局部回滚。能用REQUIRED解决的问题绝对不升级传播行为必须升级时优先考虑NESTED而不是REQUIRES_NEW因为NESTED的代价更小、语义更接近局部隔离。我见过太多为了保险无脑加REQUIRES_NEW导致连接池爆炸的案例血的教训。最后分享一个小细节。在写Transactional的时候我会习惯性地把所有属性显式声明出来即使只是标配Transactional(rollbackFor Exception.class, propagation Propagation.REQUIRED, isolation Isolation.DEFAULT)这样做的好处是别人接手代码时一眼就能看懂这个方法的事务行为是经过设计的而不是随手甩了个注解。把意图显式化比什么注释都管用。这也是我这几年做代码评审后深深体会到的一个规范的团队一定会重视这种细枝末节的严谨。

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

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

免费获取报价 →
↑