管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:仓储工作单、拆单与锁库存实战

2292 字
11 分钟
多来买:仓储工作单、拆单与锁库存实战

多来买:仓储工作单、拆单与锁库存实战#

本篇范围:支付成功推进、库存工作单、按仓分组、父子订单拆分、数据库行锁与锁定库存
事实来源:当前 OrderServiceImplWareServiceImplWareSkuMapper.xml 及 order/ware Feign Client
验证口径:代码已确认;未构建、未启动、未验证数据库事务与并发效果

本篇从订单已经支付开始,解释仓储服务怎样把一张业务订单转换为库存工作单,怎样在多仓情况下生成子订单,以及最终锁定的为什么是 stock_locked 而不是直接扣减实物库存。

本篇主链路#

sequenceDiagram participant Pay as "支付成功入口" participant Order as "order-service" participant Ware as "ware-service" participant DB as "仓储数据库" participant OrderDB as "订单数据库" Pay->>Order: successPay(orderId) Order->>OrderDB: 状态改为 PAID Order->>Ware: decreaseStock(orderId) Ware->>Order: 查询订单与明细 Ware->>DB: 创建库存工作单 Ware->>DB: 按 SKU 查询可用仓库 alt 多仓 Ware->>Order: orderSplit(parentId, wareGroups) Order->>OrderDB: 创建子订单,父单改为 SPLIT Order-->>Ware: 返回子库存任务 end Ware->>DB: SELECT ... FOR UPDATE Ware->>DB: stock_locked += skuNum Ware->>Order: successLockStock(orderId, taskStatus) Order->>OrderDB: WAIT_DELEVER 或 STOCK_EXCEPTION

阅读目标#

  • 理解工作单与工作单明细为什么要独立于业务订单存在。
  • 说明同一订单包含多个仓库 SKU 时怎样生成父子订单。
  • 区分预查总库存与指定仓库最终锁库存。
  • 说明 SELECT ... FOR UPDATEstock_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

@Data
public class WareOrderTask extends BaseEntity {
private String orderId;
private String taskStatus;
private String wareId;
private String taskComment;
@TableField(exist = false)
private List<WareOrderTaskDetail> details;
}

明细模型保存 skuIdskuNameskuNumtaskId

当前去重检查的真实边界#

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_stock
FROM ware_sku
WHERE sku_id = #{skuId}
AND warehouse_id = #{warehouseId}
FOR UPDATE

此时不再聚合所有仓库,而是检查当前库存工作单指定仓库的 SKU 行。

增加锁定库存#

UPDATE ware_sku
SET 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 增加的是锁定库存,项目没有在这条链路中完成释放锁定和最终实物扣减闭环。

关键源码导航#

阅读顺序文件关键方法或类型
1duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.javasuccessPayorderSplitsuccessLockStock
2duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/service/impl/WareServiceImpl.javadecreaseStocklockStockcheckStockDetail
3duolaimall-ware/ware-service/src/main/resources/mapper/WareSkuMapper.xml可用库存查询与行锁 SQL
4duolaimall-ware/ware-api/src/main/java/com/cskaoyan/mall/ware/api/constant/TaskStatus.java工作单状态
5duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/model/WareOrderTask.java工作单
6duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/model/WareOrderTaskDetail.java工作单明细

4 条短复习点#

  1. 支付成功后,订单状态先推进,仓储处理属于后续跨服务协作。
  2. 多仓订单先按仓分组,再由订单服务生成只保留对应明细的子订单。
  3. FOR UPDATE 保护的是最终检查窗口,更新的是 stock_locked
  4. 工作单、拆单和状态回写都需要幂等,当前实现仍有重试窗口。

本章总结#

支付成功后,仓储通过工作单、按仓分组、子订单和行锁检查组织库存处理;当前真正增加的是锁定库存,事务代理范围与重复调用防护仍需按候选风险说明。

下一篇:延迟关单与跨服务一致性实战

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:仓储工作单、拆单与锁库存实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-ware-split-stock-lock/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录