基于本文回答

播面 播面

刷题像听歌,多听自然懂
0
评论

高并发下如何保证库存扣减的正确性?

知识点图片

在高并发场景下(如秒杀、抢购),保证库存扣减的正确性主要面临两个核心挑战:超卖(库存减为负数)性能瓶颈(数据库锁竞争严重)

解决这个问题的核心思路是:将同步的数据库操作转化为异步的消息处理,利用缓存抗压,利用数据库兜底。

以下是分层级的详细解决方案,从数据库层面到架构层面逐步优化:


第一层:数据库层面的保护(最后一道防线)

即使上层架构再复杂,数据库层必须保证数据的最终一致性和原子性。

1. 避免悲观锁 (SELECT ... FOR UPDATE)

在并发量不高时可以使用,但在高并发下,悲观锁会导致严重的锁竞争,数据库连接池瞬间被打满,导致服务不可用。坚决不推荐在秒杀场景使用。

2. 使用乐观锁(CAS机制)

利用 SQL 语句本身的原子性进行更新。

  • 方案 A(版本号机制):

    sql
    UPDATE stock SET count = count - 1, version = version + 1
    WHERE id = 1 AND version = old_version;

    缺点: 高并发下冲突率极高,大量请求失败需要重试,浪费 CPU 资源。

  • 方案 B(利用条件限制 - 推荐):
    直接在更新语句中判断库存是否大于 0。

    sql
    UPDATE stock SET count = count - 1
    WHERE id = 1 AND count > 0;

    优点: 利用 MySQL 行锁保证原子性,且不会出现负库存。
    缺点: 依然直接打库,数据库抗不住每秒上万的 QPS。


第二层:缓存层面的抗压(核心方案)

为了保护数据库,必须将库存扣减操作前置到 Redis。

1. 库存预热

活动开始前,将数据库中的库存数量同步加载到 Redis 中(例如 key 为 sku_stock_1001)。

2. Redis 原子扣减 (Lua 脚本)

虽然 Redis 的 DECR 是原子的,但在业务逻辑中通常需要“先查询判断是否大于0,再扣减”。这两个步骤如果分开执行,会有并发安全问题。

解决方案:使用 Lua 脚本。Redis 会将 Lua 脚本作为一个整体原子执行,中间不会被插入其他命令。

plaintext
-- Lua 脚本示例
local key = KEYS[1]
local num = tonumber(ARGV[1])
local stock = tonumber(redis.call('get', key))

if (stock == nil) then
    return -1 -- 库存未预热
end

if (stock >= num) then
    redis.call('decrby', key, num)
    return 1 -- 扣减成功
else
    return 0 -- 库存不足
end

3. 扣减成功后的处理

Redis 扣减成功后,并不代表订单真正完成。此时需要生成一个“预订单”或“流水记录”。


第三层:异步削峰(架构优化)

Redis 扣减成功后,不能直接去更新数据库,否则数据库依然会挂。需要引入消息队列(MQ)。

1. 消息队列削峰

  • 流程: Redis 扣减成功 -> 发送“扣减库存”消息到 MQ (RabbitMQ/RocketMQ/Kafka) -> 立即返回给前端“排队中”或“抢购成功”。
  • 消费: 后端服务监听 MQ,慢慢地(按照数据库能承受的速率)去执行数据库的 UPDATE stock SET count = count - 1 操作。

2. 保证消息不丢失

  • 开启 MQ 的持久化。
  • 使用 ACK 机制确保消息被正确消费。

第四层:极端并发下的优化(分段锁)

如果单个商品的库存非常大(例如 10 万库存),且并发极高,Redis 的单 Key 热点问题(Hot Key)会成为瓶颈(Redis 单节点处理能力有限)。

解决方案:库存分段(Sharding)
仿照 ConcurrentHashMap 的思想,将一个商品的库存拆分成多个 Key。

  • 例如:stock_1001 拆分为 stock_1001_0, stock_1001_1 ... stock_1001_9
  • 每个 Key 存 1/10 的库存。
  • 用户请求过来时,随机(或通过 Hash)路由到某一个分段 Key 上进行 Lua 扣减。
  • 如果该分段库存不足,可以尝试合并剩余分段库存或直接返回失败(视业务复杂度而定)。

第五层:数据一致性与异常处理(兜底机制)

Redis 和数据库之间必然存在时间差,如何处理异常情况?

1. 缓存与数据库不一致

  • Redis 挂了: 需要降级策略,例如限流后直接走数据库,或者直接熔断。
  • Redis 扣了,MQ 发送失败: 需要本地消息表(Local Transaction Table)配合定时任务重试,保证最终一致性。

2. 什么时候扣库存?(下单 vs 支付)

  • 下单减库存(推荐用于秒杀): 用户体验好,不会出现付了钱没货的情况。但容易被恶意刷单(下单不支付,占用库存)。
    • 对策: 设置订单有效期(如 10 分钟),过期未支付则触发库存回滚(Redis + DB 都要回滚)。
  • 支付减库存: 不会少卖,但可能出现并发支付导致超卖,需要退款,用户体验差。

3. 应对恶意请求

  • 限流(Rate Limiting): 在 Nginx 或网关层(Sentinel/Hystrix)限制单用户、单 IP 的请求频率。
  • 验证码: 防止机器脚本。
  • 隐藏秒杀地址: 动态生成下单 URL。

总结:标准的高并发库存扣减流程

  1. 流量拦截: 网关层限流、黑名单校验。
  2. 缓存扣减: 请求到达服务层,通过 Redis Lua 脚本 扣减缓存库存。
    • 库存不足 -> 直接返回失败。
    • 库存充足 -> 进入下一步。
  3. 异步写入: 发送订单消息到 MQ
  4. 结果反馈: 此时直接告知用户“抢购中”或“下单成功”,前端轮询查询订单状态。
  5. 持久化: 消费者从 MQ 拉取消息,执行 数据库层面的乐观锁更新 (UPDATE ... WHERE count>0) 并创建订单。
  6. 回滚机制: 监听“订单超时未支付”或“取消订单”事件,将库存加回 Redis 和数据库。

核心口诀:
Redis 做预减,Lua 保原子,MQ 做削峰,数据库兜底,超时做回滚。

00:00
00:00