架构设计与实战指南
目录导读
- 分布式库存扣减的挑战:为什么传统方案会失败?
- 核心架构模式:从乐观锁到TCC事务的演进
- 高并发场景下的扣减策略:预扣、异步对账与缓存补偿
- 代码级实现示例:Redis+Lua + 数据库最终一致性
- 常见问题与问答:死锁、超卖、性能瓶颈如何解决?
分布式库存扣减的挑战
在电商、票务、秒杀等场景中,库存系统面临的核心矛盾是:高并发写入与数据强一致性之间的平衡,传统单体应用使用数据库行锁(SELECT ... FOR UPDATE)即可保证扣减安全,但在分布式架构下,服务拆分、远程调用、网络延迟导致以下典型问题:

- 超卖:多个节点同时读取到库存为1,各自扣减后实际库存变为负数。
- 性能瓶颈:数据库行锁在大量并发下退化为表锁,QPS(每秒查询率)难以突破数千。
- 数据不一致:本地事务与远程库存服务无法通过传统ACID保证,需要引入分布式事务。
关键洞察:库存扣减的核心不在于“扣得有多快”,而在于“怎么确保只扣一份库存”。
核心架构模式
1 乐观锁(CAS)模式
UPDATE inventory SET stock = stock - 1 WHERE sku_id = ? AND stock >= 1;
- 优点:简单,适合低并发(<1000 QPS)
- 缺点:高并发下大量更新失败,需重试,且无法解决ABA问题
2 分布式锁模式
使用Redis的SET NX EX或Zookeeper临时节点实现互斥,但缺点明显:
- 锁粒度难以控制:锁SKU会导致其他用户排队
- 锁超时导致死锁,需引入看门狗机制(如Redisson的Watchdog)
3 TCC(Try-Confirm-Cancel)模式
适用于跨服务的库存扣减,典型流程:
- Try阶段:预扣库存(状态标记为“冻结”)
- Confirm阶段:扣减成功,转换为有效库存
- Cancel阶段:扣减失败,释放冻结库存
实践注意:TCC需要业务方实现幂等接口,否则多次Confirm会导致库存重复扣减,建议在数据库添加dedup_id唯一索引。
4 异步最终一致性(推荐方案)
采用 “本地消息表 + 异步对账” 模式:
- 订单服务创建订单时,先在本地插入一条“待扣减库存”消息
- 库存服务通过MQ(消息队列)消费消息,执行扣减
- 若库存扣减失败,通过定时任务扫描补偿
优势:吞吐量可达传统方案10倍以上,且避免分布式事务带来的锁冲突。
高并发场景下的扣减策略
| 策略 | 适用场景 | 实现要点 |
|---|---|---|
| 预扣库存 | 秒杀、活动 | 用户在进入支付页面前锁定库存15分钟,超时释放 |
| Lua脚本原子扣减 | 热点SKU | 确保Redis检查、扣减、写入日志在单线程内完成 |
| 库存分片 | 大库存商品 | 将库存分散到多个Redis分片,按用户ID哈希分配 |
| 本地缓存兜底 | 读多写少 | 定期从数据库同步库存阈值,避免频繁穿透 |
经验公式:若单SKU并发请求超过1000/s,请务必引入Redis层缓存库存,但要注意缓存与数据库的最终一致性,每次扣减先写Redis日志,再异步落库。
代码级实现示例(Redis+Lua + 数据库最终一致性)
1 Lua脚本(原子扣减)
-- 参数:KEYS[1] = 商品库存key
-- ARGV[1] = 扣减数量
-- ARGV[2] = 请求唯一ID(防重)
if redis.call('exists', KEYS[1]..':lock:'..ARGV[2]) == 1 then
return -1 -- 已扣减过,返回幂等结果
end
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) < tonumber(ARGV[1]) then
return 0 -- 库存不足
end
redis.call('decrby', KEYS[1], ARGV[1])
redis.call('set', KEYS[1]..':lock:'..ARGV[2], 1, 'EX', 3600)
return 1 -- 成功
2 Java调用示例
@RedisLock(key = "inventory:lock:#skuId") // 防止并发争抢
public boolean deductStock(String skuId, int quantity, String requestId) {
String lua = loadLuaScript("deduct_stock.lua");
Object result = redisTemplate.execute(
new DefaultRedisScript<>(lua, Long.class),
Arrays.asList("inventory:" + skuId),
quantity, requestId
);
return (Long) result == 1L;
}
3 数据库最终一致性(异步对账)
- 定时任务每5秒扫描Redis中的”扣减日志” key
- 批量写入MySQL的
inventory_log表,使用INSERT ... ON DUPLICATE KEY UPDATE - 若发现Redis库存与数据库库存不一致(差值超过阈值),触发报警并人工干预
常见问题与问答
Q1:高并发下Redis扣减成功了,但数据库写入失败怎么办? A:这是典型的不一致问题,建议采用“双写一致性”方案:
- 方案A:Redis扣减成功后,立即写入MQ,保证至少一次投递。
- 方案B:使用Redisson的
RTransaction实现Redis与MySQL的XA事务(但性能会下降30%)。 - 最优解:接受秒级最终一致性,通过定时对账补偿缺失的库存。
Q2:如何防止“幽灵库存”(库存扣减成功但订单取消后库存未恢复)? A:需要实现自动回滚机制:
- 在Redis中设置扣减记录的TTL为15分钟(超时自动释放)。
- 数据库端的订单取消事件异步回调,恢复库存。 注意:回滚时要严格比对请求ID,防止两次回滚导致库存增加。
Q3:分布式锁和Lua脚本哪个更好? A:没有绝对优劣,Lua脚本保证单节点原子性,适合单一Redis实例;分布式锁适合需要在多个操作间保持互斥(如“减库存+生成订单”),通常情况下,库存扣减用Lua脚本更轻量,而库存预留(如购物车冻结)则更适合锁。
Q4:商品库存分片后,如何保证全局唯一? A:采用“一致性哈希”或“范围分片”,sku_id % 32 决定库存落在哪个Redis分片,但需要注意,若单个分片崩溃,会导致部分超卖(因为其他分片正常扣减)。降级方案:当某个分片不可用时,直接返回失败,不要让用户以“伪装”的方式扣减其他分片。
Q5:如何衡量系统是否达标?
A:关键指标:
- 吞吐量:目标应达到单Redis节点>2万QPS(4核8G实例)
- P99延迟:<5ms(Lua脚本执行时间)
- 数据一致性:最终延迟<30秒(异步对账周期)
- 极端情况:Redis宕机后,数据库库存不应出现超卖超过总库存的5%
延伸思考:对于百亿级库存系统(如电商大促),建议采用“预分配+弹性扩容”策略,预先将热门SKU的库存按比例拆解到多个Redis集群,每个集群独立处理,最后通过离线任务合并总库存,使用BRPOP或Stream实现库存事件的实时监控,确保极端情况下的快速发现与恢复。