Series Article

Day04 · WMS 仓储管理系统完整面试指南

一、系统介绍

面试官:介绍一下你负责的 WMS 系统。

我负责的是 WMS 仓储管理系统,这个系统主要解决多仓库、多库位的库存精细化管理问题。

具体来说,我们是一个跨境电商供应链 SaaS 平台,商家在 Amazon、Shopify、eBay 都开店,但仓库可能有国内仓、FBA 仓、海外仓。我们的系统需要管理所有仓库的库存,支持采购入库、销售出库、仓库调拨、库存盘点等全流程业务。

这个系统在整个供应链中的位置是中游。上游是 PMS 采购系统,采购的货到仓后由我们负责收货入库。下游是 OMS 订单系统,订单创建时需要冻结我们的库存,订单发货时需要我们出库。

日均处理出入库单 5 万+,库存流水 20 万+,数据库存储了 500 万+ SKU 的库存数据。峰值 QPS 在大促期间可以达到 3000+,主要是订单创建时的库存冻结操作。


二、核心业务流程

2.1 采购入库完整流程

采购入库是 WMS 最重要的业务流程之一,涉及质检、库位分配、库存更新、成本核算。

前置条件

  • PMS 采购系统已创建采购单
  • 供应商已发货,货物到达仓库
  • 采购单状态为”待收货”

业务流程

货物到达仓库

  • 操作人:仓管员
  • 操作内容:供应商送货到仓库,仓管员接收货物
  • 系统动作:供应商在发货时填写快递单号,系统集成第三方物流信息,可以追踪物流状态

创建入库单

  • 操作人:仓管员
  • 操作内容:扫描送货单,查询对应的采购单
  • 系统动作:创建入库单,inbound_type=1(采购入库),关联采购单号
  • 实现方式:仓管在手机/PAD 上登录,点击”创建入库单”,系统查询待收货的采购单列表,仓管选择对应的采购单,系统自动填充入库单信息
    • 我们可以在给供应商提供接口的时候 单独提供一个生成二维码的接口 采购单接口 —> 包含二维码 (内部 就是采购单的ID 基础信息)
    • 当货物到达仓库的时候 仓管人员点击创建入库单 然后会有一个扫描二维码的按钮 点击扫描二维码 就可以自动生成一个入库单
    • 入库单的信息 很多 都可以从采购单中去拷贝过来 需要仓管人员进行确认

质量检验(关键环节)

  • 操作人:质检员
  • 操作内容:外观检查 + 功能抽检,对比采购单规格(SKU)
  • 系统动作:记录质检结果
  • 分支处理:
    • 全部合格:继续下一步
    • 部分合格:分别记录合格数和不良品数,不良品移至 X 区隔离
    • 全部不合格:拒收,创建采购退货单,通知采购部
  • 实现方式:仓管在手机/PAD 上按照质检表单逐项填写,统一规范质检步骤,避免遗漏。如果出现异常,拍照上传,提交后通知采购专员,采购专员联系仓管协商处理

分配库位

  • 操作人:仓管员
  • 操作内容:选择目标库位(系统推荐,可手动调整)
  • 系统动作:查询空闲库位,按 ABC 分类推荐合适区域
    • A 类商品(高价值/销量靠前):推荐靠近出货口的 A 区
    • C 类商品(低价值/销量靠后):推荐远离出货口的 C 区
  • 实现方式:系统优先查找是否已有相同 SKU,如果有则推荐相邻库位,如果没有则推荐最近的空闲库位。仓管可以接受推荐或手动选择其他库位
    • 如果已经有相同的SKU 系统就直接推荐SKU的库位 这个库位可能还有空间 也有可能这个库位没有空间 如果没有空间了 让仓管自己决定放到哪个库位 然后在系统中进行录入
    • 如果当前仓库没有这个SKU 选择一个空的库位 如果有很多个空库位 优先选择 最靠前的(排—>列—>层)的空库位 如果没有空库位 就有仓管人员自己决定放到哪个库位 然后在系统中进行录入

货物上架

  • 操作人:仓管员
  • 操作内容:搬运货物到指定库位,扫描库位条码确认
  • 系统动作:无

确认入库(最关键的技术环节)

  • 操作人:仓管员
  • 操作内容:点击”确认入库”按钮
  • 系统动作(事务,原子操作):
    • 更新库存:inventory.quantity += 合格数量,如果存在不合格商品,同步修改 defective_qty += 不合格数量
    • 写入库存流水:log_type=1(采购入库),记录变动前后数量、操作人、操作时间
    • 更新库位状态:warehouse_location.is_occupied=1
    • 更新加权平均成本:avg_cost = (原库存×原成本 + 新入库×新成本) / 总库存
    • 更新入库单状态:inbound_order.status=2(已完成)
    • 通知 PMS 更新采购单状态:使用 RocketMQ 事务消息异步通知,保证最终一致性(入库成功后必定通知到 PMS,但允许短暂延迟)
      • 本地事务 和 MQ消息的一致性
        • 事务消息
        • 监听事务的提交事件 commitAfter 如果本地事务提交成功后 然后再去发送MQ消息(消息重试 2次) 消息转存 消息补偿
        • 本身事务 + 本地消息表 (新增)

关键技术点

  • 所有库存变动在 @Transactional 事务中执行,任何一步失败都回滚
  • 库存流水表只增不改不删,保证可追溯
  • 加权平均成本动态计算,用于后续利润分析
  • 使用数据库原子操作:quantity = quantity + N

异常处理

  • 质检不合格:不良品移至 X 区隔离,defective_qty 增加,不计入可售库存
  • 库位已满:系统提示,仓管员重新选择库位
  • 数据库异常:事务回滚,记录错误日志,告警通知

面试表达

“我们的采购入库流程分 6 个步骤。货物到达后,先质检,质检合格的分配库位上架,然后确认入库。确认入库这一步是关键,要在事务中同时更新库存、写流水、更新成本,保证数据一致性。如果质检不合格,不良品会单独隔离,不计入可售库存。“

2.2 销售出库完整流程

销售出库涉及库存扣减、FIFO 策略、拣货路径优化,是第二重要的流程。

前置条件

  • OMS 订单系统已创建订单,并通过 Feign + Seata 冻结了库存(frozen_qty 已增加)
    • OMS系统需要每 5 分钟定时从电商平台拉取订单,或者通过 Webhook 让电商平台及时推送
    • OMS(新增一个订单/订单详情) + WMS(对SKU进行库存的冻结)
  • 订单状态为”待发货”

业务流程

OMS 推送发货需求

  • 触发方:OMS 订单系统
  • 推送内容:订单号、SKU、数量、目标仓库
  • 推送方式:使用 MQ 发送消息(当OMS同步电商平台的订单后,生成订单信息后并且冻结了库存后, 可以给OMS推送一个MQ消息, 要求OMS进行出库 )
  • 不推荐 Feign 同步调用:库存操作比较繁琐,时效性要求不高
  • 系统动作:WMS 接收发货需求

当OMS系统收到出库的MQ消息后 系统需要在MQ的消费逻辑中做的事情:

  1. 创建出库单, 需要的数据 都在MQ的消息体中可以进行获取, 如果没有 也可以查询数据库拼凑出来
  2. 给仓管人员生成一个拣货路径 首先针对同一个SKU要先满足FIFO 如果该订单有多个SKU 还要满足最优路径
  3. 消费者逻辑执行完成后 通知仓管人员进行拣货 —> 显示拣货单 —> 把规划好的路径展示出来 需要拿的SKU以及数量
    1. 仓管人员 拿一个SKU 就勾选一个完成 对应拣货单的状态
    2. 直到所有的SKU都拿完了 去打包

WMS 创建出库单

  • 系统动作:创建出库单,outbound_type=1(销售出库),关联订单号
  • 出库单状态:待分配库位

FIFO 分配库位(关键技术点)

  • 系统动作:查询该 SKU 在当前仓库所有库位的库存分布
  • 分配算法:
    • last_inbound_time 升序排列(最早入库的排前面)
    • 按顺序分配拣货数量,直到满足出库需求
    • 同一天入库的,按库位物理位置排序(zone → row_no → column_no → floor_no)
  • 输出:拣货指令列表(哪个库位拣多少件)

举例说明 FIFO 分配:

假设买家买了多个商品:商品 A 100 个、商品 B 1 个、商品 C 10 个。

  • 查询商品 A 的库位,按入库时间升序排列(先入库的排最上面)
  • 拿到最上面的库位,判断库存是否 >= 100
  • 如果没有 100 个,从第二个库位再拿,拼够 100 个
  • 按照同样的流程查询商品 B 和商品 C
  • 当所有商品都找到具体库位和每个库位应该拿多少个后,开始排序
  • 按照仓库的物理顺序排序:区 → 排 → 列 → 层
  • 把排好序的信息存储起来,生成拣货单

生成拣货单

  • 系统动作:生成拣货单,推送给仓管员(移动端 App 或打印纸质单)
  • 拣货单内容:按优化后的路径排序,告诉仓管员从哪个库位拣多少件

仓管员拣货

  • 操作人:仓管员
  • 操作内容:按拣货单逐库位拣货,扫描商品条码验证
  • 系统动作:
    • 扫描条码匹配:更新拣货状态 pick_status=1(已拣)
    • 扫描条码不匹配:提示错误,防止拣错货
  • 扫码逻辑:扫码信息包含商品的 SKU 信息和库位信息,扫码后去匹配拣货单中的 item,如果不匹配说明拿错货了,如果匹配则在手机/PAD 上对拣货单的 item 打上标记

复核

  • 操作人:复核员
  • 操作内容:再次核对 SKU、数量、订单信息
  • 系统动作:复核通过后,进入打包环节

打包

  • 操作人:打包员
  • 操作内容:打包完成,贴物流面单
  • 系统动作:无

确认出库(最关键的技术环节)

  • 操作人:仓管员
  • 操作内容:点击”确认出库”按钮
  • 系统动作(事务,原子操作):
    • 扣减库存:quantity = quantity - N, frozen_qty = frozen_qty - N(同时扣减实物和冻结)
    • 写入库存流水:log_type=2(销售出库),记录变动前后数量、操作人、操作时间
    • 更新出库单状态:outbound_order.status=4(已出库)
    • 更新库位状态:如果库位库存为 0,更新 is_occupied=0
  • 事务提交后:通过 MQ 通知 TMS 创建运单,通过 MQ 通知 OMS 更新订单状态
    • 当仓库拣货完成后 需要给TMS也就是物流系统 发送一个通知 让物流系统根据收货人信息 自动选择最优的物流渠道 然后返回这个物流运单
    • 同时给OMS也就是 订单系统发送一个通知 让其修改订单状态为 已发货 —> 同步到电商平台

关键技术点

  • FIFO 保证先进先出,避免货物过期和积压
  • 拣货路径优化,减少仓管员行走距离
  • 所有库存变动在事务中执行,保证数据一致性
  • 使用数据库原子操作:quantity = quantity - N

异常处理

  • 库存不足:提示错误,无法出库
  • 拣货错误:扫码不匹配,提示重新拣货
  • 数据库异常:事务回滚,记录错误日志

面试表达

“销售出库的关键是 FIFO 分配库位和库存扣减。我们按入库时间升序排列,最早入库的优先出库,避免货物过期。同时优化拣货路径,按库位物理位置排序,减少仓管员行走距离。确认出库时,在事务中同时扣减实物库存和冻结库存,保证数据一致性。“

2.3 库存盘点流程

库存盘点用于发现和纠正库存差异,我们采用循环盘点模式。

为什么需要盘点

系统库存和实物库存会有差异:

  • 入库时漏扫码
  • 出库时多拣了货
  • 货物损坏没报损
  • 内部拿了样品没记录

循环盘点 vs 全盘

传统全盘的问题:

  • 要停止运营 2-3 天
  • 影响发货
  • 一年只能盘一次

循环盘点的好处:

  • 不停运营
  • 按 ABC 分类,高价值商品盘得更频繁
  • 全年持续盘点

ABC 分类标准:

  • A 类(销售额前 20%):每月盘一次
  • B 类(销售额 20%-50%):每季度盘一次
  • C 类(销售额后 50%):每半年盘一次

ABC分类 是有 BI 系统定期更新 把结果直接绑定到SKU表中的分类字段中

技术: 创建3个定时任务 分别是每月针对A类商品生成一个盘点单 每季度B类商品的盘点单 每半年生成一个C类商品的盘点单

盘点流程

  • 定时任务自动生成盘点计划(按照分类 每月/季度/半年,使用 XXL-Job)
  • 系统记录账面库存(book_qty)通过采购信息和订单信息 统计当前库存应该还剩余多少
  • 仓管员实际清点,填实盘数量(actual_qty)
  • 系统计算差异:diff_qty = actual_qty - book_qty
  • 有差异必须填原因
  • 管理员审核
  • 执行库存调整,写流水

差异调整的实现

盘点结束后,生成盘点记录,系统自动计算账面数量和实际数量是否一致。如果不一致,需要把差异数据更新到账面数量上。

@Transactional(rollbackFor = Exception.class)
public void auditStocktake(Long taskId) {
    // 查所有有差异的
    List<StocktakeItem> items = stocktakeItemMapper.selectList(
        new LambdaQueryWrapper<StocktakeItem>()
            .eq(StocktakeItem::getTaskId, taskId)
            .ne(StocktakeItem::getDiffQty, 0)
    );
    
    for (StocktakeItem item : items) {
        // 调整库存
        inventoryMapper.update(null,
            new LambdaUpdateWrapper<Inventory>()
                .eq(Inventory::getSkuId, item.getSkuId())
                .setSql("quantity = quantity + " + item.getDiffQty())
        );
        
        // 写流水
        InventoryLog log = new InventoryLog();
        log.setLogType(item.getDiffQty() > 0 ? 5 : 6);  // 5=盘盈 6=盘亏
        log.setChangeQty(item.getDiffQty());
        inventoryLogMapper.insert(log);
    }
}

面试表达

“我们采用循环盘点模式,不需要停止运营。定时任务每天晚上自动生成盘点计划,按 ABC 分类设置不同频率。仓管员实际清点后,系统自动计算差异,如果有差异必须填写原因。管理员审核通过后,系统在事务中执行库存调整,同时写入流水记录。“


三、技术亮点

面试官:这个系统有什么技术亮点?

这个系统有三个技术亮点:库存扣减的并发控制、库存流水的不可篡改设计、FIFO 出库策略和拣货路径优化。

3.1 库存扣减的并发控制

问题背景

多个平台的订单并发到达时,如果直接扣减 quantity,可能导致库存变负数。或者订单取消时需要频繁加减 quantity,容易出现数据不一致。

什么情况下会出现 库存 变成 负数 ??

当租户运营多个电商平台 多个店铺 而且库存没有进行统一管理 而是随意去填写的库存值

当买家进行下单的时候 电商平台只会盘点 该SKU在电商平台中的库存 并不会去查询/判断 SaaS的真实库存 所以还是有可能会出现 电商平台的库存很多 但是真实的库存已经没了

怎么解决 ?? 不能随意的设置电商平台的库存 而是应该有SaaS进行统一管理库存 每个电商平台分配多少个库存 可以交由租户自己进行分配 或者 有SaaS平台智能分配 但是最终都要确保一点 所有电商平台的所有店铺的总的库存 应该等于 实际的库存数量

解决方案

设计了冻结库存机制 + 数据库原子操作。

这个解决方案 只能解决 OMS 和 WMS 之间的数据一致性

不能解决 WMS 和 电商平台库存的一致性

具体实现

  • 订单创建时先冻结库存(frozen_qty),不动实物库存(quantity)。这样 quantity 始终反映仓库实际库存,frozen_qty 反映已分配给订单的库存
  • 拣货出库时同时扣减实物和冻结库存:quantity = quantity - N, frozen_qty = frozen_qty - N。因为货真的出去了,实物减少,预占也要释放
  • 订单取消时只释放冻结库存:frozen_qty = frozen_qty - N。因为实物一直在仓库,从来没动过
  • 使用 MyBatis-Plus 的 setSql() 实现原子操作:frozen_qty = frozen_qty + N,在 WHERE 条件中判断可售库存是否充足:quantity - frozen_qty - defective_qty - reserved_qty >= N。如果条件不满足,updated 返回 0,直接抛异常。整个在事务里,失败就回滚

如果这里进行了回滚 后期对账的时候 会出现一个问题 电商平台有订单 但是SaaS中没有订单 如果当前库存不足 我们允许出现 负值 当新收到货物的时候 要及时发货 要进行入库操作的时候 不能使用 set 库存=合格数量 应该用 set 库存=库存+合格数量

冻结库存 和 真实库存 同时存在的意义是什么 ??

在给租户进行库存展示的时候 可以更细致化 展示出有多少是正在出库的 有多少是实际库存 有多少是已经出库正在途中 …

可用库存 = 实际库存 - 冻结库存

在面试的时候 这个业务点 重点强调的是 冻结库存和实物库存的操作 不要提超卖的情况 如果面试官问起来超卖怎么办 ?? 超卖是在电商平台会出现的情况 用户是跟电商平台进行交互的 和SaaS是没有任何交互 在SaaS平台我们只是给租户进行库存的管理 如果租户按照SaaS平台的建议去进行库存的配置 是不会出现超卖的

尽量不要出现 超卖的情况

  1. 合理的库存分配 (
    1. 使用SaaS自动同步电商库存
    2. 如果租户手动录入电商库存 我们要配合定时任务 去检查 电商库存是否和SaaS库存的一致性
      1. 反复提醒运营人员 要么及时补货 要么去修改库存 否则可能会出现超卖
  2. 提前补货 不要等到没有货了 再补货

实现代码

@Transactional(rollbackFor = Exception.class)
public void freezeInventory(Long skuId, Long warehouseId, Integer qty) {
    int updated = inventoryMapper.update(null, 
        new LambdaQueryWrapper<Inventory>()
            .eq(Inventory::getSkuId, skuId)
            .eq(Inventory::getWarehouseId, warehouseId)
            // WHERE 条件判断可售库存
            .apply("quantity - frozen_qty - defective_qty - reserved_qty >= {0}", qty)
            // 原子操作
            .setSql("frozen_qty = frozen_qty + " + qty)
    );
    
    if (updated == 0) {
        throw new BusinessException("库存不足");
    }
}

效果

quantity 始终反映仓库实际库存,frozen_qty 反映已分配给订单的库存,库存状态清晰可见。配合订单超时自动取消机制(未支付 30 分钟取消,已支付 72 小时未发货取消),冻结库存不会一直占着。

面试表达

“我们使用CAS来解决实际库存和冻结库存的一致性问题。 订单创建时只冻结库存,不动实物,出库时同时扣减实物和冻结,订单取消时只释放冻结。 用数据库原子操作 + WHERE 条件判断,不需要悲观锁,性能更好。“

3.2 库存流水的不可篡改设计

问题背景

库存数据出现差异时,无法追溯是谁在什么时候改的。财务对账需要出入库明细,但如果流水可以被修改,就失去了审计价值。

解决方案

设计了只增不改不删的库存流水表。

具体实现

  • 应用层代码永远不写 UPDATE/DELETE 逻辑,只有 INSERT。每次库存变动都写入流水表,记录变动前、变动后、变动数量、操作人、操作时间、关联单号
  • 数据库账号只给 INSERT 和 SELECT 权限,没有 UPDATE/DELETE 权限。即使有人想改,数据库层面也不允许
  • 定期检查流水的连续性。如果发现断号或异常,立即告警
  • 通过流水表可以还原任意时刻的库存快照。查询目标时间之前最近的一条流水,after_qty 就是那个时刻的库存

实现代码

// 写入流水
private void writeLog(InventoryAdjustRequest request, int beforeQty, int afterQty) {
    InventoryLog log = new InventoryLog();
    log.setLogType(request.getLogType());  // 1=采购入库 2=销售出库
    log.setChangeQty(request.getChangeQty());
    log.setBeforeQty(beforeQty);
    log.setAfterQty(afterQty);
    log.setRefNo(request.getRefNo());
    log.setOperateTime(LocalDateTime.now());
    
    // 只有 INSERT,没有 UPDATE 和 DELETE
    inventoryLogMapper.insert(log);
}

// 还原历史库存
public Map<String, Object> snapshot(Long warehouseId, Long skuId, LocalDateTime targetTime) {
    InventoryLog log = inventoryLogMapper.selectOne(
        new LambdaQueryWrapper<InventoryLog>()
            .eq(InventoryLog::getWarehouseId, warehouseId)
            .eq(InventoryLog::getSkuId, skuId)
            .le(InventoryLog::getOperateTime, targetTime)
            .orderByDesc(InventoryLog::getOperateTime)
            .last("limit 1")
    );
    
    return Map.of("quantity", log == null ? 0 : log.getAfterQty());
}

效果

库存数据完全可追溯,出现问题能快速定位原因。财务对账有完整的出入库明细。库存流水表按月分表,单表数据量控制在 300 万以内,查询性能稳定。

面试表达

“我们设计了只增不改不删的库存流水表。应用层代码永远不写 UPDATE/DELETE,数据库账号也只给 INSERT 和 SELECT 权限。通过流水表可以还原任意时刻的库存快照,完全可追溯。“

3.3 FIFO 出库策略和拣货路径优化

问题背景

货物长期积压导致过期或品质下降。拣货员在仓库里来回跑,效率低。

解决方案

实现先进先出(FIFO)策略,并优化拣货路径。

具体实现

  • last_inbound_time 字段升序排列,最早入库的优先出库。这样避免了货物过期和积压
  • 在保证 FIFO 的前提下,对同一天入库的货物按库位物理位置排序:zone(区域)→ row_no(排)→ column_no(列)→ floor_no(层)
  • 生成拣货指令列表,告诉仓管员从哪个库位拣多少件,按优化后的路径排序

路径优化示例

如果只按入库时间排序,可能是:

  • A-01-02 → C-03-01 → A-02-05 → B-01-03

仓管员要在 A、C、A、B 之间来回跑。

优化后是:

  • A-01-02 → A-02-05 → B-01-03 → C-03-01

单向移动,路径最短。

实现代码

public List<Inventory> allocateFifo(Long warehouseId, Long skuId, Integer quantity) {
    // 查询该 SKU 在当前仓库所有库位的库存
    List<Inventory> inventories = inventoryMapper.selectList(
        new LambdaQueryWrapper<Inventory>()
            .eq(Inventory::getWarehouseId, warehouseId)
            .eq(Inventory::getSkuId, skuId)
            .isNotNull(Inventory::getLocationId)
            .gt(Inventory::getQuantity, 0)
            // 按入库时间升序,最早的排前面
            .orderByAsc(Inventory::getLastInboundTime)
    );
    
    // 按 FIFO 分配
    int remaining = quantity;
    for (Inventory inventory : inventories) {
        int pickQty = Math.min(remaining, inventory.getQuantity());
        inventory.setAvailableQty(pickQty);
        remaining -= pickQty;
        if (remaining == 0) break;
    }
    
    if (remaining > 0) {
        throw new BusinessException("库存不足");
    }
    
    return inventories.stream()
        .filter(item -> item.getAvailableQty() > 0)
        .toList();
}

效果

避免了货物过期和积压。拣货时间从平均 8 分钟降到 5.5 分钟,效率提升 31%。拣货员每天行走距离减少约 40%。

面试表达

“我们实现了 FIFO 先进先出策略,按入库时间升序排列,最早入库的优先出库。同时优化拣货路径,对同一天入库的货物按库位物理位置排序,减少仓管员行走距离。拣货效率提升了 31%。“


四、业务面试题

Q1: 为什么需要冻结库存?直接扣减实物库存不行吗?

其实也可以, 只是没有办法区分实际已经出库的商品和刚下单还没有出库的商品

商家看不到有多少货在仓库,有多少已经分配给订单了。

用冻结库存后,quantity 始终反映仓库实际库存,frozen_qty 反映已分配给订单的库存,订单取消时只需要减 frozen_qty,不需要加 quantity。

Q2: 为什么出库时要同时扣减 quantity 和 frozen_qty?

因为货真的出去了。quantity 减少是因为实物少了,frozen_qty 减少是因为预占要释放掉,不然这部分预占会一直占着。

举个例子:仓库有 100 件货,订单创建时冻结 50 件,quantity = 100, frozen_qty = 50。出库时,货出去了,quantity 要减 50 变成 50,frozen_qty 也要减 50 变成 0。如果只减 quantity 不减 frozen_qty,那 frozen_qty 还是 50,可售库存就变成 50 - 50 = 0,但实际仓库还有 50 件货可以卖。

Q3: 冻结库存会一直占着吗?

不会。我们有订单超时自动取消机制。未支付订单 30 分钟自动取消,已支付订单 72 小时未发货自动取消,用户主动取消立即释放。所以冻结库存不会一直占着。

具体实现是 OMS 有定时任务,每 5 分钟扫描一次超时订单,自动取消并调用 WMS 释放冻结库存。

Q4: 为什么供应链系统需要管理库存?不是电商平台自己管理库存吗?

电商平台的库存配置和供应链系统的库存是两个层面的概念。

供应链系统的库存是”真相的唯一来源”,反映仓库里真实有多少货。电商平台的库存配置是”销售策略”,决定在这个平台上展示多少库存。

举个例子:仓库里有 100 件蓝牙耳机,商家可能在 Amazon 配置 50 件、Shopify 配置 30 件、eBay 配置 20 件。当 Amazon 卖出 10 件,供应链系统的库存变为 90 件,商家需要重新分配:Amazon 还剩 40 件、Shopify 30 件、eBay 20 件。

所以,供应链系统需要管理真实库存,并定期同步到各个电商平台。同步方式有两种:一是推送模式,库存变动后主动推送到平台;二是拉取模式,平台定期查询供应链系统的库存。

但这个同步频率不高,一般是每 15 分钟同步一次,不会每秒查询几千次。

Q5: 大促期间,供应链系统的压力主要在哪里?

大促期间,供应链系统的压力主要有三个方面:

压力一:订单创建压力

电商平台推送大量订单到供应链系统,每笔订单都要创建订单、冻结库存、写流水。黑五零点,10 秒内可能涌入 10 万笔订单,数据库连接池直接打满。我们用 MQ 削峰填谷,订单先进队列,控制消费速度,保护数据库。

压力二:库存扣减压力

大量订单并发冻结库存,可能导致库存扣减冲突。我们用数据库原子操作 + WHERE 条件判断。同时,我们不用悲观锁(SELECT FOR UPDATE),而是用乐观锁(WHERE 条件),减少锁等待时间。

压力三:库存同步压力

库存变动后,需要重新计算各平台可分配库存并同步。每秒 1000 笔订单,每笔订单要同步到 3 个平台,就是每秒 3000 次同步请求。我们用 MQ 异步通知,解耦系统,提升性能。

但是,商家后台的库存查询压力不大,因为商家不会在大促期间频繁刷新库存看板。

Q6: 为什么需要库存流水表?

库存流水表有四个作用:

作用一:审计追溯

库存出问题了能查到是谁在什么时候干了什么。比如库存突然少了 100 件,通过流水表可以查到是哪个出库单、哪个操作人、什么时间操作的。

作用二:数据恢复

库存数据错了可以通过流水记录恢复。比如误操作把库存改成 0 了,通过流水表可以查到之前的库存是多少,恢复回去。

作用三:财务对账

财务对账需要出入库明细。比如这个月采购入库了多少货,销售出库了多少货,通过流水表可以统计出来。

作用四:数据分析

统计 SKU 出库频率,做 ABC 分类。比如哪些 SKU 出库频率高,可以放在离出库区近的库位,提高拣货效率。

Q7: 为什么需要 FIFO 先进先出?

FIFO 有三个好处:

好处一:避免过期

化妆品、食品这些有保质期的,必须先进先出,不然放久了过期就不能卖了。

好处二:避免积压

货放久了品质会下降,比如电子产品,放久了可能受潮或者过时。

好处三:提高周转

减少资金占用,货卖得快,资金回笼快。

Q8: 如果不用 FIFO,会有什么问题?

如果不用 FIFO,可能会出现”新货先出,旧货积压”的情况。比如 1 月 1 日入库 100 件,1 月 10 日入库 100 件。如果不用 FIFO,可能先出 1 月 10 日的货,1 月 1 日的货一直放着。等到 3 月份,1 月 1 日的货可能已经过期或者品质下降了,就不能卖了,造成损失。

Q9: 为什么需要循环盘点?传统全盘不行吗?

传统全盘有三个问题:

问题一:要停止运营 2-3 天,影响发货

大促期间不能停,平时停也影响客户体验。

停止运营 —> 电商平台可以正常售卖 —> 但是 你的入库的操作/出库操作

问题二:一年只能盘一次,发现问题太晚

比如 1 月份盘点发现库存少了 100 件,但不知道是什么时候少的,可能是 2 月份少的,也可能是 11 月份少的,很难追溯。

问题三:工作量大,容易出错

一次性盘点所有 SKU,仓管员工作量大,容易漏盘或者盘错。

循环盘点的好处是:不停运营,按 ABC 分类设置不同频率,全年持续盘点,及时发现问题。A 类 SKU(销售额前 20%)每月盘一次,B 类每季度盘一次,C 类每半年盘一次。

Q10: 库存盘点发现差异怎么处理?

盘点发现差异后,有四个步骤:

  • 仓管员必须填写差异原因。比如”入库时漏扫码”、“出库时多拣了货”、“货物损坏没报损”等
  • 管理员审核。管理员查看差异原因是否合理,如果合理就审核通过,如果不合理就驳回,要求仓管员重新盘点
  • 执行库存调整。审核通过后,系统在事务中执行库存调整:quantity = quantity + diff_qty,同时写入库存流水,log_type=5(盘盈)或 log_type=6(盘亏)
  • 分析差异原因。定期统计盘点差异,分析是哪个环节容易出错,改进流程。比如发现入库环节经常漏扫码,就加强培训或者改进扫码流程

五、技术面试题

Q1: 如何保证库存扣减的并发安全?

我们用数据库原子操作 + WHERE 条件判断。

具体是用 MyBatis-Plus 的 setSql() 实现原子操作:frozen_qty = frozen_qty + N,在 WHERE 条件中判断可售库存是否充足:quantity - frozen_qty - defective_qty - reserved_qty >= N

如果条件不满足,updated 返回 0,直接抛异常。整个在事务里,失败就回滚。

这样不需要悲观锁,性能更好。UPDATE 本身是原子操作,WHERE 条件里判断库存够不够,如果条件不满足,updated 返回 0,直接失败。

Q2: 为什么不用悲观锁(SELECT FOR UPDATE)?

悲观锁有三个问题:

问题一:会锁住整行,其他事务得等着,容易死锁

比如事务 A 锁了 SKU 1001,事务 B 锁了 SKU 1002,然后事务 A 想锁 SKU 1002,事务 B 想锁 SKU 1001,就死锁了。

问题二:锁持有时间长,性能差

从 SELECT FOR UPDATE 到 UPDATE 再到 COMMIT,整个过程都持有锁,其他事务都得等着。

问题三:实现复杂

需要先 SELECT FOR UPDATE 查询库存,再判断是否充足,再 UPDATE,代码复杂。

我们用原子操作,不会阻塞其他事务,在库存充足的情况下,冲突概率很低,实现简单,一条 SQL 搞定。如果真的是秒杀场景,那可以考虑 Redis 预扣减,但跨境电商不会有那种极端场景。

Q3: 库存流水表如何保证不被篡改?

三个层面保证:

层面一:应用层代码永远不写 UPDATE/DELETE 逻辑,只有 INSERT

代码审查时严格检查,不允许出现 UPDATE/DELETE 库存流水表的代码。

层面二:数据库账号只给 INSERT 和 SELECT 权限,没有 UPDATE/DELETE 权限

即使有人想改,数据库层面也不允许。我们用的是 MySQL,给应用账号只授予 INSERT 和 SELECT 权限:GRANT INSERT, SELECT ON inventory_log TO 'app_user'@'%';

层面三:定期检查流水的连续性

如果发现断号或异常,立即告警。比如流水 ID 从 1000 跳到 1002,中间少了 1001,说明可能被删除了,立即告警。

Q4: 如何还原任意时刻的库存快照?

通过库存流水表还原。

查询目标时间之前最近的一条流水,after_qty 就是那个时刻的库存。

具体 SQL 是:

SELECT after_qty FROM inventory_log
WHERE warehouse_id = ? AND sku_id = ?
  AND operate_time <= ?
ORDER BY operate_time DESC
LIMIT 1;

比如要查询 2026-01-15 10:00:00 的库存,就查询这个时间之前最近的一条流水,假设是 2026-01-15 09:58:30 的流水,after_qty = 100,那么 10:00:00 的库存就是 100。

Q9: 为什么要用 RocketMQ?

我们用 RocketMQ 实现了两个场景:

场景一:库存变动异步通知 OMS

WMS 出库完成后,需要通知 OMS 更新订单状态。如果用同步调用,OMS 接口慢会拖慢 WMS,OMS 服务挂了 WMS 出库失败。用 MQ 后,WMS 发消息就返回,不等 OMS 处理,解耦系统。

场景二:订单创建削峰填谷

大促期间,瞬间涌入大量订单,如果直接处理,数据库连接池打满,系统崩溃。用 MQ 后,订单先进队列,慢慢消费,控制消费速度,保护数据库。

我们选择 RocketMQ 而不是 Kafka 或 RabbitMQ,是因为 RocketMQ 支持事务消息,性能好,适合电商场景。

Q10: 如何保证 MQ 消息的可靠性和幂等性?

可靠性和幂等性分别保证:

可靠性:保证消息不丢失

  • 生产者发送失败重试。用同步发送 + 重试机制,如果发送失败,自动重试 3 次
  • Broker 持久化。RocketMQ 主从同步 + 同步刷盘,消息写入磁盘后才返回成功
  • 消费者手动 ACK。消费者处理完业务逻辑后,手动 ACK,如果处理失败,抛异常,消息会自动重试

幂等性:保证消息不重复消费

用 Redis SETNX 实现。Key 是订单号,TTL 7 天。消费消息前,先 SETNX,如果返回 false,说明已经消费过了,直接返回。如果返回 true,说明第一次消费,继续处理业务逻辑。

为什么 TTL 是 7 天?因为消息可能延迟投递,7 天足够长,防止消息延迟投递后被重复消费。


六、简历描述

完整简历模板

项目名称: 跨境电商 SaaS 供应链管理平台
项目时间: 2024.06 - 2024.12
项目角色: 核心开发
技术栈: Spring Boot 3.2、Spring Cloud、MyBatis-Plus 3.5、MySQL 8.0、Redis 7.0、
       RocketMQ 5.0、Seata 1.7、XXL-Job 2.4、Nacos、Docker 24.0、Nginx 1.24

项目描述:
这是一个面向跨境电商商家的 SaaS 供应链管理平台,我负责 WMS 仓储管理模块的
核心功能开发。系统管理多个仓库(国内仓、FBA 仓、海外仓)的库存,支持采购入库、
销售出库、仓库调拨、库存盘点等全流程业务。服务 200+ 商家,日均处理出入库单 
5 万+,库存流水 20 万+,数据库存储 500 万+ SKU 的库存数据。峰值 QPS 3000+。

我的职责:
1. 负责库存管理核心模块的设计与开发,包括入库、出库、调拨、盘点等功能
2. 设计并实现冻结库存机制,解决多平台订单并发场景下的库存扣减问题
3. 设计库存流水表的不可篡改方案,保证库存数据的准确性和可追溯性
4. 实现 FIFO 先进先出策略和拣货路径优化,提升仓库作业效率
5. 负责与 PMS 采购系统、OMS 订单系统、TMS 物流系统的接口对接

技术亮点:

1. 库存扣减的并发控制方案
   问题: 多平台订单并发到达时,直接扣减库存可能导致超卖或数据不一致
   方案: 设计了冻结库存机制 + 数据库原子操作
   实现: 订单创建时先冻结库存(frozen_qty),不动实物库存(quantity)。出库时
        同时扣减实物和冻结库存。订单取消时只释放冻结库存。使用 MyBatis-Plus 
        的 setSql() 实现原子操作,在 WHERE 条件中判断可售库存是否充足
   效果: 多平台订单并发场景下,库存数据准确率 100%,避免了超卖和库存负数问题

2. 库存流水的不可篡改设计
   问题: 库存数据出现差异时无法追溯,财务对账缺少可靠的出入库明细
   方案: 设计了只增不改不删的库存流水表
   实现: 应用层代码永远不写 UPDATE/DELETE 逻辑,数据库账号只给 INSERT 和 
        SELECT 权限。每次库存变动都写入流水表,记录变动前后数量、操作人、
        操作时间。通过流水表可以还原任意时刻的库存快照
   效果: 库存数据完全可追溯,财务对账有完整明细。库存流水表按月分表,单表
        数据量控制在 300 万以内,查询性能稳定

3. FIFO 出库策略和拣货路径优化
   问题: 货物长期积压导致过期或品质下降,拣货员在仓库里来回跑效率低
   方案: 实现先进先出(FIFO)策略并优化拣货路径
   实现: 按 last_inbound_time 升序排列,最早入库的优先出库。在保证 FIFO 的
        前提下,对同一天入库的货物按库位物理位置排序(zone → row_no → 
        column_no),生成最优拣货路径
   效果: 避免了货物过期和积压。拣货时间从平均 8 分钟降到 5.5 分钟,效率提升 
        31%。拣货员每天行走距离减少约 40%

4. Redis 缓存优化库存查询性能
   问题: 商家后台查询库存看板需要聚合多仓库、多库位数据,计算复杂耗时长
   方案: 采用 Cache Aside 模式,先更新数据库再删除缓存
   实现: 库存看板缓存 TTL 30 秒,SKU 详情缓存 TTL 60 秒,库存预警缓存 TTL 
        5 分钟。使用 TransactionSynchronizationManager 在事务提交后删除缓存,
        保证数据一致性。使用布隆过滤器防止缓存穿透,分布式锁防止缓存击穿
   效果: 库存看板查询时间从 500ms 降到 10ms,SKU 详情查询从 50ms 降到 5ms,
        数据库 QPS 降低 70%,缓存命中率 95%

5. 分布式事务保证数据一致性
   问题: OMS 创建订单和 WMS 冻结库存是跨服务操作,必须同时成功或同时失败
   方案: 使用 Seata AT 模式实现分布式事务
   实现: OMS 创建订单时使用 @GlobalTransactional 注解,通过 Feign 调用 WMS 
        冻结库存。如果任何一步失败,Seata 自动回滚所有操作
   效果: 订单创建和库存冻结强一致,避免了订单创建成功但库存未冻结的问题

6. MQ 异步解耦和削峰填谷
   问题: 大促期间订单瞬间涌入,数据库连接池打满导致系统崩溃
   方案: 使用 RocketMQ 实现异步解耦和削峰填谷
   实现: 电商平台推送订单到 MQ,OMS 消费消息创建订单。WMS 出库完成后发送 MQ 
        消息通知 TMS 创建运单。使用事务消息保证消息可靠性,使用 Redis SETNX 
        保证消息幂等性
   效果: 大促期间系统稳定,零故障。数据库连接池使用率从 95% 降到 60%

7. 循环盘点替代传统全盘
   问题: 传统全盘需要停止运营 2-3 天,一年只能盘一次,发现问题太晚
   方案: 实现循环盘点,按 ABC 分类设置不同频率
   实现: 使用 XXL-Job 定时任务每天晚上 22:00 自动生成盘点计划。A 类 SKU(销售额
        前 20%)每月盘一次,B 类每季度盘一次,C 类每半年盘一次。盘点发现差异后,
        仓管员填写原因,管理员审核,系统在事务中执行库存调整并写入流水
   效果: 不停运营,全年持续盘点,及时发现问题。库存准确率从 92% 提升到 98%

简历精简版

项目: 跨境电商 SaaS 供应链管理平台 - WMS 仓储管理模块
时间: 2024.06 - 2024.12
角色: 核心开发
技术: Spring Boot 3.2、MyBatis-Plus 3.5、MySQL 8.0、Redis 7.0、RocketMQ 5.0、Seata 1.7、Docker 24.0

项目描述:
面向跨境电商商家的 SaaS 供应链平台,负责 WMS 仓储管理模块。管理多个仓库的
库存,支持采购入库、销售出库、库存盘点等业务。日均处理出入库单 5 万+,库存
流水 20 万+,峰值 QPS 3000+。

核心亮点:
1. 设计冻结库存机制 + 数据库原子操作,解决多平台订单并发扣减问题,库存准确率 100%
2. 设计只增不改不删的库存流水表,保证数据可追溯,支持还原任意时刻库存快照
3. 实现 FIFO 出库策略和拣货路径优化,拣货效率提升 31%,行走距离减少 40%
4. 使用 Redis 缓存优化查询性能,响应时间从 500ms 降到 10ms,数据库 QPS 降低 70%
5. 使用 Seata 分布式事务保证订单创建和库存冻结强一致,使用 RocketMQ 削峰填谷

附录:关键代码示例

1. 冻结库存

@Transactional(rollbackFor = Exception.class)
public void freezeInventory(Long skuId, Long warehouseId, Integer qty) {
    int updated = inventoryMapper.update(null, 
        new LambdaUpdateWrapper<Inventory>()
            .eq(Inventory::getSkuId, skuId)
            .eq(Inventory::getWarehouseId, warehouseId)
            // WHERE 条件判断可售库存
            .apply("quantity - frozen_qty - defective_qty - reserved_qty >= {0}", qty)
            // 原子操作
            .setSql("frozen_qty = frozen_qty + " + qty)
    );
    
    if (updated == 0) {
        throw new BusinessException("库存不足");
    }
}

2. 出库扣减

@Transactional(rollbackFor = Exception.class)
public void outbound(Long skuId, Long warehouseId, Integer qty) {
    // 1. 扣减库存
    int updated = inventoryMapper.update(null, 
        new LambdaUpdateWrapper<Inventory>()
            .eq(Inventory::getSkuId, skuId)
            .eq(Inventory::getWarehouseId, warehouseId)
            .ge(Inventory::getQuantity, qty)
            .setSql("quantity = quantity - " + qty + ", frozen_qty = frozen_qty - " + qty)
    );
    
    if (updated == 0) {
        throw new BusinessException("库存不足");
    }
    
    // 2. 写流水
    writeLog(...);
    
    // 3. 删除缓存(在事务提交后)
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                redisTemplate.delete("inventory:sku:" + skuId + ":" + warehouseId);
            }
        }
    );
}

3. FIFO 分配库位

public List<Inventory> allocateFifo(Long warehouseId, Long skuId, Integer quantity) {
    // 查询该 SKU 在当前仓库所有库位的库存
    List<Inventory> inventories = inventoryMapper.selectList(
        new LambdaQueryWrapper<Inventory>()
            .eq(Inventory::getWarehouseId, warehouseId)
            .eq(Inventory::getSkuId, skuId)
            .isNotNull(Inventory::getLocationId)
            .gt(Inventory::getQuantity, 0)
            // 按入库时间升序,最早的排前面
            .orderByAsc(Inventory::getLastInboundTime)
    );
    
    // 按 FIFO 分配
    int remaining = quantity;
    for (Inventory inventory : inventories) {
        int pickQty = Math.min(remaining, inventory.getQuantity());
        inventory.setAvailableQty(pickQty);
        remaining -= pickQty;
        if (remaining == 0) break;
    }
    
    if (remaining > 0) {
        throw new BusinessException("库存不足");
    }
    
    return inventories.stream()
        .filter(item -> item.getAvailableQty() > 0)
        .toList();
}

4. 盘点差异调整

@Transactional(rollbackFor = Exception.class)
public void auditStocktake(Long taskId) {
    // 查所有有差异的
    List<StocktakeItem> items = stocktakeItemMapper.selectList(
        new LambdaQueryWrapper<StocktakeItem>()
            .eq(StocktakeItem::getTaskId, taskId)
            .ne(StocktakeItem::getDiffQty, 0)
    );
    
    for (StocktakeItem item : items) {
        // 调整库存
        inventoryMapper.update(null,
            new LambdaUpdateWrapper<Inventory>()
                .eq(Inventory::getSkuId, item.getSkuId())
                .setSql("quantity = quantity + " + item.getDiffQty())
        );
        
        // 写流水
        InventoryLog log = new InventoryLog();
        log.setLogType(item.getDiffQty() > 0 ? 5 : 6);  // 5=盘盈 6=盘亏
        log.setChangeQty(item.getDiffQty());
        inventoryLogMapper.insert(log);
    }
}