Spring事务治理:@Transactional为何受限?失效场景与编程式事务替代方案

📅 发布时间:2026/10/10 20:14:38
Spring事务治理:@Transactional为何受限?失效场景与编程式事务替代方案
先聊一个现象你去翻任何一家中型以上互联网公司的《Java开发规范》几乎都能看到一行字——“禁止使用Transactional声明式事务或必须严格控制使用场景”。但奇怪的是面试的时候任何一本Spring源码解析的书里都会花大篇幅讲Transactional的传播机制、回滚规则。一边是官方文档主推的“一行注解搞定事务”一边是大厂代码规范里近乎一刀切的限制。我自己当初也很困惑这不就是Spring提供的最方便的数据库事务控制方式吗我加上注解方法执行完自动提交抛异常自动回滚省掉多少手动代码凭什么不让用直到后来经历过几回线上事故从“背锅”到“查因”到“改代码”一路走下来才真正明白那条规范背后的分量。这篇东西不劝退Transactional而是把它的适用边界、失效陷阱、替代方案用实际踩坑的方式讲清楚。不管你是刚写CRUD的新人还是在搞复杂订单系统的老手只要往下写过一次事务代码建议花十分钟看完。能帮你少写几千行返工代码少熬几个排查BUG的夜。1. 先说清楚Transactional到底是个什么东西1.1 事务的本来面目聊Transactional之前得先把事务本身的逻辑拉出来。事务这个词说白了就是一组数据库操作的集合要么全部成功要么全部失败。经典五字真言ACID原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。其中原子性是基础一致性是目标隔离性是并发场景下的一致性保障手段持久性是保证数据落盘不丢失。举个例子你在电商平台下单至少要干三件事扣库存、生成订单、扣用户余额。这三步必须绑在一起。如果第一步成功、第二步失败了那库存被扣了订单却没生成用户账户里凭空少了钱系统就乱了。所以这三步要么都成功要么都失败。在数据库层面这三步操作就是三条SQL语句。要保证这三个SQL的原子性就得用数据库的事务能力。关系型数据库比如MySQL的InnoDB引擎天生就支持事务。SQL层面其实就是三条指令开启事务BEGIN、提交事务COMMIT、回滚事务ROLLBACK。逻辑很简单难的是保证事务执行期间的性能、并发安全、异常恢复这些由数据库引擎解决咱们应用层主要关注事务的边界和触发时机。1.2 Spring为我们做了什么声明式事务的魔术在没有Spring的年代Java写事务代码是这样的每次操作前connection.setAutoCommit(false)操作成功后connection.commit()操作失败后connection.rollback()最后还要在finally里connection.close()。Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 扣库存 updateStock(conn, productId, count); // 生成订单 insertOrder(conn, order); // 扣余额 updateBalance(conn, userId, amount); conn.commit(); } catch (Exception e) { if (conn ! null) { conn.rollback(); } throw e; } finally { if (conn ! null) { conn.close(); } }这套代码问题很明显样板代码太多、连接获取和释放容易遗漏、异常处理容易出错。Spring的声明式事务就是把这个过程封装起来开发者只需要在一个方法上标注TransactionalSpring AOP就会在方法执行前自动开启事务方法执行完自动提交抛出异常自动回滚。Transactional public void createOrder(OrderDTO orderDTO) { updateStock(orderDTO.getProductId(), orderDTO.getCount()); insertOrder(orderDTO); updateBalance(orderDTO.getUserId(), orderDTO.getAmount()); }简洁是真简洁。但简洁的背后Spring帮你做了一堆你“看不见”的事情这些“看不见”就是后面各种坑的根源。Spring的实现机制简单说就两步代理模式 拦截器。Spring容器启动时判定某个Bean的Class或方法上标注了Transactional就为这个Bean生成一个代理对象默认用的是JDK动态代理接口代理没有接口时用CGLIB子类代理。代理对象在执行目标方法前通过TransactionInterceptor拦截器读取注解上的配置结合事务管理器PlatformTransactionManager执行事务的开启、提交或回滚。这个机制意味着一个极其关键的事实只有调用“代理对象”的方法时事务拦截器才起作用调用“原始对象”的方法时注解等于摆设。1.3 事务管理器和传播机制事务管理器PlatformTransactionManager是Spring事务的底层默认有DataSourceTransactionManager单数据源JDBC事务、JpaTransactionManager、HibernateTransactionManager等。我们平时用spring-boot-starter-jdbc或mybatis时自动配置的就是DataSourceTransactionManager。传播行为Propagation解决的是“多个事务方法互相调用时怎么办”的问题。Spring定义了7种见下表传播行为说明使用频率REQUIRED当前有事务就加入没有就新建默认值日常用得最多REQUIRES_NEW无论如何都新建一个独立事务挂起当前事务解决部分业务需要独立提交的场景NESTED有事务则建Savepoint嵌套事务没有则新建少见SUPPORTS有事务就加入没有就非事务执行查询场景偶尔用NOT_SUPPORTED挂起当前事务以非事务执行少见MANDATORY当前必须存在事务否则抛异常校验用途NEVER当前必须不存在事务否则抛异常禁用场景看到这张表很多人会以为“掌握了传播行为就掌握了注解事务”。但条目越多组合场景越复杂线上出的幺蛾子也越多。大厂不让用的原因很大程度上就是这种“组合复杂度”失控了。2. 为什么大厂不推荐五个经典翻车场景2.1 自调用问题这个注解可能压根没生效这是最常见的“假事务”场景。看代码Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { updateStock(dto.getProductId(), dto.getCount()); insertOrder(dto); updateBalance(dto.getUserId(), dto.getAmount()); } }看起来没毛病。但如果同一个类内部另一个方法调用了createOrder比如Service public class OrderService { public void handleOrder(OrderDTO dto) { // 校验、检查等多步逻辑 createOrder(dto); // 这是this.createOrder不是代理对象的方法 } Transactional public void createOrder(OrderDTO dto) { updateStock(dto.getProductId(), dto.getCount()); insertOrder(dto); updateBalance(dto.getUserId(), dto.getAmount()); } }此时handleOrder方法本身没有绑定事务它调用createOrder走的是this引用直接调用原始对象的方法。代理对象根本没有机会介入Transactional完全不会触发。里面的三步操作任何一步失败前面的操作都不会回滚。这个问题的排查难度在于看代码时注解明明写在方法上IDE高亮也正常单元测试可能也正常因为测试直接注入代理对象但运行时在某些调用链路上事务就是不生效。数据库里出现脏数据后追查半天才发现是“内部方法自调用导致代理未生效”。解决思路有几种把Transactional标注在外部调用方法的入口上也就是让事务边界包含整个调用链路。将事务方法拆分到另一个Spring Bean中通过注入该Bean来调用。用AopContext.currentProxy()获取当前代理对象再调用。更根本的在事务方法内部尽量只做数据操作不要做业务编排。不用Transactional的团队压根不会遇到这个问题。他们用编程式事务后面细讲事务边界清清楚楚没有任何“代理隐身”的戏码。2.2 异常被吃掉回滚静默失败Transactional默认只在方法抛出RuntimeException或Error时回滚受检异常Checked Exception默认不回滚。这一点很多人知道但更隐蔽的问题是方法内部自己catch了异常然后“吞掉”或者返回一个错误码。Transactional public boolean createOrder(OrderDTO dto) { try { updateStock(dto.getProductId(), dto.getCount()); insertOrder(dto); updateBalance(dto.getUserId(), dto.getAmount()); return true; } catch (Exception e) { log.error(create order failed, e); return false; // 异常没有抛出去事务拦截器以为一切正常直接提交 } }这段代码是典型的“事务形同虚设”。catch块捕获了异常并返回false事务拦截器没看到任何异常信号于是执行commit。结果数据库里可能已经扣了库存但订单没生成业务层拿到false后又提示用户“下单失败”用户再点一次又扣一次库存。这种Bug的隐蔽性极强因为它不报错、不抛异常、一切看起来都很正常只有数据库里的数据对不上。正确做法是第一步事务方法内不要自己吞异常要么抛出去要么设置rollbackFor明确指定回滚异常类型第二步哪怕要在方法内处理错误也应该在catch块中手动标记事务回滚TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()然后重新抛出。但话又说回来一旦需要靠setRollbackOnly这种写法来兜底说明当前方案已经不太适合这个场景了换编程式事务反而更直接。2.3 粒度过粗一个事务包住整个业务流程这是大厂最反感的使用方式。很多人图省事把一堆跟数据库读写不直接相关的操作统统塞进一个Transactional方法里。比如Transactional public void processOrder(OrderDTO dto) { // 1. 调用外部服务查询风控数据 RiskResult risk riskService.query(dto.getUserId()); if (!risk.isPass()) { throw new BusinessException(风控不通过); } // 2. 远程调用库存服务扣减 inventoryClient.deduct(dto.getProductId(), dto.getCount()); // 3. 写本地订单表 insertOrder(dto); // 4. 调用消息中间件发送通知 mqSender.send(dto); }看着很方便但想想事务的原理事务开启后数据库连接被占用相关的行锁、间隙锁被持有。你在事务里调用外部服务通常一个HTTP调用要耗费几十到几百毫秒期间数据库连接一直挂着锁一直不放。高并发场景下很快就会出现三种情况数据库连接池被榨干。每个请求占用一个连接去等外部服务返回连接池默认大小也就是10到50个几十个请求就能把池子占满后续请求全部排队甚至超时报错。锁冲突暴增。同一行数据的更新请求互相等待接口RT从50毫秒飙升到5秒最终触发降级或熔断。长事务导致主从延迟。事务持续时间越长undo log和redo log的开销越大主库提交后从库的回放延迟越高影响所有读请求的一致性。耗时操作、远程调用、消息通知这类非数据库操作放在事务内是“大忌”。事务应该只包裹真正需要原子性的数据库写操作而且越短越好。一旦养成了“大事务包一切”的习惯系统离故障就不远了。2.4 分布式场景Transactional管不住跨库跨服务互联网大厂的核心业务系统很少有一个业务操作只涉及单一数据库的情况。订单服务、库存服务、积分服务往往拆成多个微服务各自拥有独立的数据库。这个时候Transactional只能保证“单个数据库本地事务”的原子性跨库、跨服务的数据一致性完全管不了。看这个场景用户下单订单服务写订单库同时调用库存服务扣库存。两个操作分别在不同的数据源甚至不同的物理机上。Transactional public void createOrder(OrderDTO dto) { // 本地订单库操作走了本地事务 orderMapper.insert(dto); // 远程调用库存服务这个操作不归当前事务管 inventoryService.deduct(dto.getProductId(), dto.getCount()); }一旦inventoryService.deduct执行成功但后续orderMapper.insert失败了本地事务回滚了库存服务的扣减却无法跟着回滚——因为那是一个独立的系统当前事务管理器对它的数据源没有任何控制力。于是库存扣了订单没生成数据不一致。这就是“伪分布式事务”。有些人为了硬撑会让库存服务也开一个Transactional然后配合消息队列做最终一致性或者引入Seata、ShardingSphere这类分布式事务中间件。但绝大多数情况下把本地事务的注解强加到跨服务调用上不但解决不了分布式一致性问题反而制造一种“我好像有事务保护”的虚假安全感。很多大厂在这个场景下的方案是本地事务只管本地数据库的写操作对外部服务的调用放在事务提交之后通过Spring的TransactionSynchronizationManager注册事务同步回调或用事务消息/本地消息表实现最终一致性。这个改动看起来不复杂但直接断了用Transactional包一切的念想。2.5 锁误用事务加锁的诡异互斥还有一类问题由“事务内加锁”产生。比如用了SELECT ... FOR UPDATE或者使用分布式锁Redis锁时把锁的获取放在事务方法内Transactional public void deductStock(Long productId, Integer count) { // 步骤1分布式加锁 boolean locked redisLock.tryLock(stock: productId, 5, TimeUnit.SECONDS); if (!locked) { throw new BusinessException(系统繁忙请稍后重试); } try { // 步骤2数据库扣库存 stockMapper.deduct(productId, count); } finally { // 注意这里的释放锁在事务提交之前 redisLock.unlock(stock: productId); } }这个方法的执行时序是获取Redis锁 → 执行扣减SQL → 释放Redis锁 → 方法返回 → 代理拦截器提交事务。也就是说锁释放的时刻早于事务提交的时刻。假设两个请求并发执行请求A释放锁后事务还未提交请求B拿到锁后去查库存读到的还是旧值未提交的数据然后继续执行扣减。两个请求都以为自己拿到了锁并发更新同一行数据锁形同虚设。要解决这个时序问题就得把锁的释放挪到事务提交之后。用Transactional就非常别扭因为事务提交是被代理拦截器在方法结束后自动做的你无法在方法内部精确控制“事务提交后再释放锁”这个时机。换成编程式事务就可以用代码控制public void deductStock(Long productId, Integer count) { boolean locked redisLock.tryLock(stock: productId, 5, TimeUnit.SECONDS); if (!locked) { throw new BusinessException(系统繁忙请稍后重试); } try { transactionTemplate.executeWithoutResult(status - { stockMapper.deduct(productId, count); }); } finally { // 此时事务已经提交释放锁才安全 redisLock.unlock(stock: productId); } }这种锁生命周期与事务生命周期的错位属于“不深入理解Spring事务拦截器执行时机就没办法规避”的坑。一线的同学踩一次基本半个晚上就没了。3. 大厂实际在用什么编程式事务与替代方案3.1 TransactionTemplate可控事务的不二选择Spring本身提供了一个编程式事务的工具类TransactionTemplate。用法很简单注入PlatformTransactionManager或TransactionTemplate然后在lambda块中写数据操作。Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); // 可配置隔离级别、超时、只读等按需设置 this.transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_DEFAULT); this.transactionTemplate.setTimeout(3); } public void createOrder(OrderDTO dto) { transactionTemplate.executeWithoutResult(status - { try { updateStock(dto.getProductId(), dto.getCount()); insertOrder(dto); updateBalance(dto.getUserId(), dto.getAmount()); } catch (Exception e) { // 根据需要决定是标记回滚还是部分提交 status.setRollbackOnly(); throw e; } }); } }比起TransactionalTransactionTemplate的好处非常直观事务边界是显式的哪里开始、哪里结束、提交还是回滚一目了然。没有代理模式不走AOP不存在自调用失效问题。可以灵活处理锁的获取与释放时机。支持在lambda内部对部分异常做精细化的回滚控制。超时、隔离级别、只读等设置和注解方式一样都可以配置。代价是多写几行代码。但这几行代码换来的全是确定性、可控性和可排查性。在大厂大规模高并发业务里“确定性”比“少写代码”重要得多。3.2 本地消息表与事务消息处理分布式一致性的标准姿势一旦跨服务先把“用本地事务管全局”的想法丢进垃圾桶。业界更普遍的做法是本地消息表或事务消息配合最终一致性。所谓本地消息表就是把“发消息”这个动作和业务操作放在同一个本地事务里public void createOrder(OrderDTO dto) { transactionTemplate.executeWithoutResult(status - { // 1. 写业务数据 orderMapper.insert(dto); // 2. 写消息记录 messageMapper.insert(buildMessage(order.created, dto)); }); // 事务提交后再真正发送消息 messageSender.doSend(); }业务数据和“状态变更事件”同库同事务写入后不可分割。后续由异步任务或消息队列把事件投递给下游系统。下游消费消息后做自己的事哪怕失败了也可以基于消息记录做重试。整个链路不再强依赖分布式事务而是通过“本地事务 幂等消费 重试机制”达成最终一致性。核心变化在于事务边界收窄到单库单事务跨服务的协作移到事务之外靠消息驱动。这跟用Transactional硬包一个远程调用是完全不同的架构思路。大厂不是“不用事务”而是重新定义事务的边界让每条事务短小精悍只做真正需要原子性的动作。3.3 业务层手动控制多数据源事务复杂业务也有一类场景确实需要同时操作多个数据源又希望保持本地事务的原子性。这时还可以用Spring的ChainedTransactionManager或JtaTransactionManager实现多数据源的同步提交与回滚。但需要注意这只是“尽力而为”的分布式事务并非强一致跨库事务的性能损耗也很明显。早期我自己做多数据源强一致需求时第一反应还是开两个Transactional对不同数据源结果发现注解加到第二个方法上根本不会和第一个方法形成事务联动。后来在Service层注入一个JTA事务管理器通过编程式事务统一开启和提交才把两边数据源纳入同一个事务边界。这种方案需要配置XA数据源复杂度高、性能损耗大绝大多数业务场景并不值得能拆到最终一致性的尽量拆。3.4 用领域事件解耦替代“大事务内调用外部服务”碰到需要“事务内调用外部服务”的需求经验是重新审视业务流程把“纯数据库变更”和“外部副作用”拆开。典型做法事务内只更新业务状态、记录领域事件事务提交后监听事件或订阅消息再执行外部调用。public void payOrder(Long orderId) { transactionTemplate.executeWithoutResult(status - { // 本地更新订单状态为已支付 orderMapper.updateStatus(orderId, PAID); // 记录一个待处理事件 outboxMapper.insert(new OutboxEvent(order.paid, orderId)); }); // 事务提交后执行后续动作 eventPublisher.publishOrderPaid(orderId); }这样一来事务不再包含远程调用锁的持有时间大幅缩短。外部调用失败也能通过事件表单独补偿不会回滚掉“用户已支付”的本地事实。本质上就是把“同步的分布式共享”改成“异步的最终一致”这也是大多数互联网业务系统的正确方向。4. 如果必须用Transactional正确姿势与避坑清单4.1 你的场景适合用注解事务吗也不是说Transactional完全不能碰。它适合的业务特征大概是单服务进程、单数据源。事务方法本身没有远程调用、没有重IO操作。方法体逻辑简单、执行速度快毫秒级。方法的调用方和实现方处于同一Bean或不同Bean但入口路径可控不会出现代理失效问题。比如一个纯粹的本库写操作几个update、insert组合在一起逻辑上必须同时成功或同时失败这用Transactional很合适。用户注册写用户表和写积分表都在一个库里加个注解问题不大。但如果事务方法体内有任何网路调用、消息发送、动态计算耗时超过几十毫秒的环节就要警惕了。这类代码在测试环境永远测不出问题因为测试环境的并发量、数据量、网络延迟都和线上完全不同。上线后一到峰值流量数分钟内就会出现连接池耗尽这是经典的生产事故。4.2 关键参数必须显式声明如果决定用Transactional建议配置参数不要用默认值。至少以下三个参数要主动写清楚Transactional( rollbackFor Exception.class, timeout 3, isolation Isolation.REPEATABLE_READ ) public void createOrder(OrderDTO dto) { // ... }rollbackFor Exception.class把受检异常也纳入回滚范围避免默认配置下受检异常“不触发回滚”的坑。timeout 3超过3秒自动回滚防止长事务拖死连接池。这个值要根据业务实际情况测试后设定不是越短越好。isolation Isolation.REPEATABLE_READ大多数业务使用默认RC读已提交或RR可重复读即可关键是显式写出来让Code Review的人一眼看到你的隔离级别选择是有意识的而不是随手加的。还有readOnly属性的问题。很多查询方法喜欢加readOnly true表示只读事务。它官方的用途是给数据库一个提示某些连接池和数据库驱动也可以做一些优化。但注意readOnly不等于“禁止写入”如果你在只读事务里执行了insert/updateMySQL的InnoDB引擎并不会拦截只是某些驱动比如Oracle的JDBC驱动会限制。所以不要把它当成安全控制手段。4.3 自调用问题的最优解拆Bean如果你在代码里发现Transactional方法被同类中的另一个方法直接调用最省事的修复方法不是加AopContext而是把事务方法挪到单独的Bean中。示例原代码Service public class OrderService { public void handleOrder(OrderDTO dto) { // ... this.createOrder(dto); } Transactional public void createOrder(OrderDTO dto) { // ... } }重构为Service public class OrderService { private final OrderTransactionalService orderTransactionalService; public OrderService(OrderTransactionalService orderTransactionalService) { this.orderTransactionalService orderTransactionalService; } public void handleOrder(OrderDTO dto) { // ... orderTransactionalService.createOrder(dto); } } Service public class OrderTransactionalService { Transactional public void createOrder(OrderDTO dto) { // ... } }这样调用链路上天然经过代理对象事务必定生效。Spring的代理机制在这个结构下没有再失效的可能。实践上我见过很多团队为了避免自调用问题干脆硬性规定“Controller→Service→事务Service”三层模式事务Service里只放事务方法业务编排放到外面。这也是一种行之有效的工程约束。4.4 事务方法内部禁止远程调用这条建议价值很高再展开强调一下。事务中持有数据库连接和锁连接是宝贵的有限资源。一次本地SQL操作通常1毫秒到10毫秒执行完但一个HTTP调用动辄50毫秒到500毫秒。一旦事务里做了一个远程调用事务就会一直持有连接和锁等待网络返回。高并发时连接池内的连接全部被“卡在等待网络响应”这种状态占据新的请求全部阻塞。正确做法是“事务内只写库不做外部副作用”。外部副作用包括调用下游HTTP接口、操作Redis集群、发送MQ消息、执行文件IO、写日志到远程存储。这些操作一律放到事务提交后执行。可以用TransactionSynchronizationManager.registerSynchronization()注册事务提交后的回调Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { // 事务提交后再发消息或者调用外部服务 mqSender.send(dto); } }); }甚至更干脆一点配合事件机制或本地消息表模式来做。4.5 锁与事务的生命周期匹配问题如果你用Transactional做“锁 数据库操作”的组合又要保证锁释放晚于事务提交唯一可行的路径就是让锁本身也参与到事务流程中不太现实。所以结论很直接需要加分布式锁的业务不要用Transactional。改成TransactionTemplate 手动锁释放的写法或者用Redisson的锁配合事务同步器去延迟释放。锁和事务的生命周期永远要遵循一个原则释放锁必须发生在提交或回滚之后。4.6 嵌套事务场景下的调用约定业务里偶尔也会遇见这种调用链A方法标了TransactionalA里面调B方法B也标了Transactional。B的默认传播行为是REQUIRED此时B不会新建事务而是加入A的事务。这是符合预期的。但一旦B里面发生异常回滚的是整个A的事务而不是B这一个方法。很多新人对“局部事务”有幻想以为B的事务可以单独回滚不影响A。默认REQUIRED做不到。如果确实希望B独立提交、独立回滚就需要给B标注REQUIRES_NEW。Transactional(propagation Propagation.REQUIRES_NEW) public void updateLog(LogDTO logDTO) { // 独立事务自己提交自己回滚不受外层事务影响 }但REQUIRES_NEW有一个致命副作用外层事务会被挂起当前持有的事务要暂停数据库连接要等内层事务完成才能继续。而且内层事务会独立获取一个新的数据库连接。这在连接池较小的情况下容易导致死锁或连接耗尽尤其内层事务频繁执行时。真实业务里能不用REQUIRES_NEW就不用。即使要用也只用在明确的日志、审计、补偿类场景里并做好并发压力测试。4.7 Transactional的失效清单速查整理一份非常实用的事务失效清单大家做代码审查时可以对着查失效场景原因解决方式方法自调用this调用不走代理拆分Bean或调整事务边界方法不是publicCGLIB无法代理非public方法改为public异常被catch吞掉拦截器收不到异常信号抛出或显式标记回滚受检异常不触发回滚默认只回滚RuntimeException指定rollbackForException.class数据库表不支持事务MyISAM等换InnoDB引擎多线程调用每个线程独立Connection不用注解改用编程式事务事务方法内调用同类内部私有方法代理与类的实际调用对象分离拆分到独立Bean最后一行的“多线程调用”值得一提。如果在Transactional方法中开启一个新线程去执行数据库操作新线程拿到的Connection肯定不是当前事务线程的Connection因此新线程中的数据库操作不在同一事务中。很多人在异步化处理时踩到这个坑数据库里出现半成品状态。这类代码用注解事务几乎无法补救只能用编程式事务让每个线程各自管理好自己的事务同时调整业务流程接受“主线程与异步线程之间的数据一致性由最终一致方案保证”。5. 从单体到微服务的演进事务思想的变化5.1 单体时代的事务边界单体应用中所有业务模块共享一个数据库Transactional看起来够用。订单、库存、积分都在同一个库扣减操作包在一个事务里确实能做到强一致。但随着业务增长和团队规模扩大单库连接数、表数据量、写入吞吐量会逐步逼近瓶颈。DBA开始要求分表分库、读写分离业务团队开始把订单、库存、积分拆成独立服务原来一个事务能搞定的事情变成了跨服务调用。此时事务的边界就非常尴尬了。5.2 微服务时代的事务格局微服务拆分之后每个服务独立部署、独立数据库、独立发布节奏。同一个业务流程分布在多个服务中单个服务内部的Transactional只能管住自己那一段。跨服务的原子性常见处理思路有尽量把跨服务的过程异步化本地事务只做本服务的状态变更和事件记录。通过幂等接口和重试机制保证下游最终处理成功。使用分布式事务方案Seata AT/TCC、RocketMQ事务消息、本地消息表做补偿。在这些方案里Transactional往往只承担“本服务数据库原子性”的任务不承担跨服务一致性。很多人以为引入Seata后还要在Service方法上加Transactional实际上Seata则是通过数据源代理和全局事务ID实现的它不强依赖你手动加的事务注解。反倒是那些“既加Transactional又加Seata GlobalTransactional”的方法容易产生双重事务管理回滚逻辑混乱。5.3 认识事务的三个级别这个概念是我自己后期才彻底想明白的。事务的保护能力分三个级别很多线上故障都是因为“以为自己在第二级或第三级”实际上只有第一级数据库本地事务保证单库多条SQL的原子性这是Transactional能做到的上限。应用层事务一个业务操作包含多个数据库操作、缓存操作和消息操作期望它们“看起来”原子。分布式事务跨库、跨服务的整体一致性。要判断一个方案是否合理先问自己当前场景属于哪个级别如果只是第一级Transactional没问题如果是第二级或第三级光靠Transactional远远不够需要的是一整套消息机制、状态机、幂等表和补偿策略。我见过有团队把“订单支付成功后写订单表 调用积分服务 发消息通知”全包在一个Transactional方法里自信满满地上线。结果积分服务超时数据库连接池被拖垮用户端大量支付成功但页面转圈。复盘时才发现问题的根源不是代码Bug而是用了一个不匹配级别的工具。6. 实操心得与排查预案6.1 一份可用的排查流程如果你现在正被一个“事务没生效”的Bug折磨按照下面的顺序排查效率会高很多先确认事务方法是不是public不是public直接挂。确认当前调用链路上调用方是不是通过代理对象调用的。在方法内部打印this.getClass()如果结果是原始类而不是代理类就能确认自调用问题不过这一招要在debug环境下用线上打日志要小心。确认异常类型是不是受检异常、是不是被catch吞掉了。确认数据库表引擎是不是InnoDB有没有手动commit或rollback代码混淆。确认事务管理器配置Spring Boot下DataSourceTransactionManager是否正常注入。确认是否有多线程调用、事务方法内部是否开启了线程池。如果以上都没问题再看传播行为、隔离级别、Timeout等参数有没有被误配。第2步具体怎么看可以在调用入口临时加一行// 调试输出 System.out.println(this.getClass().getName());如果打印出来的是com.example.service.OrderService而不是com.example.service.OrderService$$EnhancerBySpringCGLIB$$xxxx基本可以确定调用方拿到的不是代理对象。但这种自调用场景即使打印也要在“代理对象外部调用”时才能看到效果。更简单直接的验证方式在方法中故意抛一个RuntimeException观察数据库是否回滚。不回滚就说明事务根本没接管。6.2 连接池耗尽时的应急处理真遇到“连接池耗尽”这种级别的问题第一反应不是改代码而是先止损。止损动作包括快速定位并临时摘除问题接口的流量通过网关或注册中心。检查慢SQL和锁等待杀掉长时间占用连接的会话。如果连接池仍然被占用考虑重启部分实例释放连接。止损之后才是查根因。根因往往是某个事务方法被远程调用拖住或者出现了锁等待连接迟迟不释放。这种时候对比事务方法和非事务方法的耗时记录很容易找到元凶。排查时一定要看连接池监控确认连接占用时间的长尾分布。我印象最深的一次事故是事务方法内调用了一个第三方风控服务对方接口平均耗时从200毫秒涨到2秒我们的连接池20个连接瞬间被占满数据库连接等待队列越排越长最终所有下单接口全部超时。那次之后团队把风控调用从事务里挪出去同时把事务方法里的连接占用时长纳入监控指标从此再没犯过同类问题。6.3 事务与监控的结合生产环境中事务的健康状态不能靠直觉判断。几个监控项非常建议直接作为标配告警事务方法平均耗时和TP99。如果事务时间超过100毫秒就需要重点关注。数据库连接池活跃连接数。持续高位说明可能有长事务。事务回滚次数。正常业务回滚率应该在低位如果回滚率突然升高说明异常逻辑或数据状态出现了问题。慢事务日志。可以通过定制TransactionSynchronizationManager的afterCompletion回调记录每个事务的耗时和状态。Component public class TransactionMonitor { Autowired private TransactionSynchronizationManager synchronizationManager; public void registerTimer() { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { private long startTime; Override public void beforeCommit(boolean readOnly) { startTime System.currentTimeMillis(); } Override public void afterCompletion(int status) { long cost System.currentTimeMillis() - startTime; if (cost 200) { // 记慢事务日志结合调用链追踪定位事务边界 log.warn(slow transaction cost {} ms, status {}, cost, status); } } }); } }通过监控把事务的“隐性风险”转成显性指标才是真正避免线上事故的长久手段。6.4 团队规范怎么落地最后说说大厂是如何把这种“不推荐”变成工程规范的。光写一段规范文档没有意义要配套三个动作Code Review守门凡是新增Transactional必须有明确的说明包括为什么加、事务边界是什么、会不会包含远程调用、预计耗时多少。没有说明的一律打回。静态检查插件拦截用ArchUnit或者自研代码扫描规则扫描Service层中方法标了Transactional的同时又调用了外部RPC接口或MQ发送直接报错。这套规则可以集成进流水线。提供替代工具的SDK封装团队统一封装一个TransactionTemplate的工具类放在公共模块里业务方默认使用这个工具类处理事务。让“不用注解”成为一种顺手且自然的习惯。我用ArchUnit写过这种规则核心逻辑就是扫描方法上的Transactional注解然后检查方法体内是否出现了XXRpcClient或者XXProducer等类型的调用。写起来不复杂但落地后效果立竿见影能从源头拦住大量隐患。7. 关于Transactional的一句话总结与个人体会写了这么多其实核心判断很朴素Transactional不是坏了而是太方便了方便到让人忘掉了它背后的机制和执行代价。它不是银弹也不是洪水猛兽而是一个“用起来门槛低、用好了不容易”的工具。我个人在实际项目中的体会是事务这件事越接近SQL原生语义begin/commit/rollback越容易理解和维护越靠注解魔法越要小心翼翼。如果今天有人问我新项目里要不要用Transactional我的答案会是能用编程式事务的地方优先编程式事务只有那种纯单库、短平快的本地原子操作才用注解还得把rollbackFor、timeout都写全。如果项目里已经有很多Transactional了也别急着全量重写先把“事务内嵌远程调用”和“自调用失效”这两类高风险问题抓出来改掉就能规避掉大部分线上事故。最后再分享一个很小的技巧在压测环境里给每个事务方法加上耗时日志压测一轮之后把超过100毫秒的事务方法全部拉出来逐个review里面到底做了什么。你可能会发现有些事务方法里混着缓存写入、外部调用甚至Thread.sleep。把这些非数据库操作移出事务你的接口性能往往能直接翻倍。这一步不做后面所有关于事务的优化都只是打补丁。