供应链系统里,订单 和库存是出事最多的两个模块。
渠道一多,订单从四面八方推过来:小程序商城、电商平台、人工抄单、业务员代下、一件代发......重复推送、乱序到达、超时重发是常态;库存这边,多仓、预占、代发共用同一批货,一不留神就超卖。
这篇把这两块拆开讲透:业务场景 → 订单幂等设计 → 库存一致性四层方案 → 可复用代码 → 五个真实坑。
一、业务场景与四个典型痛点
先说清楚问题长什么样,方案才有意义。
痛点一:订单重复入库。 渠道侧网络抖动、未收到响应就重试,同一笔单推三次,系统生成三条订单。
痛点二:库存超卖。 两个渠道同时下单同一 SKU,各自读到 available = 10,都扣成功,结果卖了 20 件。
痛点三:预占不释放。 用户下单锁了库存但没付款,超时未释放,货被"锁死",实际库存变少。
痛点四:多仓分配不合理。 多个仓库都有货,系统拆单逻辑粗糙,明明一个仓能发全,硬拆成两单,运费翻倍。
这四个问题的本质是两类:幂等 (同一请求重复到达怎么办)和一致性(并发下如何保证不超卖)。
二、多渠道订单接入与幂等设计
1. 统一订单模型 + 渠道适配器
不要为每个渠道建一套订单表。正确做法是:外部订单先落到"渠道订单原始表",再由适配器转换成统一的内部订单模型。
| 层次 | 存什么 | 说明 |
|---|---|---|
| 原始层 | 渠道推送的原始报文 | 保留证据,便于排查与重放 |
| 适配层 | 渠道字段 → 内部字段的映射 | 每个渠道一个适配器实现 |
| 业务层 | 统一的订单、明细、支付、发货 | 领域模型只认这一层 |
public interface ChannelOrderAdapter {
String channelCode();
// 把渠道原始报文转换为统一的内部订单
InternalOrder convert(ChannelRawPayload payload);
}
接新渠道时只新增一个 Adapter 实现,不改动订单领域代码------这一点非常重要,否则订单服务会随渠道数量线性膨胀。
2. 幂等:三层防线
| 层次 | 做法 | 作用 |
|---|---|---|
| 第一层 | 渠道订单号唯一索引 | 数据库兜底,重复插入直接报错 |
| 第二层 | Redis 幂等键 SETNX | 挡掉绝大部分并发重复请求 |
| 第三层 | 状态机校验 | 已发货订单不允许再被"新订单"覆盖 |
// 第一、二层配合:Redis 快速拦截 + 数据库唯一索引兜底
public String receiveOrder(ChannelRawPayload payload) {
String idemKey = "order:idem:" + payload.getChannel() + ":" + payload.getChannelOrderNo();
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(idemKey, "1", Duration.ofHours(48));
if (Boolean.FALSE.equals(first)) {
return existingOrderNo(payload.getChannel(), payload.getChannelOrderNo());
}
try {
return orderService.create(payload); // 表上建有 (channel, channel_order_no) 唯一索引
} catch (DuplicateKeyException e) {
// 极少量并发下 Redis 失效的场景,由数据库唯一索引兜住,返回已有订单号
return existingOrderNo(payload.getChannel(), payload.getChannelOrderNo());
}
}
关键点:Redis 是加速层,数据库唯一索引才是最终保证。 只做前者,Redis 故障时会立刻批量重复下单。
三、分布式库存一致性:四层方案
这是我踩坑最多的地方。按一致性强度从弱到强,有四种做法,要按业务场景选,不要一刀切。
| 方案 | 一致性 | 性能 | 适用场景 |
|---|---|---|---|
| Redis 分布式锁 | 强 | 中 | 并发量不高、强调简单可靠 |
| 数据库乐观锁 CAS | 强 | 高 | 主流方案,推荐 |
| 预占 + 超时释放 | 最终一致 | 高 | 下单到支付之间有时延的业务 |
| 消息补偿 + 对账 | 最终一致 | 高 | 跨系统、跨服务的最终一致 |
1. 为什么不推荐"查一次再扣一次"
// ❌ 反例:先查再扣,中间有窗口,必然超卖
Stock s = stockMapper.selectById(skuId);
if (s.getAvailable() >= qty) {
stockMapper.update(new Stock(skuId, s.getAvailable() - qty));
}
这段代码在单机低并发下看似正常,一旦并发上来就会超卖。问题在于"判断"和"扣减"分成了两步,中间是不受保护的窗口。
2. 乐观锁 CAS:把判断和扣减合并成一条 SQL
-- ✅ 库存充足才更新成功,用返回的受影响行数判断是否抢到
UPDATE stock
SET available = available - #{qty},
frozen = frozen + #{qty},
version = version + 1
WHERE sku_id = #{skuId}
AND version = #{version}
AND available >= #{qty};
int affected = stockMapper.tryFreeze(skuId, qty, version);
if (affected == 0) {
// 没抢到:可能是并发冲突,也可能是库存不足,重试一次后按库存不足处理
throw new StockNotEnoughException(skuId);
}
在 available >= #{qty} 条件下做自减,判断与扣减在同一条原子语句里完成,这是防超卖最可靠的做法。
3. 预占 + 超时释放
// 下单:预占库存(available → frozen)
// 支付成功:冻结转扣减(frozen 减少)
// 超时未支付:定时任务释放预占(frozen → available)
@Scheduled(cron = "0 */5 * * * ?")
public void releaseExpiredHolds() {
List<HoldRecord> expired = holdMapper.listExpired(30); // 30 分钟未支付
for (HoldRecord r : expired) {
stockMapper.release(r.getSkuId(), r.getQty(), r.getVersion());
}
}
提醒:释放任务必须幂等。 多次执行同一笔释放,不能把库存加两次------用状态位 status = RELEASED 加乐观锁控制。
4. 跨系统最终一致:本地消息表
订单库扣库存成功、但要通知 WMS 时,不要用同步调用:
1) 业务事务内,往本地消息表插一条"库存已扣减"消息(与业务同事务,保证不丢)
2) 事务提交后,异步投递到 MQ
3) WMS 消费成功,回写消息状态
4) 定时任务扫描"长时间未确认"的消息,重发(保证至少一次)
5) 消费端做幂等,保证"至少一次"不导致重复扣减
四、两个容易写错的细节
1. 拆单与多仓分配
多仓有货时,优先级建议:整单可发 > 单一仓发全 > 按仓就近拆单。业务上宁愿少拆一次单,也不要为了"库存利用率"多出一笔运费。
2. 库存扣减与取消订单的顺序
取消订单时,先判断订单状态,再释放库存,最后写流水,三步在同一事务内。顺序错了会出现"库存释放了但订单还在待发货"的脏数据。
五、我们踩过的五个真实坑
坑一:只在 Redis 锁,数据库没约束。 缓存节点重启,锁失效,瞬间连环超卖。数据库层必须有 available >= qty 的兜底条件。
坑二:释放库存任务不幂等。 任务重跑一次,库存凭空多出来,账实永远对不上。所有补偿类任务都要幂等。
坑三:幂等键用了"渠道 + 时间戳"。 同一个渠道同一秒多笔订单,直接互相顶掉。幂等键必须取渠道侧的订单号,不能自造。
坑四:账本表流水只记结果不记过程。 出问题时无法回溯到底哪一步错了。库存流水要记录变更前值、变更后值和来源单据。
坑五:压测只压了下单,没压取消。 上线后集中在"下单+取消"组合场景出问题。压测要覆盖完整生命周期,而不是单个接口。
六、小结
围绕订单和库存,记住三句话就够用:
- 幂等:Redis 挡前面,数据库唯一索引兜底,两者缺一不可;
- 防超卖:判断与扣减必须在同一条原子 SQL 里完成;
- 最终一致:本地消息表 + 至少一次投递 + 消费端幂等。
这三条是供应链系统稳定的地基,其他复杂度都建立在这之上。
文中代码为业务场景下的通用实现思路,实际落地需结合自身并发量与数据量调整。技术问题欢迎在评论区交流。