
1. Transactional到底帮我们做了什么1.1 先从JDBC手动事务说起要搞懂Transactional为什么不推荐得先回到最底层在只用JDBC的年代一个数据库事务是怎么控制住的。那时候写代码拿到Connection之后要手动执行conn.setAutoCommit(false)业务操作做完之后手动conn.commit()任何一步出了异常都要在catch块里手动conn.rollback()最后还要在finally里把连接关闭。稍微漏一个分支数据就脏了。这段经历现在很多年轻人都没经历过但恰恰是这段经历让我对后面Spring帮你做的那些“隐形操作”特别敏感。后来Spring的声明式事务出现了一个Transactional注解就能替代上面那一堆样板代码。它是什么原理本质上就是Spring容器在启动的时候会为目标Bean创建一个代理对象你调用的时候走的其实是代理。代理在方法执行前开启事务方法正常结束就提交抛异常就回滚。这套机制叫AOP增强具体干活的类叫TransactionInterceptor。说白了注解只是告诉代理“你在这个方法外头给我包一层事务逻辑”真正开事务、提交、回滚都是代理在替你干。1.2 代理机制JDK动态代理还是CGLIB直接影响注解是否生效这是解读注解的第一道坎。如果目标类实现了接口Spring默认用JDK动态代理如果没实现接口用CGLIB通过生成子类的方式做代理。这俩有啥区别JDK代理创建出来的对象只能拦截接口里声明的方法CGLIB则能拦截类里几乎所有非final方法。很多新人问“为什么我的Transactional没生效”排查第一步就是看这个Bean到底有没有被代理以及你调用的是不是代理对象上的方法。这里插一个最常见的翻车案例同一个类里的方法A调方法BA没加事务注解B加了。你以为B会开一个事务实际上B根本不会走代理因为这是this调用不是通过注入进来的代理对象调用。Spring的声明式事务基于代理代理只能拦截从外部进来的调用内部自调用绕过了代理注解自然就成了一张废纸。这个问题我后面会展开讲。1.3 隔离级别和传播行为注解默认值真的够用吗Transactional的默认传播行为是REQUIRED意思是如果当前没有事务就新建一个如果有就加入当前事务。默认隔离级别是数据库默认的隔离级别MySQL一般是REPEATABLE READ。这两个默认值在大多数CRUD场景下够用但一旦涉及复杂的写业务比如一个Service方法里循环调用了多个模块的更新逻辑REQUIRED就会让所有写操作都进同一个大事务锁的持有时间会特别长这在并发高的系统里几乎必然出事。大厂不是觉得注解本身有罪而是觉得它把事务的边界藏起来了。你以为你在一处开启、处处生效实际上一个方法嵌套调多个方法整个调用链都被卷进同一个事务数据库连接从进方法到方法结束一直不能释放锁一直持有业务高峰期就是灾难。所以很多团队干脆在规范里写死一句话除非是简单到不能再简单的单表单行更新否则不要用Transactional要用编程式事务把事务边界控制在你想要它存在的那个窄范围里。2. 大厂最怕的几件事Transactional的经典失效场景2.1 自调用陷阱同一个类里的方法互相调用事务注解形同虚设这个问题我真的见过太多次而且出问题的代码往往是老员工写的看起来特别像“标准答案”。比如Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { saveOrder(dto); updateStock(dto); } public void updateStock(OrderDTO dto) { // 扣减库存 } }看起来好像没问题createOrder开了事务里面调updateStock一动全动。但如果哪天你直接在Controller里调了updateStock你会发现扣减库存这个操作本身没有事务保护。原因我刚才说了Spring事务基于代理Controller拿到的是代理对象createOrder是从代理入口进的这一层没问题。可如果某个方法没加注解却被卷进事务那你得时刻牢记Transactional只对代理入口方法生效对方法内部的this调用完全不生效。更隐蔽的版本是把Transactional注解加在private方法上。Spring对private方法是无能为力的因为代理根本拦不到private方法物理上也没法对它做增强注解会被静默忽略。还有同类里两个public方法互相调用被调用方即使加了注解也没用。有些人会为了“让事务生效”而把内部调用改成注入自己这是把问题搞复杂后面我会说更干净的解法。2.2 异常被吞、checked异常、private方法——哪天失效了你都不知道这是第二个高频翻车点。默认情况下Transactional只在运行时异常RuntimeException时回滚遇到受检异常checked exception是不会回滚的。这是什么意思你方法里catch住异常丢了个自定义的业务异常而业务异常如果不继承RuntimeException事务就直接提交了。更阴险的是有些开发为了“不把异常漏出去”在事务方法里写try-catch把异常吞掉然后返回个错误码事务层根本不知道发生了异常于是脏数据就这么落库了。所以正经项目的第一个规范就是事务方法里的异常要么不catch要么catch之后重新抛出RuntimeException绝对不能在事务方法内部把异常吃掉。如果有队友跟你说“我catch一下记个日志就行”你最好直接按住他的手。你记了日志数据已经写坏了日志救不了业务。还有一类失效场景是数据库层面的如果连的是MyISAM引擎的表InnoDB才有事务支持MyISAM根本不支持事务注解再多也没用。另外就是方法所在的类没有被Spring管理比如new关键字手动new出来的对象注解同样无效。2.3 代理机制引发的连锁问题绕过代理的N种姿势我上面提过JDK动态代理和CGLIB这里再展开一个连锁问题有些人用Transactional的时候为了让它生效会在类上直接加注解甚至把整个Service类都加上。这样做有个隐患类的所有public方法都会开启事务哪怕是一个只读查询也会尝试开启事务。读操作开事务虽然不会造成数据错误但会占用数据库连接拖慢连接池周转。更重要的是如果你把事务加在类级别再配合一些方法级别的覆盖配置配置一旦写错排查起来非常痛苦。还有一种是多线程场景。Transactional的使用范围是单线程的它开启的事务和当前线程绑在一起。如果你在事务方法里用CompletableFuture异步执行子任务子线程里根本不会共享到主线程的事务上下文。有人以为子任务里再标个Transactional就能参与主事务实际上REQUIRED传播行为在线程B里发现没有事务会直接新开一个两个事务各管各的主事务回滚子事务照样提交数据照样错。说实话这些问题单独拎出来每一个都不难理解但实际项目里往往是好几个叠在一起异步、自调用、异常被吞、跨库、连接池打满叠完之后你再想查是哪个环节导致的一致性问题那真是海底捞针。这也是大厂一般不敢让Transactional“裸奔”的根本原因它把风险藏在了注解背后等出事的时候你已经很难还原现场。3. 比失效更可怕的是长事务它在如何拖垮数据库3.1 连接被长事务占死连接池耗尽连锁反应失效充其量是“没有保护”最惨的是事务真的生效了但它生效太久。长事务最直接的影响就是数据库连接迟迟不释放。假设你的连接池最大20个连接一个事务方法里你调了外部接口这个外部接口平均响应3秒高峰时5秒甚至超时。这段等待时间里事务一直开着连接一直被占着。如果同时有20个请求打进来连接池就空了后面的请求只能排队等连接等待超过超时时间就直接报获取连接失败。这还不是最可怕的。MySQL里长事务会让Undo Log不断膨胀因为要支持MVCC事务隔离要能看到自己开始之前的数据版本。事务开得越久旧的版本链就越长其他查询要回滚到那个越来越远的历史版本磁盘IO和内存压力都会上来。很多线上“突然变慢”的诡异问题最后查出来就是有一个大事务在后台没提交把性能拖垮了。3.2 锁升级与死锁为什么线上事故总在深夜发生长事务第二个大招是锁。一个事务修改了一行数据这行数据上的锁直到事务提交或回滚才会释放。事务等待一个不存在的锁就可能导致锁等待超时两把锁互相等待就是死锁。死锁常见的现场是两个事务都先update了表A再update表B但顺序相反。在低并发下根本不会发生因为一个事务很快就执行完了锁刚产生就释放但在长事务场景下第一个事务拿着A锁迟迟不提交第二个事务拿着B锁等A第一个事务又等B就卡死了。有一次我们排查线上死锁发现罪魁祸首是一个同步批量操作循环里对一批数据逐条做update每条update之间还穿插着远程调用。正常人写代码不会这么干但业务就是这么要求的结果一个事务里包含了几十条update加十几秒的远程调用锁持有时间长得离谱。后来我们把批量改成单条短事务把远程调用挪到事务外面死锁基本绝迹。长事务不一定会死锁但它把死锁的概率放大了几十倍。3.3 主从延迟与缓存不一致事务边界影响的放大效应再说一个容易被忽略的点主从延迟。以前我们有个项目写完订单后立刻更新Redis缓存更新逻辑里先查数据库再写缓存。因为主库事务特别长从库同步延迟高查从库查到的还是老数据就把老数据写回缓存了。结果前端看到的数据一会新一会旧用户疯狂反馈。最后定位到根因还是长事务主库的写入迟迟不提交从库拿不到最新数据读库和缓存全被带偏。所以你会发现长事务的影响从来不是“业务多等了零点几秒”这么简单它会顺着连接、锁、版本链、主从同步、缓存链路一路传染出去。一个Transactional写得不谨慎可能影响的是整个服务集群的可用性。正因为这个放大效应大厂在核心写路径上对事务边界都有近乎苛刻的要求能不开就不开能短则短开之前先问自己“这个锁我到底需要持有多久”。4. 为什么大厂项目不默认推荐Transactional4.1 不可控声明式事务带来的“隐式行为”说到根子上大厂不推荐Transactional不是这个注解本身写得烂而是它能藏的东西太多了。你写一行注解等于把“何时开启事务、何时提交、何时回滚、事务传播怎么串”全部交给了框架的默认行为。开发者在代码里看不到这些控制流Review的人也看不到只有出了事故之后才去翻日志和监控。代码的可控性在大厂里比便利性值钱得多。快三个月一次的版本发布如果每个事务行为都靠注解约定那线上出问题就不是“如果”而是“什么时候”。再说调试成本。声明式事务的回滚和提交发生在代理层跟你的业务代码是分离的。你在业务代码里打断点只能看到方法执行完返回了根本看不到提交动作发生的那一刻。真要在复杂业务里排查一条数据是被谁回滚的、回滚发生在哪一行你得把AOP、代理、事务同步管理器全部翻一遍成本非常高。编程式事务虽然代码看起来“啰嗦”但每个步骤都是显式的逻辑清楚出问题的时候一行一行跟就能跟上。4.2 编程式事务TransactionTemplate把控制权拿回自己手里既然声明式事务不可控那替代方案是什么我个人最常用的就是TransactionTemplateSpring自带的编程式事务模板。用法很简单把要执行的任务塞进execute回调里回调返回正常就提交抛异常就回滚。事务的边界、回滚规则、隔离级别全都一目了然。代码量确实比注解多几行但换来的是“每一行都在我掌控之中”的安全感。Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); } public void createOrder(OrderDTO dto) { transactionTemplate.executeWithoutResult(status - { // 1. 保存订单 // 2. 扣减库存 // 3. 其他写操作 }); } }有人可能会说这不就是把注解换成了模板吗底层不还是同一套事务管理器没错底层是一样的但收益在于边界明确。注解是“整个方法都进事务”模板是“只有execute回调里的代码在事务里”。如果你要把远程调用、计算逻辑放在事务外Annotation根本没法精细控制模板代码却能轻松做到。这一点在写核心写链路的时候极其重要。4.3 什么时候仍然值得用Transactional适用场景判断上面说了这么多坏话也不是要把Transactional一刀切打死。它最适合的场景是那种“单个方法、单一数据库、短且快”的简单写操作。比如一个简单的订单状态更新、一个库存字段扣减方法本身没有远程调用没有批量循环执行时间毫秒级这种时候用注解完全没问题代码也确实简洁。但一旦方法里出现下面这些信号我建议你马上把注解拿掉改用TransactionTemplate一是方法内部有远程调用、消息发送、定时任务回调二是方法里有循环批量的写操作三是方法会被同类其他方法调用自调用场景四是方法里会有catch块用于捕获业务异常五是你需要自定义回滚条件。识别这些信号的能力其实比背任何框架API都重要。框架只是工具你真正要管理的是事务的边界、时长和风险。5. 事务替代方案与代码落地实操5.1 TransactionTemplate的标准化写法我平时写代码有个习惯凡是事务代码先写清楚哪些操作必须在事务里哪些可以放到事务外。事务内的操作尽量只包含数据库写操作和必要的校验事务外再去做日志、发消息、远程调用、缓存更新。这看起来是小事但在高并发下差别巨大。比如一个下单流程扣库存必须在事务里但给用户发短信、通知仓库系统这些完全可以在事务提交之后再异步做放在事务里只会白白拉长锁时间。标准写法我一般这样组织public void createOrder(OrderDTO dto) { // 事务外前置校验、幂等判断 String orderId transactionTemplate.execute(status - { // 事务内只做数据库写操作 Order order orderRepository.save(new Order(dto)); stockService.decreaseStock(dto.getSkuId(), dto.getCount()); return order.getId(); }); // 事务外发消息、清缓存、异步通知 mqSender.sendOrderCreatedEvent(orderId); }这个结构的核心价值是把事务的“宽度”控制在最小。很多线上问题不是事务逻辑错了而是事务里塞了太多不该塞的东西。记住一个原则事务里跑的每一毫秒都是数据库连接和锁在为你买单。能用1毫秒解决的事别拖成100毫秒。5.2 事务边界设计把锁范围缩到最小再聊一个实战细节如果你的事务内必须做多次写操作尽量保持它们之间没有业务计算、没有远程调用、没有等待。比如一次下单先插入订单表再扣库存这两步中间如果插入了一个“根据订单内容调用商品服务计算价格”的远程调用那锁就莫名其妙多持有了几秒。正确做法是把价格计算放到事务外把计算结果作为参数传进来事务内只管写入。另外如果事务内需要读取数据再决定是否写入要注意锁和快照读的区别。默认的RR隔离级别下普通SELECT是快照读不锁行但如果业务要求“先查后改”且必须防止并发修改就要考虑是否需要for update或者干脆用乐观锁版本号。这块很多人会踩坑以为事务里查到的数据就是最新的其实在高并发下快照读可能读到旧版本导致更新覆盖。做扣减库存这类业务强烈建议用数据库原子操作比如update stock stock - count where stock count来替代“先查后判再改”。还有一点是关于传播行为。TransactionTemplate默认也是REQUIRED如果当前已有事务它会加入现有事务。如果你希望某个操作无论如何都开一个新事务、独立提交回滚那就得设置Propagation.REQUIRES_NEW。REQUIRES_NEW使用场景听起来很酷但它意味着数据库会有两个并发事务同时持锁使用前一定要想清楚否则很容易出现两个事务互相等锁导致死锁。我在实际项目里见到太多人看到REQUIRES_NEW觉得很高级、一定要用结果用出好几个死锁事故所以我的建议是默认REQUIREDREQUIRES_NEW只在“必须独立失败不影响主事务”的场景下少量使用。5.3 隔离级别与回滚规则的实际选择隔离级别也是容易被忽略的点。MySQL InnoDB默认REPEATABLE READ对绝大多数业务来说够用。但如果你在读多写少的报表统计场景可以考虑用READ COMMITTED来降低间隙锁的影响不过要结合DBA的意见和具体业务场景。这里我特别想强调隔离级别不是越高越好越高往往意味着锁的范围越大、并发度越低。有些团队动不动把注解的隔离级别配成SERIALIZABLE等于让所有并发写操作排队性能直接崩盘。回滚规则方面TransactionTemplate里可以用setRollbackOn、setNoRollbackOn之类的配置但说实话大部分业务用不上这么细。我一般只遵循两条铁律一是运行时异常RuntimeException默认回滚不要在事务里catch掉二是如果碰到必须处理受检异常的场景catch之后要么重新抛RuntimeException要么在回调里通过setRollbackOnly()强制标记回滚。这样虽然啰嗦但行为可控不会出现“我以为回滚了结果提交了”的惨剧。6. 故障排查实录事务相关的典型问题与心法6.1 常见异常速查写事务代码这些年我在生产环境见过的事务相关异常来来去去就那么几个嚼碎了讲给大家。第一个是UnexpectedRollbackException这个异常的意思是事务已经被标记为rollback-only但方法正常返回了Spring发现“你说你要提交但内部已经标记了回滚”就抛这个异常。根源往往是内部嵌套的事务方法抛了异常但被外层catch掉了异常被吞回滚标记却留下了。看到这个异常第一反应就是去查内部事务方法里有没有被吞掉的异常。第二个是CannotAcquireConnectionException这个异常一看就知道是连接池没连接了但连接池为什么没连接十有八九就是某处有长事务或连接没释放。第三个是DeadlockLoserDataAccessException一听就是死锁MySQL会选一个事务回滚业务代码里如果没做重试这次请求就失败了。死锁不代表代码逻辑错了很多时候是锁顺序不一致导致的我后面会讲排查方法。下面整理个速查表方便以后排查直接对照异常常见原因第一反应UnexpectedRollbackException内部事务标记了rollback-only外层吞了异常找被吞异常CannotAcquireConnectionException连接池耗尽查长事务、连接占用DeadlockLoserDataAccessException并发锁顺序冲突检查锁顺序、事务时长TransactionSystemException事务同步异常、连接异常查数据库状态、事务管理器配置IllegalStateExceptionDataSourceUtils连接关闭、事务不同步查是否混用了手动连接与Spring事务这些异常虽然名字吓人但基本上都能从日志里顺藤摸瓜找到根因。怕的是异常日志不全或者事务方法里的catch块把异常吞了那才是真的排查地狱。6.2 排查事务问题的三板斧第一板斧是看日志。Spring的事务日志级别调到DEBUG之后会打印事务开启、提交、回滚的记录TransactionInterceptor类会输出类似“Completing transaction for [xxx]”这类信息。如果你看不到回滚日志说明事务根本没事如果看到回滚日志但数据还是不对那问题就在“有没有真回滚、谁把数据写进去的”。第二板斧是查数据库。看当前有哪些事务在跑MySQL里可以用information_schema.innodb_trx表查到未提交事务的开始时间和状态再用performance_schema查锁等待情况。这一步能快速定位是不是有长事务或者锁等待。我记得有一次线上卡顿一查innodb_trx发现一个事务已经挂了40多分钟没提交那行数据被锁得死死的排查过程直接缩短到十分钟。第三板斧是复现。事务问题往往具有并发触发条件单线程跑很难复现。我会写一个小的复现用例用两个线程模拟并发操作把隔离级别、锁顺序、事务时长都按线上配置来一点点逼近线上场景。这个方法虽然费点功夫但比看代码猜来猜去靠谱得多。事务问题本质上就是并发问题不用并发复现永远都像在黑暗中摸象。6.3 我的一点实操心得这篇文章快写完了最后分享一点我个人的偏好和习惯。我在项目里定过一条规矩新代码一律用TransactionTemplate旧代码里有Transactional的但凡在代码Review里碰到一律追问三个问题——事务边界在哪、锁持有多久、异常会不会被吞。三个问题答不上来就当场重写。听起来苛刻但效果特别好推行一年后线上事务相关的告警几乎消失了。还有一个心得是关于代码Review的Transactional这种声明式的东西最大的问题不是运行时出错而是代码读起来太“顺”了顺着顺着就没人再去质疑它。反倒是TransactionTemplate那几行略显冗余的代码每次看到都会让人本能地问一句“这里为什么要把事务圈起来”。这种“结构上的不舒服”恰恰是代码质量最有效的守护。事务这件事坦诚讲没有银弹你要选的从来不是哪个API更优雅而是哪个方案更容易让你和你的队友看清真相。