← 返回生产场景

📦 商品与库存

SPU/SKU 管理 / 库存三层模型 / 高并发扣减 / 上下架 / 价格快照

🏗️ 商品库存系统结构
SPU(商品) iPhone 15 SKU: 钛灰256G ¥8999 / 库存30 SKU: 白色128G ¥5999 / 库存50 SKU: 黑色512G ¥9999 / 库存10 库存系统 可售/锁定/实际 Redis 预扣 秒杀场景

一个 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 异步对账)
快照原则(订单存价格快照,不受改价影响)
逻辑删除(下架不改删除)