Spring Boot 4.0 事务管理:传播机制、隔离级别与失效场景
前言
创建订单至少涉及两类数据变化:保存订单和扣减库存。如果订单写入了、库存没扣,会超卖;如果库存扣了、订单回滚,又会少卖。
本章会先构造库存不足,再观察三件事:
- 已写入的订单和订单项必须回滚。
- 已扣减的库存必须恢复。
- 拒绝原因应通过独立审计事务保留。
配套源码:dvsusan/spring-boot4-demo。
最终结果
库存只有 1 件时请求购买 2 件:
curl -i -X POST "http://localhost:8080/api/v1/db/orders" \
-H "Content-Type: application/json" \
-d '{
"customerNo":"C3001",
"customerName":"事务测试客户",
"items":[
{"productId":"P2002","productName":"显示器","quantity":2,"unitPrice":999.00}
]
}'返回 HTTP 409:
{
"code": "RESOURCE_CONFLICT",
"message": "库存不足: P2002"
}随后查询该客户订单,总数为 0;查询库存仍为 1;audit_logs 中新增一条 ORDER_REJECTED。

图 1:业务失败时订单和库存一起回滚,审计使用独立事务保存证据。
第 1 步:明确事务边界
事务应包住“一组必须一起成功或失败的业务变化”:
@Transactional
public PersistentOrderView place(PlaceOrderCommand command) {
// 创建客户、订单、订单项和扣减库存都属于同一个本地事务。
CustomerEntity customer = findOrCreateCustomer(command);
PurchaseOrderEntity order = createOrder(command, customer);
reserveInventory(command);
return orderRepository.saveAndFlush(order);
}事务不能包住用户等待、远程 HTTP 调用或大文件上传。长事务会一直占连接和锁,严重时拖垮连接池。
第 2 步:拒绝非法库存
库存实体提供明确领域方法:
public void reserve(int quantity) {
// 数量非法或余额不足时抛出异常,触发整个订单事务回滚。
if (quantity <= 0 || availableQuantity < quantity) {
throw new InsufficientStockException(productId);
}
availableQuantity -= quantity;
}不要在 Controller 里比较库存后再调用一个没有事务的扣减方法。检查和修改之间可能存在并发窗口。
第 3 步:用悲观锁保护同一商品库存
/**
* 库存扣减查询在事务内对目标行加悲观写锁。
*/
public interface InventoryJpaRepository
extends JpaRepository<InventoryEntity, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select inventory
from InventoryEntity inventory
where inventory.tenantId = :tenantId
and inventory.productId = :productId
""")
Optional<InventoryEntity> findForUpdate(
String tenantId,
String productId
);
}PESSIMISTIC_WRITE 会锁定目标库存行。第二个事务必须等第一个提交或回滚,再读取最新库存。
悲观锁适合高冲突、扣减必须准确的库存场景,但锁范围要尽量小。超大事务长期持锁,会降低吞吐并增加死锁概率。
第 4 步:在事务内扣减并添加明细
for (PlaceOrderCommand.PlaceOrderItem item : command.items()) {
InventoryEntity inventory = inventoryRepository
.findForUpdate(command.tenantId(), item.productId())
.orElseThrow(() ->
new InsufficientStockException(item.productId()));
// 先扣库存,再构建订单项;任一步抛异常都会回滚整个方法。
inventory.reserve(item.quantity());
order.addItem(new OrderItemEntity(
item.productId(),
item.productName(),
item.quantity(),
item.unitPrice()
));
}
PurchaseOrderEntity saved = orderRepository.saveAndFlush(order);JPA 脏检查会把 availableQuantity 的修改更新到数据库。saveAndFlush 让订单主键和 SQL 在事务内立即执行。
第 5 步:避免同类自调用绕过代理
Spring @Transactional 依赖代理。下面这种写法无效:
public void outer() {
// 同类 this 调用不经过代理,inner 的 @Transactional 不会生效。
this.inner();
}
@Transactional
public void inner() {
// 这里看起来有事务,但通过 this 调用时只是普通 Java 方法。
}本项目的编排方法位于 PersistentOrderService,真正事务方法位于另一个 Bean OrderPlacementTransaction:
public PersistentOrderView place(PlaceOrderCommand command) {
try {
// 跨 Bean 调用才会经过 Spring 事务代理。
return placementTransaction.place(command);
} catch (InsufficientStockException exception) {
auditService.recordFailure(command.tenantId(), exception.getMessage());
throw exception;
}
}其他常见失效原因还包括:方法不是 public、异常被吞掉、类不是 Spring Bean、事务管理器配置错误。
第 6 步:用 REQUIRES_NEW 保存失败审计
@Service
public class AuditService {
private final AuditLogJpaRepository auditRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordFailure(String tenantId, String detail) {
// 新事务提交审计,不受订单事务回滚影响。
auditRepository.save(new AuditLogEntity(
tenantId,
"ORDER_REJECTED",
detail
));
}
}REQUIRED 会加入现有事务一起回滚;REQUIRES_NEW 会暂停外层事务并开启独立事务。
审计失败是否吞掉异常要按业务决定。若“审计必须成功才能拒绝订单”,两种失败需要纳入同一补偿设计,不能只是加一行注解。
第 7 步:选择隔离级别
PostgreSQL 默认隔离级别是 Read Committed。分页查询在同一个请求里先查总数再查列表,可能遇到两次语句之间的数据变化。
本项目分页查询使用:
@Transactional(
readOnly = true,
isolation = Isolation.REPEATABLE_READ
)
public OrderPage findPage(String tenantId, String customerNo, int page, int size) {
// count 和 page 读取同一事务快照。
long offset = (long) (page - 1) * size;
return new OrderPage(
queryMapper.findPage(tenantId, customerNo, offset, size),
queryMapper.count(tenantId, customerNo),
page,
size
);
}隔离级别越高,通常锁或快照代价越大。不要把所有接口都设成 Serializable;先列出具体异常,再选择最小成本方案。
第 8 步:验证失败分支
准备库存:
curl -X PUT \
"http://localhost:8080/api/v1/db/inventory/P2002?quantity=1"执行前面的两件商品订单请求,预期 409。
查询库存:
curl "http://localhost:8080/api/v1/db/inventory/P2002"预期:
{"code":"0","message":"success","data":{"productId":"P2002","availableQuantity":1}}查询客户订单:
curl "http://localhost:8080/api/v1/db/orders?customerNo=C3001"预期 totalElements=0。
自动化测试:
mvn -q testDatabaseCoreIntegrationTest.insufficientStockRollsBackOrderButPersistsAudit 会验证订单回滚和独立审计。
常见问题排查
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 异常后订单仍写入 | 异常被 catch 后没有重新抛出 | 标记回滚或继续抛出异常 |
@Transactional 完全不生效 | 同类自调用或对象不是 Bean | 拆到另一个 Bean |
| 审计也随订单回滚 | 使用了默认 REQUIRED | 审计方法改用 REQUIRES_NEW |
| 高并发下仍超卖 | 查询库存没加锁 | 使用悲观锁或原子条件更新 |
| 死锁增加 | 多商品加锁顺序不一致 | 按商品 ID 固定顺序扣减 |
| 分页总数和内容不一致 | 两次查询不在一致快照 | 同事务使用合适隔离级别 |
本章小结
事务注解本身不保证正确,边界和异常传播才决定结果:
- 事务方法必须经过 Spring 代理。
- 业务失败必须抛出或明确标记回滚。
- 锁的范围应覆盖检查与修改。
- 独立审计需要明确传播行为。
- 隔离级别按异常选择,不按“越严格越好”。
下一章会把建表 SQL 从手工执行升级为 Flyway 版本迁移。
练习题
实现批量扣减库存:
- 按
productId排序后逐个加锁。 - 任意商品不足时整批回滚。
- 写一个两线程并发扣减测试。
- 记录锁等待时间,并比较乐观锁方案。
上一节:Spring Data JPA 实体设计
下一节:Flyway 数据库版本管理
相关阅读:
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
