供应链系统实战:多渠道订单聚合与分布式库存一致性方案总结

供应链系统里,订单 和库存是出事最多的两个模块。

渠道一多,订单从四面八方推过来:小程序商城、电商平台、人工抄单、业务员代下、一件代发......重复推送、乱序到达、超时重发是常态;库存这边,多仓、预占、代发共用同一批货,一不留神就超卖。

这篇把这两块拆开讲透:业务场景 → 订单幂等设计 → 库存一致性四层方案 → 可复用代码 → 五个真实坑。


一、业务场景与四个典型痛点

先说清楚问题长什么样,方案才有意义。

痛点一:订单重复入库。 渠道侧网络抖动、未收到响应就重试,同一笔单推三次,系统生成三条订单。

痛点二:库存超卖。 两个渠道同时下单同一 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 的兜底条件。

坑二:释放库存任务不幂等。 任务重跑一次,库存凭空多出来,账实永远对不上。所有补偿类任务都要幂等。

坑三:幂等键用了"渠道 + 时间戳"。 同一个渠道同一秒多笔订单,直接互相顶掉。幂等键必须取渠道侧的订单号,不能自造。

坑四:账本表流水只记结果不记过程。 出问题时无法回溯到底哪一步错了。库存流水要记录变更前值、变更后值和来源单据。

坑五:压测只压了下单,没压取消。 上线后集中在"下单+取消"组合场景出问题。压测要覆盖完整生命周期,而不是单个接口。


六、小结

围绕订单和库存,记住三句话就够用:

  1. 幂等:Redis 挡前面,数据库唯一索引兜底,两者缺一不可;
  2. 防超卖:判断与扣减必须在同一条原子 SQL 里完成;
  3. 最终一致:本地消息表 + 至少一次投递 + 消费端幂等。

这三条是供应链系统稳定的地基,其他复杂度都建立在这之上。

文中代码为业务场景下的通用实现思路,实际落地需结合自身并发量与数据量调整。技术问题欢迎在评论区交流。

相关推荐
学逆向的1 小时前
C 数据溢出与精度丢失
c语言·开发语言·网络安全
她说可以呀2 小时前
Docker 镜像的两种创建方式
java·分布式·docker
霸道流氓气质2 小时前
AgentFlow 入门与实战:Task-Based 并发、EWMA 动态调度与四道门禁的 Java 生产级编排
java·开发语言
LZ_6662 小时前
php代码执行3(难)
php
释厄6232 小时前
错误无误公理——错误正确本同源都迭代定律——逆转有价值
android·开发语言·kotlin
2601_962885723 小时前
如何用 Python计算 CR能量指标(中间意愿指标)?
开发语言·python
临床数据科学和人工智能兴趣组3 小时前
在R语言的生态系统中,字符串处理一直是一个重要且复杂的任务
开发语言·正则表达式·r语言·r语言-4.2.1
蜗牛互联网3 小时前
Python调用Gemini Structured Outputs实现工单路由门禁
java·开发语言·人工智能·python
SEO_juper3 小时前
给网页做可引用性评分:判断内容够不够让 AI 引用(GEO 工具,附代码)
开发语言·人工智能·爬虫·python·seo·独立站