--- id: backend.redis.scenario.stock-reservation title: 抢购库存预扣:Lua 原子扣减、防重复下单、异步落库 type: scenario domain: backend stack: [redis, mq, mysql] tags: [秒杀, 库存, Lua, 防超卖, 削峰] level: advanced prerequisites: [backend.redis.lock-ratelimit] est_hours: 3 related: [backend.redis.lock-ratelimit, backend.mq.reliability, backend.redis.interview] week: [14] --- # 抢购库存预扣:Lua 原子扣减、防重复下单、异步落库 ## 业务背景与问题 电商的限时抢购活动:100 件商品,开抢瞬间几万个请求。之前直接在数据库里 `UPDATE stock SET num = num - 1 WHERE num > 0`,结果数据库行锁排队、连接池被占满,连带其他业务一起变慢;还出现过同一个用户买了多件。 ## 约束 - 绝对不能超卖;每人限购 1 件。 - 抢购接口要快速返回结果(成功进入下单 / 已抢光)。 - 数据库是最终的数据来源,Redis 可能丢数据。 - 不能影响其他业务。 ## 方案设计 ```mermaid 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](../examples/stock-lock-ratelimit/)(`stock.go`): ```lua -- 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/03-reliability.md))。 - 消费者重复消费 → 重复建单。用唯一约束和幂等处理。 - 用户抢到后不支付 → 超时取消订单并回补库存(延迟消息,见 MQ 专题)。 - Redis Cluster 下 Lua 脚本涉及的多个 key 必须在同一个槽:key 用相同的哈希标签,如 `stock:{sku1}` 和 `stock:{sku1}:buyers`。 ## 面试怎么讲 > “抢购的核心是不超卖和保护数据库。我在 Redis 用 Lua 脚本把‘限购检查、库存检查、扣减、记录用户’做成一个原子操作,预扣成功再发 MQ 异步建订单,数据库用唯一约束兜底防重复;下单失败或超时未支付时回补库存,定时对账。我写了并发测试:200 个请求抢 10 件,成功数固定是 10。” 追问准备:Redis 宕机怎么办(主从 + 对账,极端情况下暂停活动)?为什么不用分布式锁?消息丢了怎么办?