ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring事务踩坑指南:从@Transactional失效到性能杀手,大厂为何慎用

Spring事务踩坑指南:从@Transactional失效到性能杀手,大厂为何慎用 在Java后端圈子里Transactional几乎是每个Spring开发者最早接触的几个注解之一。刚入行的时候很多人对它的理解就是“在方法上加上这个注解方法里的数据库操作要么全成功要么全失败”省去了手动begin、commit、rollback的样板代码看起来确实是神器。但在大厂里待过几年后你会发现代码评审时如果有人在核心链路上直接甩一个Transactional大概率会被追问“这个事务是非开不可吗能不能去掉”不是大厂故意刁难而是这个注解背后的坑远比表面看上去的要多。这篇文章我想从实际踩坑经历出发把Transactional在真实业务场景里的“原罪”讲清楚。它为什么会失效、什么时候会拖垮性能、单库事务在分布式环境下又是怎么变成隐患的、以及大厂更倾向于用什么方案替代。不管是刚写Spring没多久的新人还是被线上事故折腾过的老手这篇文章应该都能给你一些参考。1. 内容整体设计与思路拆解在展开细节之前我想先聊聊为什么大家会集中吐槽Transactional。它本身不是坏东西但因为它太“方便”了反而容易被人用在不合适的地方。理解这个问题的关键在于要弄清楚一件事事务的本质是锁和日志的组合而锁意味着串行化日志意味着额外的IO开销。1.1 从一次线上事故说起谁动了我的数据我之前带过一个支付对账相关的项目有一个“批量入账”的接口。当时新来的同学很自然地在这个方法上加了Transactional他的逻辑很简单把所有入账操作放在一个事务里任何一个失败就全部回滚这样数据不会出现“一半成功一半失败”的状态。听起来毫无问题对不对但上线后第三天线上就开始出现大量超时告警。我让他查数据库慢查询日志结果发现表虽然不大但大量SQL都集中在同一行记录上长时间持锁。再追下去发现这个批量入账方法在事务里还调用了一个远程账户通知服务网速稍微抖一下事务就迟迟不提交锁就捏在手里不放。这个案例非常典型他做对了事务的一致性保证却忽略了事务的隔离性代价。一个简单的Transactional把“批量写库”和“外部RPC调用”绑定在了同一个生命周期里。类似的问题在大厂代码评审中几乎是每周都会出现。1.2 大厂不推荐的真正理由不是禁用而是慎用大厂并不是给Transactional判了死刑而是把它从“默认选项”降级成了“需要充分理由才能使用”。核心原因是三方面的职责边界被破坏一个方法一旦加了事务这个方法的逻辑就被迫变成一个原子单元。但现实业务中很多操作天然就是“可补偿”的不需要原子化。性能边界模糊事务的隔离级别、传播行为、锁粒度很多人并没有真正理解。默认配置直接套上去长事务、大事务、死锁问题接踵而至。分布式环境下能力不足单库事务只能在本地生效一旦涉及微服务间调用、消息队列、多数据源Transactional反而给人制造“我好像有事务保护”的错觉。所以我更愿意把这篇内容理解为什么时候你别用Transactional以及如果你真的要用怎么把风险降到最低。2. 核心细节解析与实操要点可能有人会觉得“不就是一个注解吗我用了这么多年也没出过事。”那我只能说你比较幸运还没有被线上事故教育过。下面我挑几个最容易踩的坑展开说一下这些都是我在实际项目里真真切切遇到过的。2.1 事务失效的若干种姿势如果面试被问到“Transactional为什么会失效”很多人能回答出“自调用失效”和“异常被吞了”这两条。但真实场景里的失效情况远不止这两类我整理了一个我亲手排查过的问题列表失效场景真实原因现象同类内部方法调用this.xxx()不走代理对象事务切面不生效异常抛出但数据部分更新try-catch吞掉异常Spring默认只对RuntimeException回滚异常没抛出去数据出现“半成功”状态方法不是publicSpring AOP默认只拦截public方法事务不生效数据库引擎不支持事务比如MyISAM表写操作照常但无法回滚多线程调用事务上下文不传递到子线程子线程里的写操作不在事务内每个场景背后几乎都有一次血泪线上事故。拿“异常被吞”来说很多同学写代码时喜欢在catch块里记录日志然后返回一个错误码这在普通业务方法里没问题但如果方法上有Transactional事务不会因为你返回了错误码就回滚。你必须在catch块里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()否则数据就悄悄写进去了。2.2 默认隔离级别与锁升级MySQL 默认的隔离级别是REPEATABLE_READ也就是可重复读。Spring 的Transactional如果你不指定isolation属性它走的是数据库默认隔离级别所以大多数情况下你实际用的是REPEATABLE_READ。这有什么问题呢REPEATABLE_READ为了保证同一事务内多次读取结果一致会在SELECT上加S锁共享锁在UPDATE/DELETE上加X锁排他锁。在高并发写入场景下多个事务同时更新同一行或相邻的索引范围就容易触发间隙锁Gap Lock和Next-Key Lock进而产生死锁。举个我在订单系统中实际遇到过的案例有一个“订单自动确认收货”的定时任务和用户手动点击“确认收货”的接口都会去更新同一批订单的状态。两个事务同时扫描status 待收货的订单区间时互相持有了对方需要的间隙锁MySQL 检测到死锁后会随机kill掉一个事务。被kill的那个事务如果没有正确处理死锁异常用户就会看到“操作失败”。这类问题用Transactional不是完全不能解决但你得非常清楚自己操作的数据范围、索引设计、锁顺序任何一环没考虑到就是一个隐患。2.3 事务内调用远程RPC时间黑洞这是我认为最不值得踩的坑。在事务方法里调用RPC、HTTP接口、消息发送、文件上传本质上是在用一个数据库事务去控制一个毫秒级甚至秒级延迟的外部系统。这么做有两个后果事务持有时间变长数据库连接池被长期占用高并发下连接耗尽外部系统调用失败时事务回滚了但外部系统已经做了操作两边数据不一致。我见过最夸张的一个案例是有人在一个Transactional方法里循环调用了20多次外部供应商接口每次平均耗时300毫秒。一次请求这个事务至少要跑6秒数据库连接直接被卡死。这已经不是在写事务性代码了这是在制造线上事故。正确的做法是什么把外部调用放在事务外或者干脆去掉事务靠状态机重试幂等来控制数据一致性。这个后面我会详细讲。3. 实操过程与核心环节实现既然吐槽了这么多那总得告诉你怎么做是合理的。下面我结合几个真实场景分别给出“别用Transactional的替代方案”以及“如果确实要用怎么把伤害降到最低”。3.1 方案一用自研轻量级事务工具类控制边界大厂里很多基础架构团队会给业务方提供一个TransactionTemplate的封装核心思路就是把事务边界显式化而不是靠注解的隐式切面。你可以在代码里精确控制“在哪个方法里开启事务、在哪个节点提交”。Service public class OrderService { Autowired private TransactionTemplate transactionTemplate; Autowired private OrderMapper orderMapper; Autowired private AccountService accountService; public void createOrder(OrderDO order) { // 前置校验、幂等检查都可以放在事务外面 boolean created transactionTemplate.execute(status - { try { orderMapper.insert(order); // 注意这里不要调RPC只做纯DB操作 return true; } catch (Exception e) { status.setRollbackOnly(); throw e; } }); if (created) { // 事务提交之后再发MQ通知、调外部接口 accountService.deductBalance(order.getUserId(), order.getAmount()); } } }这个方式相比Transactional最大的好处是事务边界肉眼可见。你把“数据库原子写入”和“外部动作”彻底拆开事务只保护真正需要对一致性负责的那一小段逻辑。很多大厂的基础架构规范里都会明确推荐TransactionTemplate因为它更显式、更能强迫开发者思考事务的真实边界。3.2 方案二拆分为独立事务 幂等补偿很多业务操作本质上是“本地数据库写操作 下游系统操作”的组合。比如“下单成功后发优惠券”、“支付成功后发积分”。这类需求用Transactional去包住下游操作是完全不现实的因为你既不能回滚别人的数据库也不能因为下游失败而撤销主业务。业内通用的做法是本地事务只保护主流程数据 可靠消息/事件驱动下游。流程大致是开启本地事务写入业务表数据同时插入一张“事件表”或“消息表”状态为“待发送”。本地事务提交后通过一个后台任务或MQ把事件表中的记录异步推送出去。下游系统处理成功后回调确认事件表状态更新为“已发送”。如果推送失败后台任务不断重试消费方保证幂等。这样做的好处是本地事务非常短锁持有时间短下游失败不会影响主流程已完成的数据最终通过重试机制达到最终一致性。3.3 方案三如果一定要用Transactional这几条红线你得守住如果你评估完发现自己就是“单库、单服务、操作不复杂、没有外部调用”那用Transactional也无可厚非。但有几条经验红线你最好牢牢记住。首先方法体内绝对不能有RPC、HTTP、MQ等外部网络操作这个已经不需要再解释了就是血的教训。其次能用小事务解决的就不要搞大事务比如一个批量导入几十万数据的场景拆分批次提交而不是一个事务包到底否则undo log膨胀和长时间锁等待会把你压垮。还有一点很多人容易忽略Transactional上尽量显式指定rollbackFor因为默认值只对RuntimeException和Error生效受检异常如Exception是不会触发回滚的。Transactional(rollbackFor Exception.class) public void updateData() { // 你的业务逻辑 }如果你在代码里把Exception通过try-catch捕获那rollbackFor也没用。所以我的习惯是异常一律往外抛由最外层的事务切面统一处理回滚逻辑。如果你确实想在事务方法内部消化异常那就手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()这是唯一能确保事务回滚的方式。3.4 给“不得不开事务”的场景做瘦身有时候业务就是需要一个比较大的事务范围比如“同时更新主单、更新明细、更新库存”。这种情况下你需要做的是尽可能缩小锁定范围具体可以从这几个方向切入控制数据量不要在事务里for循环一条条查再一条条改尽量用批量SQL减少往返次数和锁的持有时间。保证索引有效更新操作务必走索引否则全表扫描时会在所有记录上加上锁后果非常可怕。统一锁顺序如果两个方法分别需要更新A表和B表尽量保持一致的加锁顺序。例如先更新A再更新B避免两个事务互相持锁等待形成死锁。降低隔离级别如果业务允许可以把事务隔离级别从默认的可重复读降到READ_COMMITTED。这个调整能显著减少间隙锁死锁的概率代价是同一事务内可能读到其他事务已提交的新数据。我们当时处理订单自动确认收货的死锁问题最终就是靠“走索引降低隔离级别”双管齐下解决的。4. 常见问题与排查技巧实录在我带团队这几年里关于Transactional的排查问题几乎每个月都能碰到一两个。下面我把一些高频问题和排查思路整理成速查表方便大家直接抄作业。4.1 高频问题速查表问题现象排查方向常见根因解决建议方法抛异常后数据依然写入检查是否被catch吞掉异常没有抛到事务切面要么外层抛异常要么手动setRollbackOnly()事务完全没有生效检查方法是否为publicSpring AOP只拦截public方法改为public或者用TransactionTemplate同事内部调用事务失效检查是否是this.xxx()调用没有经过代理对象注入自身代理或拆分到另一个Bean高并发下大量死锁检查SQL是否走索引间隙锁范围过大优化索引、降低隔离级别、统一锁顺序数据库连接池被打满检查事务内是否有RPC事务持有时间过长外部调用移出事务异步化批量操作很慢检查是否一个事务包太多undo日志、锁竞争严重分批提交控制单事务数据量4.2 排查事务失效的定位技巧如果你怀疑某个Transactional根本没生效最快的验证方法是开debug日志观察spring的TransactionInterceptor有没有拦截到方法调用。配置如下logging.level.org.springframework.transaction.interceptorDEBUG logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManagerDEBUG看到日志里有Creating new transaction with name [com.xxx.YourService.xxxMethod]说明事务已经开启。如果没有任何输出说明这个方法压根没走代理先检查是不是public、是不是内部调用。这个方法在排查问题时比看代码快得多我基本每次都会先用它确认方向。4.3 从日志里捞死锁信息死锁问题最麻烦的是你不知道它什么时候发生。MySQL在处理死锁时会立刻回滚其中一个事务并在错误日志里记录详细的死锁信息。我的排查习惯是打开死锁日志SHOW ENGINE INNODB STATUS\G重点看LATEST DETECTED DEADLOCK部分里面会列出两个事务各自持有哪些锁、等待哪个锁以及执行的SQL语句。看到 SQL 基本就能判断是哪段业务代码了再配合业务日志里的请求参数基本能定位到具体方法。这里分享一个小技巧我在定位一次订单系统死锁时日志里显示两个事务都在操作order_status状态字段一个更新的是“待发货”到“已发货”另一个更新的是“待发货”到“已取消”。单独看都不觉得有问题但打开执行计划后发现更新条件没走索引全表扫描把锁散到了所有记录上这才导致两个事务互相等待。后面给状态字段加了联合索引死锁直接消失。4.4 避免事务问题的一些设计习惯除了上面这些排查手段我更想分享的是几个能从根本上减少事务问题概率的设计习惯。一是写操作的方法签名上不要带返回值之外的副作用。也就是说一个事务方法只做数据变更不做远程通知、不拼装大报文、不做复杂的JSON解析。这些脏活累活放到事务外面去。二是在领域层区分“服务方法”和“基础设施方法”。领域服务里的方法可以放心用Transactional因为它处理的是纯粹的领域逻辑但 Controller 层、Facade 层、RPC 入站接口层这些方法里尽量别直接加事务因为这一层往往涉及参数校验、多服务聚合事务边界容易被拉大。三是读写分离场景要格外小心。如果项目用了主从分离默认情况下Transactional会让整个方法绑定在主库上。哪怕你方法里只有一个查询操作也会因为开启了事务而走了主库把读压力全部压到主库上。很多人发现“为什么我的从库一点流量都没有”很可能就是这个原因。解决办法是对于只读场景显式指定事务为只读或者干脆去掉事务。5. 从“能不能用”到“怎么用对”事务控制的进阶思考聊了这么多如果只记住“别用Transactional”那还是没理解透。这个东西不是毒药只是很容易用错。我自己在项目里也经常用但会在用之前问自己几个问题这里整理出来供大家参考。5.1 用之前先问自己四个问题第一个问题这个操作是不是必须原子生效如果不是那就别开事务。很多业务场景其实只是“顺序执行多个操作”并不要求每个操作都成功失败的话后面补偿就行。第二个问题事务范围能不能控制在纯DB操作内如果方法里有任何非DB调用那你需要重新审视这个方法的边界。记住一个原则事务里只做数据操作业务编排放在事务外。第三个问题这个事务的粒度是否够小如果你要更新1万条数据拆成每500条一个事务远比一个事务包到底要好。这样如果中途失败不需要回滚全部数据锁竞争也小得多。第四个问题回滚之后下游系统知不知道你回滚了如果你的事务里发了MQ消息事务回滚了但消息已经发出去了消费方那边依然会执行。这种情况下就算你用Transactional包住了本地数据库仍然无法保证全局一致性。5.2 本地事务和分布式事务的边界意识很多人对大厂不推荐Transactional的另一个误解是因为大厂都是微服务架构所以单库事务没用了。这个说法不完全对。微服务架构下每个服务内部依然有本地数据库事务只是你不能再指望用本地事务去约束跨服务的数据一致性。真正合理的架构是本地事务管住自己的状态跨服务的状态用可靠消息/事件驱动来同步。你可以把Transactional用在“下单主流程”中但它只管本地订单表和事件表的写入。至于下游的积分服务、优惠券服务、物流服务都是通过事件异步触发的不会和主事务放在同一个事务里。有一个非常好的实践叫本地消息表。这个模式的核心就是利用本地事务把业务操作和消息发送放在同一个事务里事务提交后消息表里的记录就是可靠的后台任务再把消息发出去。这个方案既保证了本地数据的一致性又为跨系统的最终一致性提供了基础。在这个模式里Transactional反而扮演了非常关键的角色。5.3 谨慎使用REQUIRES_NEW它不是银弹事务传播行为里的REQUIRES_NEW经常被拿来当“局部事务独立提交”的解法。比如有人说“我在外层事务里调用一个方法希望它单独提交即使外层回滚它也不受影响那就用REQUIRES_NEW。”这个思路在特定场景下是对的但要特别谨慎。REQUIRES_NEW会挂起当前事务开启一个新事务新事务提交后再恢复原事务。这意味着这个方法的数据库操作和原事务不在同一个原子单元里而且会额外占用一个数据库连接。我见过有同学在循环中调用REQUIRES_NEW方法结果连接池直接被打满。因为外层事务一直持有一个连接内层的REQUIRES_NEW每循环一次就要从连接池拿一个新连接即使连接池默认有20个连接也不能这么造。如果你确实需要“部分数据独立提交”优先考虑把这一部分抽到独立服务去调用或者用事务同步钩子处理器TransactionSynchronizationManager.registerSynchronization在事务提交后异步执行。6. 写在最后事务是工具但别让它替你思考做技术这行久了你会发现很多问题都不是“技术不够”导致的而是“默认配置太方便思考变懒了”导致的。Transactional就是最典型的例子。它让你一行注解就拿到了事务能力却也把事务的代价、边界、异常处理逻辑全部隐去了。当你面对的仅仅是“单表单库的简单CRUD”时它确实省心。但一旦你的方法开始变得复杂涉及外部调用、多表关联、批量处理、异步逻辑这个注解就会变成一把没有安全栓的枪。我自己现在的习惯是新项目里默认不用Transactional标注业务方法而是根据实际场景去选。纯DB小操作用TransactionTemplate或直接在Mapper层做跨服务一致性直接用可靠消息只有那种“必须原子更新若干张表且不掺杂任何外部动作”的场景我才会把事务注解加上并且严格限定rollbackFor、走索引、控制数据量。最后送你一个排查口诀看到事务先问边界看到异常先看回滚看到锁先查索引看到慢先抓连接。这四个问题想清楚了Transactional对你来说就不再是玄学而是一个真正可控的工具。
返回列表