Files
zhuangang/topics/backend/redis/scenarios/stock-reservation.md
T

4.2 KiB
Raw Blame History

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week
id title type domain stack tags level prerequisites est_hours related week
backend.redis.scenario.stock-reservation 抢购库存预扣:Lua 原子扣减、防重复下单、异步落库 scenario backend
redis
mq
mysql
秒杀
库存
Lua
防超卖
削峰
advanced
backend.redis.lock-ratelimit
3
backend.redis.lock-ratelimit
backend.mq.reliability
backend.redis.interview
14

抢购库存预扣:Lua 原子扣减、防重复下单、异步落库

业务背景与问题

电商的限时抢购活动:100 件商品,开抢瞬间几万个请求。之前直接在数据库里 UPDATE stock SET num = num - 1 WHERE num > 0,结果数据库行锁排队、连接池被占满,连带其他业务一起变慢;还出现过同一个用户买了多件。

约束

  • 绝对不能超卖;每人限购 1 件。
  • 抢购接口要快速返回结果(成功进入下单 / 已抢光)。
  • 数据库是最终的数据来源,Redis 可能丢数据。
  • 不能影响其他业务。

方案设计

flowchart LR
  U[用户] --> N[Nginx / 网关限流] --> A[抢购接口]
  A -->|Lua 原子预扣| R[(Redis 库存 + 已购用户)]
  A -->|预扣成功:发消息| Q[[消息队列]]
  Q --> C[下单消费者] -->|事务:建订单 + 扣数据库库存| D[(MySQL)]
  C -->|失败:回补 Redis 库存| R
  1. 层层削峰:前端按钮防重复点击、网关按用户 / IP 限流、活动未开始直接拒绝。
  2. Redis 预扣:活动开始前把库存加载到 Redis;Lua 脚本原子执行“是否已购买 → 库存是否大于 0 → 扣减 → 记录用户”。
  3. 异步下单:预扣成功后发消息到 MQ,接口立即返回“抢购成功,订单生成中”;消费者按数据库能承受的速度建订单,用数据库唯一约束 (活动ID, 用户ID) 兜底防重复。
  4. 对账和回补:下单失败或超时未支付时回补 Redis 库存;定时对账 Redis 和数据库的库存。

关键代码

完整代码和并发测试见 examples/stock-lock-ratelimit(stock.go):

-- KEYS[1] = 库存,KEYS[2] = 已购用户集合,ARGV[1] = 用户 ID
if redis.call("SISMEMBER", KEYS[2], ARGV[1]) == 1 then
  return -2                     -- 已经买过
end
local left = tonumber(redis.call("GET", KEYS[1]) or "0")
if left <= 0 then
  return -1                     -- 售罄
end
redis.call("DECR", KEYS[1])
redis.call("SADD", KEYS[2], ARGV[1])
return left - 1

测试:100 个用户各点两次、共 200 个并发请求抢 10 件,结果固定是成功 10 次、剩余库存 0、购买人数 10。

取舍

方案 优点 缺点
数据库条件更新 简单、强一致 高并发下行锁排队,拖垮数据库
Redis Lua 预扣 + MQ 异步下单 扛高并发、保护数据库 链路长,要处理 Redis 与数据库不一致、消息丢失
Redis DECR 后判断负数再 INCR 回去 不用 Lua 不好同时做限购;有短暂负数
分布式锁包住“查 + 扣” 好理解 所有请求串行抢锁,吞吐很低

易错点

  • 先 GET 判断再 DECR(不是原子的)→ 超卖。
  • 预扣成功但消息发送失败 → 库存少了但没有订单。处理:发送失败时回补库存,或者使用可靠消息(见消息可靠性)。
  • 消费者重复消费 → 重复建单。用唯一约束和幂等处理。
  • 用户抢到后不支付 → 超时取消订单并回补库存(延迟消息,见 MQ 专题)。
  • Redis Cluster 下 Lua 脚本涉及的多个 key 必须在同一个槽:key 用相同的哈希标签,如 stock:{sku1} 和 stock:{sku1}:buyers。

面试怎么讲

“抢购的核心是不超卖和保护数据库。我在 Redis 用 Lua 脚本把‘限购检查、库存检查、扣减、记录用户’做成一个原子操作,预扣成功再发 MQ 异步建订单,数据库用唯一约束兜底防重复;下单失败或超时未支付时回补库存,定时对账。我写了并发测试:200 个请求抢 10 件,成功数固定是 10。”

追问准备:Redis 宕机怎么办(主从 + 对账,极端情况下暂停活动)?为什么不用分布式锁?消息丢了怎么办?