高并发下如何保证库存扣减的正确性?
在高并发场景下(如秒杀、抢购),保证库存扣减的正确性主要面临两个核心挑战:超卖(库存减为负数) 和 性能瓶颈(数据库锁竞争严重)。
解决这个问题的核心思路是:将同步的数据库操作转化为异步的消息处理,利用缓存抗压,利用数据库兜底。
以下是分层级的详细解决方案,从数据库层面到架构层面逐步优化:
第一层:数据库层面的保护(最后一道防线)
即使上层架构再复杂,数据库层必须保证数据的最终一致性和原子性。
1. 避免悲观锁 (SELECT ... FOR UPDATE)
在并发量不高时可以使用,但在高并发下,悲观锁会导致严重的锁竞争,数据库连接池瞬间被打满,导致服务不可用。坚决不推荐在秒杀场景使用。
2. 使用乐观锁(CAS机制)
利用 SQL 语句本身的原子性进行更新。
方案 A(版本号机制):
sqlUPDATE stock SET count = count - 1, version = version + 1 WHERE id = 1 AND version = old_version;缺点: 高并发下冲突率极高,大量请求失败需要重试,浪费 CPU 资源。
方案 B(利用条件限制 - 推荐):
直接在更新语句中判断库存是否大于 0。sqlUPDATE 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 脚本作为一个整体原子执行,中间不会被插入其他命令。
-- 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。
总结:标准的高并发库存扣减流程
- 流量拦截: 网关层限流、黑名单校验。
- 缓存扣减: 请求到达服务层,通过 Redis Lua 脚本 扣减缓存库存。
- 库存不足 -> 直接返回失败。
- 库存充足 -> 进入下一步。
- 异步写入: 发送订单消息到 MQ。
- 结果反馈: 此时直接告知用户“抢购中”或“下单成功”,前端轮询查询订单状态。
- 持久化: 消费者从 MQ 拉取消息,执行 数据库层面的乐观锁更新 (
UPDATE ... WHERE count>0) 并创建订单。 - 回滚机制: 监听“订单超时未支付”或“取消订单”事件,将库存加回 Redis 和数据库。
核心口诀:
Redis 做预减,Lua 保原子,MQ 做削峰,数据库兜底,超时做回滚。