阶段2(上):后端专题 Go/Gin/PostgreSQL/MySQL/Redis/MQ/gRPC 的课程、企业场景、面试题和可运行示例

This commit is contained in:
Shikui Wang committed 2026-10-23 15:24:57 +08:00
1 parent 0bae9916a9
commit 5eb67c5d08
65 files changed
+6457 -77

No files matched your search

@@ -0,0 +1,85 @@
---
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 宕机怎么办(主从 + 对账,极端情况下暂停活动)?为什么不用分布式锁?消息丢了怎么办?