
最近在项目里接了一个改造需求同一段业务流程里需要拆出两个独立事务第一步写订单第二步写库存而且第二步失败时并不能让第一步跟着回滚。看着方法上那排整整齐齐的Transactional我当时就意识到注解这条路走不通了——方法级声明式事务只能给整个方法扣一个大帽子不可能在一个方法里灵活切出多个互不干扰的事务边界。于是我把 Spring 的编程式事务也就是大家常说的“手动开启事务”重新翻了出来一边翻源码一边踩坑最后才算把这件事彻底理明白。这篇东西不讲Transactional的八股用法只讲手动事务。我会把手动事务涉及的三个核心概念先掰开揉碎然后给出两种最主流的实现代码再把我实际踩过的坑用完整的排查链路还原给你看最后聊聊在订单库存这类业务里手动事务到底应该怎么用、和分布式事务又是什么关系。适合已经用过Transactional、但开始觉得注解不够用的人也适合准备面试时被问到“手动事务和声明式事务区别”的同学。1. 放着 Transactional 不用为什么非要手动开事务1.1 声明式事务最容易翻车的四个场景大多数 Spring Boot 项目里的数据一致性都是靠Transactional撑着的。注解用起来是真方便方法一标Spring 容器会自动通过 AOP 在方法前后帮你开启事务、提交或回滚。但这个方法级的事务模型有几个天然的“边界问题”越是复杂的业务越容易撞上。第一个场景就是一个方法里需要多个独立事务。比如订单创建和库存扣减业务上要求“订单能建、库存也能扣但库存扣减失败时订单不能回滚”那Transactional就没辙了。它只会让整段逻辑在一个大事务里任何一个 Runtime 异常都会把全部操作回滚掉。第二个场景是自调用导致注解失效。同一类里一个方法调另一个带Transactional的方法很多新手以为事务还在实际上 Spring 的事务是通过代理对象生效的this.method()这种内部调用根本没有走代理事务直接蒸发。这种问题用注解特别隐蔽很多人排查半天才发现。第三个场景是事务边界里混进了不该有的操作。比如在Transactional方法里调用远程服务、发消息或者做耗时的文件处理。这些操作阻塞数据库连接的时间非常长在高并发下连接池很快就被打满最后整个服务雪崩。这时候手动事务能帮你精确控制“哪些代码进事务哪些代码不进事务”。第四个场景是业务条件不满足但没抛异常、你却想回滚。声明式事务默认只在抛异常时回滚但有些业务逻辑是“不抛错、但要全部撤销”你只能靠TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()这种偏门办法侵入性很强。手动事务里直接调一下setRollbackOnly()或者rollback()就完事了。声明式事务的本质是“把事务控制交给代理”而手动事务的本质是“把事务控制拿回自己手里”。前者适合简单场景后者适合精细控制两者不是替代关系而是不同粒度下的互补关系。1.2 手动事务的本质把 AOP 替你做的事摆到台面上手动开启事务在 Spring 里有个正式的名字叫编程式事务。它一点都不神秘。声明式事务之所以能自动提交、自动回滚是因为 Spring 在代理对象里偷偷调用了PlatformTransactionManager的getTransaction、commit、rollback这几个方法。手动事务就是把这几步调用从代理里拿出来你自己写在一个方法里什么时机提交、什么时机回滚全都由你说了算。用最直白的话概括手动事务 自己拿事务管理器 自己设置事务定义 自己提交/回滚。全程没有 AOP也不需要代理同一个类里的方法互相调用也完全不受影响因为压根不依赖“代理对象”这层机制。这也带来了一个副作用如果你只是想在方法外层简单包一个事务那用Transactional明显更省事。手动事务的代码更啰嗦但它能精确控制一次方法调用中不同代码块的事务归属这是声明式事务做不到的。所以我的建议很简单能用注解就用注解当你遇到上面说的那几类“边界问题”时再上手动事务也不迟。2. 手动开启事务前必须吃透的三个底层概念2.1 PlatformTransactionManagerSpring 事务的统一入口不管你是用注解还是手动方式Spring 里所有事务操作最终都绕不过PlatformTransactionManager这个接口。它定义了三个方法public interface PlatformTransactionManager { TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException; }这三个方法就是整个 Spring 事务的基石。getTransaction负责开启事务如果当前已有事务则按传播行为决定是否新建commit和rollback负责结束事务。Spring Boot 里最常见的实现类是DataSourceTransactionManager它的背后就是一个DataSource。当我们用spring-boot-starter-jdbc或spring-boot-starter-data-jpa时Spring Boot 会自动装配一个事务管理器。你也可以手动定义多个事务管理器来对应多个数据源。手动事务的第一步不是写业务代码而是先拿到正确的事务管理器这一步错了后面全是白搭。2.2 TransactionStatus那枚“事务令牌”getTransaction方法返回的TransactionStatus对象你可以把它理解成“当前线程正在执行的这个事务的令牌”。它内部记录了事务对应的数据库连接、是否已完成、是否只读、事务名称等状态信息。commit(status)和rollback(status)都是通过这个令牌来定位是哪个事务在做操作。在DataSourceTransactionManager里这个状态内部还持有一个ConnectionHolder里面保存着Connection。Spring 会把连接绑定到当前线程上通过TransactionSynchronizationManager这样整个事务期间你在这个线程里的所有数据库操作都会复用同一个连接也才能共同参与一个事务的提交和回滚。理解了这一点后面很多“连接用完没释放”“连接池耗尽”的问题就都好解释了。2.3 传播行为与隔离级别手动模式下没人替你兜底使用Transactional的时候传播行为和隔离级别都是靠注解属性配置的很多人可能一年都没改过默认值。但在手动事务里你要自己构造一个TransactionDefinition来传进去不设置就全部用默认值。DefaultTransactionDefinition def new DefaultTransactionDefinition(); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); def.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); def.setTimeout(30);其中最常用的传播行为是PROPAGATION_REQUIRED当前没有事务就新建一个有事务就加入。这和声明式事务的默认行为完全一致。既然手动模式下默认值也一致为什么还要自己设置一遍因为手动模式的特殊之处在于你可以在一个方法里多次调用getTransaction每次都传入不同的TransactionDefinition从而实现在不同代码块里使用不同的传播行为。写库存和写订单要独立成两个事务就是通过这种灵活定义实现的。隔离级别同理查询类操作可以给个READ_UNCOMMITTED减少锁竞争写操作再用READ_COMMITTED这在手动事务里是可以并存的注解模式则做不到这么细。3. 两种主流手动实现方式与完整代码3.1 方式一TransactionTemplate推荐首选用PlatformTransactionManager写原生代码还是有点繁琐Spring 为此封装了一个更友好的模板类TransactionTemplate。它内部封装了开启、提交、回滚的逻辑你只需要在回调里写业务代码异常处理和回滚交给它统一管理。先把它配成一个 Spring BeanConfiguration public class TransactionConfig { Bean public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) { TransactionTemplate template new TransactionTemplate(); template.setTransactionManager(transactionManager); template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); template.setTimeout(30); return template; } }无返回值的事务操作用executeWithoutResultService public class OrderService { Autowired private TransactionTemplate transactionTemplate; public void createOrderTx(Long userId, Long skuId, Integer count) { transactionTemplate.executeWithoutResult(status - { orderDao.insert(userId, skuId, count); stockDao.deduct(skuId, count); }); } }需要业务方法返回结果时用executepublic Result createOrderWithResult(OrderDTO dto) { return transactionTemplate.execute(status - { try { orderDao.insert(dto); stockDao.deduct(dto.getSkuId(), dto.getCount()); return Result.success(); } catch (Exception e) { log.error(订单创建失败事务回滚, e); status.setRollbackOnly(); return Result.fail(e.getMessage()); } }); }注意上面这个例子里的status.setRollbackOnly()很有用。它是告诉 Spring“这个事务我打算回滚”但又不直接抛异常这样业务代码还能正常返回一个失败结果给前端。声明式事务想做同样的事就得借助TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动事务直接就有状态对象在手。TransactionTemplate有一个很大的好处它是线程安全的可以做成单例 Bean 到处注入。它内部会在事务结束后自动清理当前线程绑定的事务资源所以你不太需要关心连接释放的问题。3.2 方式二PlatformTransactionManager 手动三连如果你不想用模板类或者需要完全掌控每个步骤也可以直接操作PlatformTransactionManager。核心就是三步拿到事务状态 → 执行业务 → 提交/回滚。Service public class OrderService { Autowired private PlatformTransactionManager transactionManager; public void createOrder(OrderDTO dto) { // 第一步定义事务属性 DefaultTransactionDefinition def new DefaultTransactionDefinition(); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); def.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); // 第二步开启事务 TransactionStatus status transactionManager.getTransaction(def); try { orderDao.insert(dto); stockDao.deduct(dto.getSkuId(), dto.getCount()); // 第三步正常提交 transactionManager.commit(status); } catch (Exception e) { // 出现任何异常都回滚 transactionManager.rollback(status); throw new BizException(创建订单失败已经回滚, e); } } }这段代码有一个关键点容易被忽略rollback(status)之后你原来的业务异常已经被替换成了BizException。如果调用方依赖原来的异常类型做后续处理就会踩坑。我一般在 catch 里做两件事先把原始异常记录日志再抛一个新的包装异常日志里保留根因。另一种更稳妥的写法是把回滚条件控制得更精确public void createOrder(OrderDTO dto) { DefaultTransactionDefinition def new DefaultTransactionDefinition(); TransactionStatus status transactionManager.getTransaction(def); try { orderDao.insert(dto); stockDao.deduct(dto.getSkuId(), dto.getCount()); } catch (Exception e) { status.setRollbackOnly(); throw e; } finally { if (status.isRollbackOnly()) { transactionManager.rollback(status); } else { transactionManager.commit(status); } } }status.isRollbackOnly()这个判断很实用。它让你在 catch 里不用急着决定是提交还是回滚而是把决策权集中到 finally 里统一处理。理论上更合理但一开始写的时候容易漏掉isRollbackOnly()这个分支导致回滚被跳过这一点要特别注意。3.3 两种方式的选型对比我在项目里基本只用TransactionTemplate原因很朴素代码少、不容易漏提交或漏回滚。这里放一个对比表方便你按场景选对比项TransactionTemplatePlatformTransactionManager代码量简洁回调风格繁琐手动控制生命周期回滚处理回调抛异常或 setRollbackOnly手动 catch 后调 rollback嵌套事务控制支持多个模板可混用支持可精确控制每个状态误用风险低较高容易忘记提交/回滚精细控制程度较高最高推荐度日常首选特殊复杂场景再用选型的一个直接经验能用TransactionTemplate解决的别去手动搞getTransaction。只有当你需要在同一段代码里同时维护多个不同传播行为的事务状态时才值得直接用PlatformTransactionManager。4. 手动事务的边界设计提交点、回滚点、传播行为4.1 一个业务用例一个事务边界手动事务最大的优势是边界可控但反过来说边界要是划错了问题比注解事务还严重。最常见的错误是把所有操作都塞进一个事务里什么远程调用、消息发送、文件读写全往回调里塞。数据库事务是需要持有连接、加锁的你处理的时间越长数据库锁被占用的时间越长。并发稍微上来一点连接池直接就吃满了。我在实际项目中给团队定过一条很简单的事务边界规矩一个业务用例一个事务边界事务里只放同一个数据源的 CRUD远程调用和消息发送一律放事务外。如果业务流程需要在事务提交后去干别的事那就在提交后按序执行或者用事务同步器后面会讲到。手动事务的另一个价值是能精确控制方法的哪一小段需要事务。比如某个方法前面要做耗时校验、中间要写数据库、后面要调第三方接口用Transactional无法只包中间那一段但手动事务可以把事务开启的语句放在校验完之后把提交放在调用第三方接口之前。这样数据库连接被占用的时间就压缩到了最小。4.2 自调用与代理失效手动事务也要守 Spring 的规矩声明式事务有经典的自调用问题手动事务表面上没有代理这层困扰但如果你在一个手动事务里调用了一个被Transactional标记的方法还是要小心。看个例子Service public class OrderService { public void outerMethod() { transactionTemplate.executeWithoutResult(status - { // do something innerTransactionalMethod(); // 这里其实不会新建独立事务 }); } Transactional public void innerTransactionalMethod() { // do something } }同一个类内部调用innerTransactionalMethod()时Transactional注解根本不会生效因为它没走代理。你以为你在手动事务里又开了一个子事务实际上它就是同一个连接上的普通方法调用而已。想要它真的按REQUIRES_NEW生效得把方法拆到另一个 Spring Bean 里或者用AopContext.currentProxy()显式走代理。这种混用场景最容易让人误判排查时也不能只看有没有注解还要关注调用链路是否真的经过了代理对象。4.3 嵌套事务与 REQUIRES_NEW 的真实表现手动事务里实现嵌套独立事务就是用两个不同的TransactionTemplate并给其中一个设置PROPAGATION_REQUIRES_NEWService public class OrderService { Autowired private TransactionTemplate txTemplate; Autowired Qualifier(requiresNewTxTemplate) private TransactionTemplate requiresNewTxTemplate; public void createOrderWithStock() { // 外层事务写订单 txTemplate.executeWithoutResult(status - { orderDao.insert(order); try { // 内层事务扣库存独立提交不受外层回滚影响 requiresNewTxTemplate.executeWithoutResult(innerStatus - { stockDao.deduct(skuId, count); }); } catch (Exception e) { log.error(库存扣减失败但订单不回滚, e); } }); } }需要注意内层REQUIRES_NEW事务其实是获取了一个新的数据库连接。也就是说外层事务持有的连接和内层事务持有的连接不是同一个。内层事务提交后不管外层事务最后是提交还是回滚内层的提交结果都已经写进数据库了。脏数据风险、数据不一致风险都在这里产生所以用之前一定要问清楚业务到底能不能接受这种部分成功的结果。如果不小心把内层的异常吞掉了在外层看来一切都是正常的但库存实际也没扣成功数据一致性问题比直接抛异常更隐蔽。我的建议是内层事务如果失败至少要把原因记录到日志并考虑是否要落一张补偿表这样后续对账还能有据可查。5. 我踩过的手动事务的坑完整排查链路5.1 坑一catch 里吞了异常事务悄悄提交了有段时间我接手过一个订单模块线上出现了“有一批订单成功创建了但库存没扣减”的脏数据。第一反应是事务没生效可代码里明明用了TransactionTemplate。我一步步顺着代码查最终问题定位在了回调内部的异常处理上transactionTemplate.executeWithoutResult(status - { try { orderDao.insert(order); stockDao.deduct(skuId, count); } catch (Exception e) { log.error(库存扣减失败, e); // 没有 setRollbackOnly也没有重新抛出异常 } });这段逻辑的问题很典型。TransactionTemplate判断事务是否回滚靠的是“回调过程中有没有异常抛出去”。这里 catch 把异常吞了等于告诉事务一切都好可以提交。于是订单成功入库库存操作失败但被忽略。排查过程其实不复杂关键是路线要对发现脏数据订单在、库存没扣先从日志找线索找到了库存扣减失败的 ERROR 日志。反查代码确认TransactionTemplate的回调里确实 catch 了异常并且没有往外抛。检查事务状态——TransactionStatus.isRollbackOnly()返回false说明 Spring 从头到尾都没收到回滚信号。修正catch 里要么throw e重新抛出要么调用status.setRollbackOnly()。这个问题后来我专门在团队里讲过一条规范在事务回调里永远不要吞异常。要么抛出去要么显式标记回滚。如果确实需要捕获异常做补偿动作至少先setRollbackOnly()再处理。5.2 坑二多数据源下注入的事务管理器不对项目引入第二个数据源后我有一段事手动事务突然“失效”了。查了很久发现代码里注入的PlatformTransactionManager实际管理的是主数据源而我事务里要写的表在第二个数据源上事务当然管不到它。因为 Spring Boot 在多数据源的情况下会按Primary选择一个默认的事务管理器。如果你不仔细看很容易把默认管理器当成万能管理器用。排查链路是这样的断点打在transactionManager上打印它的实际类型和内部持有的DataSource。对比当前方法里orderDao.insert用的DataSource发现两把钥匙开的是两把锁。修正为第二个数据源单独配置事务管理器然后用Qualifier指定注入。Bean public PlatformTransactionManager secondaryTransactionManager(Qualifier(secondaryDataSource) DataSource ds) { return new DataSourceTransactionManager(ds); }Autowired Qualifier(secondaryTransactionManager) private PlatformTransactionManager txManager;这个问题如果发生在注解事务里你大概率会借助Transactional(transactionManager xxx)来指定但手动事务里很多人忘了这一步。咱们写代码时一定要把“当前操作的是哪个数据源”和“当前事务管的是哪个数据源”对应起来。5.3 坑三事务同步器与异步线程的资源绑定另一个花了我不少时间的坑是注册的事务同步回调没有按预期执行。在手动事务里我用TransactionSynchronizationManager.registerSynchronization注册了一个afterCommit回调想在事务提交后发一条 MQ 消息。但线上发现事务提交了MQ 消息却没发出去。排查后发现registerSynchronization注册的同步器是绑定在当前线程上的。而事务提交动作发生在一个异步子线程里和注册同步器的线程根本就不是同一个线程。Spring 在提交时会去当前线程的事务资源里找同步器找不到就不触发回调。// 错误示例注册线程和提交线程不一致 Thread t new Thread(() - { transactionTemplate.executeWithoutResult(status - { // do something }); // 提交发生在子线程里 }); TransactionSynchronizationManager.registerSynchronization(...); // 在主线程注册没用 // 正确做法在事务回调内部注册同步器 transactionTemplate.executeWithoutResult(status - { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { mqSender.send(message); } }); // 事务代码 });这个坑给我最大的教训是手动事务和线程模型是强相关的。跨线程使用事务资源要么通过编程式事务把同步器的注册和事务执行放在同一个线程要么干脆用TransactionTemplate.execute的回调包装所有内容别绕开模板线程模型去操作。6. 手动事务在复杂场景中的实践从订单库存到分布式事务6.1 本地事务编排订单与库存的原子性问题订单 库存是讨论事务时最经典的案例。如果订单服务和库存服务在同一数据库、同一服务内最合理的设计就是一个本地事务同时处理两个表的写入public void createOrder(OrderDTO dto) { transactionTemplate.executeWithoutResult(status - { orderDao.insert(buildOrder(dto)); stockDao.deduct(dto.getSkuId(), dto.getCount()); }); }这种场景下一个事务就能保证原子性。但很多项目发展着就会拆服务订单和库存拆成两个服务之后原来的本地事务就变成了跨服务调用单纯靠手动事务已经解决不了问题。这时候常见做法是每个服务内部用自己的本地事务保证单库操作的一致性跨服务的一致性靠消息、对账或补偿机制来兜底。换句话说手动事务在微服务架构下依然是基本功只是它管的是“每个服务内部的原子性”而不是“整个链路的最终一致性”。如果连服务内部的本地事务都没做好上来就谈分布式事务框架最终只会更难排查。6.2 手动事务 事务同步器提交后发消息编程式事务配合事务同步器是实现“事务提交后再做某事”的一种优雅套路。它的好处在于如果事务最终回滚了后面那件事就不会触发避免出现很多“发了消息但库里没数据”的经典问题。public void createOrder(OrderDTO dto) { transactionTemplate.executeWithoutResult(status - { orderDao.insert(buildOrder(dto)); stockDao.deduct(dto.getSkuId(), dto.getCount()); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { // 事务提交后发送订单创建成功的消息 mqSender.sendOrderCreatedEvent(dto.getOrderId()); } }); }); }这种写法的价值不止是“晚一点发消息”。它把“发消息”这个动作从数据的写入生命周期里解耦出来既不占用事务时间又能够在事务真正提交后才广播结果。对比那种在事务里直接发 MQ 的写法它对一致性友好得多。因为如果消息发了但事务回滚了下游收到的消息就会和数据库对不上数据的正确性就完全失控了。6.3 分布式事务里编程式事务的地位很多人会把“分布式事务”和相关框架如 Seata挂在嘴边但实际落到代码层面最终每个参与分支内部跑的仍然是本地事务。也就是说无论你用什么分布式事务方案底层本地事务都可能是Transactional或手动事务。尤其是需要精细控制分支事务时手动事务反而比注解更灵活。面试里如果聊到分布式事务有一个问题经常被追问“本地事务和分布式事务的区别是什么”我的回答一般分三层本地事务管一个数据库连接的原子性分布式事务管多个服务之间数据操作的最终一致性而编程式事务是本地事务里的精细化控制工具它在分库分表、多数据源、混合事务场景里的存在感非常强。我自己用得最多的组合是主流程走TransactionTemplate保证本地事务边界可控跨服务的一致性通过事务同步器发消息 定时对账兜底。这套方案比一上来就引入重量级分布式事务框架实用得多也更能经受业务压力和故障排查的考验。如果你刚接触手动事务我建议先从TransactionTemplate入手把“回调抛异常 回滚”这条规则记牢再试着把一个方法拆成两个独立小事务。等你跑通了边界控制的感觉再去碰PlatformTransactionManager底层 API你会觉得那些代码其实并不难难的是想清楚每个事务到底应该在什么时刻提交、什么时刻回滚。这个“想清楚”的能力恰恰是手动事务带给你的最大价值。