“方法上已经写了 @Transactional,为什么库存减了,订单却没有创建?”这类问题不能只看注解是否存在。需要沿着一次调用确认:事务有没有开启,哪些操作加入了它,异常是否触发回滚,以及出错的动作是否本来就在事务范围内。
本文讨论 Spring 常见的代理模式、同步 JDBC 本地事务。响应式事务和 AspectJ 编织模式有不同语义,不混在同一结论里。
先把事务的业务范围说清楚
假设一个操作要减少库存,并在同一数据库创建订单。业务要求是两者一起成功或一起失败。这里可以用一个事务方法包住两条 SQL,前提是使用支持事务的数据库表、同一个事务管理器及其管理的数据源。
下面是 Spring Service 示例,需要现成的 Spring 容器、JdbcTemplate 和数据库配置;它不是可直接运行的完整项目。
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final JdbcTemplate jdbc;
public OrderService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void place(long orderId, long productId, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("购买数量必须大于零");
}
// 由数据库在更新语句中判断库存,避免先查再改的竞态。
int changed = jdbc.update(
"UPDATE inventory SET available = available - ? "
+ "WHERE id = ? AND available >= ?",
quantity, productId, quantity);
if (changed != 1) {
throw new IllegalStateException("商品不存在或库存不足");
}
jdbc.update(
"INSERT INTO orders(id, product_id, quantity) VALUES (?, ?, ?)",
orderId, productId, quantity);
}
}
inventory.id 与 orders.id 应具有主键或等价唯一约束。在通常的事务型关系数据库中,条件更新由数据库仲裁并发;对具体数据库的锁、隔离级别与执行计划,仍要结合实际环境验证。事务本身不会把所有“先查再写”代码自动变成无竞态逻辑。

第一条线索:调用经过代理了吗
常见错误是同一个对象内调用另一个带注解的方法:
public void checkout() {
this.saveOrder();
}
@Transactional
public void saveOrder() {
// 数据库操作
}
如果 checkout 本身没有事务,且 saveOrder 仅通过这条内部调用进入,在默认代理模式下,它的注解不会被代理拦截。更清楚的结构通常是把事务操作放在独立的 Spring Bean 中,由外部调用者通过注入的 Bean 进入。1
但不能把规则缩写成“同类调用一定没有事务”:如果外层方法已经开启事务,内部数据库操作仍可能参与外层事务;失效的是内层注解本应提供的拦截语义。例如内层声明 REQUIRES_NEW,内部直接调用并不会凭空创建新事务。
手动 new OrderService(...) 也不是获取 Spring 代理。方法可见性还与代理类型、Spring 版本有关;本文示例选择外部调用的 public 方法,不把“只有 public 才支持”当成适用于所有版本的绝对规则。
第二条线索:异常有没有到达事务边界
在未配置其他规则时,Spring 声明式事务默认针对 RuntimeException 与 Error 回滚,受检异常并不会仅因被抛出就默认触发回滚。可以针对业务需要配置 rollbackFor;新版本也提供全局调整默认回滚策略的能力,因此排查时要同时看注解与全局配置。[1]2
如果业务代码把异常捕获后返回一个失败字符串,事务代理可能只看到“正常返回”。这时不是注解突然失效,而是异常信号被消费了。可以让业务异常继续抛出,或者用明确的编程式事务策略处理,而不是随意 catch 所有异常。
还有相反的情况:内层通过代理参与 REQUIRED 事务并抛出异常,事务已经被标记为 rollback-only;外层即使捕获异常,提交时仍可能收到 UnexpectedRollbackException。3 因此,吞掉异常既不保证提交,也不保证回滚,要看异常经过了哪些事务边界。
第三条线索:是不是换了线程或资源
在本文讨论的常见同步本地事务中,事务资源通常绑定在线程上下文。把数据库操作扔给异步任务,不能假定它继承调用线程的同一事务。任务里是否另开事务、何时提交,需要单独设计。
REQUIRES_NEW 也不只是“更强的事务”。它使用独立事务,内层提交后,外层再回滚不会自动撤销内层结果。外层挂起期间还可能占着连接,内层又申请新连接,连接池容量不足时容易产生新的等待问题。3
如果目标本来是订单与库存一起成功,把它们分成两个独立事务,反而可能破坏原本要求。传播行为应根据一致性目标选择,不是遇到异常就换一个参数试试。
HTTP 成功不会被本地事务撤销
假设事务方法里先调用支付接口,再写数据库;随后写库失败。本地数据库回滚,不意味着远程支付也回滚。数据库事务没有覆盖那个 HTTP 服务。
类似问题还包括发短信、发邮件、上传文件。对于“提交成功后可靠发事件”,可以考虑同库写业务数据与待发送记录,再由独立任务投递的 outbox 方式,同时处理重复投递和消费幂等。仅注册一个提交后回调,并不能保证进程崩溃后消息仍会发送。
这不是要求所有项目立刻引入分布式事务,而是要求承诺与实际边界一致。一个简单博客后端和支付系统需要的保障不同,应按业务后果选择方案。
用数据库结果验证,而不是看方法返回值
验证上面的 Service,可以先准备库存,再让订单 ID 与已有记录冲突。订单插入触发异常后,从事务结束后的独立查询确认库存没有变化。成功路径则验证库存减少与订单创建同时发生。
集成测试也要避免测试框架自己的外层事务掩盖真实调用边界。只验证“抛出了异常”不够,必须观察最终数据库状态;只用 mock 验证调用次数,也无法证明真实数据库执行了回滚。
排查时把“入口 → 代理 → 业务方法 → 数据库资源 → 异常出口”串起来,很多看似随机的事务问题就会变成可定位的边界问题。注解是声明,真正决定结果的是这条调用路径。
参考资料
- 1 Spring:Using @Transactional — 代理、自调用与可见性。
- 2 Spring:Rolling Back a Declarative Transaction — 回滚规则。
- 3 Spring:Transaction Propagation — REQUIRED、REQUIRES_NEW 与 rollback-only。
代码为需要数据库配置的教学片段,未在本环境启动 Spring 集成测试。资料核对日期:2026-10-08。
