← 返回生产场景
🏗️ 商品库存系统结构
一个 SPU → 多个 SKU → 每个 SKU 独立库存 → 高并发走 Redis 原子扣减
📋问题1:SPU 和 SKU 怎么设计基础
一个商品有多种颜色、内存组合。怎么建模让前端能动态选择规格、后端能精确管理库存。
| 概念 | 说明 | 举例 |
| SPU | 标准化产品单元(一类商品,不区分规格) | iPhone 15 |
| SKU | 最小库存单位(具体规格组合,有独立价格和库存) | iPhone 15 钛灰色 256GB |
| 规格属性 | 颜色/内存/版本等维度 | 颜色=[钛灰,白,黑],内存=[128G,256G,512G] |
// SPU 表(商品基础信息)
CREATE TABLE spu (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(200), // iPhone 15
category_id BIGINT, // 分类:手机
brand_id BIGINT, // 品牌:Apple
status TINYINT DEFAULT 1 // 1上架 0下架
);
// SKU 表(规格 + 价格 + 库存,一个 SPU 对应多条 SKU)
CREATE TABLE sku (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
spu_id BIGINT NOT NULL, // 关联 SPU
sku_code VARCHAR(64) UNIQUE, // SKU编码(对接ERP用)
price DECIMAL(12,2), // 价格(DECIMAL不用float)
saleable INT DEFAULT 0, // 可售库存
locked INT DEFAULT 0, // 锁定库存(下单未付)
actual INT DEFAULT 0, // 实际库存(仓库真实)
attributes JSON, // {"颜色":"钛灰色","内存":"256GB"}
status TINYINT DEFAULT 1 // 1在售 0停售
);
// 前端交互:用户打开商品详情 → 后端返回 SPU + 所有 SKU
// 用户选「钛灰色」+「256GB」→ 前端匹配到对应 SKU → 显示价格和库存
// 加购/下单时传 skuId → 后端基于 skuId 扣库存
关键:价格和库存放在 SKU 表(每个规格组合价格不同)。规格属性用 JSON 存(灵活,不用为每种属性建列)。
🔄问题2:库存三层模型(可售/锁定/实际)核心
用户下单后还没付款的库存不能让别人继续买,但也不能真扣(用户可能取消)。需要三层库存。
| 类型 | 字段 | 说明 | 下单时 | 支付时 | 取消时 |
| 可售 | saleable | 用户能买的数量 | -1 | 不变 | +1(释放回来) |
| 锁定 | locked | 已下单未付款 | +1 | -1(转实际扣减) | -1(释放回可售) |
| 实际 | actual | 仓库真实数量 | 不变 | -1 | 不变 |
库存流转图
// 下单:锁定库存(可售-1,锁定+1)
// 条件更新防超卖:只有可售足够才执行
int n = skuMapper.lockStock(skuId, qty);
// UPDATE sku SET saleable=saleable-?, locked=locked+?
// WHERE id=? AND saleable >= ?
if (n == 0) throw "库存不足";
// 支付成功:扣实际(锁定-1,实际-1)
skuMapper.deductStock(skuId, qty);
// UPDATE sku SET locked=locked-?, actual=actual-?
// WHERE id=? AND locked >= ?
// 取消:释放(锁定-1,可售+1)
skuMapper.releaseStock(skuId, qty);
// UPDATE sku SET locked=locked-?, saleable=saleable+?
// WHERE id=? AND locked >= ?
为什么用条件更新(WHERE saleable>=?):不用"先查再减"(stock = get(); if(stock>=qty) deduct()),因为并发下两个线程同时查到 stock=1,都通过检查,都扣减 → 超卖。条件更新让 DB 保证原子性。
⚡问题3:高并发库存扣减(秒杀场景)高频
秒杀 100 件 10 万人抢,DB 的条件更新虽然能防超卖,但几百个并发同时 UPDATE 同一行 → 行锁竞争 → DB 扛不住。
直接打 DB
每个请求 UPDATE → 同一行几百个锁竞争 → 连接打满 → 慢 → 雪崩
Redis 预扣 + DB 异步
库存预热到 Redis → Lua 原子扣减(10万QPS无压力)→ 异步下单到 DB → 对账
// 活动前:预热库存到 Redis
redisTemplate.opsForValue().set("seckill:stock:" + skuId, String.valueOf(stock));
// 秒杀时:Lua 脚本原子扣减(读-判-写三步原子,Redis 单线程保证)
private static final String LUA = """
local stock = redis.call('GET', KEYS[1])
if not stock then return -1 end
local remaining = tonumber(stock) - tonumber(ARGV[1])
if remaining < 0 then return 0 end
redis.call('SET', KEYS[1], remaining)
return remaining
""";
public boolean deduct(Long skuId) {
Long r = redisTemplate.execute(new DefaultRedisScript<>(LUA, Long.class),
List.of("seckill:stock:" + skuId), "1");
// -1=未预热, 0=售罄, >0=成功
return r != null && r > 0;
}
// 抢到名额 → 异步下单到 DB(削峰)
if (deduct(skuId)) {
mq.send("order.create", new OrderMsg(userId, skuId));
return "抢购成功";
}
// 活动后:Redis 剩余回写 DB(对账)
int remaining = Integer.parseInt(redis.get("seckill:stock:" + skuId));
skuMapper.updateActual(skuId, remaining);
为什么用 Lua:Redis 单线程执行 Lua,中间不会插入其他命令。读库存→判断→扣减原子完成,不会超卖。比 DB 的 WHERE 快 100 倍(内存 vs 磁盘、无锁等待)。
💰问题4:改价格不影响历史订单(快照原则)
运营把 iPhone 从 ¥6999 改成 ¥5999。之前 ¥6999 买的订单金额不能变。
// 订单明细表:存下单时的价格快照,不是关联SKU的外键
// order_item 表:
// sku_id → 关联SKU(查商品信息用)
// snapshot_price → 下单时的价格快照(不受后续改价影响)
// snapshot_name → 下单时的商品名称快照(防改名)
public void createOrderItem(Long skuId, int qty) {
Sku sku = skuMapper.selectById(skuId);
OrderItem item = new OrderItem();
item.setSkuId(skuId);
item.setSnapshotPrice(sku.getPrice()); // 价格快照(关键!)
item.setSnapshotName(sku.getName()); // 名称快照
item.setQty(qty);
item.setSubTotal(sku.getPrice().multiply(BigDecimal.valueOf(qty)));
orderItemMapper.insert(item);
// 之后 SKU 价格变了,这个订单金额不受影响
}
快照原则:订单里存的不应该是 SKU 的引用(价格会变),而是下单时刻的快照(价格、名称都复制一份)。退款按订单里的快照价格退,不是当前价格退。
🏷️问题5:上下架不影响已有订单
运营下架了商品,已下单的用户订单怎么办?
| 场景 | 处理 | 原因 |
| 商品下架 | status=0 → 前端不展示,搜索搜不到 | 新用户不能买了 |
| 已下单未付款 | 订单照常有效,可以继续支付 | 不能让订单消失 |
| 已付款待发货 | 正常发货 | 交易已成立 |
原则:下架 ≠ 删除(只改 status,不 DELETE)。已有订单的完整性不受商品下架影响。删除商品是逻辑删除(is_deleted=1)。
🏭生产案例:大促库存对不上真实事故
现象:大促结束后 Redis 库存比 DB 多了 3 件。
排查:用户支付超时 → 订单取消 → Redis 库存加回来了,但异步下单消息消费失败(没到 DB),所以 DB 那边没扣。
解决:① Redis 扣减后 DB 操作必须最终成功(MQ 消费端幂等+重试+死信兜底)② 活动结束后用 DB 实际订单数校准 Redis ③ 定时对账任务报警。
教训:Redis 预扣和 DB 实际扣减之间的一致性是核心风险,对账是最后一道防线。
商品库存核心:
① SPU/SKU(价格和库存按 SKU 管理)
② 三层库存(可售/锁定/实际,条件更新防超卖)
③ 高并发走 Redis(Lua 原子扣减 + DB 异步对账)
④ 快照原则(订单存价格快照,不受改价影响)
⑤ 逻辑删除(下架不改删除)