写这篇文章的起因,是上周帮某团队排查一个线上数据重复入库的问题。现象很典型:代码里明明加了@Transactional,异常也抛出来了,数据却还是写进去了。翻代码翻到怀疑人生,最后定位到的根因既不是配错了事务管理器,也不是数据库隔离级别,而是一群人都在用的同一个“事务失效”写法。这让我意识到,很多团队在事务这个问题上,其实一直处于“会用注解,但没吃透边界”的状态。
这篇文章我想把Spring Boot里事务手动控制和声明式自动事务这两个东西,从原理、边界到实战选择,完整地捋一遍。内容适合刚接触事务、被各种“失效”问题困扰的初级开发者,也适合正在设计事务边界的后端负责人。核心想法就一句话:@Transactional不是银弹,手动控制也不是更复杂,两者互为补充,关键在于搞清楚它们各自适用什么场景。
1. 注解看着“自动”,底层其实也是“手动包了一层”
很多人对@Transactional的理解停留在“方法开始自动开事务,方法结束自动提交,抛异常自动回滚”。这个说法没错,但它掩盖了真正重要的东西:这个“自动”并不是Spring凭空注入的,而是通过AOP代理实现的。换句话说,本质上还是有人在方法前后帮你执行了beginTransaction和commit/rollback,只是这个人从你手写代码变成了框架生成的代理对象。
1.1 声明式事务的感觉,以及被隐藏的代理逻辑
先看最常见的写法:
@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { orderDao.insert(dto.getOrder()); itemDao.batchInsert(dto.getItems()); stockService.deduct(dto.getProductId(), dto.getQuantity()); } }这段代码在业务上很典型,但如果你把它当成“一个天然的事务方法”,就麻烦了。真正执行的时候,Spring容器里实际放进去的不是OrderService实例本身,而是一个CGLIB或者JDK动态代理生成的子类对象。方法调用会先落到代理类上,代理类在createOrder执行前获取数据库连接,开启事务,然后才调用你写的真实方法;方法正常返回就提交,抛出RuntimeException就回滚。
所以@Transactional的“自动”,本质上就是代理在替你做手动事务控制。如果你绕过代理直接调用这个类的方法(比如类内部this.createOrder()),等于跳过了所有事务逻辑,注解瞬间变成摆设。这一点是后面所有“事务失效”问题的总根源,先记住。
1.2 手动控制的真身:TransactionTemplate与PlatformTransactionManager
手动控制的核心API并不是什么冷门工具,就是TransactionTemplate,大家平时可能在老项目里见过注解之外的写法,但没太当回事:
@Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void createOrderWithManualTx(OrderDTO dto) { transactionTemplate.execute(status -> { orderDao.insert(dto.getOrder()); itemDao.batchInsert(dto.getItems()); stockService.deduct(dto.getProductId(), dto.getQuantity()); return null; }); } }TransactionTemplate内部会处理事务的开启、提交、回滚,以及异常时的状态标记。PlatformTransactionManager是各种事务管理器的统一入口,在Spring Boot里通常由DataSourceTransactionManager充当,默认自动装配好了。你不用管它怎么拿连接,只要把事务管理器交给TransactionTemplate,它就具备了完整的控制能力。所谓“手动控制”,指的是你在代码里精确指定每段逻辑属于哪个事务边界,而不是在方法级别一刀切。
1.3 两种方式执行流对比
| 对比项 | @Transactional 声明式 | TransactionTemplate 手动 |
|---|---|---|
| 事务边界 | 整个方法,方法结束提交/回滚 | 代码块内任意位置,可精确控制 |
| 代理依赖 | 强依赖,调用方必须是代理对象 | 不依赖代理,直接执行 |
| 异常处理 | 默认RuntimeException触发回滚 | execute内部自动回滚RuntimeException |
| 部分提交 | 不可能,粒度为整个方法 | 可以在块内多次execute实现分段 |
| 可读性 | 高,注解一目了然 | 中等,需要理解模板回调 |
| 调试成本 | 低,逻辑直白 | 略高,得看回调里做了什么 |
从这张表能看出来,声明式事务的优势在于简单和优雅,适合业务边界清晰的场景;手动事务的价值在于灵活和可控,适合边界复杂、需要分批提交、需要在事务内做精细异常处理的场景。
2. 为什么@Transactional会“静默失效”:代理机制与自调用的完整排查链路
我遇见过的绝大多数“事务没生效”问题,看着五花八门,其实都在两个框里:一是调用方式没走代理,二是异常类型没触发回滚规则。第二个好理解,默认只回滚RuntimeException,如果你抛的是Exception子类却不指定rollbackFor,事务就会照常提交。第一个更隐蔽,尤其是自调用。
2.1 失效第一现场:this调用绕过了代理
比如下面的代码:
@Service public class UserService { @Transactional public void updateUser(Long id, String name) { userDao.updateName(id, name); } public void updateUserWithLog(Long id, String name) { // 事务失效现场 this.updateUser(id, name); logDao.saveLog(id, "update user"); } }表面上看,updateUserWithLog方法内部执行了updateUser,后者有@Transactional,应该开事务。但this.updateUser(id, name)调用的是当前对象的方法,也就是原始Bean实例上的方法,根本没有经过容器里的代理对象。代理都没经过,事务开启逻辑自然也不会执行,updateUser里的数据库更新会以自动提交模式执行。如果在saveLog时抛了异常,updateUser已经提交的数据无法回滚。线上那种“部分成功、部分失败”的数据脏状态,就是这么来的。
2.2 排查代理是否生效的三种验证法
只要怀疑事务没生效,第一件事不是去看代码逻辑,而是确认方法到底有没有代理参与。我常用三个办法,从轻到重:
第一种,看启动日志里的代理模式。Spring Boot默认spring.aop.proxy-target-class=true,会输出类似Class EnhancerBySpringCGLIB的信息。如果某Service类没有出现在代理日志里,就要怀疑它根本没被AOP增强。
第二种,在方法里打印this.getClass()。如果是原生类名,说明你调用的是原始实例;如果是$$EnhancerBySpringCGLIB$$开头的类,说明代理生效了。很多事务失效案例,这一行打印就能实锤。
第三种,强制使用AopContext.currentProxy()。在spring.aop.expose-proxy=true开启时,可以通过下面这种方式强制走代理:
public void updateUserWithLog(Long id, String name) { UserService proxy = (UserService) AopContext.currentProxy(); proxy.updateUser(id, name); logDao.saveLog(id, "update user"); }这个方法能解决问题,但我不推荐一上来就用它,因为这会把代理机制强耦合进业务代码里。更优雅的做法是拆类或者用TransactionTemplate。拆类的核心思想是,让外部需要通过容器获取代理对象来调用事务方法:
@Service public class UserService { private final UserUpdateService userUpdateService; public void updateUserWithLog(Long id, String name) { userUpdateService.updateUser(id, name); logDao.saveLog(id, "update user"); } } @Service public class UserUpdateService { @Transactional public void updateUser(Long id, String name) { userDao.updateName(id, name); } }这个方法把“开事务”这件事,放在了一个独立组件里,外部调用它的方法时,拿到的一定是代理,事务自然生效。这里的原理,其实就是让事务方法的调用边界和代理管理边界保持一致。
2.3 更多“假事务”场景与应对方案
| 失效场景 | 原因 | 推荐方案 |
|---|---|---|
| 私有方法加@Transactional | private方法不会走代理,编译期甚至不报错 | 改为public,放到单独Service |
| final方法加@Transactional | CGLIB无法重写final方法,事务不生效 | 移除final修饰 |
| 同一个类内this.方法调用 | 绕过代理,事务不生效 | 拆类或注入自身代理 |
| 抛出受检异常但不指定rollbackFor | 默认不回滚受检异常 | 加上rollbackFor = Exception.class |
| 捕获异常后“吞掉” | 异常没抛出去,代理不知道要回滚 | 要么重新抛出,要么手动标记回滚 |
| 多线程内调用事务方法 | 新线程拿不到父线程的事务上下文 | 事务方法要在主线程同步调用 |
注意最后一类多线程场景。很多人以为新线程里执行一个带@Transactional的方法就能开事务,实际上如果这个线程是自己new出来的,既没有容器管理,又不处于原有事务上下文里,事务注解形同虚设。这种情况我会建议直接在子线程里使用TransactionTemplate,明确告诉它事务怎么开、怎么提交,绕开代理依赖。
3. 传播行为真的用对了吗:REQUIRED、REQUIRES_NEW与NESTED的应用边界
事务失效搞清楚了,下一个高频困惑是传播行为。Spring定义了7种,但日常项目里真正用得多的只有REQUIRED、REQUIRES_NEW、NESTED三种。选错了,不会报错,但会在特定场景下产生很难发现的逻辑错误,比如“明明外层回滚了,内层却提交了”,或者“内层应该独立提交,却被外层一起带滚了”。
3.1 默认REQUIRED的合并机制与长事务风险
REQUIRED是默认传播行为:如果当前没有事务,就新建一个;如果外层已经有事务,就直接加入外层事务。注意“加入”这两个字,加入意味着内外层共享同一个数据库连接和同一个回滚点。就像两个人签在同一张合同上,一个人毁约,整份合同作废。
这个合并机制在日常业务里很常见。外层Service调内层Service,两个方法都标了@Transactional,它们就在一个事务里,要么一起成功,要么一起回滚。大部分“保证数据一致性”的需求用REQUIRED就够了。
但合并机制的副作用是长事务。如果一个外层方法里循环调用了很多内层方法,或者混入了耗时较长的网络请求,数据库连接会被占用很久。并发一高,连接池很快就会被占满,应用表现为接口越来越慢、直到超时。我在某项目里见过一个批量导入接口,5000条数据只开了一个事务,处理到后面每一条插入都要几秒,整个连接池被这一个请求拖垮。所以用REQUIRED时,心里要有一个“事务时长红线”,尽量让事务方法缩到最短,别在里面做无关的远程调用。
3.2 独立事务REQUIRES_NEW:解耦和失败不牵连
REQUIRES_NEW的含义是:不管外层有没有事务,都挂起外层事务,重新开一个独立事务。外层事务回滚了,不影响内层已经提交的结果;内层回滚了,也不影响外层事务。这个特性特别适合“日志记录”和“审计追踪”类需求。
举个例子。导入订单的业务方法上可能标了@Transactional,负责整体数据一致性。但在导入过程中,你希望每当处理失败一条数据,就把错误原因记录到一张日志表里,即使整批最终回滚,日志也得留下来方便排查。如果日志写入用的是REQUIRED,它会跟着整个导入事务回滚,你什么都查不到。如果用了REQUIRES_NEW,日志就能独立提交,不管主流程成功还是失败,记录永远在。
@Service public class ImportService { @Transactional public void batchImport(List<Item> items) { for (Item item : items) { try { saveItem(item); } catch (Exception e) { importLogService.logError(item.getId(), e.getMessage()); } } } } @Service public class ImportLogService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void logError(Long itemId, String message) { logDao.insert(itemId, message); } }但要谨慎使用REQUIRES_NEW。它每发生一次都会新开一个数据库连接,在调整并发高的场景下会加剧连接池竞争。而且“内层已提交”这种结果不可撤销,属于故意的部分成功,业务上要能接受这个语义。
3.3 NESTED与REQUIRES_NEW的区别:同样是嵌套,侧重点完全不同
很多人把NESTED和REQUIRES_NEW混一起,觉得都是“嵌套事务”。实际上差别非常明显:REQUIRES_NEW是完整的独立事务,内层提交后不关外层的事,等等;NESTED则是通过数据库的保存点(Savepoint)机制实现的嵌套,它仍然参与外层事务,只不过有自己独立的回滚点。
具体来说,外层事务方法调用NESTED内层方法时,内层逻辑出现了异常,内层会回滚到保存点,不影响外层其他逻辑继续执行。但如果外层事务最终因为别的原因整体回滚,内层NESTED跟着一起回滚,因为本质上它还是外层这个大局的一部分。这个特性适合的场景是“整批处理中的分批校验”:某一批数据校验失败,只回退这一批的处理逻辑,不干扰其他批次的正常提交。
这里有个实际开发中容易忽略的坑:NESTED的Savepoint能力依赖数据库和连接池实现。MySQL的InnoDB引擎是支持保存点的,但某些数据库驱动或连接池配置可能会退化成“整个事务回滚”而不是“回滚到保存点”。上线前最好做一次针对性验证,别指望在所有环境下行为一致。
4. 我选择手动控制事务的四个实战场景
前面说了这么多对比,落到项目里,我什么时候会主动放弃@Transactional,改用TransactionTemplate?总结下来是四个场景。每个都是真实踩过的坑,下面逐个拆开讲。
4.1 场景一:批量导入中的单条失败,不能回滚整批
如果整个批量导入只有一个事务,循环到第300条数据出错时,前299条全部白干。这种“全有或全无”的语义在导入场景里并不友好。用户更希望的是:坏数据被标记出来,好数据照常入库,最后汇总出错误清单。
用TransactionTemplate可以实现“每条数据一个事务”,代码如下:
public BatchImportResult batchImport(List<Item> items) { int successCount = 0; List<ErrorRecord> errors = new ArrayList<>(); for (Item item : items) { try { transactionTemplate.execute(status -> { itemDao.insert(item); return null; }); successCount++; } catch (Exception e) { errors.add(new ErrorRecord(item.getId(), e.getMessage())); } } return new BatchImportResult(successCount, errors); }这里的核心收益是:单条失败不会让整批回滚,循环可以继续,还能在异常时记录下来,最后把错误汇总给前端。如果使用@Transactional,循环内一旦抛异常,整个方法的事务都会处于回滚状态,后续条目处理没有任何意义。
4.2 场景二:“先查后写”的校验逻辑需要精确控制提交点
很多业务方法有“先查后写”的模式:先查询一条记录,检查它的状态是否允许更新,再执行更新;如果检查通过,但更新动作又依赖前面查询的结果,你可能需要明确控制“查的时候用不上事务,写的时候必须事务”。
用@Transactional的做法是整个方法都处于事务中,即使只是做一个查询,也会先获取数据库连接并开启事务。而手动控制可以让查询放在事务外,只有真正执行写入时才开启短事务,减少不必要的事务保持时间:
public void transferWithCheck(Long fromId, Long toId, BigDecimal amount) { Account from = accountDao.findById(fromId); Account to = accountDao.findById(toId); // 这里可以做各种校验,不占用事务资源 if (from.getBalance().compareTo(amount) < 0) { throw new BusinessException("余额不足"); } transactionTemplate.execute(status -> { accountDao.decreaseBalance(fromId, amount); accountDao.increaseBalance(toId, amount); return null; }); }这个例子充分体现了“事务边界越短越好”的实践原则。事务只包裹真正需要原子性的两行更新,前面的查询和校验都在事务外执行,对数据库连接的压力明显更低。
4.3 场景三:事务里混了Redis操作与远程调用
在事务方法里操作Redis,或者调用外部HTTP接口,是一件需要反复权衡的事。@Transactional只保证数据库事务,管不了Redis和外部系统的状态。如果在数据库事务进行过程中写Redis成功,随后数据库操作报错回滚,Redis里已经写进去的缓存就变成脏数据了。
手动控制在这里的价值,不是让Redis和数据库“一致”(本地事务做不到这一点),而是让你能够显式地分离“前置事件”和“核心事务”:
public void handleWithCache(String key, OrderDTO dto) { transactionTemplate.execute(status -> { orderDao.insert(dto); return dto.getId(); }); // 事务提交成功之后再做缓存过期或刷新 redisTemplate.delete(key); }上面这种写法,把数据库写入和缓存失效放在同一业务方法里,但边界是分开的:先提交数据库事务,再操作缓存。即使缓存操作失败,最坏的情况也只是缓存多存活一段时间,不会出现因缓存误写导致的数据永久不一致。如果换成@Transactional,你很难表达这种“提交后操作”的时序。
4.4 场景四:事务内捕获异常后,仍然要让整个事务回滚
@Transactional有一个特别坑的细节:方法里自己用try-catch捕获了异常,事务感知不到异常发生,所以不会回滚。你可能会说“那我抛出异常不就行了”,但在批量场景里,你既想中断后续处理,又不想吞掉事务状态,还要返回一个业务结果对象给上层,这就变得很别扭。
使用TransactionTemplate时,可以通过status.setRollbackOnly()主动标记回滚:
public Result doSomething(List<Item> items) { return transactionTemplate.execute(status -> { for (Item item : items) { try { processItem(item); } catch (BusinessException e) { // 记录下错误,但整个事务回滚 status.setRollbackOnly(); return new Result(false, "处理到第" + item.getId() + "条时失败"); } } return new Result(true, "全部成功"); }); }这里标记了setRollbackOnly()之后,即使execute内部没有异常继续抛出,事务最终也会回滚。声明式事务里要做同样的事,得注入TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),写法反而更隐晦。手动控制在“部分失败但不想抛异常”的语义表达上,更清晰。
5. 几种绕不开的“事务边界”陷阱与实测避坑做法
除了一二章的经典失效问题,线上生产环境里还有几个比较隐蔽的边界情况,它们不会让你编译报错,但会影响数据正确性。
5.1 自增主键回滚后的空洞:事务回滚和ID分配不是一回事
很多时候,事务回滚了你以为数据库恢复原样,实际上自增主键的消耗不会被回滚。比如一套数据在@Transactional方法里插入后回滚,表结构里的自增计数器已经前进了,下一次插入会跳过被回滚的ID,形成“空洞”。
这个空洞对业务一般没什么影响,但如果你在对外接口里用“ID连续”做过数据完整性校验,就要注意。这不是Bug,是InnoDB引擎的自增机制决定的。我见过有团队因为ID空洞去排查“是不是有数据被删了”,浪费了一天时间才搞清楚。如果系统对ID连续有硬性要求,就别用数据库自增主键,改用一个专门发号器或者应用层ID生成方案。
5.2 事务内做远程调用:连接被占用,服务假死
这是我认为最需要强调的一个坑。有些人给一个方法加了@Transactional,然后在方法里面调用第三方HTTP接口,或者发送消息到消息队列。表面看没什么问题,但如果上游接口响应很慢,事务持有的数据库连接就会一直被拽着不放。并发上来后,连接池耗竭,其他正常接口全部排队等着拿连接,应用表现为“所有接口都慢,甚至超时”。
我的处理原则是:事务方法内禁止远程调用。要发消息,先记到本地表里;等事务成功提交后,再通过事务同步回调或者定时任务把记录捞出来发出去。Spring提供了一个@TransactionalEventListener,可以在事务提交后触发后续动作,这个模式比在事务里直接调远程接口安全得多。
5.3 隔离级别与死锁:不要盲目把所有方法都加上事务,更不要盲目调高隔离级别
项目里有个常见做法:所有Service方法不管有没有写操作,统一加上@Transactional。这样做的直接后果,是每进入一个方法就开启一次数据库事务,即便这个方法只是做了一次纯查询。在InnoDB默认的REPEATABLE READ隔离级别下,事务会创建一致性视图,多个长时间运行的只读事务会累积undo日志,影响性能。
至于隔离级别,不是调的越高越好。SERIALIZABLE几乎不用在生产环境默认使用,它会对读取范围加锁,极其容易引发死锁。大部分场景READ COMMITTED就够了,连网上那些“并发扣库存需要SERIALIZABLE”的说法,在锁策略合理的前提下也不是必须的。判断隔离级别是否合理,不能只看并发量,还要看具体业务的锁冲突频率。我在排查一个死锁问题时,发现两个事务都在“先查后写”,因为隔离级别太高,相互等锁等到超时。降到READ COMMITTED之后,配合合适的SELECT ... FOR UPDATE,问题才消失。
5.4 日志与监控:事务边界看不到,出问题就只能瞎猜
事务是横切关注点,不会直接出现在业务日志里。平时开发时,我强烈建议把事务边界信息加入到日志体系的观测范围内。比如在TransactionTemplate执行前打印“开始事务”,执行后打印“提交事务”,出现异常时打印“事务回滚”。@Transactional方法可以通过AOP切面统一做好这件事。
这里有一个实操技巧:调用TransactionSynchronizationManager.isActualTransactionActive()可以判断当前是否存在真实的事务;调用TransactionSynchronizationManager.getCurrentTransactionName()可以拿到当前事务对应的Bean方法名。在日志里输出这两个值,排查问题时能快速定位到“这个位置到底是不是事务边界”。
归根结底,事务控制方式不是二选一
如果你让我给个项目级建议:默认优先用@Transactional,因为它声明简单、团队认知度高;但每个业务团队都应该保留TransactionTemplate手动控制的“工具箱”,在批量处理、复杂异常语义、解耦写入这几类场景里,手动控制反而是更低成本的方案。
我个人在实际项目里,已经从“只信注解”变成“注解为主、手动为辅”。判断标准就一条:事务边界是否与业务边界完全重合。完全重合,注解简单直接;不重合,比如需要部分提交、需要记录失败日志、需要控制短事务边界,就别硬着头皮用注解加各种拐弯抹角的写法,老老实实掏出TransactionTemplate。
如果你正被某个事务问题缠了一两天,试着把方法里那些异常处理、远程调用、日志写入全部分别标注出来,看看它们各自在不在同一个事务边界内。大多数让人头秃的事务Bug,本质都是边界没有对齐。