Spring 同类方法调用导致事务不生效?从代理边界到拆分服务的排查
来源:17golang原创
时间:2026-07-19 13:35:22 406浏览 收藏
订单创建接口偶尔出现“订单已生成、库存却没扣”的反馈,第一反应往往是数据库回滚配置错了。可翻日志排查会发现更可疑的现象:库存更新那段方法明明写了 @Transactional,调用过程也没报错,却完全找不到事务开启的相关痕迹。这类问题大多不是注解拼写错误,而是调用路径绕开了Spring代理。
Spring声明式事务的拦截逻辑只会作用于跨代理对象发起的方法调用,同个类内部直接调用带@Transactional的方法,天然不会触发事务增强,这也是绝大多数同类方法调用事务失效的根本原因。
- Spring默认靠代理拦截实现事务能力;同一个对象里用
this直接调用方法,不会经过事务代理逻辑。 - 优先用事务日志、断点调试或者数据库连接状态确认事务边界,别只盯着注解有没有写对。
- 把订单编排逻辑和库存写入逻辑拆分到两个独立Bean,普遍比自注入、手动取代理的方案更容易维护。
- 事务能否正常回滚,还要同步检查异常类型、异常捕获逻辑和事务传播属性的配置。
现象:注解写好了,库存更新却完全没被事务包裹
这是日常开发里很常见的订单服务写法。createOrder 负责参数校验和生成订单记录,之后直接调用 saveOrderAndStock。后者写入 orders 数据后扣减 sku_stock 库存,库存不足时直接抛出业务异常。
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final StockRepository stockRepository;
public OrderService(OrderRepository orderRepository, StockRepository stockRepository) {
this.orderRepository = orderRepository;
this.stockRepository = stockRepository;
}
public Long createOrder(CreateOrderCommand command) {
checkCommand(command);
return saveOrderAndStock(command);
}
@Transactional
public Long saveOrderAndStock(CreateOrderCommand command) {
Order order = orderRepository.save(Order.pending(command.skuId(), command.count()));
int changed = stockRepository.decrease(command.skuId(), command.count());
if (changed != 1) {
throw new IllegalStateException("stock not enough");
}
return order.getId();
}
private void checkCommand(CreateOrderCommand command) {
if (command.count()
这段代码最容易迷惑人的点是:多数情况下业务跑起来看起来完全正常。单条SQL靠连接自动提交的规则就能正常执行,库存充足的请求都能顺利走完流程;只有碰到库存更新失败、后续还要写优惠记录,或者某条SQL执行报错的场景,之前已经落库的不一致数据才会暴露出来。

先做三层检查:调用对象、事务日志、数据库结果
排查的时候别急着改事务传播属性。先确认真正被调用的对象是什么,再看事务有没有正常启动,最后核对数据有没有按预期回滚。三个步骤走完,比只在IDE里翻注解要靠谱很多。
1. 确认调用是不是从代理对象进入
Spring的声明式事务基本靠AOP代理实现。容器注入给控制器或者其他服务类的都是代理对象;代理接收到外部调用之后,才会在目标方法的前后插入事务处理逻辑。createOrder 内部直接调用 saveOrderAndStock 的时候,方法接收者还是当前的 OrderService 原始实例,调用过程根本不会走到代理层。
调试的时候在 saveOrderAndStock 第一行打断点查看 this.getClass(),再到调用方的位置查看容器注入的 orderService.getClass() 对象,前者一般就是原始业务类,后者会带明显的代理类特征。核对这个差异不是为了背类名,是为了确认事务拦截器有没有机会介入调用流程。
2. 开启事务拦截器的日志打印
测试环境里可以临时把事务相关的日志级别调高。不用把整段日志都贴出来,只要找单次请求的上下文里,有没有“创建新事务”和“完成事务”成对出现的记录。碰到同类方法内部调用的场景,很容易看到SQL日志已经正常打印,事务拦截器却没有输出任何对应记录。
logging.level.org.springframework.transaction.interceptor=TRACE logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
3. 用失败请求验证两张表的数据状态
提前准备一个库存只有 1 件、请求下单数量为 2 的测试SKU。请求跑完之后分别查询 orders 和 sku_stock。如果订单记录还留在库里,而库存SQL因为条件不满足返回了 0,就说明订单写入没有和库存操作放在同一个事务里一起回滚。
| 检查点 | 正常事务边界的信号 | 内部调用绕过时的信号 |
|---|---|---|
| 调用入口 | 从其他Spring Bean进入带注解的方法 | 当前类直接调用带注解的方法 |
| 事务日志 | 事务开始和事务完成的记录成对出现 | 只有SQL执行日志,没有事务拦截的相关记录 |
| 失败后的数据 | orders 与 sku_stock 一起回退到执行前状态 | 前面的写入操作数据残留,后续执行步骤直接失败 |
根因:@Transactional 不是写在方法里就能生效的内部开关
如果把事务注解理解成“方法一进去就自动开事务”,很容易踩坑。它更像代理对象识别到对应方法之后附加的一层执行规则。外部Bean主动调用 orderWriteService.saveOrderAndStock() 的时候会经过这层规则校验;this.saveOrderAndStock() 只是普通的Java方法调用,完全不会触发代理逻辑。
这也解释了为什么有人把注解挪到 private 方法上还是没用:代理只能拦截它能感知到的调用边界。不同代理模式、不同配置的可见性细节有差异,但“同一个实例内部直接调用不会天然触发代理拦截”是这类问题最稳定的判断标准。
最稳妥的修复方案:把写入边界拆分到独立服务
如果业务定义的事务边界就是“创建订单和扣库存必须同时成功同时失败”,更推荐把这组写入逻辑放进独立的 OrderWriteService 里。跨Bean调用之后天然就会经过代理逻辑,代码表达的职责也更清晰:OrderService 只负责流程编排,OrderWriteService 对整组数据的一致性负责。
@Service
public class OrderWriteService {
private final OrderRepository orderRepository;
private final StockRepository stockRepository;
public OrderWriteService(OrderRepository orderRepository, StockRepository stockRepository) {
this.orderRepository = orderRepository;
this.stockRepository = stockRepository;
}
@Transactional
public Long createWithStock(CreateOrderCommand command) {
Order order = orderRepository.save(Order.pending(command.skuId(), command.count()));
int changed = stockRepository.decrease(command.skuId(), command.count());
if (changed != 1) {
throw new IllegalStateException("stock not enough");
}
return order.getId();
}
}
@Service
public class OrderService {
private final OrderWriteService orderWriteService;
public OrderService(OrderWriteService orderWriteService) {
this.orderWriteService = orderWriteService;
}
public Long createOrder(CreateOrderCommand command) {
if (command.count()

拆分不是为了硬凑类的数量。这个方案只适合已经有明确独立写入边界的场景:订单落库、库存变更、优惠占用和流水记录属于同一组原子动作的时候才值得拆分。如果只是读取数据之后做轻量转换,没必要为了凑事务逻辑硬拆出一个独立服务。
另外两个常见误区:异常被吞掉和事务边界放太大
就算调用过程已经走到了代理逻辑,也不代表事务一定会正常回滚。下面两类问题经常和同类方法调用的问题一起出现,排查的时候顺手检查就能少踩很多坑。
- 捕获异常之后直接返回成功。如果在事务方法内部捕获库存异常,只打一行日志就直接返回,事务管理器根本看不到异常从方法抛出,自然会按正常流程提交事务。需要转换异常的时候,要么保留原异常抛出,要么手动标记事务回滚。
- 使用受检异常却没配置回滚规则。Spring事务默认只对RuntimeException和Error触发回滚。业务确实需要抛出受检异常的时候,要主动评估场景并配置对应的回滚条件,不要靠默认规则的模糊印象判断。
- 事务逻辑里夹杂慢HTTP调用。把远程通知、文件上传或者长时间等待的逻辑放到数据库事务内部,会长时间占用数据库连接和行锁。先提交核心写入逻辑,之后靠可靠消息或者后续异步任务处理外部操作,稳定性会好很多。
反向验证:用失败请求确认修复真正生效
修复完别只看接口返回200就完事。写一段集成测试,故意构造库存扣减失败的场景,之后校验订单表里没有残留的订单记录。这个用例既能避免后续重构的时候有人把调用逻辑又改回同类方法直调,也能防止异常逻辑被悄悄吞掉之后破坏事务逻辑。
@Test
void shouldRollbackOrderWhenStockIsInsufficient() {
stockRepository.save(Stock.of("sku-7", 1));
assertThrows(IllegalStateException.class, () ->
orderService.createOrder(new CreateOrderCommand("sku-7", 2))
);
assertThat(orderRepository.count()).isZero();
assertThat(stockRepository.findCountBySkuId("sku-7")).isEqualTo(1);
}
测试数据最好在每个用例执行之前清空,尤其是用共享测试库的场景。如果订单号、库存表或者审计表有异步写入逻辑,断言只需要覆盖当前事务需要负责的核心表就行,不要把其他流程的最终一致性要求混到这个测试里。
上线前检查清单
- 带
@Transactional的公共方法,是不是都从其他Spring Bean发起调用。 - 失败分支逻辑是不是真的抛出了能触发回滚的异常,没有被捕获之后直接返回普通成功结果。
- 事务范围是不是只包含必要的数据库读写操作,没有长时间的网络等待逻辑。
- 模拟库存不足、唯一键冲突的失败请求之后,相关核心表的数据是不是一起回退到了执行前的状态。
- 集成测试有没有覆盖“前一条写入成功、后一条写入失败”的真实业务顺序。
相关问题
把事务注解加在调用方 createOrder 上可以吗?
可以,前提是调用方本身是从外部Bean经过代理进入的,而且整个方法的执行边界本来就应该同时覆盖订单和库存操作。边界太大的时候会把无关的查询或者远程调用也包进事务,需要根据业务场景权衡。
自注入当前服务再调用带注解的方法是不是可行?
技术上确实能让调用过程重新经过代理,但依赖关系会变得很绕,后续重构的时候也很容易失效。对于有明确独立写入职责的业务,拆分出独立服务的方案普遍更直观好维护。
事务方法一定要定义成public吗?
主流代理配置下,公开方法是最稳妥的选择。非公开方法的执行行为会受代理模式和框架配置影响,不适合作为团队通用的开发约定。
库存扣减返回0的时候为什么要主动抛异常?
返回0说明条件更新没命中,常见原因是库存不足或者SKU状态已经发生变化。主动抛出业务异常能让当前整组原子操作直接回滚,也能让上层返回明确的失败结果。
事务类问题的核心从来不是多写一个注解,而是把数据一致性的边界放在真正能经过代理、同时能被失败用例验证的位置。订单和库存这类核心写入逻辑把职责拆分清楚之后,线上排查的多余噪音会少很多。
-
187 收藏
-
412 收藏
-
114 收藏
-
241 收藏
-
101 收藏
-
文章 · java教程 | 1天前 | Java · HTTP · ndjson · httpclient · 性能实践 · 流式读取 背压 Java HttpClient NDJSON BodyHandlers.ofLines309 收藏
-
文章 · java教程 | 3天前 | 并发 · Java · CompletableFuture · Java CompletableFuture 任务取消 orTimeout completeOnTimeout152 收藏
-
300 收藏
-
文章 · java教程 | 4天前 | 数据库 · 性能优化 · Java教程 · Optional · orElseGet · java 数据库查询 懒加载 optional supplier orElse orElseGet238 收藏
-
文章 · java教程 | 4天前 | 数据库 · 性能优化 · Java教程 · Optional · orElseGet · java 数据库查询 懒加载 optional supplier orElse orElseGet186 收藏
-
文章 · java教程 | 5天前 | Record · Java教程 · 防御式拷贝 · List.copyOf · Arrays.copyOf · 不可变性 · arrays.copyof 可变集合 Java record List.copyOf 防御式拷贝 数组克隆247 收藏
-
文章 · java教程 | 6天前 | Java · 后端开发 · 批处理 · Stream API · JDK 24 · Gatherers · 分组 Java 24 Stream Gatherers windowFixed Stream.gather 批量接口411 收藏
-
文章 · java教程 | 6天前 | Java · 文件上传 · spring · nio · 后端开发 · java 文件上传 临时文件 数据清理 MultipartFile Files.move314 收藏
-
135 收藏
-
文章 · java教程 | 1星期前 | map · Java · 后端开发 · Collectors · Stream API · java Stream Collectors toMap 重复key Map合并159 收藏
-
文章 · java教程 | 1星期前 | 调试 · 异常处理 · Java教程 · try-with-resources · AutoCloseable · 文件关闭 AutoCloseable Java教程 Java try-with-resources suppressed exceptions327 收藏
-
430 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习