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

86 lines
4.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 宕机怎么办(主从 + 对账,极端情况下暂停活动)?为什么不用分布式锁?消息丢了怎么办?