ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

TransactionTemplate编程式事务实战:从原理到踩坑,彻底掌握Spring事务控制

TransactionTemplate编程式事务实战:从原理到踩坑,彻底掌握Spring事务控制 我做过好几个以数据同步、批量对账为核心的老项目早期代码里密密麻麻全是Transactional。表面看起来挺干净可一旦涉及多数据源、分批提交、失败重试声明式事务的“黑盒”属性就让人抓狂。后来我花了大量时间研究TransactionTemplate把一批批原本只能靠猜测和调试的事务逻辑重构成了显式的、可控的程序化事务。这篇文章不准备讲太多基础概念重点放在 TransactionTemplate 的机制拆解、高级用法和我在生产环境中踩过的真实坑上希望能帮你少走弯路。1. 声明式事务解决不了的那部分问题恰好是TransactionTemplate的主场1.1 Transactional被神化之后大家容易忽视的三个事实很多团队把Transactional当成事务管理的默认答案我见过不少代码库几乎每个 Service 方法头上都挂着这个注解。它确实好用但也带来了一种思维惯性只要注解标了事务就该按我想的跑。实际上有三个容易被忽略的事实Transactional生效依赖 Spring AOP 代理。同类内部调用、new出来的对象里调用、final方法上标识这些情况下注解根本不工作而且不报错只是静默失效。声明式事务的边界是“方法级”。方法内部如果包含多次数据库操作你只能在方法入口和出口之间做整体提交或回滚无法在方法内部做“先处理一部分、明确提交再处理下一部分”。异常与回滚的映射规则看起来简单实际处处是细节。RuntimeException默认回滚checked exception默认提交rollbackFor配置稍微写错就出现“事务明明没回滚”的诡异现象。这三个事实不是要否定Transactional而是想说明它本质上是“声明意图由框架代理执行”的封装。封装的好处是简单坏处是丢失了细粒度控制。当业务要求你精确控制事务的起点、终点、回滚范围时声明式事务就显得笨重了。1.2 哪些场景下TransactionTemplate是更合理的选型我总结过几类场景遇到它们时我会直接选择TransactionTemplate而不是继续跟注解较劲批处理任务比如一张表有十万条数据要同步到另一张表必须每 500 条一个事务提交避免单事务过长导致锁和日志膨胀。Transactional根本做不到“方法内多次提交”。循环内的独立事务比如定时任务遍历一批订单每个订单的结算逻辑需要独立事务一个订单失败不能影响其他订单。把循环体抽到独立 Bean 再用注解确实也行但代码被拆得很碎不如循环内直接套TransactionTemplate直观。动态判断事务边界事务是否开启取决于运行时的某个条件比如只有从 MQ 消费的路径需要事务本地调试路径不需要。注解方式得拆两个方法模板方式一个方法内就能表达。需要精确控制回滚时机的复杂业务某几个操作失败要回滚另几个操作失败不需要回滚甚至需要在 catch 到异常后继续做记录日志等操作同时保留事务内的更新。虽然Transactional也可以配合REQUIRES_NEW之类的传播属性但代码的可读性远不如模板清晰。总结一句话当你需要的是“事务作为一段代码块的控制流”而不是“事务作为方法级声明”时TransactionTemplate就是更贴合需求的东西。2. 拆开TransactionTemplate它本质上是个什么物件2.1 模板方法模式在事务管理上的落地TransactionTemplate是 Spring 对“模板方法模式”的经典应用。这个设计模式的核心思路是把某个固定流程的骨架写好把流程中变化的部分留给调用方通过回调参数传入。拿事务来说固定骨架是获取事务状态根据传播行为决定是新建还是加入已有事务。执行业务逻辑。根据业务逻辑是否抛异常决定提交还是回滚。清理事务资源、恢复之前的事务状态。变化的部分只有一个中间那一段业务逻辑。TransactionTemplate通过TransactionCallbackT接口把这段逻辑交给调用方。这段骨架逻辑在TransactionTemplate源码里非常清晰核心就是execute方法Override Nullable public T T execute(TransactionCallbackT action) throws TransactionException { TransactionStatus status this.transactionManager.getTransaction(this); T result; try { result action.doInTransaction(status); } catch (RuntimeException | Error ex) { rollbackOnException(status, ex); throw ex; } catch (Throwable ex) { rollbackOnException(status, ex); throw new UndeclaredThrowableException(ex, TransactionCallback threw undeclared checked exception); } this.transactionManager.commit(status); return result; }我建议每个想深入理解 Spring 事务的人都认真读一遍这个方法的源码总共不过二十行左右但却包含了事务控制的所有精髓事务获取、业务执行、异常回滚、正常提交。2.2 从TransactionTemplate到TransactionManager的完整调用链TransactionTemplate本身并不直接操作数据库连接它依赖PlatformTransactionManager来完成真正的事务管理。常见实现包括事务管理器适用场景底层机制DataSourceTransactionManager单一数据源的 JDBC / MyBatis通过 DataSourceUtils 获取连接绑定到当前线程JpaTransactionManagerJPA / Hibernate管理 EntityManager 与事务同步JtaTransactionManager分布式事务JTA依赖应用服务器的交易管理器HibernateTransactionManager纯 Hibernate 5 以下场景已被 JpaTransactionManager 逐步取代调用链大致是这样的业务代码 - transactionTemplate.execute(callback) - platformTransactionManager.getTransaction(transactionDefinition) - DataSourceTransactionManager.doBegin - DataSourceUtils.getConnection(dataSource) - 创建数据库连接设置 autoCommitfalse配置隔离级别 - 把连接绑定到 TransactionSynchronizationManager 的 ThreadLocal这里有一个非常关键但很多人没意识到的点Spring 事务是线程绑定的。连接被挂在TransactionSynchronizationManager的ThreadLocal上同一个线程里后续的数据库操作MyBatis、JdbcTemplate 等会从 ThreadLocal 中拿到同一个连接从而保证它们在同一个事务里。理解了这个机制很多坑的答案就浮出水面了为什么跨线程调用会让事务失效因为子线程的 ThreadLocal 是空的拿不到父线程绑定的连接。为什么同类内部方法自调用不会开启事务因为Transactional的切入逻辑在代理对象上this调用绕过了代理根本没有事务拦截器参与也不会调用TransactionTemplate对应的逻辑。为什么事务内能读到未提交的数据因为同一个线程拿到的是同一个数据库连接而数据库本身在该连接上还没提交。2.3 回滚的判定逻辑异常是怎么决定事务命运的看execute源码你会发现回滚的触发条件是RuntimeException或Error。如果业务回调里抛出一个checked exception比如IOExceptionTransactionTemplate会把它包装成UndeclaredThrowableException同时执行回滚。这里和Transactional的默认行为完全一致RuntimeException/Error回滚checked exception 提交。但区别在于在TransactionTemplate里你可以在业务代码内部做更精细的控制主动调用status.setRollbackOnly()来标记需要回滚。transactionTemplate.executeWithoutResult(status - { try { orderDao.updateStatus(orderId, PROCESSING); // 发送消息失败了也要回滚数据库操作 mqSender.send(orderId); } catch (MqException e) { status.setRollbackOnly(); log.error(消息发送失败事务标记为回滚, e); } });这种“主动标记回滚”的能力比单纯靠异常类型判断要灵活得多。你可以根据自己的业务规则——不是根据异常类型而是根据异常发生的位置、发生后的其他状态——来决定是否回滚。另外有一个细节值得注意TransactionTemplate里如果 callback 正常返回但事务被标记为rollbackOnlySpring 会抛出UnexpectedRollbackException。这个异常经常出现在嵌套事务场景里我在后面的踩坑部分会专门讲到。3. 高级用法实战嵌套、隔离、超时与重试的组合拳3.1 事务边界切割REQUIRES_NEW的记账场景先聊一个真实需求。我曾经做过一个活动积分发放服务主流程是更新用户参与状态 - 给用户加积分 - 写积分流水。如果每次给用户加积分时积分流水写入失败我不希望整个参与状态都回滚因为用户确实参与了活动。但积分流水又必须保证准确性。这时候可以用REQUIRES_NEW将积分流水写成独立事务核心代码是public void joinActivity(Long userId, Long activityId) { transactionTemplate.executeWithoutResult(status - { userActivityDao.markJoined(userId, activityId); try { pointsTransactionTemplate.executeWithoutResult(pointsStatus - { pointsDao.addPoints(userId, 100); pointsLogDao.insert(userId, 100, ACTIVITY_REWARD); }); } catch (Exception e) { log.error(积分发放失败不影响主流程, e); // 可以在这里决定是重试、忽略、还是记录补偿任务 compensationService.record(userId, activityId, e); } }); }注意这里声明了一个独立的TransactionTemplate实例专门配置了PROPAGATION_REQUIRES_NEWTransactionTemplate pointsTransactionTemplate new TransactionTemplate(transactionManager); pointsTransactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);为什么必须REQUIRES_NEW因为外层事务已经存在如果积分流水也走默认的REQUIRED传播它会直接加入外层事务。一旦积分流水失败外层事务的状态也可能被连带标记为rollbackOnly导致主流程默默回滚。我把这个坑总结成一句话嵌套事务不是“子事务”它是另一条独立的事务边界理解这一点很多诡异问题都能解释清楚。关于PROPAGATION_NESTED也要说一句它依赖数据库的 savepoint 机制比如 MySQL 的SAVEPOINT子事务回滚只会回滚到 savepoint 位置外层事务仍然可以提交。但 savepoint 只在连接层有效如果事务管理器不支持保存点机制NESTED会退化成REQUIRED。所以我的习惯是需要独立提交用REQUIRES_NEW需要局部回滚且整体可提交用NESTED但后者必须先确认数据库驱动支持保存点。3.2 隔离级别与锁语义什么时候必须主动设置很多业务场景其实不用关心隔离级别默认的数据库隔离级别就够了。但某些场景下隔离级别会直接影响正确性。比如库存扣减场景为了避免超卖常规做法是SELECT ... FOR UPDATE加锁但如果事务隔离级别是READ_UNCOMMITTED未提交的数据会干扰判断反过来如果你只是做大量报表查询也没必要用高隔离级别拖慢性能。TransactionTemplate设置隔离级别非常直接TransactionTemplate template new TransactionTemplate(transactionManager); template.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); template.setReadOnly(true);有些细节需要补充一下setReadOnly(true)对 MySQL 来说本质上是在连接上设置connection.setReadOnly(true)并在事务提交时做优化——但它并不阻止你在只读事务里执行写操作这是一个常见的误解。隔离级别设置会覆盖数据库默认隔离级别但只对当前事务生效。如果你的数据库是 MySQL InnoDB 默认的REPEATABLE_READ而应用层需要保证更高的并发读一致性保持默认即可只有当你明确知道当前事务需要更强的读一致性或更弱的锁竞争时才去主动调整。高隔离级别会带来更大的锁竞争和死锁概率。死锁发生后Spring 会把数据库抛出的死锁异常原样抛出TransactionTemplate不会自动重试但我们可以结合重试机制来处理。3.3 超时控制从数据库层到Spring层的双重保险事务超时是经常被忽略的点。一个慢查询撑住事务几秒钟不释放连接在低并发下没什么感觉一旦并发上来连接池被占满整个应用就卡死了。Spring 提供了事务超时机制在TransactionTemplate里这样设置TransactionTemplate template new TransactionTemplate(transactionManager); template.setTimeout(10); // 单位秒底层行为是DataSourceTransactionManager在开启事务时如果检测到超时设置会给连接设置queryTimeout。如果事务执行时间超过设定值后续的 JDBC 操作会抛出QueryTimeoutException。这里我想强调一个很容易搞错的地方Spring 的事务超时不是“硬性中止”它不会主动 kill 掉正在执行的 SQL。它的原理是给连接设置一个查询超时时间让 JDBC 驱动在下次调用时检查并抛异常。如果你有一个执行了 30 秒的慢 SQL而超时设的是 10 秒那么这个 SQL 本身不会被中断只有执行完后的下一个 SQL 调用才会发现超时并抛异常。所以正确的事务超时策略应该是数据库层设置innodb_lock_wait_timeout控制锁等待的容忍时间。SQL 层设置合理的max_execution_timeMySQL 8.0 支持。Spring 层设置setTimeout作为业务逻辑总耗时的兜底。应用层还要配合连接池的connectionTimeout设置防止连接池排队时无限等待。3.4 retry与事务模板的协作方式事务回滚之后有些操作是可以重试的尤其是偶发的死锁、唯一键冲突、网络抖动。Spring Retry 配合TransactionTemplate是一个非常经典的组合。我的做法是先想清楚哪些异常值得重试再写重试逻辑。Bean public RetryTemplate retryTemplate() { RetryTemplate retryTemplate new RetryTemplate(); ExponentialBackOffPolicy backOffPolicy new ExponentialBackOffPolicy(); backOffPolicy.setInitialInterval(500); backOffPolicy.setMultiplier(2.0); backOffPolicy.setMaxInterval(5000); retryTemplate.setBackOffPolicy(backOffPolicy); SimpleRetryPolicy retryPolicy new SimpleRetryPolicy(3, Collections.singletonMap(DeadlockLoserDataAccessException.class, true)); retryTemplate.setRetryPolicy(retryPolicy); return retryTemplate; }然后这样使用public void processWithRetry(OrderDTO order) { retryTemplate.execute(context - { try { return transactionTemplate.execute(status - { orderDao.lock(order.getId()); orderDao.updateState(order.getId(), PAID); inventoryDao.deduct(order.getProductId(), order.getQuantity()); return true; }); } catch (CannotAcquireLockException e) { log.warn(order {} 获取锁失败准备重试次数 {}, order.getId(), context.getRetryCount()); throw e; } }); }这里有两个经验第一重试要配合退避策略不要无间隔狂重试。数据库死锁和锁等待往往是瞬时高峰导致的等几百毫秒再试成功率会高很多。第二不是所有异常都值得重试。比如订单状态非法、数据校验失败这类业务异常重试一万次结果也一样。我习惯只对DeadlockLoserDataAccessException、CannotAcquireLockException这类偶发性异常开启重试。另外重试时要注意幂等。如果一个操作已经部分执行成功但因为回滚失败或者事务边界设计不严谨重试可能会产生重复数据。所以所有进入重试流程的业务操作我都要求底层有幂等键或唯一约束兜底。4. 高频踩坑现场事务不出错才怪的那些细节4.1 自调用导致的“假事务”先说最经典的坑。很多人在一个 Service 内部写类似下面的代码Service public class OrderService { public void createOrder(OrderDTO dto) { // 这里通过 this 调用事务直接失效 this.createOrderWithTransaction(dto); } Transactional public void createOrderWithTransaction(OrderDTO dto) { orderDao.insert(dto); stockDao.deduct(dto.getProductId(), dto.getQuantity()); } }这个问题的根因就是Transactional依赖 AOP 代理。Spring 注入到别的 Bean 里的是代理对象代理对象上才有事务拦截器。但this.createOrderWithTransaction(dto)里this指向的是原始对象不是代理对象所以事务拦截器根本没机会执行。用TransactionTemplate之后这个问题就天然消失了。因为TransactionTemplate本身就是普通对象你调它的execute方法事务逻辑就在那里跟代理无关Transactional public void createOrder(OrderDTO dto) { transactionTemplate.executeWithoutResult(status - { orderDao.insert(dto); stockDao.deduct(dto.getProductId(), dto.getQuantity()); }); }从这个角度看TransactionTemplate是对“代理失效”场景的一种有效兜底。如果你维护的老代码里发现了类似的自调用问题与其想办法把方法拆到不同 Bean 里不如直接改成模板方式改动更小、逻辑更直观。4.2 连接池耗尽TransactionTemplate也会中招TransactionTemplate并不是银弹它同样会因为事务边界不清晰、循环内长时间占用事务导致连接池耗尽。我之前排查过一个线上事故现象是服务偶发卡死连接池活跃连接数一直打满大量请求报HikariPool-1 - Connection is not available, request timed out after 30000ms。排查的过程非常典型先看慢 SQL结果并没有明显的慢 SQL。再看连接池监控发现活跃连接曲线和某个定时任务的时间段高度重合。检查定时任务代码发现它在一个大循环里调用了transactionTemplate.execute并且循环体内有远程调用public void syncData() { ListLong ids dataDao.getNeedSyncIds(); for (Long id : ids) { transactionTemplate.executeWithoutResult(status - { Data data dataDao.getById(id); remoteService.sync(data); // 远程调用耗时1-3秒 dataDao.updateSyncFlag(id); }); } }问题很明显事务在getById之前已经开启事务内包含了一次远程调用。远程调用耗时越长连接被事务占用的时间就越长。100 个 id 循环下来单线程就要占用连接几百秒如果并发再叠加连接池必然会耗尽。这类问题的解决思路是缩短事务边界把非数据库操作移出事务。所以先查数据、再远程调用、最后在事务内做状态更新public void syncData() { ListLong ids dataDao.getNeedSyncIds(); for (Long id : ids) { Data data dataDao.getById(id); // 非事务读取 boolean success remoteService.sync(data); // 远程调用不在事务内 transactionTemplate.executeWithoutResult(status - { dataDao.updateSyncFlag(id, success); }); } }这个案例给我的教训是TransactionTemplate给了一个很灵活的控制手柄但用得好不好完全取决于你是否清楚“哪些代码必须放在事务里”。原则只有一条——事务里只放必要的数据库操作远程调用、IO 等待、消息发送、线程切换统统移出去。4.3 异步线程里的事务边界陷阱Spring 的Async注解和事务是一对容易踩坑的组合。很多人以为在异步方法上加Transactional就有事务但实际上框架层的事务同步是通过ThreadLocal在线程内传递的异步方法跑在另一个线程里根本拿不到调用线程的事务上下文。如果要在异步线程里做事务操作正确做法是在异步方法内部显式使用TransactionTemplate开启一个新事务。比如Async public void asyncProcess(Long orderId) { transactionTemplate.executeWithoutResult(status - { orderDao.markProcessing(orderId); // 业务处理 orderDao.markCompleted(orderId); }); }这里要特别注意两点事务管理器必须能处理跨线程获取连接。DataSourceTransactionManager的实现是DataSourceUtils.getConnection它会先尝试从当前线程的TransactionSynchronizationManager拿连接。异步线程没有绑定连接就会走dataSource.getConnection()新建连接所以是可以正常开启新事务的。异步方法里的事务提交时机和主线程事务完全独立。如果主线程和异步线程同时操作同一行数据就可能出现脏读或者死锁。我的建议是异步任务里操作的业务实体尽量和主线程操作的数据分开或者用乐观锁、分布式锁做并发控制。4.4 与声明式事务混合使用时的UnexpectedRollbackException这可能是TransactionTemplate最防不胜防的一个坑。场景是这样的外层方法用Transactional标注方法内部又用了TransactionTemplate执行一段逻辑。外层和模板默认都是PROPAGATION_REQUIRED所以模板实际加入的是外层事务。但问题来了如果模板内部的回调执行时触发了setRollbackOnly()标记事务对象被标记为回滚。由于外层方法没有捕获这个异常模板执行正常返回外层方法最终执行完后Spring 尝试提交事务时发现事务已经被标记为rollbackOnly就会抛出UnexpectedRollbackException。这个异常的名字起得太容易让人误解了——它不是“意外的回滚”而是“事务已经被标记回滚但代码没有主动抛异常代理层在提交时发现状态不对”。很多开发者在看到这个异常的时候一脸懵因为业务代码看起来完全没有问题。规避方式有三种明确内外事务边界如果内层逻辑需要独立提交用REQUIRES_NEW隔离。如果内层就是希望参与外层事务那么内层一旦设置rollbackOnly就应该向外抛出异常让外层感知到事务状态由外层统一决定回滚。尽量避免在一个事务里混用两种事务控制方式。一个方法要么走声明式要么走编程式不要叠加。我在实际项目中倾向于第三种一个事务边界只用一种控制方式。方法级事务用Transactional内部需要细分的事务边界全部用TransactionTemplate而且内部模板要么用REQUIRES_NEW要么不用setRollbackOnly。5. 生产级落地方案我如何封装自己的事务执行器5.1 对外API设计保留模板的简洁补足不足TransactionTemplate本身已经很好用了但在真正的项目里我习惯再包一层目的有三统一超时和隔离级别默认值、自动记录事务耗时、把重试策略收敛到一处。我封装出来的效果大致是这样的Component public class TxExecutor { private final TransactionTemplate transactionTemplate; private final MeterRegistry meterRegistry; public TxExecutor(PlatformTransactionManager transactionManager, MeterRegistry meterRegistry) { this.transactionTemplate new TransactionTemplate(transactionManager); this.transactionTemplate.setTimeout(30); this.meterRegistry meterRegistry; } public T T execute(String bizName, TransactionCallbackT action) { long start System.currentTimeMillis(); try { return transactionTemplate.execute(action); } finally { long cost System.currentTimeMillis() - start; meterRegistry.timer(tx.execute, biz, bizName) .record(cost, TimeUnit.MILLISECONDS); } } public T T executeWithRetry(String bizName, TransactionCallbackT action) { return retryTemplate.execute(context - execute(bizName, action)); } }这个封装有几个好处业务代码里写txExecutor.execute(deduct, status - {...})比直接操作TransactionTemplate更语义化。超时、异常处理策略都集中在TxExecutor里不会每个业务类各设各的。事务耗时直接以bizName维度上报监控系统定位慢事务非常方便。5.2 可观测性把事务状态暴露给监控生产环境里事务问题最难受的地方在于你看到连接池告警或者接口超时但不知道是哪个事务、哪个业务导致的。所以我强烈建议把事务执行的几个关键指标通过 Micrometer 或类似组件暴露出来事务执行次数按业务名区分事务执行耗时P99 / P999事务回滚次数按异常类型区分事务内耗时占比用来发现“事务里混入了远程调用”这类问题如果用了 5.1 的封装这些指标自动就有了。我在公司落地这套东西后排查事务相关问题的效率提升了不止一倍。以前是看到连接池告警再去翻日志找代码现在是打开监控大盘直接定位到是哪个bizName的事务占用了大量时间。5.3 关于事务模板的几条硬性原则最后说一下我在多个项目里沉淀下来的几条原则都是在线上用事故换来的事务里禁止远程调用。任何 RPC、HTTP、消息发送、文件读写都不应该出现在事务边界内。如果实在无法避免事务内远程调用失败时必须主动回滚并记录补偿日志。大循环禁止单条事务。批处理场景按批次提交批次大小根据数据量和数据库吞吐能力动态调整一般 500 到 1000 条一个事务比较合适。事务边界尽可能短。拿到连接的时间越短连接池和锁资源的压力越小。跨数据源不硬撑单事务。如果你需要同时操作两个数据源并且要求强一致TransactionTemplate配合JtaTransactionManager可以做到但分布式事务的性能代价和复杂度都很高。绝大多数业务场景更适合用“本地消息表 消息最终一致性”或者“SAGA”模式来解耦。宁可显式不要隐式。这是我对事务控制的最核心看法。事务是业务正确性的最后一道防线越显式越好。TransactionTemplate把事务边界、提交时机、回滚条件都明明白白写进代码里阅读者对执行逻辑没有任何误解空间。相比注解的“魔法”这种显式性在维护老代码时价值巨大。多提一句调试技巧如果你不确定当前代码是否运行在事务里、事务状态是什么样的可以注入TransactionSynchronizationManager静态方法查看boolean actualTransactionActive TransactionSynchronizationManager.isActualTransactionActive(); String currentTransactionName TransactionSynchronizationManager.getCurrentTransactionName();在关键代码里临时打印这两个值能帮你快速判断事务是否真的开启了、当前事务的名称是什么。这个技巧在排查“我以为有事务但实际没有”的问题时屡试不爽。说到底Transactional和TransactionTemplate不是竞争关系而是互为补充。我的选型标准很简单如果事务边界正好是一个完整的 public 方法且没有内部细分需求用注解如果事务边界是方法内的某一段逻辑、循环内部的某个步骤或者需要手动控制回滚、挂起、恢复用TransactionTemplate。把这两套工具都吃透你在 Spring 事务这块基本就能应对绝大多数真实业务场景了。
返回列表