Series Article

Day04 · WMS 核心业务面试准备

前置知识:供应链系统与电商平台的关系

面试官经常会问”你们的供应链系统和电商平台是什么关系”、“为什么供应链系统会有高并发”,必须先把这个讲清楚。

系统架构关系

三层架构:

【电商平台层】
├─ Amazon 店铺
├─ Shopify 店铺  
├─ eBay 店铺
└─ 独立站

        ↓ 订单推送 / 库存查询

【供应链 SaaS 系统 - 我们的系统】
├─ OMS(订单管理系统)
│  ├─ 接收各电商平台的订单
│  ├─ 统一管理订单状态
│  └─ 调用 WMS 冻结库存

└─ WMS(仓储管理系统)
   ├─ 管理真实库存(quantity)
   ├─ 管理冻结库存(frozen_qty)
   └─ 处理出入库操作

        ↓ 库存分配同步

【电商平台层】
└─ 各平台获得分配的库存配额

核心要点:

  1. 供应链系统是库存的”真实来源”,电商平台只是”展示渠道”
  2. 商家在多个平台开店,但库存是统一管理的(一个仓库的货)
  3. 库存同步流程:WMS 真实库存 → 同步到各电商平台展示

供应链系统的真正价值:

  1. 多平台统一库存管理:商家不需要在每个平台单独管理库存,避免库存分散、数据不一致
  2. 全局库存可见性:实时查看各平台销量、总销量、剩余库存,一目了然
  3. 智能库存分配:根据各平台销售速度,动态调整库存分配策略(如 Amazon 卖得快就多分配)
  4. 智能补货预警:根据销售速度和库存水位,自动预警需要补货的 SKU
  5. 数据分析能力:统计各平台销售占比、热销 SKU、库存周转率等,辅助运营决策

注意:超卖问题是电商平台自己的责任

  • 用户在 Amazon 下单时,Amazon 平台会校验 Amazon 店铺的库存
  • 如果 Amazon 平台自己的库存管理有问题,可能会出现超卖
  • 供应链系统的职责是:管理仓库实际库存、分配各平台库存配额、提供库存可见性
  • 供应链系统不能也不应该解决电商平台的超卖问题,这是两个系统的职责边界

库存分配流程示例

场景:商家在 Amazon、Shopify、eBay 都开了店,卖同一个商品(蓝牙耳机)

核心策略:固定分配库存,而不是实时同步

// 1. 供应链系统的真实库存
WMS: quantity = 500(仓库里真的有500件)

// 2. 商家根据各平台销售情况,固定分配库存
Amazon 店铺:分配 200 件(40%,销量最大)
Shopify 店铺:分配 150 件(30%,销量中等)
eBay 店铺:分配 100 件(20%,销量较小)
独立站:分配 50 件(10%,销量最小)

总计:200 + 150 + 100 + 50 = 500 件(不超过仓库库存)

// 3. 用户在 Amazon 下单买了50件
Amazon 平台:扣减 Amazon 库存 200 - 50 = 150
Amazon 平台:推送订单到供应链系统(MQ消息)

// 4. 供应链系统 OMS 接收订单
OMS: 创建订单,调用 WMS 冻结库存
WMS: frozen_qty = 0 + 50 = 50, quantity = 500, 可售 = 450

// 5. 供应链系统重新计算各平台可分配库存
仓库剩余可分配:500 - 50(已冻结)= 450

商家可以选择:
方案1:保持原分配(Amazon 150, Shopify 150, eBay 100, 独立站 50
方案2:动态调整(Amazon 卖得快,多分配一些)

// 6. 如果选择动态调整,同步新的库存到各平台
Amazon 店铺:150180(增加30件)
Shopify 店铺:150130(减少20件)
eBay 店铺:10090(减少10件)
独立站:5050(不变)

总计:180 + 130 + 90 + 50 = 450

关键点:

  1. 不是”实时同步” - 不是把仓库的500件同步给每个平台,那样会导致多个平台竞争同一库存
  2. 而是”固定分配” - 总和不超过仓库库存,每个平台有自己的配额
  3. 可以动态调整 - 根据销售速度,定期(如每天)重新分配各平台库存
  4. 供应链系统的价值 - 统一管理库存分配,避免商家在每个平台单独手动调整

为什么供应链系统会有高并发?

1. 库存查询压力

用户在电商平台浏览商品 → 电商平台实时查询供应链系统库存

大促期间:
- Amazon:每秒10万人浏览商品详情页
- Shopify:每秒8万人浏览
- eBay:每秒5万人浏览

总计:每秒23万次库存查询请求打到供应链系统!

2. 订单创建压力

用户在电商平台下单 → 电商平台推送订单到供应链系统 → OMS创建订单 → WMS冻结库存

大促期间(比如黑色星期五零点):
- 0:00:00 - 0:00:10:10秒内涌入10万笔订单
- 每笔订单需要:
  1. OMS 创建订单(写数据库)
  2. WMS 冻结库存(写数据库)
  3. 写库存流水(写数据库)

数据库连接池:最大50个连接
10万笔订单 / 50个连接 = 每个连接要处理2000笔订单
数据库直接打满,系统崩溃!

3. 库存同步压力

WMS 库存变动 → 重新计算各平台可分配库存 → 同步到各电商平台

大促期间:
- 每秒1000笔订单成功
- 每笔订单需要重新计算库存分配并同步到3个平台(Amazon、Shopify、eBay)
- 每秒3000次库存同步请求

面试时怎么讲

“我们的供应链系统是一个跨境电商 SaaS 中台,服务于多个电商平台。商家在 Amazon、Shopify、eBay 都开了店,但库存是统一管理的。

供应链系统的核心价值是多平台统一库存管理:商家不需要在每个平台单独管理库存,我们的系统可以根据各平台销售速度,智能分配库存配额。比如仓库有500件货,Amazon 卖得快分配200件,Shopify 分配150件,eBay 分配100件,独立站分配50件。商家可以实时查看各平台销量、总销量、剩余库存,并根据销售速度自动预警需要补货的 SKU。

用户在任何平台下单,订单都会推送到我们的供应链系统,由 OMS 统一管理订单,WMS 统一管理库存。大促期间(如黑色星期五),各电商平台的流量会汇聚到我们的供应链系统,所以我们也会面临高并发的挑战,比如库存查询 QPS 5000+,订单瞬间涌入等。我们用 Redis 缓存、消息队列、数据库原子操作等技术手段来应对这些挑战。“


核心业务流程(面试必问)

面试官经常会问”你们的入库流程是怎样的”、“从订单到出库的完整流程”,所以必须把业务流程讲清楚。

采购入库完整流程

这是最重要的流程,面试必问。

退货入库 仓库之间的调动

前置条件:

  • 采购单已审核通过,状态为”待收货”
  • 供应商已发货,货物在途

流程步骤:

步骤1:货物到达仓库

  • 操作人:门卫/收货台
  • 操作内容:登记到货时间、车牌号、送货单号
  • 系统动作:无

我们的系统中可以直接查询到 收货时间 和 快递的单号 因为供应商在进行发货的时候 会要求供应商填写 快递单号 而我们的系统也集成了第三方的 物流信息

步骤2:创建入库单

  • 操作人:仓管员
  • 操作内容:扫描送货单,查询对应的采购单
  • 系统动作:创建入库单,inbound_type=1(采购入库),关联采购单号

你需要在手机/PAD上 让仓管进行登录 点击 创建 入库单 —> 让仓管快速选择 —> 查询到系统中 有没有采购单 —> 如果有 就可以列出来 —> 仓管进行选择 —> 系统就会自动填充入库单信息 —> 仓管 要进行质检

步骤3:质量检验(关键环节)

  • 操作人:质检员
  • 操作内容:外观检查 + 功能抽检,对比采购单规格(SKU)
  • 系统动作:记录质检结果
  • 分支处理:
    • 全部合格:继续下一步
    • 部分合格:分别记录合格数和不良品数,不良品移至 X 区隔离
    • 全部不合格:拒收,创建采购退货单,通知采购部

当仓管开始进行质检的时候 —> 在手机/Pad上做一个页面 —> 让仓管按照这个页面上的内容 进行一项一项填充 —> 统一规范 仓管的步骤 —> 避免仓管漏掉某个步骤 —> 如果出现异常 —> 拍照 上传 —> 提交 —> 通知采购专员 —> 采购专员 联系 仓管 —> 协商如何处理这个异常情况 —> 提交质检的表单

步骤4:分配库位

  • 操作人:仓管员
  • 操作内容:选择目标库位(系统推荐,可手动调整)
  • 系统动作:查询空闲库位,按 ABC 分类推荐合适区域
    • A 类商品(高价值):推荐靠近出货口的 A 区
    • C 类商品(低价值):推荐远离出货口的 C 区

在仓管的手机/Pad —> 点击入库 —> 选择采购单 —> 显示推荐的的库位 —> SKU 应该存放在哪个区哪排哪列哪层(先判断是否已经有相同的SKU 如果有找相邻的库位 如果没有找最近的空闲的库位) —> 如果仓管同意这个库位(就把SKU放到指定的库位即可) —> 如果仓管不同意(可以修改库位 页面提供一个可以选择的区 排 行 层 在进行创建仓库的时候就已经指定了 后期也运行扩容 也就是手动的去增加库位信息) —> 选定库位后 —> 货物上架

步骤5:货物上架

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

当货物存放到指定的库位时 —> 提交入库

步骤6:==确认入库(最关键的技术环节)==

  • 操作人:仓管员
  • 操作内容:点击”确认入库”按钮
  • 系统动作(事务,原子操作):
    1. 更新库存:inventory.quantity += 合格数量
      1. 如果存在不合格的商品 也要同步修改 defective_qty += 不合格的数量
    2. 写入库存流水:log_type=1(采购入库),记录变动前后数量、操作人、操作时间
    3. 更新库位状态:warehouse_location.is_occupied=1
    4. 更新加权平均成本:avg_cost = (原库存×原成本 + 新入库×新成本) / 总库存
    5. 更新入库单状态:inbound_order.status=2(已完成)
    6. 更新采购单状态:如果采购单所有明细都已入库,更新采购单为”已完成”
      1. 采购单状态: 采购单 采购单明细 —> 可能需要使用到 Feign 调用 或者 MQ异步调用
      2. 如果采用的是 Feign调用 要使用分布式事务
      3. 如果使用的是 MQ异步调用 要保证MQ消息和本地事务的一致性 推荐使用 MQ的事务消息

关键技术点:

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

异常处理:

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

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


销售出库完整流程

这是第二重要的流程,涉及库存扣减、FIFO 策略。

前置条件:

  • OMS 订单系统已创建订单,并冻结了库存(frozen_qty 已增加)
  • 订单状态为”待发货”

流程步骤:

步骤1:OMS 推送发货需求

  • 触发方:OMS 订单系统
  • 推送内容:订单号、SKU、数量、目标仓库
    • 使用的MQ发送消息
      • 当用户创建完订单后 生成订单信息 就直接给用户返回 下单成功即可
      • 对应库存的操作 以及 出库的操作 都使用MQ异步去实现即可
    • 不推荐使用Feign同步调用
      • 时效性 库存的操作 比较繁琐
  • 系统动作:WMS 接收发货需求

步骤2:WMS 创建出库单

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

步骤3:FIFO 分配库位(关键技术点)

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

如果一个买家 买了多个商品 那么这多个商品可能是这样的 商品A 100个 商品B 买了1个 商品C 买了10个 那么我们需要生成拣货单

  1. 我们要先查询 商品A 的库位 按照入库的时间 进行升序 (先入库的排在最上面)
  2. 拿到最上面的库位 判断库存是否 >= 100
  3. 如果没有100个 从第二入库的库位中再拿 拼够 100个
  4. 然后按照上面的流程 去查询 商品B 和 商品C
  5. 当我们把商品A 商品B 商品C 都已经找到了具体的库位 和每个库位应该拿多少个商品后
  6. 开始排序 按照仓库的物理顺序进行排序 区 —> 排 —> 列 —> 层
  7. 把排好序的信息 存储起来 也就是我们的拣货单

步骤4:生成拣货单

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

步骤5:仓管员拣货

  • 操作人:仓管员
  • 操作内容:按拣货单逐库位拣货,扫描商品条码验证 扫码是为了判断商品的规格
  • 系统动作:
    • 扫描条码匹配:更新拣货状态 pick_status=1(已拣)
    • 扫描条码不匹配:提示错误,防止拣错货
      • 扫码的那个信息 其实就是商品的SKU的信息以及库位的信息
      • 扫码的操作 是在拣货单的页面显示的 当扫码识别到该SKU信息后 去匹配拣货单中的 item
      • 如果不匹配 说明 拿错货了 如果匹配 我们可以在手机/PAD上对 拣货单中的 item 打上标记

步骤6:复核

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

步骤7:打包

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

步骤8:确认出库(最关键的技术环节)

  • 操作人:仓管员
  • 操作内容:点击”确认出库”按钮
  • 系统动作(事务,原子操作):
    1. 扣减库存:quantity -= 出库数量frozen_qty -= 出库数量(同时扣减)
      1. frozen_qty 数量是在下单的时候就已经做了 += 的冻结操作
      2. 所以在真正出库的时候 需要解冻
    2. 写入库存流水:log_type=2(销售出库),记录变动前后数量
    3. 更新库位状态:如果库位已空,is_occupied=0
      1. 这个库位的状态 不一定每次都会清空
      2. 一个库位上存放了N多个SKU
      3. 每个SKU又有很多数量的货物
    4. 更新出库单状态:outbound_order.status=4(已出库)
    5. 回调 OMS:通知订单系统已出库,OMS 触发物流创建
      1. 使用MQ异步消息通知

步骤9:TMS 创建运单

  • 触发方:OMS 收到 WMS 回调后,通知 TMS
  • 系统动作:TMS 物流系统创建运单,货物发出

关键技术点:

  • FIFO 策略保证先进先出,避免货物积压
  • 拣货路径优化,减少行走距离 40%
  • 出库时同时扣减 quantityfrozen_qty,释放预占
  • 所有操作在事务中,保证数据一致性

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

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

面试时怎么讲: “销售出库流程的关键是 FIFO 分配库位和库存扣减。OMS 推送发货需求后,WMS 按先进先出原则分配库位,生成拣货单。仓管员拣货完成后,确认出库时要同时扣减实物库存和冻结库存,并写入流水记录。整个过程在事务中执行,保证数据一致性。“


库存盘点完整流程

这是第三重要的流程,体现了系统的数据准确性保障机制。

前置条件:

  • 系统已按 ABC 分类标记好每个 SKU 的盘点频率
  • 定时任务每天晚上 22:00 自动检查是否需要生成盘点计划

流程步骤:

步骤1:定时任务生成盘点计划

  • 触发方:XXL-Job 定时任务(每天 22:00)
  • 系统动作:
    1. 查询今日需要盘点的 SKU(按 ABC 分类)
      • A 类:本月未盘过的 按照销售额 TOP 20% 算作A类
      • B 类:本季度未盘过的
      • C 类:本半年未盘过的
    2. 创建盘点任务:task_type=3(循环盘),status=0(待开始)
    3. 生成盘点明细:记录每个 SKU 的账面库存(book_qty
      1. 每类的商品都会有很多个SKU 所以需要生成/统计当前需要盘点的SKU的 book_qty
      2. 循环盘 因为每次盘点的SKU种类不多 通常可以放到晚上下班之后去盘点 所以不影响出库 也不需要进行状态的锁定

步骤2:锁定相关库位(可选,全盘时才锁)

  • 系统动作:如果是全盘,更新库位状态 status=2(锁定)
    • 锁定只是禁止出库和入库的操作 但是线上销售 还是可以进行的 只是不能发货
    • 线上销售不会去修改真实库存 而是去修改 冻结库存
    • 还有一个就是当我们生成盘点计划的时候 已经把真实库存 存储到 book_qty 也不会受到线上下单的影响
    • 但是不能入库/出库 因为入库/出库会影响 线下的真实的库存的数量
  • 影响:锁定期间禁止该库位出入库操作
  • 注意:循环盘点通常不锁定库位,不影响日常运营

步骤3:仓管员实际清点

  • 操作人:仓管员
  • 操作内容:按盘点任务清单,逐个 SKU 清点实物数量
  • 系统动作:在 App 上填写实盘数量(actual_qty

步骤4:系统自动计算差异

  • 系统动作:diff_qty = actual_qty - book_qty 拿 线下真实的货物数量 - 线上的账面数量
    • 正数:盘盈(实物多于账面)
    • 负数:盘亏(实物少于账面)
    • 零:无差异

步骤5:填写差异原因(如果有差异)

  • 操作人:仓管员
  • 操作内容:必须填写差异原因(如:入库时漏扫码、货物损坏未报损)
  • 系统动作:记录差异原因到 diff_reason 字段

步骤6:管理员审核

  • 操作人:仓库主管
  • 操作内容:审核差异报告,确认差异是否合理
  • 系统动作:
    • 审核通过:进入下一步
    • 审核不通过:退回重新清点

步骤7:执行库存调整(最关键的技术环节)

  • 操作人:系统自动执行
  • 系统动作(事务,原子操作):
    1. 更新库存:quantity += diff_qty(盘盈为正,盘亏为负)
      1. 只有当 存在 差异的时候 才去更新这个库存数量
      2. 不管是盘盈还是盘亏 都是使用的 += 因为盘亏相当于是加上一个负数
    2. 写入库存流水:log_type=5(盘盈)或 log_type=6(盘亏)
      1. 只要是动了这个库存 都必须要记录流水
    3. 更新盘点明细:is_adjusted=1adjust_time=NOW()
    4. 更新盘点任务:status=3(已完成),end_time=NOW()

步骤8:解锁库位(如果之前锁定了)

  • 系统动作:更新库位状态 status=1(正常)
  • 影响:恢复正常出入库操作

步骤9:生成盘点报告

  • 系统动作:生成报告,包含:
    • 盘点 SKU 总数
    • 有差异的 SKU 数量
    • 盘盈总数量、总金额
    • 盘亏总数量、总金额
    • 差异 SKU 明细列表

    每次盘点的结果都需要保存到数据库中

关键技术点:

  • 循环盘点不停运营,按 ABC 分类设置不同频率
    • 也不用每天都去扫码 可以配置Cron表达式 按照月份 季度 半年份 去扫描
  • 盘点差异调整在事务中执行,保证数据一致性
  • 每次调整都写入流水,可追溯
  • 定时任务自动生成盘点计划,无需人工干预
    • 生成盘点计划的时候 需要统计线上的库存数量(快照)

ABC 分类标准:

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

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


Redis 缓存方案(重点加分项)

为什么需要 Redis 缓存

在实际生产环境中,库存查询是高频操作:

  • 商品列表页要显示库存状态(有货/无货)
  • 订单创建前要校验库存是否充足
  • 大促期间,热门 SKU 的库存查询 QPS 可达 5000+

如果每次都查数据库,会有两个问题:

  1. 数据库压力大,慢查询增多
  2. 响应时间长,用户体验差

所以我们引入了 Redis 缓存热点 SKU 的库存数据。

什么是热点 SKU

不是所有 SKU 都需要缓存,我们只缓存热点 SKU:

  • 近 7 天销量 TOP 200 的 SKU
    • 通过定时任务(每小时执行一次)自动识别热点 SKU,动态更新缓存列表。缓存到Redis中 缓存的时间 7 天
    • 除了使用定时任务扫描 也可以使用Redis中的 HyperLogLog 进行统计 贼节省内存
  • 正在参加活动的 SKU
    • 只要是参加活动的 一定一定要提前预热
      • 预热 提前批量的缓存 批量就容易导致 雪崩 避免雪崩
        • 要么把活动相关的Key 都设置为永久Key 当活动结束后 再通过脚本去删除活动Key
        • 要么把这些Key的有效期 设置为活动时间 + 随机值
    • 如果要参加活动 缓存的就不只是库存 还有很多SKU相关的其他的信息 也不一定都使用Redis缓存 也可以使用静态化 CDN 等
  • 新品推广期的 SKU
    • 打广告 导致 流量激增
    • 逻辑和 活动的逻辑一样 也要进行 提前预热

SKU的销售的数据很少 比如: 某个SKU三个月还没有卖个一个 也就意味着说 你存储的这个SKU的库存缓存 放到Redis不会被使用 Redis 存储的数据 都是有时效性的数据 不能想着把所有的Key都设置为永久Key 应该都配置一个过期时间

缓存的数据结构

我们用 Redis Hash 存储库存信息:

Key: inventory:sku:{skuId}:{warehouseId}
Field: quantity, frozen_qty, available_qty, update_time
TTL: 60 分钟

示例:

HSET inventory:sku:10001:1 quantity 500
HSET inventory:sku:10001:1 frozen_qty 50
HSET inventory:sku:10001:1 available_qty 450
HSET inventory:sku:10001:1 update_time 1737705600
EXPIRE inventory:sku:10001:1 1800

为什么用 Hash 而不是 String?

  • Hash 可以只更新部分字段,比如只更新 frozen_qty
  • 节省内存,一个 Key 存多个字段
  • 方便扩展,后续可以加更多字段

缓存更新策略(重点)

我们采用的是 Cache Aside 模式先更新数据库,再删除缓存。

为什么是删除缓存,而不是更新缓存?

因为库存数据的计算逻辑复杂:

  • available_qty = quantity - frozen_qty - defective_qty - reserved_qty
  • 如果更新缓存,需要重新计算所有字段
  • 如果删除缓存,下次查询时自动从数据库加载最新数据

具体流程:

@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);
            }
        }
    );
}

为什么要在事务提交后删除缓存?

如果在事务提交前删除缓存,可能出现这种情况:

  1. 线程 A 删除了缓存
  2. 线程 B 查询缓存未命中,从数据库加载旧数据到缓存
  3. 线程 A 事务提交,数据库更新
  4. 结果:缓存是旧数据,数据库是新数据,不一致了

所以必须在事务提交后删除缓存。

缓存穿透、击穿、雪崩的解决方案(关联问)

1. 缓存穿透(查询不存在的数据)

问题:恶意用户查询不存在的 SKU,缓存没有,数据库也没有,每次都打到数据库。

解决方案: 参数的合法性校验 + 布隆过滤器 + 空值缓存

自己去实现 布隆过滤器 Bitmap 比如: 你可以调用相关的工具 Hutool Guava Bitmap

在新增SKU的时候 把这个SKU的ID 转成数值 Hash 对我们的数组取模

什么是 布隆过滤器 快速判断一个值 是否在一个数组中 如果判断是存在 说明 不一定存在 如果判断是不存在 说明 一定不存在

public Inventory getInventory(Long skuId, Long warehouseId) {
    // 1. 先用布隆过滤器判断 SKU 是否存在
    if (!bloomFilter.mightContain(skuId)) {
        return null;  // 一定不存在,直接返回
    }
    
    // 2. 查 Redis
    String key = "inventory:sku:" + skuId + ":" + warehouseId;
    Map<Object, Object> cache = redisTemplate.opsForHash().entries(key);
    if (!cache.isEmpty()) {
        return convertToInventory(cache);
    }
    
    // 3. 查数据库
    Inventory inventory = inventoryMapper.selectOne(...);
    
    // 4. 如果数据库也没有,缓存空值(TTL 5 分钟)
    if (inventory == null) {
        redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
        return null;
    }
    
    // 5. 写入缓存
    redisTemplate.opsForHash().putAll(key, convertToMap(inventory));
    redisTemplate.expire(key, 30, TimeUnit.MINUTES);
    return inventory;
}

布隆过滤器在系统启动时加载所有 SKU ID,占用内存很小(100 万个 SKU 只需 1.2MB)。

2. 缓存击穿(热点 Key 过期)

问题:热门 SKU 的缓存过期瞬间,大量请求同时打到数据库。

解决方案:分布式锁 + 缓存预热

public Inventory getInventory(Long skuId, Long warehouseId) {
    String key = "inventory:sku:" + skuId + ":" + warehouseId;
    
    // 1. 查 Redis
    Map<Object, Object> cache = redisTemplate.opsForHash().entries(key);
    if (!cache.isEmpty()) {
        return convertToInventory(cache);
    }
    
    // 2. 缓存未命中,加分布式锁
    String lockKey = "lock:inventory:" + skuId + ":" + warehouseId;
    // 可以Redisson
    boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 3. 再次检查缓存(双重检查)
            // 再次判断 
            cache = redisTemplate.opsForHash().entries(key);
            if (!cache.isEmpty()) {
                return convertToInventory(cache);
            }
            
            // 4. 查数据库
            Inventory inventory = inventoryMapper.selectOne(...);
            
            // 5. 写入缓存
            redisTemplate.opsForHash().putAll(key, convertToMap(inventory));
            redisTemplate.expire(key, 30, TimeUnit.MINUTES);
            return inventory;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 6. 没抢到锁,等待 100ms 后重试
        Thread.sleep(100);
        return getInventory(skuId, warehouseId);
    }
}

另外,对于超级热点 SKU(如大促爆款),我们会提前预热缓存,并设置永不过期。

3. 缓存雪崩(大量 Key 同时过期)

问题:大量缓存同时过期,数据库瞬间压力暴增。

解决方案:过期时间加随机值

// 基础 TTL 30 分钟,加上 0-5 分钟的随机值
int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);
redisTemplate.expire(key, ttl, TimeUnit.SECONDS);

这样即使批量加载缓存,过期时间也是分散的,不会同时失效。

缓存一致性问题

延迟双删策略(可选方案)

如果担心”先更新数据库,再删除缓存”还有并发问题,可以用延迟双删:

@Transactional(rollbackFor = Exception.class)
public void outbound(Long skuId, Long warehouseId, Integer qty) {
    String key = "inventory:sku:" + skuId + ":" + warehouseId;
    
    // 1. 先删除缓存
    redisTemplate.delete(key);
    
    // 2. 更新数据库
    inventoryMapper.update(...);
    
    // 3. 延迟 500ms 再删除一次缓存
    CompletableFuture.runAsync(() -> {
        try {
            Thread.sleep(500);
            redisTemplate.delete(key);
        } catch (InterruptedException e) {
            log.error("延迟双删失败", e);
        }
    });
}

但我们实际没用这个方案,因为:

  • 增加了复杂度
  • 500ms 的延迟不好控制
  • 我们的业务场景下,“先更新数据库,再删除缓存”已经足够

面试时怎么讲

“我们对热点 SKU 的库存数据做了 Redis 缓存,用 Hash 结构存储,TTL 60 分钟。 缓存更新策略是先更新数据库,再删除缓存,并且在事务提交后删除,保证一致性。 针对缓存穿透,我们用了布隆过滤器和空值缓存;针对缓存击穿,用了分布式锁;针对缓存雪崩,给过期时间加了随机值。 这套方案上线后,库存查询接口的 P99 延迟从 80ms 降到 5ms,数据库 QPS 降低了 70%。“


消息队列方案(重点加分项)

为什么需要消息队列

在 WMS 系统中,有两个场景必须用消息队列:

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

当 WMS 确认出库后,需要通知 OMS 订单系统更新订单状态。如果用同步调用:

  • OMS 接口慢,会拖慢 WMS 出库流程
  • OMS 服务挂了,WMS 出库失败(但货已经出去了)
  • 强耦合,WMS 要依赖 OMS 的接口

用消息队列后:

  • WMS 发消息就返回,不等 OMS 处理
  • OMS 挂了也不影响 WMS 出库
  • 解耦,WMS 只管发消息,不关心谁消费

场景 2:削峰填谷(秒杀场景)

大促期间,瞬间涌入大量订单,如果直接处理:

  • 数据库连接池打满
  • 库存扣减冲突严重
  • 系统崩溃

用消息队列后:

  • 订单先进队列,慢慢消费
  • 控制消费速度,保护数据库
  • 系统稳定,不会崩溃

我们用的是 RocketMQ

选型对比:

  • Kafka:吞吐量最高,但不保证消息顺序,适合日志收集
  • RabbitMQ:功能丰富,但性能一般,运维复杂
  • RocketMQ:阿里开源,性能好,支持事务消息,适合电商场景

库存变动异步通知的实现

1. WMS 发送消息

@Transactional(rollbackFor = Exception.class)
public void confirmOutbound(Long outboundId) {
    // 1. 扣减库存
    inventoryMapper.update(...);
    
    // 2. 更新出库单状态
    outboundOrder.setStatus(4);  // 已出库
    outboundOrderMapper.updateById(outboundOrder);
    
    // 3. 写流水
    writeLog(...);
    
    // 4. 发送消息(在事务提交后)
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                OutboundEvent event = new OutboundEvent();
                event.setOrderNo(outboundOrder.getOrderNo());
                event.setOutboundTime(LocalDateTime.now());
                event.setItems(outboundOrder.getItems());
                // 这个是基于Spring的事务 去实现的MQ的消息发送
                // 但是这个不是MQ的事务消息 这里也可以使用MQ的事务消息
                // 如果使用的是这种模式发送消息 其实也就是一个普通的同步消息
                // 就需要考虑 消息发送失败的情况 你需要做消息的补偿
                // 我们可以在这里做一个 try catch的处理 如果消息发送失败了 我们可以把这个消息的内容临时存储起来
                // 然后再通过定时任务 去把这个临时存储的消息进行定期重新发送
                rocketMQTemplate.syncSend("wms-outbound-topic", event);
            }
        }
    );
}

为什么在事务提交后发送消息?

如果在事务提交前发送消息:

  1. 消息发送成功
  2. OMS 收到消息,开始处理
  3. WMS 事务回滚了(比如写流水失败)
  4. 结果:OMS 以为出库了,但 WMS 实际没出库

所以必须在事务提交后发送消息。

2. OMS 消费消息

@RocketMQMessageListener(
    topic = "wms-outbound-topic",
    consumerGroup = "oms-consumer-group"
)
public class OutboundEventListener implements RocketMQListener<OutboundEvent> {
    
    @Override
    public void onMessage(OutboundEvent event) {
        try {
            // 1. 幂等性校验(防止重复消费)
            // 如果你的消息 都是能够被及时消费 且 该消息的消费者 最好是集群模式的 可以使用Redis进行幂等性的存储
            // 因为Redis存储的幂等性信息是有时间限制的 如果超过了这个时间 再去判断 幂等性就会失败
            // 所以短时间内判断是没有问题的 如果你想要持久化这个消费的记录 就要使用 MySQL 这种持久化的存储数据库
            String idempotentKey = "outbound:consumed:" + event.getOrderNo();
            Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(idempotentKey, "1", 7, TimeUnit.DAYS);
            
            if (Boolean.FALSE.equals(success)) {
                log.info("消息已消费过,跳过: {}", event.getOrderNo());
                return;  // 已经消费过了
            }
            
            // 2. 更新订单状态
            orderService.updateStatus(event.getOrderNo(), OrderStatus.SHIPPED);
            
            // 3. 通知 TMS 创建运单
            // 也是 MQ 异步消息
            tmsService.createWaybill(event.getOrderNo());
            
            log.info("出库事件处理成功: {}", event.getOrderNo());
            
        } catch (Exception e) {
            log.error("出库事件处理失败: {}", event.getOrderNo(), e);
            throw e;  // 抛异常,消息会重试
        }
    }
}

消息幂等性保证(重点)

为什么需要幂等性?

消息队列至少会投递一次(At Least Once),可能重复投递:

  • 网络抖动,消费者处理完了但 ACK 没发出去
  • 消费者重启,消息重新投递
  • 手动重试,消息再次投递

如果不做幂等性控制,可能导致:

  • 订单状态被重复更新
  • 运单被重复创建
  • 库存被重复扣减

幂等性方案:Redis + 唯一键

// 方案 1:用 Redis SETNX
String key = "outbound:consumed:" + event.getOrderNo();
Boolean success = redisTemplate.opsForValue()
    .setIfAbsent(key, "1", 7, TimeUnit.DAYS);

if (Boolean.FALSE.equals(success)) {
    return;  // 已经消费过了
}

// 方案 2:用数据库唯一索引
// 在消息消费记录表上建唯一索引:UNIQUE(order_no)
try {
    messageLogMapper.insert(new MessageLog(event.getOrderNo()));
} catch (DuplicateKeyException e) {
    return;  // 已经消费过了
}

我们用的是方案 1(Redis),因为:

  • 性能更好,不用查数据库
  • TTL 自动过期,不用手动清理
  • 简单,一行代码搞定

幂等性 Key 的设计原则:

  • 必须全局唯一:用订单号、出库单号等业务主键
  • 必须有过期时间:避免 Redis 内存爆满
  • 过期时间要足够长:至少 7 天,防止消息延迟投递

方案一 更通用 所有的消息消费都可以使用 方案二 有一定的局限性 你在消费者逻辑中 必须是数据库的写操作 而且要写的数据 必须要包含一个唯一约束的字段 如果能使用方案二 优先方案二

削峰填谷的实现

场景:大促期间订单创建

// 订单创建接口
@PostMapping("/create")
public Result<String> createOrder(@RequestBody OrderRequest request) {
    // 1. 基础校验
    validateOrder(request);
    
    // 2. 发送消息到队列
    OrderCreateEvent event = new OrderCreateEvent();
    event.setUserId(request.getUserId());
    event.setItems(request.getItems());
    event.setCreateTime(LocalDateTime.now());
    
    rocketMQTemplate.syncSend("order-create-topic", event);
    
    // 3. 立即返回
    return Result.success("订单提交成功,请稍后查看");
}

// 异步消费订单创建消息
@RocketMQMessageListener(
    topic = "order-create-topic",
    consumerGroup = "order-consumer-group",
    consumeThreadMax = 20  // 控制并发消费线程数
)
public class OrderCreateListener implements RocketMQListener<OrderCreateEvent> {
    
    @Override
    public void onMessage(OrderCreateEvent event) {
        // 1. 冻结库存
        inventoryService.freezeInventory(event.getItems());
        
        // 2. 创建订单
        orderService.create(event);
        
        // 3. 扣减积分
        pointService.deduct(event.getUserId(), event.getPoints());
    }
}

效果:

  • 接口响应时间从 800ms 降到 50ms
  • 数据库连接池使用率从 95% 降到 60%
  • 大促期间系统稳定,没有崩溃

消息丢失的防护

三个环节都可能丢消息:

  1. 生产者发送失败:网络抖动,消息没发出去

    • 解决:同步发送 + 重试机制
    SendResult result = rocketMQTemplate.syncSend("topic", event, 3000, 3);
    if (result.getSendStatus() != SendStatus.SEND_OK) {
        throw new BusinessException("消息发送失败");
    }
  2. Broker 宕机:消息在内存中还没持久化

    • 解决:RocketMQ 主从同步 + 刷盘策略(同步刷盘)
  3. 消费者处理失败:消费者宕机或处理异常

    • 解决:手动 ACK + 重试机制
    @Override
    public void onMessage(OutboundEvent event) {
        try {
            // 处理业务逻辑
            processEvent(event);
        } catch (Exception e) {
            log.error("消息处理失败", e);
            throw e;  // 抛异常,消息会自动重试
        }
    }

面试时怎么讲

“我们用 RocketMQ 实现了两个场景:一是库存变动异步通知 OMS,解耦系统;二是大促期间削峰填谷,保护数据库。消息幂等性用 Redis SETNX 实现,Key 是订单号,TTL 7 天。消息发送在事务提交后执行,保证数据一致性。消息消费失败会自动重试,最多重试 16 次。这套方案上线后,系统稳定性大幅提升,大促期间零故障。“


库存扣减这块一定要讲清楚

先把库存字段搞明白

我们系统里库存不是简单的一个数字,分了好几种:

  • quantity:实物库存,仓库里真实有多少货
  • frozen_qty:冻结库存,被订单预占了但还没出库的
  • defective_qty:不良品,质检不合格的
  • reserved_qty:预留库存,人工预留给活动的
  • in_transit_qty:在途库存,采购单确认了货在路上但还没到

真正能卖的是:quantity - frozen_qty - defective_qty - reserved_qty

为什么需要冻结库存?(面试高频问题)

问题场景:如果不用冻结库存,直接扣实物会怎样?

初始:quantity = 100

订单1创建(买50件):quantity = 100 - 50 = 50
订单2创建(买50件):quantity = 50 - 50 = 0

问题1:订单1还没发货,实物明明在仓库,但quantity已经变成0了
问题2:这时候来了订单3买10件,系统显示没货(但实际仓库有货)
问题3:如果订单1取消,quantity要加回去:quantity = 0 + 50 = 50
问题4:如果订单1、2都取消,quantity要加回100,但期间可能有其他订单,容易出错

用冻结库存的好处:

初始:quantity = 100, frozen_qty = 0, 可售 = 100

订单1创建(买50件):
  frozen_qty = 0 + 50 = 50
  quantity = 100(不动!)
  可售 = 100 - 50 = 50

订单2创建(买50件):
  frozen_qty = 50 + 50 = 100
  quantity = 100(不动!)
  可售 = 100 - 100 = 0

订单3创建(买10件):
  可售库存不足,下单失败 ✓

订单1取消:
  frozen_qty = 100 - 50 = 50
  quantity = 100(不动!)
  可售 = 100 - 50 = 50

订单4创建(买10件):
  frozen_qty = 50 + 10 = 60
  quantity = 100(不动!)
  可售 = 100 - 60 = 40

订单2发货(货真的出去了):
  quantity = 100 - 50 = 50(实物减少)
  frozen_qty = 60 - 50 = 10(释放预占)
  可售 = 50 - 10 = 40

订单4发货:
  quantity = 50 - 10 = 40(实物减少)
  frozen_qty = 10 - 10 = 0(释放预占)
  可售 = 40 - 0 = 40

核心优点:

  1. quantity 始终反映真实库存(仓库里真的有多少货)
  2. 订单取消只需要减 frozen_qty,不需要加 quantity
  3. **不会出现”实物在仓库但系统显示没货”**的情况
  4. 配合订单超时自动取消机制,冻结库存不会一直占着

订单超时自动取消机制

冻结库存会一直占着吗?不会!

// OMS 订单系统有超时取消机制
@Scheduled(cron = "0 */5 * * * ?")  // 每5分钟扫描一次
public void cancelTimeoutOrders() {
    // 查询超过30分钟未支付的订单
    List<Order> timeoutOrders = orderMapper.selectList(
        new LambdaQueryWrapper<Order>()
            .eq(Order::getStatus, OrderStatus.PENDING_PAYMENT)
            .lt(Order::getCreateTime, LocalDateTime.now().minusMinutes(30))
    );
    
    for (Order order : timeoutOrders) {
        // 1. 取消订单
        order.setStatus(OrderStatus.CANCELLED);
        orderMapper.updateById(order);
        
        // 2. 释放冻结库存
        inventoryService.unfreezeInventory(order.getOrderNo());
    }
}

超时取消规则:

  • 未支付订单:30分钟自动取消,释放冻结库存
  • 已支付订单:72小时未发货自动取消,释放冻结库存
  • 用户主动取消:立即释放冻结库存

库存扣减的完整流程(重点)

举个例子,客户在淘宝下单买 20 件蓝牙耳机:

初始状态:

quantity = 100(仓库有100件货)
frozen_qty = 0
可售 = 100

第一步:订单创建,冻结库存

OMS 订单系统收到订单后,立即调 WMS 接口冻结库存:

UPDATE inventory 
SET frozen_qty = frozen_qty + 20 
WHERE sku_id = ? 
  AND (quantity - frozen_qty - defective_qty - reserved_qty) >= 20

执行后:

quantity = 100(货还在仓库,没动)
frozen_qty = 20(预留给这个订单了)
可售 = 80(其他订单只能看到80件)

第二步:拣货出库,货真的出去了

仓管员拣货完成,确认出库:

UPDATE inventory 
SET quantity = quantity - 20,
    frozen_qty = frozen_qty - 20
WHERE sku_id = ? AND quantity >= 20

执行后:

quantity = 80(货出去了)
frozen_qty = 0(预占释放了)
可售 = 80

第三步:如果订单取消了

UPDATE inventory 
SET frozen_qty = frozen_qty - 20
WHERE sku_id = ?

执行后:

quantity = 100(货一直在仓库,没动过)
frozen_qty = 0(释放预占)
可售 = 100(恢复了)

为什么需要冻结库存?库存状态管理的核心

核心场景:订单已接收,但货还没发出去

假设商家在淘宝、京东、拼多多都开了店,仓库里某个 SKU(蓝牙耳机)有 100 件货。

初始状态:
quantity = 100(仓库实际有100件货)
frozen_qty = 0(没有订单占用)
可售库存 = 100

时刻1:Amazon 平台推送订单(买50件)
- OMS 接收订单,调用 WMS 冻结库存
- frozen_qty = 0 + 50 = 50
- quantity = 100(货还在仓库,没动)
- 可售库存 = 100 - 50 = 50

时刻2:Shopify 平台推送订单(买30件)
- OMS 接收订单,调用 WMS 冻结库存
- frozen_qty = 50 + 30 = 80
- quantity = 100(货还在仓库,没动)
- 可售库存 = 100 - 80 = 20

此时商家在供应链系统的库存看板上可以看到:
┌─────────────────────────────────────┐
│ SKU: 蓝牙耳机                        │
│ 仓库实际库存: 100 件                 │
│ 已分配给订单: 80 件                  │
│   - Amazon 订单: 50 件(待发货)     │
│   - Shopify 订单: 30 件(待发货)    │
│ 可售库存: 20 件                      │
└─────────────────────────────────────┘

时刻3:Amazon 订单发货(50件货出库)
- 拣货员从仓库拿走50件货
- quantity = 100 - 50 = 50(实物减少)
- frozen_qty = 80 - 50 = 30(释放预占)
- 可售库存 = 50 - 30 = 20

此时商家看到:
┌─────────────────────────────────────┐
│ SKU: 蓝牙耳机                        │
│ 仓库实际库存: 50 件                  │
│ 已分配给订单: 30 件                  │
│   - Shopify 订单: 30 件(待发货)    │
│ 可售库存: 20 件                      │
└─────────────────────────────────────┘

时刻4:Shopify 订单取消(客户申请退款)
- frozen_qty = 30 - 30 = 0(释放预占)
- quantity = 50(货一直在仓库,没动)
- 可售库存 = 50 - 0 = 50

此时商家看到:
┌─────────────────────────────────────┐
│ SKU: 蓝牙耳机                        │
│ 仓库实际库存: 50 件                  │
│ 已分配给订单: 0 件                   │
│ 可售库存: 50 件                      │
└─────────────────────────────────────┘

冻结库存的真正价值:

  1. 库存状态可见性

    • 商家可以清楚看到:仓库有多少货、有多少已分配给订单、还有多少可以卖
    • 不用冻结库存的话,只能看到一个 quantity,不知道哪些是空闲的、哪些是已分配的
  2. 多平台库存统一管理

    • 商家在淘宝、京东、拼多多都开店,可以看到每个平台占用了多少库存
    • 比如:淘宝订单占用50件、京东订单占用30件,一目了然
  3. 准确反映库存状态

    • quantity 始终反映仓库实际库存(货真的在仓库里)
    • frozen_qty 反映已分配给订单的库存(订单已接收,但货还没发出去)
    • 订单取消时,只需要释放 frozen_qty,不需要加 quantity(因为货一直在仓库)
  4. 避免库存数据混乱

    • 如果不用冻结库存,订单创建时直接扣 quantity,订单取消时又要加回去
    • 容易出现”实物在仓库但系统显示没货”或”系统显示有货但实物已经没了”的情况

面试官肯定会问的几个问题

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

答:因为货真的出去了啊!quantity 减少是因为实物少了,frozen_qty 减少是因为预占要释放掉,不然这 20 件的预占会一直占着。

Q:订单取消为什么只减 frozen_qty,不加 quantity?

答:因为实物一直在仓库里,从来没动过。订单创建时只是”预留”了库存,实物没动;订单取消时只是取消”预留”,实物还是没动。

Q:如果不用冻结库存,直接扣实物会怎样?

答:会出大问题。订单创建时就扣实物,如果订单取消还得加回去,如果订单长时间不发货,实物明明在仓库但系统显示没货,其他订单就买不了了。虽然用了冻结库存,可售库存也会减少,但配合订单超时自动取消机制(未支付30分钟取消,已支付72小时未发货取消),冻结库存会自动释放,不会一直占着。

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

答:冻结库存的核心作用是区分”仓库实际库存”和”已分配给订单的库存”。商家在 Amazon、Shopify、eBay 都开了店,但仓库只有一个,库存是共享的。当订单从各个平台推送到供应链系统后,我们需要标记这些库存”已分配给订单”,但货还在仓库里没发出去。这样商家可以清楚看到:仓库有100件货,其中 Amazon 订单占用50件、Shopify 订单占用30件,还剩20件可售。如果不用冻结库存,只有一个 quantity 字段,商家就不知道哪些是空闲的、哪些是已分配的,库存状态就不清晰了。

Q:大促期间,供应链系统有什么压力?不应该只是电商平台压力大吗?

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

  1. 库存查询压力:用户在电商平台浏览商品时,电商平台会实时查询供应链系统的库存。大促期间(如黑色星期五),Amazon、Shopify、eBay 的流量汇聚到我们的供应链系统,热门 SKU 的库存查询 QPS 可达 5000+。
  2. 订单创建压力:用户在电商平台下单后,订单会推送到供应链系统。黑五零点,10秒内可能涌入10万笔订单,每笔订单都要创建订单、冻结库存、写流水,数据库连接池直接打满。
  3. 库存同步压力:库存变动后,需要重新计算各平台可分配库存并同步到各个电商平台。每秒1000笔订单,每笔订单要同步到3个平台,就是每秒3000次同步请求。

所以我们用 Redis 缓存解决库存查询压力,用 MQ 削峰填谷解决订单创建压力,用异步通知解决库存同步压力。

代码实现(重点讲这个)

我们用的是 MyBatis-Plus,核心思路是数据库原子操作 + WHERE 条件校验。

冻结库存的代码:

@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 条件判断可售库存够不够
            // CAS 更新 保障了原子性操作
            .apply("quantity - frozen_qty - defective_qty - reserved_qty >= {0}", qty)
            // 原子操作
            .setSql("frozen_qty = frozen_qty + " + qty)
    );
    
    if (updated == 0) {
        throw new BusinessException("库存不足");
    }
}

出库扣减的代码:

@Transactional(rollbackFor = Exception.class)
public void outbound(Long skuId, Long warehouseId, Integer qty) {
    int updated = inventoryMapper.update(null, 
        new LambdaUpdateWrapper<Inventory>()
            .eq(Inventory::getSkuId, skuId)
            .eq(Inventory::getWarehouseId, warehouseId)
            .ge(Inventory::getQuantity, qty)
            // 同时扣实物和冻结
            // 这边还需要使用 CAS 更新吗 不需要了 
            .setSql("quantity = quantity - " + qty + ", frozen_qty = frozen_qty - " + qty)
    );
    
    if (updated == 0) {
        throw new BusinessException("库存不足");
    }
    
    // 写流水
    writeLog(...);
}

为什么这样是安全的?

  1. UPDATE 本身是原子操作
  2. WHERE 条件里判断库存够不够
  3. 如果条件不满足,updated 返回 0,直接失败
  4. 整个在事务里,失败就回滚

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

面试官可能会问这个。

悲观锁的问题:

  • 会锁住整行,其他事务得等着
  • 容易死锁
  • 锁持有时间长,性能差

我们用原子操作的好处:

  • 不会阻塞其他事务
  • 在库存充足的情况下,冲突概率很低
  • 实现简单,一条 SQL 搞定

如果真的是秒杀场景,那可以考虑 Redis 预扣减,但跨境电商不会有那种极端场景。

库存流水表(必问)

为什么需要流水表?

  1. 审计追溯:库存出问题了能查到是谁在什么时候干了什么
  2. 数据恢复:库存数据错了可以通过流水记录恢复
  3. 对账:财务对账需要出入库明细
  4. 分析:统计 SKU 出库频率,做 ABC 分类

流水表的核心原则:==只增不改不删==

我写的代码:

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);
}

怎么保证不被篡改?

  • 应用层:代码里永远不写 UPDATE/DELETE
  • 数据库权限:应用账号只有 INSERT 和 SELECT 权限
  • 审计监控:定期检查流水的连续性

怎么还原历史库存?

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());
}

这样就能还原任意时刻的库存状态。


FIFO 先进先出的出库策略

为什么需要 FIFO

  1. 避免过期:化妆品、食品这些有保质期的,必须先进先出
  2. 避免积压:货放久了品质会下降
  3. 提高周转:减少资金占用

业务场景

订单要出库 100 件蓝牙耳机,仓库里有:

  • 库位 A-01-02(2025-01-01 入库):80 件
  • 库位 A-01-03(2025-01-10 入库):60 件
  • 库位 A-02-01(2025-01-15 入库):120 件

按 FIFO:

  • 先从 A-01-02 拣 80 件(最早的)
  • 再从 A-01-03 拣 20 件
  • A-02-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();
}

返回的就是拣货指令列表,告诉仓管员从哪个库位拣多少件。

拣货路径优化

如果只按入库时间排序,可能会让仓管员在仓库里来回跑。

比如仓库布局是 A区 → B区 → C区:

原始 FIFO(只按时间):

A-01-02 (80件, 1月1日)
C-03-01 (20件, 1月2日)
A-02-05 (30件, 1月2日)
B-01-03 (50件, 1月3日)

路线:A → C → A → B(来回跑)

优化后(同一天的按区域排):

A-01-02 (80件, 1月1日)  -- 第1天,必须先拣
A-02-05 (30件, 1月2日)  -- 第2天,A区
B-01-03 (50件, 1月3日)  -- 第3天,B区
C-03-01 (20件, 1月2日)  -- 第2天,C区

路线:A → A → B → C(单向移动)

实现思路:

  1. 先按入库日期分组
  2. 每组内按区域、排、列、层排序
  3. 这样既保证了 FIFO,又优化了路径

效果:拣货时间从 8 分钟降到 5.5 分钟,效率提升 30%。


库存盘点

为什么要盘点

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

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

循环盘点 vs 全盘

传统全盘的问题:

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

循环盘点的好处:

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

ABC 分类:

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

盘点流程

  1. 定时任务自动生成盘点计划(每天晚上 22:00)
  2. 系统记录账面库存(book_qty)
  3. 仓管员实际清点,填实盘数量(actual_qty)
  4. 系统算差异:diff_qty = actual_qty - book_qty
  5. 有差异必须填原因
  6. 管理员审核
  7. 执行库存调整,写流水

差异调整的代码

盘点结束之后 需要生成对应的盘点记录 系统会自动计算: 账面的数量 实际的数量 是否一致 如果不一致 也就是存在差异 我们需要把这个差异的数据 更新到账面数量上来

@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);
        
        // 更新盘点明细
        item.setIsAdjusted(true);
        stocktakeItemMapper.updateById(item);
    }
    
    // 更新任务状态
    StocktakeTask task = new StocktakeTask();
    task.setId(taskId);
    task.setStatus(3);  // 已完成
    stocktakeTaskMapper.updateById(task);
}

关键是要在事务里,要么全成功,要么全回滚。


简历怎么写

项目描述

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

这是个跨境电商的供应链 SaaS 平台,我负责仓储管理模块。系统要管理多个仓库
(国内仓、FBA 仓、海外仓)的库存,支持采购入库、销售出库、仓库调拨、库存
盘点等全流程业务。日均处理出入库单 5 万+,库存流水 20 万+。

核心难点:
1. 高并发场景下的库存状态管理,准确区分实物库存和已分配库存
2. 多仓库、多库位的库存精细化管理
3. 库存数据的准确性和可追溯性
4. 先进先出(FIFO)出库策略和拣货路径优化
5. 热点 SKU 的高频查询性能优化
6. 海量库存流水数据的存储和查询优化

核心亮点(8 个重点)

1. 库存扣减的并发控制方案

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

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

具体实现:

  • 订单创建时先冻结库存(frozen_qty),不动实物库存(quantity
  • 拣货出库时同时扣减实物和冻结库存
  • 订单取消时只释放冻结库存
  • 使用 MyBatis-Plus 的 setSql() 实现原子操作:frozen_qty = frozen_qty + N
  • 在 WHERE 条件中判断库存是否充足,不满足直接失败

效果:多平台订单并发场景下,库存数据准确率 100%,quantity 始终反映仓库实际库存,frozen_qty 反映已分配给订单的库存,库存状态清晰可见

技术细节:

// 关键代码
int updated = inventoryMapper.update(null, 
    new LambdaUpdateWrapper<Inventory>()
        .eq(Inventory::getSkuId, skuId)
        // WHERE 条件判断可售库存
        .apply("quantity - frozen_qty >= {0}", qty)
        // 原子操作
        .setSql("frozen_qty = frozen_qty + " + qty)
);
if (updated == 0) {
    throw new BusinessException("库存不足");
}

2. 库存流水表的不可篡改设计

问题:库存数据出现差异时,无法追溯是谁在什么时候改的

方案:设计了只增不改不删的库存流水表

具体实现:

  • 每次库存变动都写入流水表,记录变动前、变动后、变动数量、操作人、操作时间
  • 应用层代码永远不写 UPDATE/DELETE 逻辑
  • 数据库账号只给 INSERT 和 SELECT 权限,没有 UPDATE/DELETE 权限
  • 通过流水表可以还原任意时刻的库存快照

效果:库存数据完全可追溯,出现问题能快速定位原因

技术细节:

// 还原历史库存
InventoryLog log = inventoryLogMapper.selectOne(
    new LambdaQueryWrapper<InventoryLog>()
        .le(InventoryLog::getOperateTime, targetTime)
        .orderByDesc(InventoryLog::getOperateTime)
        .last("limit 1")
);
return log.getAfterQty();  // 那个时刻的库存

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

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

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

具体实现:

  • last_inbound_time 字段升序排列,最早入库的优先出库
  • 在保证 FIFO 的前提下,对同一天入库的货物按库位物理位置排序
  • 库位排序规则:zone(区域)→ row_no(排)→ column_no(列)→ floor_no(层)
  • 生成拣货指令列表,告诉仓管员从哪个库位拣多少件

效果:

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

技术细节:

// 按入库时间 + 库位位置排序
List<Inventory> inventories = inventoryMapper.selectList(
    new LambdaQueryWrapper<Inventory>()
        .orderByAsc(Inventory::getLastInboundTime)  // FIFO
        .orderByAsc(Inventory::getZone)             // 区域
        .orderByAsc(Inventory::getRowNo)            // 排
);

4. 循环盘点机制

问题:传统全盘需要停止运营 2-3 天,影响发货

方案:实现循环盘点,按 ABC 分类设置不同盘点频率

具体实现:

  • 使用 XXL-Job 定时任务,每天晚上 22:00 自动生成盘点计划
  • A 类 SKU(销售额前 20%)每月盘一次
  • B 类 SKU(销售额 20%-50%)每季度盘一次
  • C 类 SKU(销售额后 50%)每半年盘一次
  • 盘点差异调整在事务中执行,同时写入库存流水

效果:

  • 不影响日常运营
  • 盘点差异率从 2.3% 降到 0.5%
  • 全年持续盘点,及时发现问题

5. 五种库存类型的精细化管控

问题:简单的库存数量无法满足复杂业务场景

方案:设计了五种库存类型

具体实现:

  • quantity:实物库存(仓库里真实有多少货)
  • frozen_qty:冻结库存(被订单预占但还没出库的)
  • in_transit_qty:在途库存(采购单确认在路上但还没到的)
  • defective_qty:不良品库存(质检不合格不能卖的)
  • reserved_qty:预留库存(人工预留给活动的)
  • 可售库存 = quantity - frozen_qty - defective_qty - reserved_qty

效果:

  • 支持多平台库存统一管理,清晰展示各平台库存占用情况
  • 支持在途库存管理,提前做补货计划
  • 支持不良品隔离,避免发错货

6. 热点 SKU 库存的 Redis 缓存方案

问题:大促期间,热门 SKU 的库存查询 QPS 达 5000+,数据库压力大,P99 延迟 80ms

方案:对热点 SKU 实现 Redis 缓存,解决三大缓存问题

具体实现:

  • 用 Redis Hash 存储库存信息,TTL 60 分钟
  • 缓存更新策略:先更新数据库,再删除缓存(在事务提交后删除)
  • 缓存穿透:布隆过滤器(100 万 SKU 占用 1.2MB)+ 空值缓存(TTL 5 分钟)
  • 缓存击穿:分布式锁(Redis SETNX)+ 双重检查 + 热点 SKU 预热
  • 缓存雪崩:过期时间加随机值(60 分钟 ± 5 分钟)

效果:

  • 库存查询接口 P99 延迟从 80ms 降到 5ms
  • 数据库 QPS 降低 70%
  • 缓存命中率 95%

技术细节:

// 在事务提交后删除缓存
TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            redisTemplate.delete("inventory:sku:" + skuId + ":" + warehouseId);
        }
    }
);

7. 基于 RocketMQ 的异步解耦和削峰填谷

问题:WMS 出库后需要同步调用 OMS 接口,OMS 慢会拖慢 WMS;大促期间订单瞬间涌入,数据库连接池打满

方案:引入 RocketMQ 实现系统解耦和削峰填谷

具体实现:

  • 库存变动异步通知 OMS:WMS 在事务提交后发送消息,OMS 异步消费
  • 订单创建削峰:订单先进队列,控制消费速度(20 个线程并发消费)
  • 消息幂等性:Redis SETNX 实现,Key 为订单号,TTL 7 天
  • 消息可靠性:同步发送 + 重试机制 + 主从同步 + 手动 ACK

效果:

  • WMS 出库接口响应时间从 800ms 降到 50ms
  • 大促期间系统稳定,零故障
  • 数据库连接池使用率从 95% 降到 60%

技术细节:

// 消息幂等性实现
String key = "outbound:consumed:" + event.getOrderNo();
Boolean success = redisTemplate.opsForValue()
    .setIfAbsent(key, "1", 7, TimeUnit.DAYS);
if (Boolean.FALSE.equals(success)) {
    return;  // 已经消费过了
}

8. 库存流水表按月分表 + 批量操作优化

问题:库存流水表只增不删,日均 20 万条,一年 7300 万条,单表查询慢;批量入库逐个插入流水,性能差

方案:按月分表 + 批量操作优化

具体实现:

  • 按月分表:inventory_log_202501、inventory_log_202502…
  • 应用层路由:根据 operate_time 动态计算表名
  • 自动建表:定时任务每月 1 号自动创建下个月的表
  • 历史归档:超过 1 年的数据归档到 OSS,删除表
  • 批量插入:MyBatis-Plus saveBatch,一次插入 100 条
  • 慢 SQL 优化:分页查询用延迟关联,库存预警增加冗余字段和索引

效果:

  • 单表数据量控制在 300 万以内,查询性能稳定
  • 批量入库 100 个 SKU,从 500ms 降到 100ms
  • 分页查询从 2.3 秒降到 80ms
  • 库存预警查询从 1.5 秒降到 20ms
  • 系统整体性能提升 60%

技术细节:

// 动态表名
String tableName = "inventory_log_" + 
    log.getOperateTime().format(DateTimeFormatter.ofPattern("yyyyMM"));

// 批量插入
inventoryLogService.saveBatch(logs, 100);

// 延迟关联分页
List<Long> ids = inventoryLogMapper.selectIdPage(pageNum, pageSize);
List<InventoryLog> logs = inventoryLogMapper.selectBatchIds(ids);

性能优化方案(加分项)

批量入库的性能优化

问题场景:

采购入库时,一个采购单可能有 100 个 SKU,如果逐个插入库存流水:

for (InboundItem item : items) {
    inventoryLogMapper.insert(log);  // 100 次数据库交互
}

执行时间:100 次 × 5ms = 500ms,太慢了!

优化方案 1:MyBatis-Plus 批量插入

@Transactional(rollbackFor = Exception.class)
public void batchInbound(List<InboundItem> items) {
    List<InventoryLog> logs = new ArrayList<>();
    
    for (InboundItem item : items) {
        // 1. 更新库存
        inventoryMapper.update(...);
        
        // 2. 构建流水对象
        InventoryLog log = new InventoryLog();
        log.setSkuId(item.getSkuId());
        log.setChangeQty(item.getQty());
        // ... 其他字段
        logs.add(log);
    }
    
    // 3. 批量插入流水(一次性插入 100 条)
    inventoryLogService.saveBatch(logs, 100);
}

执行时间:100ms,性能提升 5 倍!

优化方案 2:批量更新库存

库存更新也可以批量:

// 方案 A:拼接 SQL(不推荐,有 SQL 注入风险)
UPDATE inventory SET quantity = CASE 
    WHEN sku_id = 1001 THEN quantity + 50
    WHEN sku_id = 1002 THEN quantity + 30
    ELSE quantity
END
WHERE sku_id IN (1001, 1002);

// 方案 B:用 MyBatis foreach(推荐)
<update id="batchUpdate">
    <foreach collection="list" item="item" separator=";">
        UPDATE inventory 
        SET quantity = quantity + #{item.qty}
        WHERE sku_id = #{item.skuId} 
          AND warehouse_id = #{item.warehouseId}
    </foreach>
</update>

但要注意:MySQL 默认不支持多语句执行,需要在连接串加参数:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/wms?allowMultiQueries=true

效果:

  • 批量入库 100 个 SKU,从 500ms 降到 100ms
  • 数据库连接占用时间减少 80%
  • 大批量入库(1000+ SKU)性能提升更明显

慢 SQL 优化

问题 1:库存流水分页查询慢

原始 SQL:

SELECT * FROM inventory_log
WHERE tenant_id = 1001
ORDER BY operate_time DESC
LIMIT 100000, 20;

执行时间:2.3 秒(扫描了 10 万行)

优化方案:延迟关联

-- 先查主键(走索引,很快)
SELECT id FROM inventory_log
WHERE tenant_id = 1001
ORDER BY operate_time DESC
LIMIT 100000, 20;

-- 再根据主键查详情
SELECT * FROM inventory_log
WHERE id IN (上面查出的 20 个 ID);

执行时间:80ms,性能提升 28 倍!

代码实现:

public PageResult<InventoryLog> pageLogs(PageQuery query) {
    // 1. 先查 ID(走索引)
    List<Long> ids = inventoryLogMapper.selectIdPage(
        query.getPageNum(), 
        query.getPageSize()
    );
    
    if (ids.isEmpty()) {
        return PageResult.empty();
    }
    
    // 2. 再查详情
    List<InventoryLog> logs = inventoryLogMapper.selectBatchIds(ids);
    
    // 3. 查总数(可以缓存)
    long total = inventoryLogMapper.selectCount(...);
    
    return PageResult.of(logs, total);
}

问题 2:库存预警查询慢

原始 SQL:

SELECT * FROM inventory
WHERE quantity - frozen_qty - defective_qty - reserved_qty <= safety_stock;

执行时间:1.5 秒(全表扫描,无法走索引)

优化方案:冗余字段 + 索引

在 inventory 表增加冗余字段:

ALTER TABLE inventory ADD COLUMN available_qty INT DEFAULT 0;
CREATE INDEX idx_available_safety ON inventory(available_qty, safety_stock);

每次库存变动时更新 available_qty:

inventoryMapper.update(null, 
    new LambdaUpdateWrapper<Inventory>()
        .setSql("quantity = quantity - " + qty)
        .setSql("available_qty = quantity - frozen_qty - defective_qty - reserved_qty")
);

查询改为:

SELECT * FROM inventory
WHERE available_qty <= safety_stock;

执行时间:20ms,性能提升 75 倍!

索引优化原则:

  1. 高频查询字段建索引:tenant_id, sku_id, warehouse_id
  2. 排序字段建索引:operate_time, create_time
  3. 联合索引遵循最左前缀:(tenant_id, sku_id, warehouse_id)
  4. 避免在计算字段上查询:quantity - frozen_qty 无法走索引

库存流水表分表(按月分表)

问题:

库存流水表只增不删,数据量暴增:

  • 每天 10 万条流水
  • 一年 3650 万条
  • 三年 1 亿+

单表数据量过大会导致:

  • 查询慢(即使有索引)
  • 备份慢
  • 归档难

解决方案:按月分表

表结构:

inventory_log_202501  -- 2025年1月
inventory_log_202502  -- 2025年2月
inventory_log_202503  -- 2025年3月
...

方案 1:应用层路由(我们用的)

public class InventoryLogMapper {
    
    @Autowired
    private SqlSessionFactory sqlSessionFactory;
    
    public void insert(InventoryLog log) {
        // 1. 根据操作时间计算表名
        String tableName = "inventory_log_" + 
            log.getOperateTime().format(DateTimeFormatter.ofPattern("yyyyMM"));
        
        // 2. 动态设置表名
        log.setTableName(tableName);
        
        // 3. 插入
        SqlSession session = sqlSessionFactory.openSession();
        session.insert("insertLog", log);
    }
    
    public List<InventoryLog> selectByMonth(String month) {
        String tableName = "inventory_log_" + month;
        return sqlSessionFactory.openSession()
            .selectList("selectLogs", Map.of("tableName", tableName));
    }
}

MyBatis XML:

<insert id="insertLog">
    INSERT INTO ${tableName} (sku_id, change_qty, operate_time, ...)
    VALUES (#{skuId}, #{changeQty}, #{operateTime}, ...)
</insert>

<select id="selectLogs" resultType="InventoryLog">
    SELECT * FROM ${tableName}
    WHERE tenant_id = #{tenantId}
    ORDER BY operate_time DESC
</select>

方案 2:ShardingSphere 自动分表(更优雅)

配置文件:

spring:
  shardingsphere:
    rules:
      sharding:
        tables:
          inventory_log:
            actual-data-nodes: ds0.inventory_log_$->{202501..202612}
            table-strategy:
              standard:
                sharding-column: operate_time
                sharding-algorithm-name: log-month-algorithm
        sharding-algorithms:
          log-month-algorithm:
            type: CLASS_BASED
            props:
              strategy: STANDARD
              algorithmClassName: com.lyf.sharding.MonthShardingAlgorithm

自定义分片算法:

public class MonthShardingAlgorithm implements StandardShardingAlgorithm<LocalDateTime> {
    
    @Override
    public String doSharding(Collection<String> availableTargetNames, 
                             PreciseShardingValue<LocalDateTime> shardingValue) {
        LocalDateTime time = shardingValue.getValue();
        String suffix = time.format(DateTimeFormatter.ofPattern("yyyyMM"));
        return "inventory_log_" + suffix;
    }
}

使用时完全透明:

// 代码不用改,ShardingSphere 自动路由到正确的表
inventoryLogMapper.insert(log);

自动建表:

定时任务每月 1 号自动创建下个月的表:

@Scheduled(cron = "0 0 1 1 * ?")  // 每月 1 号凌晨 1 点
public void createNextMonthTable() {
    String nextMonth = LocalDate.now().plusMonths(1)
        .format(DateTimeFormatter.ofPattern("yyyyMM"));
    String tableName = "inventory_log_" + nextMonth;
    
    String sql = "CREATE TABLE IF NOT EXISTS " + tableName + " LIKE inventory_log_template";
    jdbcTemplate.execute(sql);
    
    log.info("自动创建分表: {}", tableName);
}

历史数据归档:

超过 1 年的数据归档到 OSS:

@Scheduled(cron = "0 0 2 1 * ?")  // 每月 1 号凌晨 2 点
public void archiveOldData() {
    String archiveMonth = LocalDate.now().minusYears(1)
        .format(DateTimeFormatter.ofPattern("yyyyMM"));
    String tableName = "inventory_log_" + archiveMonth;
    
    // 1. 导出数据到 CSV
    List<InventoryLog> logs = inventoryLogMapper.selectAll(tableName);
    String csvFile = exportToCsv(logs);
    
    // 2. 上传到 OSS
    ossClient.putObject("wms-archive", archiveMonth + ".csv", new File(csvFile));
    
    // 3. 删除表
    jdbcTemplate.execute("DROP TABLE IF EXISTS " + tableName);
    
    log.info("归档完成: {}", tableName);
}

效果:

  • 单表数据量控制在 300 万以内
  • 查询性能稳定,不随时间增长而变慢
  • 历史数据归档到 OSS,成本降低 90%

其他性能优化点

1. 连接池优化

spring:
  datasource:
    hikari:
      maximum-pool-size: 50        # 最大连接数
      minimum-idle: 10             # 最小空闲连接
      connection-timeout: 30000    # 连接超时 30 秒
      idle-timeout: 600000         # 空闲连接超时 10 分钟
      max-lifetime: 1800000        # 连接最大存活 30 分钟

2. 读写分离

库存查询走从库,库存变动走主库:

@Transactional(readOnly = true)  // 走从库
public List<Inventory> queryInventory() {
    return inventoryMapper.selectList(...);
}

@Transactional(rollbackFor = Exception.class)  // 走主库
public void updateInventory() {
    inventoryMapper.update(...);
}

3. 异步写日志

库存流水写入改为异步:

@Async("logExecutor")
public void writeLogAsync(InventoryLog log) {
    inventoryLogMapper.insert(log);
}

配置线程池:

@Configuration
public class AsyncConfig {
    @Bean("logExecutor")
    public Executor logExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("log-");
        executor.initialize();
        return executor;
    }
}

面试时怎么讲

“我们做了三个方面的性能优化。第一是批量操作,批量入库用 MyBatis-Plus 的 saveBatch,性能提升 5 倍。第二是慢 SQL 优化,分页查询用延迟关联,从 2.3 秒降到 80ms;库存预警查询增加冗余字段和索引,从 1.5 秒降到 20ms。第三是库存流水表按月分表,单表数据量控制在 300 万以内,查询性能稳定。另外还做了连接池优化、读写分离、异步写日志等。这些优化上线后,系统整体性能提升 60%,P99 延迟从 500ms 降到 80ms。“


面试高频问题

Q1:如何保证库存数据的准确性?

四个层面:

  1. 事务保证:所有库存变动在事务中执行
  2. 库存流水:每次变动都写流水,只增不改不删
  3. 定期盘点:循环盘点,高价值商品每月盘
  4. 并发控制:原子操作 + WHERE 条件校验

Q2:如果库存扣减失败怎么办?

三种情况:

  1. 库存不足:返回错误,提示用户
  2. 并发冲突:WHERE 条件不满足,updated 返回 0,事务回滚
  3. 数据库异常:事务回滚,记录日志,告警

如果是高并发场景,可以考虑 Redis 预扣减。

Q3:库存流水表数据量大怎么办?

五个优化:

  1. 分表:按月分表,单表控制在 300 万以内
  2. 归档:超过 1 年的归档到 OSS
  3. 索引:(tenant_id, sku_id, operate_time)
  4. 读写分离:查询走从库
  5. 冷热分离:近 3 个月 SSD,历史数据 HDD

Q4:为什么不用悲观锁?

悲观锁问题:

  • 锁住整行,其他事务等待
  • 容易死锁
  • 性能差

原子操作优势:

  • 不阻塞
  • 冲突概率低
  • 实现简单

Q5:FIFO 怎么保证的?

last_inbound_time 升序排列,最早入库的排前面,然后按顺序分配拣货数量。

Q6:Redis 缓存和数据库如何保证一致性?

我们用的是 Cache Aside 模式:先更新数据库,再删除缓存。

关键点:

  1. 在事务提交后删除缓存,避免脏数据
  2. 删除而不是更新缓存,因为库存计算逻辑复杂
  3. 下次查询时自动从数据库加载最新数据

如果担心并发问题,可以用延迟双删,但我们实际没用,因为增加了复杂度。

Q7:消息队列如何保证消息不丢失?

三个环节都要保证:

  1. 生产者:同步发送 + 重试机制
  2. Broker:主从同步 + 同步刷盘
  3. 消费者:手动 ACK + 异常重试

另外,消息发送在事务提交后执行,保证数据一致性。

Q8:消息队列如何保证幂等性?

用 Redis SETNX 实现:

String key = "outbound:consumed:" + event.getOrderNo();
Boolean success = redisTemplate.opsForValue()
    .setIfAbsent(key, "1", 7, TimeUnit.DAYS);

if (Boolean.FALSE.equals(success)) {
    return;  // 已经消费过了
}

Key 设计原则:

  • 全局唯一:用订单号等业务主键
  • 有过期时间:至少 7 天
  • 先判断再处理:避免重复消费

Q9:批量入库为什么比逐个插入快?

逐个插入:100 次数据库交互,每次 5ms,总共 500ms 批量插入:1 次数据库交互,总共 100ms

原因:

  • 减少网络往返次数
  • 减少 SQL 解析次数
  • 减少事务提交次数

Q10:分页查询为什么用延迟关联?

原始查询:LIMIT 100000, 20 需要扫描 10 万行,然后丢弃前 9.998 万行。

延迟关联:先查 ID(走索引,只返回 20 个 ID),再根据 ID 查详情。

效果:从 2.3 秒降到 80ms,性能提升 28 倍。

也可以使用游标定位到分页数据的开始位置

Q11:为什么要按月分表?

单表数据量过大会导致:

  • 查询慢(即使有索引)
  • 备份慢
  • 归档难

按月分表后:

  • 单表数据量控制在 300 万以内
  • 查询性能稳定
  • 历史数据可以单独归档

数据可控 操作简单 只需要依赖 MyBatis 或者 MyBatis-Plus 即可实现

Q12:缓存穿透、击穿、雪崩有什么区别?

  • 穿透:查询不存在的数据,缓存和数据库都没有,每次都打到数据库

    • 解决:布隆过滤器 + 空值缓存
  • 击穿:热点 Key 过期瞬间,大量请求同时打到数据库

    • 解决:分布式锁 + 缓存预热
  • 雪崩:大量 Key 同时过期,数据库瞬间压力暴增

    • 解决:过期时间加随机值

面试准备建议

必须能画的图

库存扣减流程:

订单创建 → 冻结库存 → 拣货出库 → 扣减实物和冻结

    订单取消 → 释放冻结

FIFO 分配:

需要 100 件

库位 A (80件, 1月1日) → 拣 80 ✓
库位 B (60件, 1月10日) → 拣 20 ✓
库位 C (120件, 1月15日) → 不拣

Redis 缓存更新流程:

更新库存 → 提交事务 → 删除缓存 → 下次查询时重新加载

消息队列异步通知:

WMS 出库 → 提交事务 → 发送消息 → OMS 消费 → 更新订单状态

必须能讲清楚的

基础概念:

  • 为什么需要冻结库存?
  • 为什么出库时要同时扣 quantityfrozen_qty
  • 为什么订单取消只减 frozen_qty
  • 什么是库存流水表?为什么只增不改不删?
  • 什么是 FIFO?为什么需要?

技术方案:

  • Redis 缓存为什么先更新数据库再删除缓存?
  • 缓存穿透、击穿、雪崩的区别和解决方案?
  • 消息队列如何保证幂等性?
  • 为什么要按月分表?
  • 批量操作为什么比逐个操作快?

必须能写的 SQL

-- 冻结库存
UPDATE inventory 
SET frozen_qty = frozen_qty + 20
WHERE sku_id = ? 
  AND (quantity - frozen_qty) >= 20;

-- 出库扣减
UPDATE inventory 
SET quantity = quantity - 20,
    frozen_qty = frozen_qty - 20
WHERE sku_id = ? AND quantity >= 20;

-- 查历史快照
SELECT after_qty 
FROM inventory_log
WHERE sku_id = ? 
  AND operate_time <= '2025-01-24 15:00:00'
ORDER BY operate_time DESC
LIMIT 1;

-- 延迟关联分页
SELECT * FROM inventory_log
WHERE id IN (
    SELECT id FROM inventory_log
    WHERE tenant_id = 1001
    ORDER BY operate_time DESC
    LIMIT 100000, 20
);

必须能写的代码片段

1. 在事务提交后删除缓存

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            redisTemplate.delete("inventory:sku:" + skuId);
        }
    }
);

2. 消息幂等性校验

String key = "consumed:" + event.getOrderNo();
Boolean success = redisTemplate.opsForValue()
    .setIfAbsent(key, "1", 7, TimeUnit.DAYS);
if (Boolean.FALSE.equals(success)) {
    return;  // 已消费
}

3. 批量插入

inventoryLogService.saveBatch(logs, 100);

4. 动态表名

String tableName = "inventory_log_" + 
    LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMM"));

表达技巧

  1. 先讲业务,再讲技术
  2. 用数据说话:效率提升 30%、延迟降低 80%
  3. 主动展开:不要等面试官问,主动讲缓存、消息队列、分表
  4. 准备追问:每个点都要准备 2-3 个追问
  5. 不要背书:要自然地表达

可能的追问和回答

Q:Redis 缓存如果删除失败怎么办? A:我们有两层保障。第一,缓存有 TTL,最多 30 分钟后自动过期。第二,我们有监控告警,删除失败会记录日志并告警,运维会及时处理。

Q:消息队列如果消费失败怎么办? A:RocketMQ 有重试机制,最多重试 16 次。如果还是失败,消息会进入死信队列,我们有专门的监控和人工处理流程。

Q:分表后怎么查询跨月的数据? A:我们会根据查询的时间范围,动态计算需要查询哪几张表,然后并行查询,最后合并结果。 比如查询 1 月到 3 月的数据,就查 inventory_log_202501、202502、202503 三张表。

Q:为什么不用 ShardingSphere 自动分表? A:我们评估过,ShardingSphere 功能强大但引入了额外的复杂度。我们的分表逻辑很简单(按月),应用层路由完全够用,而且更灵活,出问题也好排查。

Q:布隆过滤器的误判率是多少? A:我们设置的误判率是 0.01%,也就是 1 万次查询可能有 1 次误判。误判的影响是:本来不存在的 SKU,布隆过滤器说可能存在,然后去查数据库发现没有,缓存空值。这个代价可以接受。 数组的大小和Hash的次数 也就是说 你的误判率越低 性能越差


完整简历版

项目经历

跨境电商 SaaS 供应链管理平台 - 仓储管理模块(WMS)
2024.06 - 2024.12 | 核心开发
技术栈:Spring Boot 3.2、MyBatis-Plus、MySQL 8.0、Redis 7.0、RocketMQ 5.0、XXL-Job

项目描述:
负责跨境电商供应链 SaaS 平台的仓储管理模块开发,管理国内仓、FBA 仓、海外仓等多仓库库存,支持采购入库、销售出库、仓库调拨、库存盘点等全流程业务。系统日均处理出入库单 5 万+,库存流水 20 万+。

核心业绩:

  1. 使用冻结库存机制 + 数据库原子操作 实现了多平台库存状态管理 达到了库存数据准确率 100%,quantity 始终反映仓库实际库存

    • 设计五种库存类型(实物、冻结、在途、不良品、预留),订单创建时冻结库存不动实物,出库时同时扣减实物和冻结库存
    • 使用 MyBatis-Plus setSql() 实现原子操作(frozen_qty = frozen_qty + N),在 WHERE 条件中判断库存充足性
    • 设计只增不改不删的库存流水表,每次变动记录操作人、时间、变动前后数量,支持还原任意时刻库存快照
  2. 使用 Redis 缓存 + 布隆过滤器 实现了热点 SKU 库存的高性能查询 达到了 P99 延迟从 80ms 降到 5ms,数据库 QPS 降低 70%

    • 用 Redis Hash 存储热点 SKU 库存信息(TTL 30 分钟),采用 Cache Aside 模式(先更新数据库,事务提交后删除缓存)
    • 解决缓存穿透:布隆过滤器(100 万 SKU 占用 1.2MB)+ 空值缓存(TTL 5 分钟)
    • 解决缓存击穿:分布式锁(Redis SETNX)+ 双重检查 + 热点 SKU 预热
    • 解决缓存雪崩:过期时间加随机值(30 分钟 ± 5 分钟),缓存命中率达 95%
  3. 使用 RocketMQ 消息队列 实现了系统异步解耦和削峰填谷 达到了出库接口响应时间从 800ms 降到 50ms,大促期间零故障

    • 库存变动异步通知 OMS:WMS 在事务提交后发送消息,OMS 异步消费,解耦系统依赖
    • 订单创建削峰:订单先进队列,控制消费速度(20 个线程并发消费),数据库连接池使用率从 95% 降到 60%
    • 消息幂等性:Redis SETNX 实现(Key 为订单号,TTL 7 天),防止重复消费
    • 消息可靠性:同步发送 + 重试机制 + 主从同步 + 手动 ACK,保证消息不丢失
  4. 使用 FIFO 策略 + 拣货路径优化 实现了先进先出出库和高效拣货 达到了拣货时间从 8 分钟降到 5.5 分钟,行走距离减少 40%

    • 按 last_inbound_time 升序排列,最早入库的优先出库,避免货物过期和积压
    • 在保证 FIFO 前提下,对同一天入库的货物按库位物理位置排序(zone → row_no → column_no → floor_no)
    • 生成优化后的拣货指令列表,减少仓管员在仓库中的来回移动,拣货效率提升 31%
  5. 使用按月分表 + 批量操作 实现了海量库存流水数据的高效存储和查询 达到了系统整体性能提升 60%

    • 按月分表(inventory_log_202501、202502…),应用层路由动态计算表名,单表数据量控制在 300 万以内
    • 定时任务每月 1 号自动创建下个月的表,超过 1 年的数据归档到 OSS 并删除表
    • 批量插入优化:MyBatis-Plus saveBatch 一次插入 100 条,批量入库从 500ms 降到 100ms
    • 慢 SQL 优化:分页查询用延迟关联(从 2.3 秒降到 80ms),库存预警增加冗余字段和索引(从 1.5 秒降到 20ms)
  6. 使用 XXL-Job 定时任务 + ABC 分类 实现了循环盘点机制 达到了盘点差异率从 2.3% 降到 0.5%,不影响日常运营

    • 定时任务每天晚上 22:00 自动生成盘点计划,A 类 SKU(销售额前 20%)每月盘一次,B 类每季度盘一次,C 类每半年盘一次
    • 盘点差异调整在事务中执行,同时写入库存流水,保证数据一致性和可追溯性
    • 全年持续盘点,及时发现库存问题,避免传统全盘需要停止运营 2-3 天的问题

最后提醒:

面试前把这份笔记看 2-3 遍,重点记住:

  • 库存扣减的三个时刻(冻结、出库、取消)
  • FIFO 算法和路径优化
  • 盘点流程(ABC 分类、循环盘点)
  • Redis 缓存方案(三大问题:穿透、击穿、雪崩)
  • 消息队列方案(幂等性、可靠性)
  • 性能优化方案(分表、批量、慢 SQL)
  • 简历上的八个亮点

面试时不要背书,要像讲故事一样自然地表达。如果面试官追问细节,可以展开讲技术实现。准备好画图,库存扣减流程图、FIFO 示意图、缓存更新流程图一定要能画出来。

记住:技术是为业务服务的,先讲清楚业务场景和遇到的问题,再讲技术方案,最后用数据证明效果。这样的表达逻辑清晰,面试官容易理解,也更容易给高分。