多来买:仓储工作单、拆单与锁库存实战
多来买:仓储工作单、拆单与锁库存实战
本篇范围:支付成功推进、库存工作单、按仓分组、父子订单拆分、数据库行锁与锁定库存
事实来源:当前OrderServiceImpl、WareServiceImpl、WareSkuMapper.xml及 order/ware Feign Client
验证口径:代码已确认;未构建、未启动、未验证数据库事务与并发效果
本篇从订单已经支付开始,解释仓储服务怎样把一张业务订单转换为库存工作单,怎样在多仓情况下生成子订单,以及最终锁定的为什么是 stock_locked 而不是直接扣减实物库存。
本篇主链路
阅读目标
- 理解工作单与工作单明细为什么要独立于业务订单存在。
- 说明同一订单包含多个仓库 SKU 时怎样生成父子订单。
- 区分预查总库存与指定仓库最终锁库存。
- 说明
SELECT ... FOR UPDATE与stock_locked += num的配合关系。 - 识别事务代理自调用、重复通知和状态条件更新的当前边界。
第八步:支付成功推进订单
代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java
关键方法:successPay
OrderInfo orderInfo = new OrderInfo();orderInfo.setId(orderId);orderInfo.setOrderStatus(OrderStatus.PAID.name());orderInfo.setUpdateTime(new Date());orderInfoMapper.updateById(orderInfo);
wareApiClient.decreaseStock(orderId);状态先写 PAID,再同步调用仓储。这里没有:
- 条件
WHERE order_status = UNPAID; - 对更新行数的判断;
- 包含远程调用的可靠事件;
- 失败补偿或状态扫描。
如果仓储服务不可用,订单已经是 PAID。如果订单已处理但 Feign 响应丢失,上游重试又会再次调用仓储。
第九步:仓储创建库存工作单
代码路径:duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/service/impl/WareServiceImpl.java
关键方法:decreaseStock
OrderInfoDTO orderInfoDTO = orderApiClient .getOrderInfoDTOByOrderId(orderId);
WareOrderTask wareOrderTask = wareOrderTaskConverter .convertOrderInfoDTO(orderInfoDTO);
wareOrderTask.setTaskStatus( TaskStatus.PAID.name());
this.saveWareOrderTask(wareOrderTask);
List<WareOrderTask> orderTaskList = this.checkOrderSplit(wareOrderTask);
if (orderTaskList != null && orderTaskList.size() >= 2) { for (WareOrderTask task : orderTaskList) { this.lockStock(task); }} else { this.lockStock(wareOrderTask);}仓储先反向查询订单与明细,再转换成库存工作单。工作单把“哪张订单、哪些 SKU、每个 SKU 多少件、由哪个仓库处理”保存为仓储侧业务事实。
工作单模型
代码路径:duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/model/WareOrderTask.java
@Datapublic class WareOrderTask extends BaseEntity { private String orderId; private String taskStatus; private String wareId; private String taskComment;
@TableField(exist = false) private List<WareOrderTaskDetail> details;}明细模型保存 skuId、skuName、skuNum 和 taskId。
当前去重检查的真实边界
QueryWrapper<WareOrderTask> wrapper = new QueryWrapper<>();wrapper.in("order_id", wareOrderTask.getOrderId());
WareOrderTask origin = wareOrderTaskMapper.selectOne(wrapper);if (origin != null) { return origin;}
wareOrderTaskMapper.insert(wareOrderTask);// 继续插入工作单明细saveWareOrderTask 会查询已有工作单并返回,但 decreaseStock 忽略返回值,仍使用新转换的 wareOrderTask 继续拆单和 lockStock。所以这次查询不能阻止重复库存处理。
数据库是否有 order_id 唯一约束也未确认。即使主表插入被避免,后续重复锁库存风险仍存在。
第十步:判断是否按仓拆单
按仓库聚合 SKU
QueryWrapper<WareSku> queryWrapper = new QueryWrapper();queryWrapper.in("sku_id", skuIdlist);
List<WareSku> wareSkuList = wareSkuMapper.selectList(queryWrapper);
Map<String, List<String>> wareSkuMap = new HashMap<>();
for (WareSku wareSku : wareSkuList) { List<String> skuListOfWare = wareSkuMap.get(wareSku.getWarehouseId());
if (skuListOfWare == null) { skuListOfWare = new ArrayList<>(); }
skuListOfWare.add(wareSku.getSkuId()); wareSkuMap.put( wareSku.getWarehouseId(), skuListOfWare);}输出结构近似:
warehouse-1 -> [sku-29, sku-31]warehouse-2 -> [sku-30, sku-32]当前算法根据查询到的 ware_sku 记录直接分组,没有看到“从多个候选仓库中按距离、容量或优先级选择一个仓库”的过程。如果一个 SKU 在多个仓库都有记录,是否会被重复分组取决于实际数据约束,本轮无法确认。
单仓与多仓分支
if (wareSkuDTOList.size() == 1) { WareSkuDTO group = wareSkuDTOList.get(0); wareOrderTask.setWareId(group.getWareId()); return null;}
List<WareOrderTaskDTO> subTasks = orderApiClient.orderSplit( wareOrderTask.getOrderId(), wareSkuDTOList);- 只有一个仓库:给原工作单写
wareId,后续直接锁库存; - 两个及以上仓库:调用订单服务生成子订单和子工作单。
空列表没有显式分支,会进入多仓 else 并调用拆单,属于候选异常路径。
第十一步:订单服务生成子订单
代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java
关键方法:orderSplit
每个仓库复制一份子订单
for (WareSkuDTO wareGroup : wareSkuDTOList) {
String wareId = wareGroup.getWareId(); List<String> skuIds = wareGroup.getSkuIds();
OrderInfo subOrderInfo = orderInfoConverter .copyOrderInfo(originOrderInfoDTO);
subOrderInfo.setId(null); subOrderInfo.setParentOrderId( Long.valueOf(orderId));
subOrderInfo.setOutTradeNo( String.valueOf( hutoolIdUtil.getSnowflakeId()));只保留当前仓库的明细
List<OrderDetail> subOrderDetails = orderDetailList.stream() .filter(detail -> skuIds.contains( detail.getSkuId() .toString())) .map(detail -> { OrderDetail entity = orderDetailConverter .convertOrderDetailToDTO( detail); entity.setId(null); return entity; }) .collect(Collectors.toList());
subOrderInfo.setOrderDetailList( subOrderDetails);subOrderInfo.setTradeBody( subOrderDetails.get(0).getSkuName());subOrderInfo.sumTotalAmount();
saveOrderInfo(subOrderInfo);原订单改为已拆分
OrderInfo origin = new OrderInfo();origin.setId(Long.valueOf(orderId));origin.setOrderStatus(OrderStatus.SPLIT.name());orderInfoMapper.updateById(origin);此过程会写多个子订单、多个明细,再更新父订单。orderSplit 自身没有 @Transactional;它在同一个类中直接调用带事务注解的 saveOrderInfo,属于 Spring 代理自调用。默认代理事务下,内部调用不会再次经过代理,因此不能把整次拆单描述成一个完整原子事务。
第十二步:最终库存检查与行锁
代码路径:duolaimall-ware/ware-service/src/main/resources/mapper/WareSkuMapper.xml
指定仓库查询并加锁
SELECT stock - IFNULL(stock_locked, 0) AS available_stockFROM ware_skuWHERE sku_id = #{skuId} AND warehouse_id = #{warehouseId}FOR UPDATE此时不再聚合所有仓库,而是检查当前库存工作单指定仓库的 SKU 行。
增加锁定库存
UPDATE ware_skuSET stock_locked = IFNULL(stock_locked, 0) + #{stockLocked}WHERE sku_id = #{skuId} AND warehouse_id = #{warehouseId}SQL 没有执行 stock = stock - num,而是执行:
stock 保持不变stock_locked = stock_locked + 本次购买数量available = stock - stock_locked因此当前阶段更准确的名称是“锁定库存”。最终发货后怎样把锁定库存转成实际扣减、订单取消后怎样释放,当前代码没有形成闭环。
第十三步:lockStock 两阶段处理
代码路径:duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/service/impl/WareServiceImpl.java
关键方法:lockStock
第一轮:检查全部明细
String comment = "";
for (WareOrderTaskDetail detail : wareOrderTask.getDetails()) {
WareSku wareSku = new WareSku(); wareSku.setWarehouseId( wareOrderTask.getWareId()); wareSku.setStockLocked( detail.getSkuNum()); wareSku.setSkuId(detail.getSkuId());
int availableStock = wareSkuMapper .selectStockBySkuidForUpdate( wareSku);
if (availableStock - detail.getSkuNum() < 0) { comment += "减库存异常:" + detail.getSkuName(); }}第二轮:全部足够才更新
if (comment.length() > 0) { wareOrderTask.setTaskComment(comment); wareOrderTask.setTaskStatus( TaskStatus.OUT_OF_STOCK.name());
updateStatusWareOrderTaskByOrderId( wareOrderTask.getOrderId(), TaskStatus.OUT_OF_STOCK);} else { for (WareOrderTaskDetail detail : wareOrderTask.getDetails()) {
WareSku wareSku = new WareSku(); wareSku.setWarehouseId( wareOrderTask.getWareId()); wareSku.setStockLocked( detail.getSkuNum()); wareSku.setSkuId(detail.getSkuId());
wareSkuMapper.incrStockLocked( wareSku); }
wareOrderTask.setTaskStatus( TaskStatus.DEDUCTED.name());
updateStatusWareOrderTaskByOrderId( wareOrderTask.getOrderId(), TaskStatus.DEDUCTED);}两阶段的目标是避免第一条 SKU 已锁、第二条库存不足。但它依赖“所有 FOR UPDATE 锁一直保持到后续 UPDATE 和方法提交”。
事务代理自调用风险
lockStock 标有 @Transactional,但当前调用方式是:
// WareServiceImpl#decreaseStock 内部this.lockStock(orderTask);checkOrderSplit 也通过 this.checkOrderSplit(...) 调用,saveWareOrderTask 则是带 @Transactional 的 private 方法。当前仓库没有看到 AspectJ 事务模式或通过代理对象回调自身。
在 Spring 默认代理事务模型下:
- 同类内部调用不会穿过代理;
- private 方法也不能被代理;
@Transactional可能没有开启预期的新事务;FOR UPDATE是否覆盖整个检查与更新过程成为明确候选风险。
这是静态代码推导,未通过运行时事务日志验证,所以在状态口径上应写为“候选风险”,但不能忽略。
第十四步:库存结果回写订单
仓储处理完成后调用订单内部接口。订单按任务状态更新:
代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java
关键方法:successLockStock
OrderInfo orderInfo = new OrderInfo();orderInfo.setId(Long.parseLong(orderId));
if ("DEDUCTED".equals(taskStatus)) { orderInfo.setOrderStatus( OrderStatus.WAIT_DELEVER.name());} else { orderInfo.setOrderStatus( OrderStatus.STOCK_EXCEPTION.name());}
orderInfo.setUpdateTime(new Date());orderInfoMapper.updateById(orderInfo);当前只有 DEDUCTED 映射成待发货,其他任何字符串都映射成库存异常。没有校验订单当前必须是 PAID,也没有判断更新行数。
本篇限制与风险收束
successPay先把订单写成PAID,再同步调用仓储;远程失败不会回滚订单数据库。- 仓储工作单的查询去重与插入不是一个数据库唯一约束所保证的原子动作。
lockStock内部调用同类的checkStockDetail,可能绕过 Spring 事务代理,影响FOR UPDATE预期边界。- 多仓拆单会复制订单和明细;重试必须依赖业务唯一性或状态机,否则可能重复生成子单。
- 当前 SQL 增加的是锁定库存,项目没有在这条链路中完成释放锁定和最终实物扣减闭环。
关键源码导航
| 阅读顺序 | 文件 | 关键方法或类型 |
|---|---|---|
| 1 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java | successPay、orderSplit、successLockStock |
| 2 | duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/service/impl/WareServiceImpl.java | decreaseStock、lockStock、checkStockDetail |
| 3 | duolaimall-ware/ware-service/src/main/resources/mapper/WareSkuMapper.xml | 可用库存查询与行锁 SQL |
| 4 | duolaimall-ware/ware-api/src/main/java/com/cskaoyan/mall/ware/api/constant/TaskStatus.java | 工作单状态 |
| 5 | duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/model/WareOrderTask.java | 工作单 |
| 6 | duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/model/WareOrderTaskDetail.java | 工作单明细 |
4 条短复习点
- 支付成功后,订单状态先推进,仓储处理属于后续跨服务协作。
- 多仓订单先按仓分组,再由订单服务生成只保留对应明细的子订单。
FOR UPDATE保护的是最终检查窗口,更新的是stock_locked。- 工作单、拆单和状态回写都需要幂等,当前实现仍有重试窗口。
本章总结
支付成功后,仓储通过工作单、按仓分组、子订单和行锁检查组织库存处理;当前真正增加的是锁定库存,事务代理范围与重复调用防护仍需按候选风险说明。
下一篇:延迟关单与跨服务一致性实战。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!