前置知识:供应链系统与电商平台的关系
面试官经常会问”你们的供应链系统和电商平台是什么关系”、“为什么供应链系统会有高并发”,必须先把这个讲清楚。
系统架构关系
三层架构:
【电商平台层】
├─ Amazon 店铺
├─ Shopify 店铺
├─ eBay 店铺
└─ 独立站
↓ 订单推送 / 库存查询
【供应链 SaaS 系统 - 我们的系统】
├─ OMS(订单管理系统)
│ ├─ 接收各电商平台的订单
│ ├─ 统一管理订单状态
│ └─ 调用 WMS 冻结库存
│
└─ WMS(仓储管理系统)
├─ 管理真实库存(quantity)
├─ 管理冻结库存(frozen_qty)
└─ 处理出入库操作
↓ 库存分配同步
【电商平台层】
└─ 各平台获得分配的库存配额
核心要点:
- 供应链系统是库存的”真实来源”,电商平台只是”展示渠道”
- 商家在多个平台开店,但库存是统一管理的(一个仓库的货)
- 库存同步流程:WMS 真实库存 → 同步到各电商平台展示
供应链系统的真正价值:
- 多平台统一库存管理:商家不需要在每个平台单独管理库存,避免库存分散、数据不一致
- 全局库存可见性:实时查看各平台销量、总销量、剩余库存,一目了然
- 智能库存分配:根据各平台销售速度,动态调整库存分配策略(如 Amazon 卖得快就多分配)
- 智能补货预警:根据销售速度和库存水位,自动预警需要补货的 SKU
- 数据分析能力:统计各平台销售占比、热销 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 店铺:150 → 180(增加30件)
Shopify 店铺:150 → 130(减少20件)
eBay 店铺:100 → 90(减少10件)
独立站:50 → 50(不变)
总计:180 + 130 + 90 + 50 = 450 件
关键点:
- 不是”实时同步” - 不是把仓库的500件同步给每个平台,那样会导致多个平台竞争同一库存
- 而是”固定分配” - 总和不超过仓库库存,每个平台有自己的配额
- 可以动态调整 - 根据销售速度,定期(如每天)重新分配各平台库存
- 供应链系统的价值 - 统一管理库存分配,避免商家在每个平台单独手动调整
为什么供应链系统会有高并发?
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:==确认入库(最关键的技术环节)==
- 操作人:仓管员
- 操作内容:点击”确认入库”按钮
- 系统动作(事务,原子操作):
- 更新库存:
inventory.quantity += 合格数量- 如果存在不合格的商品 也要同步修改
defective_qty+= 不合格的数量
- 如果存在不合格的商品 也要同步修改
- 写入库存流水:
log_type=1(采购入库),记录变动前后数量、操作人、操作时间 - 更新库位状态:
warehouse_location.is_occupied=1 - 更新加权平均成本:
avg_cost = (原库存×原成本 + 新入库×新成本) / 总库存 - 更新入库单状态:
inbound_order.status=2(已完成) - 更新采购单状态:如果采购单所有明细都已入库,更新采购单为”已完成”
- 采购单状态: 采购单 采购单明细 —> 可能需要使用到 Feign 调用 或者 MQ异步调用
- 如果采用的是 Feign调用 要使用分布式事务
- 如果使用的是 MQ异步调用 要保证MQ消息和本地事务的一致性 推荐使用 MQ的事务消息
- 更新库存:
关键技术点:
- 所有库存变动在
@Transactional事务中执行,任何一步失败都回滚 - 库存流水表只增不改不删,保证可追溯
- 加权平均成本动态计算,用于后续利润分析
- 使用数据库原子操作:
quantity = quantity + N
异常处理:
- 质检不合格:不良品移至 X 区隔离,
defective_qty增加,不计入可售库存 - 库位已满:系统提示,仓管员重新选择库位
- 数据库异常:事务回滚,记录错误日志,告警通知
面试时怎么讲: “我们的采购入库流程分 6 个步骤。货物到达后,先质检,质检合格的分配库位上架,然后确认入库。确认入库这一步是关键,要在事务中同时更新库存、写流水、更新成本,保证数据一致性。如果质检不合格,不良品会单独隔离,不计入可售库存。“
销售出库完整流程
这是第二重要的流程,涉及库存扣减、FIFO 策略。
前置条件:
- OMS 订单系统已创建订单,并冻结了库存(
frozen_qty已增加) - 订单状态为”待发货”
流程步骤:
步骤1:OMS 推送发货需求
- 触发方:OMS 订单系统
- 推送内容:订单号、SKU、数量、目标仓库
- 使用的MQ发送消息
- 当用户创建完订单后 生成订单信息 就直接给用户返回 下单成功即可
- 对应库存的操作 以及 出库的操作 都使用MQ异步去实现即可
- 不推荐使用Feign同步调用
- 时效性 库存的操作 比较繁琐
- 使用的MQ发送消息
- 系统动作:WMS 接收发货需求
步骤2:WMS 创建出库单
- 系统动作:创建出库单,
outbound_type=1(销售出库),关联订单号 - 出库单状态:待分配库位
步骤3:FIFO 分配库位(关键技术点)
- 系统动作:查询该 SKU 在当前仓库所有库位的库存分布
- 分配算法:
- 按
last_inbound_time升序排列(最早入库的排前面) - 按顺序分配拣货数量,直到满足出库需求
- 按照物理的顺序进行排列 A —> B —> C
- 同一天入库的,按库位物理位置排序(zone → row_no → column_no)
- 按
- 输出:拣货指令列表(哪个库位拣多少件)
如果一个买家 买了多个商品 那么这多个商品可能是这样的 商品A 100个 商品B 买了1个 商品C 买了10个 那么我们需要生成拣货单
- 我们要先查询 商品A 的库位 按照入库的时间 进行升序 (先入库的排在最上面)
- 拿到最上面的库位 判断库存是否 >= 100
- 如果没有100个 从第二入库的库位中再拿 拼够 100个
- 然后按照上面的流程 去查询 商品B 和 商品C
- 当我们把商品A 商品B 商品C 都已经找到了具体的库位 和每个库位应该拿多少个商品后
- 开始排序 按照仓库的物理顺序进行排序 区 —> 排 —> 列 —> 层
- 把排好序的信息 存储起来 也就是我们的拣货单
步骤4:生成拣货单
- 系统动作:生成拣货单,推送给仓管员(移动端 App 或打印纸质单)
- 拣货单内容:按优化后的路径排序,告诉仓管员从哪个库位拣多少件
步骤5:仓管员拣货
- 操作人:仓管员
- 操作内容:按拣货单逐库位拣货,扫描商品条码验证 扫码是为了判断商品的规格
- 系统动作:
- 扫描条码匹配:更新拣货状态
pick_status=1(已拣) - 扫描条码不匹配:提示错误,防止拣错货
- 扫码的那个信息 其实就是商品的SKU的信息以及库位的信息
- 扫码的操作 是在拣货单的页面显示的 当扫码识别到该SKU信息后 去匹配拣货单中的 item
- 如果不匹配 说明 拿错货了 如果匹配 我们可以在手机/PAD上对 拣货单中的 item 打上标记
- 扫描条码匹配:更新拣货状态
步骤6:复核
- 操作人:复核员
- 操作内容:再次核对 SKU、数量、订单信息
- 系统动作:复核通过后,进入打包环节
步骤7:打包
- 操作人:打包员
- 操作内容:打包完成,贴物流面单
- 系统动作:无
步骤8:确认出库(最关键的技术环节)
- 操作人:仓管员
- 操作内容:点击”确认出库”按钮
- 系统动作(事务,原子操作):
- 扣减库存:
quantity -= 出库数量,frozen_qty -= 出库数量(同时扣减)frozen_qty数量是在下单的时候就已经做了+=的冻结操作- 所以在真正出库的时候 需要解冻
- 写入库存流水:
log_type=2(销售出库),记录变动前后数量 - 更新库位状态:如果库位已空,
is_occupied=0- 这个库位的状态 不一定每次都会清空
- 一个库位上存放了N多个SKU
- 每个SKU又有很多数量的货物
- 更新出库单状态:
outbound_order.status=4(已出库) - 回调 OMS:通知订单系统已出库,OMS 触发物流创建
- 使用MQ异步消息通知
- 扣减库存:
步骤9:TMS 创建运单
- 触发方:OMS 收到 WMS 回调后,通知 TMS
- 系统动作:TMS 物流系统创建运单,货物发出
关键技术点:
- FIFO 策略保证先进先出,避免货物积压
- 拣货路径优化,减少行走距离 40%
- 出库时同时扣减
quantity和frozen_qty,释放预占 - 所有操作在事务中,保证数据一致性
为什么出库时要同时扣 quantity 和 frozen_qty?
quantity减少:因为货物真的出去了,实物库存少了frozen_qty减少:因为预占要释放掉,不然这部分预占会一直占着
面试时怎么讲: “销售出库流程的关键是 FIFO 分配库位和库存扣减。OMS 推送发货需求后,WMS 按先进先出原则分配库位,生成拣货单。仓管员拣货完成后,确认出库时要同时扣减实物库存和冻结库存,并写入流水记录。整个过程在事务中执行,保证数据一致性。“
库存盘点完整流程
这是第三重要的流程,体现了系统的数据准确性保障机制。
前置条件:
- 系统已按 ABC 分类标记好每个 SKU 的盘点频率
- 定时任务每天晚上 22:00 自动检查是否需要生成盘点计划
流程步骤:
步骤1:定时任务生成盘点计划
- 触发方:XXL-Job 定时任务(每天 22:00)
- 系统动作:
- 查询今日需要盘点的 SKU(按 ABC 分类)
- A 类:本月未盘过的 按照销售额 TOP 20% 算作A类
- B 类:本季度未盘过的
- C 类:本半年未盘过的
- 创建盘点任务:
task_type=3(循环盘),status=0(待开始) - 生成盘点明细:记录每个 SKU 的账面库存(
book_qty)- 每类的商品都会有很多个SKU 所以需要生成/统计当前需要盘点的SKU的
book_qty - 循环盘 因为每次盘点的SKU种类不多 通常可以放到晚上下班之后去盘点 所以不影响出库 也不需要进行状态的锁定
- 每类的商品都会有很多个SKU 所以需要生成/统计当前需要盘点的SKU的
- 查询今日需要盘点的 SKU(按 ABC 分类)
步骤2:锁定相关库位(可选,全盘时才锁)
- 系统动作:如果是全盘,更新库位状态
status=2(锁定)- 锁定只是禁止出库和入库的操作 但是线上销售 还是可以进行的 只是不能发货
- 线上销售不会去修改真实库存 而是去修改 冻结库存
- 还有一个就是当我们生成盘点计划的时候 已经把真实库存 存储到
book_qty也不会受到线上下单的影响 - 但是不能入库/出库 因为入库/出库会影响 线下的真实的库存的数量
- 影响:锁定期间禁止该库位出入库操作
- 注意:循环盘点通常不锁定库位,不影响日常运营
步骤3:仓管员实际清点
- 操作人:仓管员
- 操作内容:按盘点任务清单,逐个 SKU 清点实物数量
- 系统动作:在 App 上填写实盘数量(
actual_qty)
步骤4:系统自动计算差异
- 系统动作:
diff_qty = actual_qty - book_qty拿 线下真实的货物数量 - 线上的账面数量- 正数:盘盈(实物多于账面)
- 负数:盘亏(实物少于账面)
- 零:无差异
步骤5:填写差异原因(如果有差异)
- 操作人:仓管员
- 操作内容:必须填写差异原因(如:入库时漏扫码、货物损坏未报损)
- 系统动作:记录差异原因到
diff_reason字段
步骤6:管理员审核
- 操作人:仓库主管
- 操作内容:审核差异报告,确认差异是否合理
- 系统动作:
- 审核通过:进入下一步
- 审核不通过:退回重新清点
步骤7:执行库存调整(最关键的技术环节)
- 操作人:系统自动执行
- 系统动作(事务,原子操作):
- 更新库存:
quantity += diff_qty(盘盈为正,盘亏为负)- 只有当 存在 差异的时候 才去更新这个库存数量
- 不管是盘盈还是盘亏 都是使用的
+=因为盘亏相当于是加上一个负数
- 写入库存流水:
log_type=5(盘盈)或log_type=6(盘亏)- 只要是动了这个库存 都必须要记录流水
- 更新盘点明细:
is_adjusted=1,adjust_time=NOW() - 更新盘点任务:
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+
如果每次都查数据库,会有两个问题:
- 数据库压力大,慢查询增多
- 响应时间长,用户体验差
所以我们引入了 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);
}
}
);
}
为什么要在事务提交后删除缓存?
如果在事务提交前删除缓存,可能出现这种情况:
- 线程 A 删除了缓存
- 线程 B 查询缓存未命中,从数据库加载旧数据到缓存
- 线程 A 事务提交,数据库更新
- 结果:缓存是旧数据,数据库是新数据,不一致了
所以必须在事务提交后删除缓存。
缓存穿透、击穿、雪崩的解决方案(关联问)
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);
}
}
);
}
为什么在事务提交后发送消息?
如果在事务提交前发送消息:
- 消息发送成功
- OMS 收到消息,开始处理
- WMS 事务回滚了(比如写流水失败)
- 结果: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%
- 大促期间系统稳定,没有崩溃
消息丢失的防护
三个环节都可能丢消息:
-
生产者发送失败:网络抖动,消息没发出去
- 解决:同步发送 + 重试机制
SendResult result = rocketMQTemplate.syncSend("topic", event, 3000, 3); if (result.getSendStatus() != SendStatus.SEND_OK) { throw new BusinessException("消息发送失败"); } -
Broker 宕机:消息在内存中还没持久化
- 解决:RocketMQ 主从同步 + 刷盘策略(同步刷盘)
-
消费者处理失败:消费者宕机或处理异常
- 解决:手动 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
核心优点:
- quantity 始终反映真实库存(仓库里真的有多少货)
- 订单取消只需要减 frozen_qty,不需要加 quantity
- **不会出现”实物在仓库但系统显示没货”**的情况
- 配合订单超时自动取消机制,冻结库存不会一直占着
订单超时自动取消机制
冻结库存会一直占着吗?不会!
// 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 件 │
└─────────────────────────────────────┘
冻结库存的真正价值:
-
库存状态可见性
- 商家可以清楚看到:仓库有多少货、有多少已分配给订单、还有多少可以卖
- 不用冻结库存的话,只能看到一个 quantity,不知道哪些是空闲的、哪些是已分配的
-
多平台库存统一管理
- 商家在淘宝、京东、拼多多都开店,可以看到每个平台占用了多少库存
- 比如:淘宝订单占用50件、京东订单占用30件,一目了然
-
准确反映库存状态
quantity始终反映仓库实际库存(货真的在仓库里)frozen_qty反映已分配给订单的库存(订单已接收,但货还没发出去)- 订单取消时,只需要释放
frozen_qty,不需要加quantity(因为货一直在仓库)
-
避免库存数据混乱
- 如果不用冻结库存,订单创建时直接扣
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:大促期间,供应链系统有什么压力?不应该只是电商平台压力大吗?
答:大促期间,供应链系统的压力非常大!主要有三个方面:
- 库存查询压力:用户在电商平台浏览商品时,电商平台会实时查询供应链系统的库存。大促期间(如黑色星期五),Amazon、Shopify、eBay 的流量汇聚到我们的供应链系统,热门 SKU 的库存查询 QPS 可达 5000+。
- 订单创建压力:用户在电商平台下单后,订单会推送到供应链系统。黑五零点,10秒内可能涌入10万笔订单,每笔订单都要创建订单、冻结库存、写流水,数据库连接池直接打满。
- 库存同步压力:库存变动后,需要重新计算各平台可分配库存并同步到各个电商平台。每秒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(...);
}
为什么这样是安全的?
- UPDATE 本身是原子操作
- WHERE 条件里判断库存够不够
- 如果条件不满足,updated 返回 0,直接失败
- 整个在事务里,失败就回滚
为什么不用悲观锁(SELECT FOR UPDATE)
面试官可能会问这个。
悲观锁的问题:
- 会锁住整行,其他事务得等着
- 容易死锁
- 锁持有时间长,性能差
我们用原子操作的好处:
- 不会阻塞其他事务
- 在库存充足的情况下,冲突概率很低
- 实现简单,一条 SQL 搞定
如果真的是秒杀场景,那可以考虑 Redis 预扣减,但跨境电商不会有那种极端场景。
库存流水表(必问)
为什么需要流水表?
- 审计追溯:库存出问题了能查到是谁在什么时候干了什么
- 数据恢复:库存数据错了可以通过流水记录恢复
- 对账:财务对账需要出入库明细
- 分析:统计 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
- 避免过期:化妆品、食品这些有保质期的,必须先进先出
- 避免积压:货放久了品质会下降
- 提高周转:减少资金占用
业务场景
订单要出库 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(单向移动)
实现思路:
- 先按入库日期分组
- 每组内按区域、排、列、层排序
- 这样既保证了 FIFO,又优化了路径
效果:拣货时间从 8 分钟降到 5.5 分钟,效率提升 30%。
库存盘点
为什么要盘点
系统库存和实物库存会有差异:
- 入库时漏扫码
- 出库时多拣了货
- 货物损坏没报损
- 内部拿了样品没记录
循环盘点 vs 全盘
传统全盘的问题:
- 要停止运营 2-3 天
- 影响发货
- 一年只能盘一次
循环盘点的好处:
- 不停运营
- 按 ABC 分类,高价值商品盘得更频繁
- 全年持续盘点
ABC 分类:
- A 类(销售额前 20%):每月盘一次
- B 类(销售额 20%-50%):每季度盘一次
- C 类(销售额后 50%):每半年盘一次
盘点流程
- 定时任务自动生成盘点计划(每天晚上 22:00)
- 系统记录账面库存(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);
// 更新盘点明细
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 倍!
索引优化原则:
- 高频查询字段建索引:
tenant_id,sku_id,warehouse_id - 排序字段建索引:
operate_time,create_time - 联合索引遵循最左前缀:
(tenant_id, sku_id, warehouse_id) - 避免在计算字段上查询:
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:如何保证库存数据的准确性?
四个层面:
- 事务保证:所有库存变动在事务中执行
- 库存流水:每次变动都写流水,只增不改不删
- 定期盘点:循环盘点,高价值商品每月盘
- 并发控制:原子操作 + WHERE 条件校验
Q2:如果库存扣减失败怎么办?
三种情况:
- 库存不足:返回错误,提示用户
- 并发冲突:WHERE 条件不满足,updated 返回 0,事务回滚
- 数据库异常:事务回滚,记录日志,告警
如果是高并发场景,可以考虑 Redis 预扣减。
Q3:库存流水表数据量大怎么办?
五个优化:
- 分表:按月分表,单表控制在 300 万以内
- 归档:超过 1 年的归档到 OSS
- 索引:(tenant_id, sku_id, operate_time)
- 读写分离:查询走从库
- 冷热分离:近 3 个月 SSD,历史数据 HDD
Q4:为什么不用悲观锁?
悲观锁问题:
- 锁住整行,其他事务等待
- 容易死锁
- 性能差
原子操作优势:
- 不阻塞
- 冲突概率低
- 实现简单
Q5:FIFO 怎么保证的?
按 last_inbound_time 升序排列,最早入库的排前面,然后按顺序分配拣货数量。
Q6:Redis 缓存和数据库如何保证一致性?
我们用的是 Cache Aside 模式:先更新数据库,再删除缓存。
关键点:
- 在事务提交后删除缓存,避免脏数据
- 删除而不是更新缓存,因为库存计算逻辑复杂
- 下次查询时自动从数据库加载最新数据
如果担心并发问题,可以用延迟双删,但我们实际没用,因为增加了复杂度。
Q7:消息队列如何保证消息不丢失?
三个环节都要保证:
- 生产者:同步发送 + 重试机制
- Broker:主从同步 + 同步刷盘
- 消费者:手动 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 消费 → 更新订单状态
必须能讲清楚的
基础概念:
- 为什么需要冻结库存?
- 为什么出库时要同时扣
quantity和frozen_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"));
表达技巧
- 先讲业务,再讲技术
- 用数据说话:效率提升 30%、延迟降低 80%
- 主动展开:不要等面试官问,主动讲缓存、消息队列、分表
- 准备追问:每个点都要准备 2-3 个追问
- 不要背书:要自然地表达
可能的追问和回答
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 万+。
核心业绩:
-
使用冻结库存机制 + 数据库原子操作 实现了多平台库存状态管理 达到了库存数据准确率 100%,quantity 始终反映仓库实际库存
- 设计五种库存类型(实物、冻结、在途、不良品、预留),订单创建时冻结库存不动实物,出库时同时扣减实物和冻结库存
- 使用 MyBatis-Plus setSql() 实现原子操作(frozen_qty = frozen_qty + N),在 WHERE 条件中判断库存充足性
- 设计只增不改不删的库存流水表,每次变动记录操作人、时间、变动前后数量,支持还原任意时刻库存快照
-
使用 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%
-
使用 RocketMQ 消息队列 实现了系统异步解耦和削峰填谷 达到了出库接口响应时间从 800ms 降到 50ms,大促期间零故障
- 库存变动异步通知 OMS:WMS 在事务提交后发送消息,OMS 异步消费,解耦系统依赖
- 订单创建削峰:订单先进队列,控制消费速度(20 个线程并发消费),数据库连接池使用率从 95% 降到 60%
- 消息幂等性:Redis SETNX 实现(Key 为订单号,TTL 7 天),防止重复消费
- 消息可靠性:同步发送 + 重试机制 + 主从同步 + 手动 ACK,保证消息不丢失
-
使用 FIFO 策略 + 拣货路径优化 实现了先进先出出库和高效拣货 达到了拣货时间从 8 分钟降到 5.5 分钟,行走距离减少 40%
- 按 last_inbound_time 升序排列,最早入库的优先出库,避免货物过期和积压
- 在保证 FIFO 前提下,对同一天入库的货物按库位物理位置排序(zone → row_no → column_no → floor_no)
- 生成优化后的拣货指令列表,减少仓管员在仓库中的来回移动,拣货效率提升 31%
-
使用按月分表 + 批量操作 实现了海量库存流水数据的高效存储和查询 达到了系统整体性能提升 60%
- 按月分表(inventory_log_202501、202502…),应用层路由动态计算表名,单表数据量控制在 300 万以内
- 定时任务每月 1 号自动创建下个月的表,超过 1 年的数据归档到 OSS 并删除表
- 批量插入优化:MyBatis-Plus saveBatch 一次插入 100 条,批量入库从 500ms 降到 100ms
- 慢 SQL 优化:分页查询用延迟关联(从 2.3 秒降到 80ms),库存预警增加冗余字段和索引(从 1.5 秒降到 20ms)
-
使用 XXL-Job 定时任务 + ABC 分类 实现了循环盘点机制 达到了盘点差异率从 2.3% 降到 0.5%,不影响日常运营
- 定时任务每天晚上 22:00 自动生成盘点计划,A 类 SKU(销售额前 20%)每月盘一次,B 类每季度盘一次,C 类每半年盘一次
- 盘点差异调整在事务中执行,同时写入库存流水,保证数据一致性和可追溯性
- 全年持续盘点,及时发现库存问题,避免传统全盘需要停止运营 2-3 天的问题
最后提醒:
面试前把这份笔记看 2-3 遍,重点记住:
- 库存扣减的三个时刻(冻结、出库、取消)
- FIFO 算法和路径优化
- 盘点流程(ABC 分类、循环盘点)
- Redis 缓存方案(三大问题:穿透、击穿、雪崩)
- 消息队列方案(幂等性、可靠性)
- 性能优化方案(分表、批量、慢 SQL)
- 简历上的八个亮点
面试时不要背书,要像讲故事一样自然地表达。如果面试官追问细节,可以展开讲技术实现。准备好画图,库存扣减流程图、FIFO 示意图、缓存更新流程图一定要能画出来。
记住:技术是为业务服务的,先讲清楚业务场景和遇到的问题,再讲技术方案,最后用数据证明效果。这样的表达逻辑清晰,面试官容易理解,也更容易给高分。