☰
Spring事务治理:@Transactional为何受限?失效场景与编程式事务替代方案
2026/10/10 20:14:21 网站建设 项目流程

先聊一个现象:你去翻任何一家中型以上互联网公司的《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的声明式事务就是把这个过程封装起来,开发者只需要在一个方法上标注@Transactional,Spring 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; } }); } }

比起@Transactional,TransactionTemplate的好处非常直观:

  • 事务边界是显式的,哪里开始、哪里结束、提交还是回滚,一目了然。
  • 没有代理模式,不走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/update,MySQL的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方法标了@Transactional,A里面调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指定rollbackFor=Exception.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折磨,按照下面的顺序排查,效率会高很多:

  1. 先确认事务方法是不是public,不是public直接挂。
  2. 确认当前调用链路上,调用方是不是通过代理对象调用的。在方法内部打印this.getClass(),如果结果是原始类而不是代理类,就能确认自调用问题(不过这一招要在debug环境下用,线上打日志要小心)。
  3. 确认异常类型,是不是受检异常、是不是被catch吞掉了。
  4. 确认数据库表引擎是不是InnoDB,有没有手动commit或rollback代码混淆。
  5. 确认事务管理器配置,Spring Boot下DataSourceTransactionManager是否正常注入。
  6. 确认是否有多线程调用、事务方法内部是否开启了线程池。
  7. 如果以上都没问题,再看传播行为、隔离级别、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。把这些非数据库操作移出事务,你的接口性能往往能直接翻倍。这一步不做,后面所有关于事务的优化都只是打补丁。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询