文章目录前言1 先搞清楚Spring 事务到底在帮我们做什么2. 用一个订单流程看懂 Transactional 为什么会失效2.1 方法自调用你以为调用了事务方法其实绕过了代理2.2 异常被吞掉你以为失败了Spring 以为成功了2.3 异常类型不对不是所有异常都会默认回滚2.4 Async线程一切换事务边界就变了2.5 跨数据源一个 Transactional 管不了所有库2.6 事务范围太大没失效但可能更危险3. 两种事务管理方式到底应该怎么用3.1 声明式事务适合“边界清晰”的数据库操作3.2 编程式事务补上“事务边界不可控”的缺口3.3 编程式事务能解决什么解决不了什么① 跨线程Async② 跨数据源 / 分布式事务③ 外部系统的一致性RPC / MQ / 文件 / 第三方写在文后前言我以前对Transactional的理解其实很简单方法上加一下出错就回滚数据库就安全了。直到有一次写订单流程的时候事情开始不对劲。那是一个很常见的链路创建订单、扣库存、写日志、发通知。我很自然地把整个方法包上了Transactional觉得这已经是“标准答案”了。结果测试的时候发现——明明中间抛了异常日志也打出来了但数据库里的订单数据居然还在。第一反应是是不是我哪里写错了再看一遍代码注解在、异常也有、逻辑也不复杂看起来完全没问题。后来一点点排查才发现问题根本不在“有没有加注解”而是在一些我之前完全没在意的细节上有的方法调用根本没经过 Spring 代理有的异常被我自己吞掉了还有一部分逻辑被我丢进了异步线程里……这些情况叠在一起的时候Transactional基本等于没生效。也是从那之后我才意识到一件事Transactional从来不是一个“加上就安全”的开关它只是 Spring 基于 AOP 提供的一层能力而这层能力是有边界的。1 先搞清楚Spring 事务到底在帮我们做什么在聊Transactional为什么会失效之前得先把一个问题说清楚Spring 事务本身不是数据库事务的替代品它只是帮我们更方便地管理数据库事务。数据库事务真正解决的是 ACID 这一类问题比如一组 SQL 要么一起成功要么一起失败。而 Spring 做的事情是在业务代码执行前后帮我们把“开启事务、提交事务、回滚事务、释放连接”这些重复操作统一接管掉。在 Spring 里常见的事务管理方式主要有两种方式典型写法特点适合场景编程式事务TransactionTemplate、PlatformTransactionManager手动控制事务边界代码侵入性更强但范围更清晰需要精确控制事务范围比如只让几行数据库操作进入事务声明式事务Transactional通过注解交给 Spring AOP 处理代码更简洁但容易忽略生效条件边界清晰的 Service 方法比如创建订单、扣库存、更新状态大多数业务项目里我们用得最多的是第二种也就是Transactional。它的好处很明显不用手动写try-catch-finally不用自己调用提交和回滚只要在方法上加一个注解Spring 就会在方法执行前开启事务在方法正常结束后提交事务在方法抛出符合规则的异常时回滚事务。看起来很省心但问题也正出在这里。很多人会下意识把Transactional理解成一个“保险开关”只要方法上加了它这段业务就一定安全只要中间报错了数据库就一定回滚。但实际上Transactional并不是魔法。它能生效是因为 Spring 在背后给目标对象创建了一个代理对象。一次正常的调用大概是这样外部调用 - Spring 代理对象 - 开启事务 - 调用真实业务方法 - 根据执行结果提交或回滚也就是说事务增强逻辑并不是写进了你的业务方法里而是包在方法外面的一层“壳”。这就带来了一个很重要的前提事务是否生效不是只看方法上有没有Transactional而是看这次调用有没有真正经过 Spring 代理。如果调用没有经过代理比如同一个类里用this.xxx()调用另一个事务方法那么 Spring 根本没有机会在外面套上事务逻辑。如果异常被你自己 catch 掉了代理对象看到的就是“方法正常执行结束”它自然会提交事务。如果代码切到了另一个线程比如用了Async事务上下文也不一定还能跟过去。如果一次业务操作跨了多个数据源一个普通本地事务管理器也不可能天然保证多个库一起提交、一起回滚。如果你把远程调用、文件上传、发消息都塞进一个大事务里事务虽然可能还在生效但数据库连接和锁也会被长时间占用反而带来性能和稳定性问题。2. 用一个订单流程看懂 Transactional 为什么会失效假设现在有一个很常见的订单创建流程TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){saveOrder(request);// 保存订单deductStock(request);// 扣减库存saveOperationLog(request);// 写操作日志sendNotify(request);// 发送通知}从业务上看这段代码很自然创建订单时保存订单、扣库存、记录日志最后通知用户。很多人第一反应也是这不就是典型的事务场景吗直接在createOrder()上加一个Transactional就好了。但真实项目里问题往往就藏在这些“看起来没问题”的代码里。2.1 方法自调用你以为调用了事务方法其实绕过了代理先看一个很容易忽略的写法ServicepublicclassOrderService{publicvoidcreateOrder(CreateOrderRequestrequest){saveOrder(request);this.deductStock(request);}TransactionalpublicvoiddeductStock(CreateOrderRequestrequest){// 扣减库存}}乍一看deductStock()方法上已经加了Transactional扣库存失败应该能回滚。但问题是this.deductStock()是当前对象自己调用自己的方法并没有经过 Spring 创建的代理对象。前面说过Transactional的事务逻辑不是直接写进业务方法里的而是由代理对象在方法外面包了一层外部调用 - 代理对象 - 开启事务 - 调用真实方法而this.deductStock()的调用链更像这样当前对象 - 当前对象的方法中间没有代理对象Spring 自然也就没有机会开启事务。只要调用没有经过 Spring 代理Transactional就不会生效。如果确实需要单独给deductStock()开事务更合理的做法是把它放到另一个 Spring Bean 里通过外部 Bean 调用ServicepublicclassOrderService{ResourceprivateStockServicestockService;publicvoidcreateOrder(CreateOrderRequestrequest){saveOrder(request);stockService.deductStock(request);}}ServicepublicclassStockService{TransactionalpublicvoiddeductStock(CreateOrderRequestrequest){// 扣减库存}}这样调用会经过StockService的代理对象事务逻辑才有机会被织入。2.2 异常被吞掉你以为失败了Spring 以为成功了再看一个订单创建中很常见的写法TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){try{saveOrder(request);deductStock(request);sendNotify(request);}catch(Exceptione){log.error(创建订单失败,e);}}这段代码的问题不在于 catch而在于 catch 之后没有继续抛出异常。从业务角度看deductStock()或sendNotify()失败了这次创建订单应该失败。但从 Spring 事务拦截器的角度看createOrder()方法最后是正常返回的。既然正常返回它就会认为这次方法执行成功然后提交事务。这也是很多人第一次遇到事务没回滚时最困惑的地方明明日志里都打印异常了为什么数据库还是提交了原因就是异常被你自己处理掉了。如果希望事务回滚至少要让异常继续往外抛TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){try{saveOrder(request);deductStock(request);sendNotify(request);}catch(Exceptione){log.error(创建订单失败,e);throwe;}}或者在确实不能抛异常的情况下手动标记当前事务回滚TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){try{saveOrder(request);deductStock(request);sendNotify(request);}catch(Exceptione){log.error(创建订单失败,e);TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();}}但后者要谨慎使用。因为它会让业务代码和 Spring 事务 API 产生耦合读代码的人也不一定第一眼能看出这里已经手动设置了回滚。更推荐的方式还是该失败就失败该抛异常就抛异常不要把异常悄悄吃掉。2.3 异常类型不对不是所有异常都会默认回滚还有一种情况更隐蔽异常确实抛出去了但事务依然没有回滚。比如TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest)throwsIOException{saveOrder(request);deductStock(request);thrownewIOException(通知文件写入失败);}很多人会觉得只要抛了异常事务就应该回滚。但 Spring 默认并不是对所有异常都回滚。默认情况下它主要对RuntimeException和Error进行回滚对 checked exception也就是受检异常不会默认回滚。IOException就是典型的受检异常。所以这段代码的表现可能是方法确实抛出了异常但前面的订单和库存操作已经提交了。如果业务上希望任何异常都触发回滚可以显式指定Transactional(rollbackForException.class)publicvoidcreateOrder(CreateOrderRequestrequest)throwsIOException{saveOrder(request);deductStock(request);thrownewIOException(通知文件写入失败);}不过这里也不要走向另一个极端所有方法都无脑加rollbackFor Exception.class。更合理的做法是先想清楚这个异常到底意味着业务失败还是只是一个非核心流程失败比如订单已经创建成功但发送通知失败了这时候真的要把订单回滚吗不一定。可能更好的方案是记录失败状态然后通过补偿任务重新发送通知。所以这里真正要关注的不是rollbackFor这个参数而是异常和事务边界之间的关系。不是所有异常都应该回滚也不是所有失败都适合放进一个数据库事务里解决。2.4 Async线程一切换事务边界就变了订单创建完成后通常还会发短信、发邮件、推送站内信。为了不影响接口响应时间很多人会把通知逻辑改成异步TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){saveOrder(request);deductStock(request);notifyService.sendNotifyAsync(request);}AsyncpublicvoidsendNotifyAsync(CreateOrderRequestrequest){sendSms(request);sendEmail(request);}这样写本身没有问题问题在于很多人会误以为sendNotifyAsync()还是整个createOrder()事务的一部分。实际上Async会把方法丢到另一个线程里执行而事务上下文通常是绑定在当前线程上的。也就是说主线程里的事务是主线程的异步线程里的逻辑是异步线程的。它们不是同一个事务边界。这会带来几个问题。比如主事务还没提交异步线程就开始查订单可能查不到数据或者查到的不是最终状态。再比如异步通知失败了它也不会自动让createOrder()的事务回滚。因为主线程可能早就执行完了甚至事务已经提交了。所以异步逻辑不要默认依赖外层事务。更稳妥的做法是把异步通知放在事务提交之后再触发。比如使用事务事件TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){saveOrder(request);deductStock(request);applicationEventPublisher.publishEvent(newOrderCreatedEvent(orderId));}然后在事务提交后处理TransactionalEventListener(phaseTransactionPhase.AFTER_COMMIT)publicvoidhandleOrderCreated(OrderCreatedEventevent){notifyService.sendNotify(event.getOrderId());}这样语义会清楚很多订单创建成功并提交之后再去做通知。如果通知失败也不应该直接回滚订单而是进入重试、补偿、告警流程。事务管的是当前线程里的数据库操作不是你脑子里的整条业务流程。2.5 跨数据源一个 Transactional 管不了所有库再把订单流程稍微复杂一点。假设订单表和库存表不在同一个数据库TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){orderMapper.insert(request);// order_dbstockMapper.deduct(request);// stock_db}这时候很多人还是会下意识觉得方法上有Transactional订单和库存就应该一起提交、一起回滚。但实际上一个普通的本地事务管理器通常只管理一个数据源。如果当前事务管理器绑定的是order_db那它能保证的是订单库里的操作。至于库存库里的操作是否加入了事务、由哪个事务管理器管理、失败时能不能一起回滚都不是一个默认的Transactional能自动解决的。多数据源场景下至少要明确当前方法使用的是哪个事务管理器Transactional(transactionManagerorderTransactionManager)publicvoidcreateOrder(CreateOrderRequestrequest){orderMapper.insert(request);}如果一次业务真的要同时操作多个数据库并且要求强一致那就已经不是普通本地事务能轻松解决的问题了。这时候要考虑的可能是本地消息表、Outbox、TCC、Saga或者 Seata 这类分布式事务方案。当然并不是所有跨数据源都需要强一致。很多业务可以接受最终一致性那就不必强行把所有操作塞进一个大事务里。Transactional解决的是本地事务问题不是天然的分布式事务方案。2.6 事务范围太大没失效但可能更危险还有一种情况严格来说不算“事务失效”但在项目里一样危险。比如TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){saveOrder(request);deductStock(request);paymentClient.freezeAmount(request);fileService.uploadContract(request);messageProducer.sendOrderCreatedMessage(request);updateOrderStatus(request);}这段代码的问题是事务范围太大了。数据库事务开启后会占用数据库连接。某些更新操作还可能持有锁。如果中间穿插了远程支付、文件上传、消息发送这类耗时操作事务就会被长时间挂着。一旦远程接口响应慢数据库连接会被占住如果并发量上来连接池可能被打满锁等待可能变长整个接口都会变得不稳定。更麻烦的是外部操作通常无法随着数据库事务一起回滚。比如消息已经发出去了后面的数据库更新失败了消息能撤回吗文件已经上传成功了事务回滚了文件会自动删除吗支付冻结调用成功了本地事务失败了冻结金额会自动释放吗大概率都不会。所以事务不是包得越大越安全。很多时候事务越大系统反而越脆弱。更合理的做法是缩小事务范围TransactionalpublicvoidcreateOrderInTransaction(CreateOrderRequestrequest){saveOrder(request);deductStock(request);}事务提交之后再处理外部动作publicvoidcreateOrder(CreateOrderRequestrequest){createOrderInTransaction(request);sendOrderCreatedMessage(request);}当然真实项目里还要考虑消息发送失败、事务提交成功但程序宕机等问题这就需要本地消息表、Outbox 或 MQ 事务消息这类方案兜底。数据库事务只负责数据库能控制的事情不要拿它兜所有外部系统的不确定性。把这些场景串起来看其实会发现它们不是孤立的“坑”。方法自调用本质是没有经过代理边界。异常被吞掉本质是异常边界没有传递出去。Async本质是线程边界变了。跨数据源本质是数据源边界变了。事务范围太大本质是业务边界和事务边界混在一起了。所以Transactional不是不能用而是使用它之前要想清楚这段代码到底是不是在 Spring 能管理的事务边界之内3. 两种事务管理方式到底应该怎么用3.1 声明式事务适合“边界清晰”的数据库操作Transactional的优势很明显简单、直观、侵入性低。当你的业务方法本身就是一个完整的“数据库事务单元”时它是最合适的选择。比如TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){saveOrder(request);deductStock(request);updateUserStatistics(request);}这类逻辑有几个特点主要是数据库写操作边界清晰就是这一个方法不依赖复杂的调用链和线程切换这种场景下用Transactional是最自然、也是成本最低的方式。但问题也在这里——它的边界是“方法级 代理调用”一旦业务复杂起来就会开始吃力。3.2 编程式事务补上“事务边界不可控”的缺口编程式事务的价值不在于“更底层”而在于你可以自己决定事务到底包住哪一段代码。比如这样一段逻辑publicvoidcreateOrder(CreateOrderRequestrequest){saveOrder(request);deductStock(request);paymentClient.freezeAmount(request);// RPCsendMessage(request);// MQ}如果直接加Transactional事务会把 RPC、MQ 全包进去这通常是不合理的。用TransactionTemplate可以这样写publicvoidcreateOrder(CreateOrderRequestrequest){transactionTemplate.execute(status-{saveOrder(request);deductStock(request);returnnull;});paymentClient.freezeAmount(request);sendMessage(request);}这段代码的变化不大但语义完全变了事务只包数据库操作RPC、MQ 在事务之外执行事务边界一眼就能看清这正好弥补了声明式事务的一个核心缺陷Transactional很难精确控制“只包哪几行代码”它更适合“整个方法就是事务”。再比如一个方法里有两段逻辑需要分两个事务publicvoidprocess(){stepA();// 事务1stepB();// 事务2}用Transactional很难优雅表达除非拆方法 代理调用但用编程式事务就很直接publicvoidprocess(){transactionTemplate.execute(status-{stepA();returnnull;});transactionTemplate.execute(status-{stepB();returnnull;});}所以可以这样理解声明式事务适合“一个方法 一个事务”编程式事务适合“一个方法里有多个事务边界”。3.3 编程式事务能解决什么解决不了什么很多人以为既然编程式事务更灵活那是不是可以解决所有Transactional的问题其实不是。它能解决的本质上是事务边界控制问题不想让整个方法进事务 → 可以只包一段代码一个方法需要多个事务 → 可以拆成多个块想明确控制提交/回滚时机 → 可以手动决定但它解决不了这些问题① 跨线程AsynctransactionTemplate.execute(status-{saveOrder();asyncService.doSomething();// 新线程returnnull;});异步线程依然不在当前事务里。编程式事务并不能“把事务带到另一个线程”。解决方式不是换事务写法而是换设计用事务提交后事件TransactionalEventListener用 MQ / 本地消息表Outbox 模式做最终一致性而不是强依赖一个本地事务② 跨数据源 / 分布式事务transactionTemplate.execute(status-{orderMapper.insert();// DB1stockMapper.update();// DB2returnnull;});编程式事务也只能绑定一个TransactionManager。它并不会自动帮你协调多个数据库。这种问题本质上不是“事务写法”的问题而是系统架构问题。常见解法是不追求强一致 → 用最终一致性MQ / Outbox必须强一致 → TCC / Saga / Seata 等分布式事务方案③ 外部系统的一致性RPC / MQ / 文件 / 第三方事务只能控制数据库。你在事务里做这些调支付接口发 MQ上传文件即使事务回滚了这些操作大概率也不会自动撤销。编程式事务也一样解决不了。所以要换思路MQ用事务消息 / 本地消息表RPC设计幂等 补偿文件延迟处理 / 标记状态不要试图用数据库事务去兜住外部系统的不确定性。写在文后期待您的一键三连如果有什么问题或建议欢迎在评论区交流