库存系统分布式并发扣减

wen java案例 3

架构设计与实战指南

目录导读

  1. 分布式库存扣减的挑战:为什么传统方案会失败?
  2. 核心架构模式:从乐观锁到TCC事务的演进
  3. 高并发场景下的扣减策略:预扣、异步对账与缓存补偿
  4. 代码级实现示例:Redis+Lua + 数据库最终一致性
  5. 常见问题与问答:死锁、超卖、性能瓶颈如何解决?

分布式库存扣减的挑战

在电商、票务、秒杀等场景中,库存系统面临的核心矛盾是:高并发写入与数据强一致性之间的平衡,传统单体应用使用数据库行锁(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)模式

适用于跨服务的库存扣减,典型流程:

  1. Try阶段:预扣库存(状态标记为“冻结”)
  2. Confirm阶段:扣减成功,转换为有效库存
  3. Cancel阶段:扣减失败,释放冻结库存

实践注意:TCC需要业务方实现幂等接口,否则多次Confirm会导致库存重复扣减,建议在数据库添加dedup_id唯一索引。

4 异步最终一致性(推荐方案)

采用 “本地消息表 + 异步对账” 模式:

  1. 订单服务创建订单时,先在本地插入一条“待扣减库存”消息
  2. 库存服务通过MQ(消息队列)消费消息,执行扣减
  3. 若库存扣减失败,通过定时任务扫描补偿

优势:吞吐量可达传统方案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 数据库最终一致性(异步对账)

  1. 定时任务每5秒扫描Redis中的”扣减日志” key
  2. 批量写入MySQL的inventory_log表,使用INSERT ... ON DUPLICATE KEY UPDATE
  3. 若发现Redis库存与数据库库存不一致(差值超过阈值),触发报警并人工干预

常见问题与问答

Q1:高并发下Redis扣减成功了,但数据库写入失败怎么办? A:这是典型的不一致问题,建议采用“双写一致性”方案:

  • 方案A:Redis扣减成功后,立即写入MQ,保证至少一次投递。
  • 方案B:使用Redisson的RTransaction实现Redis与MySQL的XA事务(但性能会下降30%)。
  • 最优解:接受秒级最终一致性,通过定时对账补偿缺失的库存。

Q2:如何防止“幽灵库存”(库存扣减成功但订单取消后库存未恢复)? A:需要实现自动回滚机制

  1. 在Redis中设置扣减记录的TTL为15分钟(超时自动释放)。
  2. 数据库端的订单取消事件异步回调,恢复库存。 注意:回滚时要严格比对请求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集群,每个集群独立处理,最后通过离线任务合并总库存,使用BRPOPStream实现库存事件的实时监控,确保极端情况下的快速发现与恢复。

抱歉,评论功能暂时关闭!