下单扣库存,要把事务边界放在哪里
在很多后端项目里,“下单扣库存”几乎是最常见的场景之一。它看起来简单,但只要一进入并发、异常、重试,这个小流程就会立刻暴露出事务边界、库存一致性、幂等性这些核心问题。我们这个项目正好用一个很小的下单模型,把这些点串了起来。
核心流程其实就两步:先扣库存,再落订单。项目里 OrderApplicationService.placeOrder() 把这个流程放在一个本地事务里:
@Transactional public CreateOrderResponse placeOrder(CreateOrderRequest request) { inventoryService.reserve(request.skuCode(), request.quantity()); long orderId = orderRepository.save( request.orderNo(), request.skuCode(), request.quantity(), calculateAmount(request.quantity()), OrderStatus.CREATED.name() ); return new CreateOrderResponse(orderId, request.orderNo(), OrderStatus.CREATED.name(), ...); }这里最重要的不是“代码看起来整齐”,而是它表达了一个明确的业务承诺:要么库存和订单一起成功,要么一起失败。如果库存扣了,订单没落进去,系统状态就会不一致;如果订单落了,库存没扣成功,同样会出错。所以事务边界不能随便放,通常要包住“业务原子性”最强的那一层,也就是应用服务层。
库存扣减本身也不是简单的“先查再减”。项目里 InventoryService.reserve() 只关心结果,不关心过程:
public void reserve(String skuCode, int quantity) { int updatedRows = inventoryRepository.deductAvailableStock(skuCode, quantity); if (updatedRows == 0) { throw new IllegalStateException("Stock is not enough"); } }真正的关键在仓储层的条件更新:
update inventory_item set available = available - ? where sku_code = ? and available >= ?这行 SQL 的价值在于,它把“判断库存够不够”和“扣减库存”合成了一次原子操作。这样就避免了经典的先查后改竞态:两个请求如果都先查到库存充足,再分别去扣,就可能超卖。条件更新的方式,能把并发窗口压到最小,数据库直接帮你做一致性判断。
这类写法特别适合解释成一句话:不是先判断再修改,而是把判断写进更新条件里,让数据库替你守住并发边界。
不过事务不是只会“包起来”就完事,真正容易踩坑的是异常和代理机制。项目里的 TransactionInterviewService 很适合拿来讲这两个坑。
第一个坑是:RuntimeException 默认回滚,checked exception 默认不回滚。代码里有两组对比:
@Transactional public void createAndFailWithRuntimeException(String orderNo) { insertOrder(orderNo); throw new IllegalStateException("Runtime exception should trigger rollback"); }@Transactional public void createAndFailWithCheckedException(String orderNo) throws Exception { insertOrder(orderNo); throw new Exception("Checked exception does not rollback by default"); }这两个方法非常适合用来讲“事务为什么没有生效”。很多人以为只要加了 @Transactional 就一定会回滚,其实不是。Spring 默认只对运行时异常触发回滚,如果是受检异常,要么显式配置 rollbackFor = Exception.class,要么自己把异常体系设计好。
第二个坑是 self-invocation,也就是类内部自己调用自己的事务方法。项目里这个方法正好能演示:
public void callTransactionalMethodInternally(String orderNo) { try { this.internalTransactionalMethod(orderNo); } catch (IllegalStateException ignored) { // The test checks persisted rows to prove that self-invocation bypasses the proxy. } }而被调用的方法上虽然也标了事务:
@Transactional public void internalTransactionalMethod(String orderNo) { insertOrder(orderNo); throw new IllegalStateException("Self invocation bypasses transactional proxy"); }问题在于,Spring 事务依赖代理,如果你在同一个类里 this.xxx() 直接调用,代理层会被绕过,事务切面可能根本没机会介入。这个坑在真实项目里都很常见,属于“看起来加了注解,实际上没生效”。
如果把这套内容整理成文字表达:下单流程拆成应用服务、库存扣减和订单持久化三层。应用服务负责事务边界,库存层用条件更新防止超卖,事务演示里再补充运行时异常回滚、受检异常默认不回滚、以及 self-invocation 失效这几个关键点。
Checklist
- 事务边界放在真正需要原子性的业务层
- 库存扣减用条件更新,不要先查后改
- 关键失败路径要明确抛出异常
- 区分运行时异常和受检异常的回滚规则
- 注意同类内部调用会绕过事务代理
