Spring @Transactional 事务,那些让你怀疑人生的坑

📅 2026/8/24 13:44:13
Spring @Transactional 事务,那些让你怀疑人生的坑
文章目录一、Transactional 生效的坑最容易翻车的地方1. 只对 public 方法生效2. 异常捕获问题4 种情况必须分清情况一异常没有被 catch直接往外抛 → **回滚**情况二异常被 try-catch 吃掉不往外抛 → **不回滚提交**情况三catch 之后重新抛出 → **回滚**情况四catch 内部手动标记回滚 → **回滚**3. 默认只回滚 RuntimeException 和 Error受检异常不回滚易错场景Service 内部调用本类方法事务失效二、事务传播行为 propagation7 种记住前 4 个就够用1. REQUIRED默认值最常用2. REQUIRES_NEW全新独立事务3. NESTED 嵌套事务保存点机制4~7. 剩下四种扫一眼就够快速区分 REQUIRED / REQUIRES_NEW / NESTED三、MySQL 事务隔离级别4 种解决 3 个问题1. 读未提交Read Uncommitted2. 读已提交Read Committed3. 可重复读Repeatable Read—— MySQL InnoDB 默认4. 串行化Serializable四、两个手写高频坑题把上面的知识串起来五、配置示例全文总结核心知识点复盘常见问题 / 避坑指南你有没有遇到过这种情况明明方法上加了Transactional结果异常一抛数据还是被写进去了或者反过来明明想让 A、B 两个操作一起成功一起失败结果 B 失败滚了A 却偷偷提交了事务是后端开发绕不开的一个坎。它看起来简单——一个注解加上去就完事但实际上Transactional的坑踩一个坑半天。这篇文章我会用一个真实的 demo 项目trans-demo把Transactional的生效机制、传播行为、MySQL 隔离级别这三件事一次性讲清楚。先放一张整体结构图方便你建立全局概念一、Transactional 生效的坑最容易翻车的地方1. 只对 public 方法生效这是最基础的坑也是最容易被忽略的。Transactional之所以能自动管理事务靠的是Spring AOP 动态代理而不是 Java 原生语法。简单理解一下这个过程你写了一个UserService类Spring 启动时发现它上面或方法上有TransactionalSpring 不会直接把你写的这个对象放进容器而是生成一个代理对象一个替身你从容器里Autowired拿到的其实是这个代理对象代理对象在真正调用你的方法之前先开启事务调用之后再提交或回滚。而 Spring AOP 生成代理时只对public方法做增强。如果方法是private、protected或默认包级访问权限代理根本包不住它事务自然就失效了。ServicepublicclassUserService{// ❌ 私有方法不会生成代理事务失效TransactionalprivateIntegerinsertUserInner(UserInfouserInfo){returnuserInfoMapper.insertUser(userInfo);}// ✅ public 方法事务正常生效TransactionalpublicIntegerinsertUser(UserInfouserInfo){returnuserInfoMapper.insertUser(userInfo);}}一句话记住想让事务生效方法必须是public而且必须是通过 Spring 容器拿到的代理对象去调用。2. 异常捕获问题4 种情况必须分清这是实际项目里最常出问题的地方。核心原因只有一个Spring 的事务回滚依赖的是异常被抛出来这个动作。如果异常在半路被你catch掉了Spring 根本不知道出事了自然就当作一切正常去提交。下面我用trans-demo里的真实代码逐一演示。这个项目里TransController的每个方法都做了先插入用户再故意制造异常的实验。情况一异常没有被 catch直接往外抛 →回滚TransactionalRequestMapping(/r2)publicStringr2(UserInfouserInfo){// 1. 插入用户IntegerresultuserService.insertUser(userInfo);// 2. 故意抛出运行时异常除 0inta10/0;// 抛出 ArithmeticExceptionreturnsuccess;}这里10 / 0会抛出ArithmeticException属于RuntimeException异常一路向上抛代理对象捕获到它回滚事务。用户不会真正插入数据库。情况二异常被 try-catch 吃掉不往外抛 →不回滚提交TransactionalRequestMapping(/r3)publicStringr3(UserInfouserInfo){IntegerresultuserService.insertUser(userInfo);try{inta10/0;}catch(Exceptione){log.error(插入失败:e.getMessage(),e);// 只是记日志不往外抛}returnsuccess;// 方法正常返回事务提交}这里异常被catch住了方法正常返回success。Spring 感知不到任何异常于是提交事务。结果就是你以为出错了会回滚实际上用户已经写进数据库了。⚠️ 这是最隐蔽的坑。很多人catch (Exception e)只是为了打日志结果忘了重新抛出去事务就静默提交了。情况三catch 之后重新抛出 →回滚TransactionalRequestMapping(/r4)publicStringr4(UserInfouserInfo){IntegerresultuserService.insertUser(userInfo);try{inta10/0;}catch(Exceptione){log.error(插入失败:e.getMessage(),e);throwe;// 关键把异常重新抛出去}returnsuccess;// 永远执行不到}只要把异常重新throw出去代理对象就能感知到事务正常回滚。记完日志记得抛出去这是最常见的正确写法。情况四catch 内部手动标记回滚 →回滚TransactionalRequestMapping(/r5)publicStringr5(UserInfouserInfo){IntegerresultuserService.insertUser(userInfo);try{inta10/0;}catch(Exceptione){log.error(插入失败:e.getMessage(),e);// 手动把当前事务标记为需要回滚TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();}returnsuccess;}有些场景下你既不想把异常抛出去比如希望方法正常返回一个降级结果又想让事务回滚就可以用setRollbackOnly()手动把事务标记为只回滚。方法返回时Spring 发现事务被标记成 rollback-only就会回滚。这四种情况小结成一张表场景异常是否往外抛事务结果情况一不 catch直接抛是回滚情况二catch 掉不打日志不抛出否提交静默失败情况三catch 后重新 throw是回滚情况四catch 后手动setRollbackOnly()否回滚3. 默认只回滚 RuntimeException 和 Error受检异常不回滚这里要区分 Java 里的两种异常运行时异常RuntimeException及其子类和错误Error比如ArithmeticException、NullPointerException。Spring默认遇到这类异常会回滚。受检异常Exception及其子类但不含RuntimeException比如IOException、SQLException。这种异常编译期就强制你必须处理try-catch或throwsSpring默认不会回滚。看一个真实例子TransactionalRequestMapping(/r6)publicStringr6(UserInfouserInfo)throwsIOException{IntegerresultuserService.insertUser(userInfo);if(true){thrownewIOException(手动抛出受检异常);// 受检异常默认不回滚}returnsuccess;}这段代码里虽然抛出了IOException但它是受检异常Spring 的默认规则是不理会于是事务照常提交用户仍然被插入。那想让它回滚怎么办手动指定Transactional(rollbackFor{IOException.class})// 指定遇到 IOException 也回滚RequestMapping(/r7)publicStringr7(UserInfouserInfo)throwsIOException{IntegerresultuserService.insertUser(userInfo);if(true){thrownewIOException(手动抛出受检异常);// 这次会回滚}returnsuccess;}rollbackFor就是专门用来告诉 Spring“除了默认的 RuntimeException 和 Error这些异常也要回滚。” 日常开发中为了让代码更省心很多人直接写Transactional(rollbackForException.class)// 所有异常都回滚为什么 Spring 要这么设计因为受检异常在 Java 语义里属于可预期、可恢复的异常比如文件不存在Spring 默认认为这类异常不影响数据一致性所以不回滚。但实际业务里我们通常希望只要出错就回滚所以几乎都会显式加rollbackFor Exception.class。易错场景Service 内部调用本类方法事务失效这是事务失效的经典名场面也是出镜率最高的一个坑。ServicepublicclassUserService{AutowiredprivateUserInfoMapperuserInfoMapper;// 带事务的方法TransactionalpublicvoidinsertUserWithLog(UserInfouserInfo){// 内部直接调用本类的方法this.insertUser(userInfo);// ❌ 通过 this 调用事务不生效}TransactionalpublicIntegerinsertUser(UserInfouserInfo){returnuserInfoMapper.insertUser(userInfo);}}为什么会失效回到第一点讲的代理机制Spring 是通过代理对象来管理事务的。但是当你写this.insertUser(...)时this指的是你写的那个原始对象本身而不是 Spring 生成的代理对象。绕过了代理事务拦截器根本没机会执行所以事务就失效了。怎么解决常见有三种方式把被调用的方法抽到另一个 Service 里通过Autowired注入后调用最推荐代码职责也更清晰。自己注入自己利用代理对象ServicepublicclassUserService{AutowiredprivateUserServiceself;// 注入代理对象publicvoidinsertUserWithLog(UserInfouserInfo){self.insertUser(userInfo);// 通过代理调用事务生效}}使用AopContext.currentProxy()获取当前代理对象需要开启exposeProxy。这里顺手提醒一句trans-demo里的TransController是把Transactional加在了 Controller 层方法上。功能上能跑通Controller 也是 Spring 管理的 bean会被代理但规范做法是把事务放到 Service 层Controller 只负责参数接收和返回这样职责清晰、也更利于复用。二、事务传播行为 propagation7 种记住前 4 个就够用传播行为propagation回答的问题是当一个带事务的方法 A去调用另一个也带事务的方法 B 时B 到底是用 A 的那个事务还是自己新开一个还是干脆不用事务Spring 定义了 7 种传播级别日常记住 4 个核心的就够了。先看一张全景表再逐个拆解。传播级别含义有无外层事务时的行为REQUIRED默认有就加入没有就新建最常用REQUIRES_NEW永远新开独立事务两个事务互不干扰NESTED嵌套事务保存点依附外层事务SUPPORTS有就加入没有就非事务只读查询可用MANDATORY强制必须有事务没有事务直接抛异常NOT_SUPPORTED挂起当前事务非事务执行偶尔用NEVER绝对不能有事务有事务直接抛异常1. REQUIRED默认值最常用规则如果外层已经有事务就加入这个事务如果外层没有就自己新建一个事务。效果A、B 共用同一套事务任何一处异常全部一起回滚。看trans-demo里的/r8这个真实场景用户注册的同时要记录一条操作日志。// LogInfoService记录操作日志ServicepublicclassLogInfoService{AutowiredprivateIogInfoMapperlogInfoMapper;Transactional(propagationPropagation.REQUIRED)// 加入外层事务publicvoidinsertLog(Stringname,Stringop){inta10/0;// 故意制造异常logInfoMapper.insertLog(name,用户注册);}}// TransController注册用户 记录日志Transactional(propagationPropagation.REQUIRED)RequestMapping(/r8)publicStringr8(UserInfouserInfo)throwsIOException{IntegerresultuserService.insertUser(userInfo);// 第一步插用户logInfoService.insertLog(userInfo.getUserName(),用户注册);// 第二步记日志这里抛异常returnsuccess;}因为insertLog用的是REQUIRED它会加入r8开启的那个事务。现在insertLog内部抛异常整个事务回滚——用户和日志都插入失败。这个场景的典型应用就是注册用户 记录操作日志这种必须一起成功一起失败的强原子操作。日志出错用户注册也要跟着回滚保证数据一致性。2. REQUIRES_NEW全新独立事务规则不管外层有没有事务B 永远新开一个独立事务同时把外层事务挂起。两个事务互不干扰。效果A 报错回滚B 可以正常提交B 报错回滚A 不受影响。ServicepublicclassLogInfoService{Transactional(propagationPropagation.REQUIRES_NEW)// 独立事务publicvoidinsertLog(Stringname,Stringop){logInfoMapper.insertLog(name,op);}}典型场景比如业务主逻辑失败了但操作日志或审计记录必须保存下来。主流程是一个事务日志是另一个独立事务主流程失败回滚了日志照样提交这样你才能事后查到这个人刚才到底干了什么。注意代价REQUIRES_NEW会挂起外层事务意味着外层事务持有的数据库连接等资源要先让出来等内层事务提交后再恢复。频繁使用会有性能开销别滥用。3. NESTED 嵌套事务保存点机制规则外层有事务时B 不会新开独立事务而是在外层事务里打一个保存点savepoint成为外层大事务的一个子事务。效果分两种情况如果 B 内部自己catch了异常只回滚到保存点只回滚 B 自己A 可以正常提交如果异常向外抛出去那么外层整个大事务全部回滚A、B 一起滚。Transactional(propagationPropagation.NESTED)publicvoidinsertLog(Stringname,Stringop){try{// 可能出错的操作logInfoMapper.insertLog(name,op);}catch(Exceptione){// 内部吞掉异常只回滚到保存点不影响外层事务}}和 REQUIRES_NEW 的关键区别NESTED依赖外层事务它不是独立的而是外层事务的一个可回滚的检查点。REQUIRES_NEW完全独立的两套事务各自提交、各自回滚互不干涉。一句话理解REQUIRES_NEW是分家过NESTED是同住一个屋檐下但给自己留了个保险栓。⚠️ 一个重要的坑NESTED 依赖数据库保存点而保存点只在支持它的数据库上生效MySQL InnoDB、PostgreSQL 支持但 JPA/部分数据源下可能不支持。如果底层不支持 savepointSpring 会自动降级成普通事务行为就和你预期不一样了。4~7. 剩下四种扫一眼就够SUPPORTS外层有事务就加入没有就以非事务方式执行。适合纯查询方法。MANDATORY强制要求外层必须有事务否则直接抛异常。用于我必须在事务里被调用的硬性约束。NOT_SUPPORTED挂起当前事务以非事务方式执行。偶尔用于这段逻辑不能被事务包裹。NEVER绝对不能存在外层事务否则直接抛异常。很少用。快速区分 REQUIRED / REQUIRES_NEW / NESTED这三个是绝对的核心务必能脱口而出级别关系效果REQUIRED共用同一个事务一荣俱荣、一损俱损全部一起回滚REQUIRES_NEW两个完全独立事务互不干扰子回滚不影响父NESTED子保存点依附父事务子内部捕获异常仅子回滚异常外抛父子一起回滚三、MySQL 事务隔离级别4 种解决 3 个问题隔离级别解决的是并发事务互相干扰的问题。先搞清楚要解决的三个脏现象脏读读到了别的事务还没提交的数据这些数据随时可能被回滚读到就是脏的。不可重复读同一个事务里前后两次读同一条数据中间别的事务修改并提交了导致两次读到的内容不一样。幻读同一个事务里前后两次查询中间别的事务插入/删除了数据并提交导致两次查到的行数不一样。一句话版脏读 读到别人未提交的数据不可重复读 前后读同一行值变了幻读 前后查询行数变了。1. 读未提交Read Uncommitted可以读到别的事务还没 commit 的数据 → 存在脏读。几乎没人用。2. 读已提交Read Committed只能读到别人已经 commit的数据解决了脏读。但同一个事务里两次 select如果中间别的事务修改并提交了两次结果会不一样 → 存在不可重复读。这里要纠正一个常见误区读已提交是Oracle、PostgreSQL 的默认隔离级别MySQL InnoDB 的默认隔离级别不是它而是下面要讲的可重复读。3. 可重复读Repeatable Read—— MySQL InnoDB 默认同一个事务内部多次查询读到的数据始终一致解决了脏读和不可重复读。至于幻读InnoDB 通过MVCC多版本并发控制加上间隙锁next-key lock的组合来抑制基本也能解决。想确认一下你当前 MySQL 的隔离级别可以执行SELECT transaction_isolation;默认返回REPEATABLE-READ。4. 串行化Serializable最高隔离级别事务全部串行执行、疯狂加锁并发性能极差几乎不用。四个级别一张表总结隔离级别脏读不可重复读幻读说明读未提交❌ 存在❌❌几乎不用读已提交✅ 解决❌ 存在❌Oracle/PostgreSQL 默认可重复读✅ 解决✅ 解决✅ 基本解决MVCC间隙锁MySQL InnoDB 默认串行化✅ 解决✅ 解决✅ 解决性能差几乎不用简单记忆隔离级别越高一致性越好但并发性能越差。默认用 InnoDB 的可重复读是 MySQL 在一致性和性能之间找的一个平衡点。四、两个手写高频坑题把上面的知识串起来题目 1Transactional加在方法上方法里try-catch捕获了 Exception没有往外抛会回滚吗答不会回滚事务会正常提交。原因Spring 的事务回滚依赖异常被抛出到代理层这个信号。你把它catch掉了Spring 根本感知不到异常会认为方法执行成功于是提交。解决① catch 之后重新throw抛出异常② 或者 catch 内手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。题目 2REQUIRES_NEW 和 NESTED 有什么区别答REQUIRES_NEW是两个完全独立的事务内层事务提交/回滚完全不受外层影响外层事务会被挂起。NESTED是保存点savepoint机制依赖外层父事务是父事务的一个子事务。子事务内部捕获异常时只回滚到保存点不影响父事务但异常向外抛出时父子事务一起回滚。五、配置示例日常使用频率最高的两个配置// 指定所有异常包括受检异常都回滚Transactional(rollbackForException.class)// 指定传播级别 所有异常回滚Transactional(propagationPropagation.REQUIRES_NEW,rollbackForException.class)再来一个完整可运行的 Service 示例结合trans-demo的项目结构ServicepublicclassUserService{AutowiredprivateUserInfoMapperuserInfoMapper;/** * 注册用户 * 1. 使用默认传播级别 REQUIRED * 2. 显式指定所有异常回滚避免受检异常漏回滚 */Transactional(rollbackForException.class)publicIntegerinsertUser(UserInfouserInfo){// 插入用户若此处抛出任何异常事务都会回滚returnuserInfoMapper.insertUser(userInfo);}}全文总结这篇文章围绕Transactional讲了三大块生效机制事务靠 Spring AOP 动态代理实现所以只有public方法、且通过代理对象调用才生效异常被catch掉就不会回滚默认只回滚RuntimeException和Error受检异常需要rollbackFor显式指定。传播行为REQUIRED共用事务、REQUIRES_NEW独立事务、NESTED保存点三个是核心其余四种了解即可。隔离级别理解脏读、不可重复读、幻读三个问题记住 MySQL InnoDB 默认是可重复读靠 MVCC 间隙锁解决问题。贯穿全文的一条主线Spring 的事务管理本质是代理 异常信号。代理对象负责在方法前后开启/提交事务而是否回滚取决于有没有异常抛到代理层。理解了这条主线所有看起来玄乎的坑本质都是代理没生效或异常没抛出去这两件事。核心知识点复盘事务为什么失效四大原因方法非public、本类自调用this绕过代理、异常被catch吞掉、异常类型不是默认回滚范围受检异常。四种异常处理结果直接抛→回滚吞掉→提交重新抛→回滚setRollbackOnly()→回滚。传播行为三巨头REQUIRED共用一个事务、REQUIRES_NEW独立事务、NESTED保存点子事务。隔离级别三问题脏读读未提交、不可重复读值变了、幻读行数变了。MySQL InnoDB 默认可重复读。回滚/不回滚的边界rollbackFor决定哪些异常回滚默认只认RuntimeException和Error。常见问题 / 避坑指南不要只写Transactional不写rollbackFor。默认不回滚受检异常一旦方法里抛了IOException、SQLException事务会静默提交排查半天。直接写rollbackFor Exception.class最省心。不要在catch里只打日志不抛异常。记完日志记得throw或者手动setRollbackOnly()。不要在 Service 内部用this调用本类的带事务方法。这样会绕过代理。要么拆到别的 Service要么注入代理对象。Transactional尽量放在 Service 层不要放 Controller 层。Controller 职责是参数接收和返回事务属于业务逻辑放 Service 更规范、更易复用。不要滥用REQUIRES_NEW。它会挂起外层事务有资源开销只在主流程失败也要独立记录这类场景下用。NESTED依赖数据库保存点。底层不支持 savepoint 时行为会退化使用前确认你的数据库和数据源是否支持。记得验证隔离级别。线上排查并发问题时先SELECT transaction_isolation;确认当前级别别默认以为就是可重复读有些环境会配成读已提交。事务方法里别做耗时操作发邮件、调外部接口、大循环。事务会一直占用数据库连接拖长事务时间会加剧锁等待甚至拖垮并发性能。这类操作应该放到事务提交之后。