diff --git a/docs/architecture/seckill-design.md b/docs/architecture/seckill-design.md new file mode 100644 index 0000000..e9c43c6 --- /dev/null +++ b/docs/architecture/seckill-design.md @@ -0,0 +1,379 @@ +# 高并发秒杀系统设计(Go) + +!!! note "一句话摘要" + 秒杀的核心矛盾是「极小并发窗口 vs 极大瞬时流量」。设计的本质是**分层削峰**:把海量请求挡在各层之外,只让极少数"真买家"穿透到后端,再用 Redis 原子扣减保证不超卖、不重复买,最后异步落库保护数据库。 + +--- + +## 核心概念 + +1. **分层削峰** — 从 CDN → Nginx → 网关 → 秒杀服务 → Redis → 消息队列 → 数据库,逐层把绝大多数请求挡在外层,只有真买家透传。 +2. **限流(返回 429)** — 网关/服务层用限流算法控制 QPS,超限返回 `429 Too Many Requests` + `Retry-After`,与"售罄"严格区分。 +3. **Redis 原子性** — Redis 单线程串行执行,Lua 脚本中"读→判→扣"是一个不可分割的整体,这是不超卖的底层保证。 +4. **限购幂等** — 用 Redis 已购标记 + 数据库唯一约束,保证同一用户对同一商品只能买一件。 +5. **异步削峰落库** — 扣库存成功后只写消息队列,由消费者批量异步写数据库,避免瞬时写爆 DB。 + +## 分层架构(削峰金字塔) + +> 秒杀系统设计的精髓:**请求在每一层被越削越少,真正打到数据库的只有个位数 QPS。** + +```mermaid +graph TD + A[海量用户
瞬时 1w+ 并发点击] --> B + subgraph 接入层 + B[CDN / Nginx 静态资源
只返回页面, 不碰后端] + C[网关 限流·限购·风控
超限返回 429] + end + B --> C + C --> D[秒杀服务 Go
按用户维度限流 检查幂等] + D --> E{Redis 原子扣减 Lua} + E -->|成功| F[消息队列 MQ
削峰异步] + E -->|库存不足| G[返回 无库存/已抢光] + E -->|已购过| H[返回 已抢购过 拦截] + F --> I[消费者批量异步写库
订单表 唯一约束兜底] +``` + +**关键认知**:数据库只接待"削峰后极少量"的写入,绝不让瞬时高并发直接压到 MySQL 上。 + +## 分层防护冲次表 + +| 层级 | 承接请求 | 拦掉的方式 | 返回 | +|------|---------|-----------|------| +| CDN/Nginx | 全量页面 | 静态资源缓存 | 页面本身 | +| 网关 | 剩余真实点击 | IP/总 QPS 限流 | 429 | +| 秒杀服务 | 更少 | 用户维度限流、幂等 | 429 / 已购 | +| Redis Lua | 少数真买家 | 原子扣库存 | 成功/售罄/已购 | +| 消息队列 | 抢到的人 | 削峰排队 | 异步受理 | +| 数据库 | 极少量 | 乐观锁 + 唯一约束 | 最终落库 | + +--- + +## 详解 + +### 限流返回 429(答用户:被限流启返回什么) + +**限流 ≠ 售罄 ≠ 故障**。必须用不同状态码和业务码让客户端区分"还有机会"和"已经没了",否则会产生重试风暴或误导用户。 + +#### 返回码策略 + +| 场景 | 状态码 | 语义 | +|------|--------|------| +| 被限流 | **429** Too Many Requests | 别急,还有机会,稍后再试 | +| 商品售罄 | `404` 或业务码 | 真没了,别刷了 | +| 无法流通 | `503` Service Unavailable | 系统忙,稍缓 | + +#### 响应头携带重试信息(关键) + +```http +HTTP/1.1 429 Too Many Requests +Retry-After: 5 # 告诉客户端 5 秒后再重试 +X-RateLimit-Remaining: 60 # 剩余配额 +X-RateLimit-Reset: 1712330 # 配额重置时间(unix) +``` + +**Retry-After 头尤其重要**:能让流量平滑,避免客户端无视间隔疯狂重试,形成二次"重试风暴"。 + +#### Nginx 层限流 + +```nginx +limit_req_zone $binary_remote_addr zone=seckill:10m rate=10r/s; + +location /api/seckill { + limit_req zone=seckill burst=20 nodelay; + limit_req_status 429; # 超限返回 429 (默认503) + limit_req_log_level warn; + proxy_pass http://seckill_backend; +} +``` + +#### Go 限流中间件(令牌桶) + +```go +// 令牌桶限流, 每秒100个, 桶容量200 +var limiter = rate.NewLimiter(rate.Limit(100), 200) + +func RateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + if !limiter.Allow() { + c.Header("Retry-After", "5") + c.Header("X-RateLimit-Remaining", + fmt.Sprintf("%d", limiter.Burst()-limiter.Len())) + c.JSON(429, gin.H{"code": 429, + "msg": "当前比较拥挤, 已为你自动排队, 请稍候"}) + c.Abort() + return + } + c.Next() + } +} +``` + +> 前端拿到 429 应展示友善文案(如"已自动排队"),而不是「系统繁忙」,否则用户会不停手动刷新,放大压力。 + +--- + +### 超卖防护:Redis Lua 原子扣减 + +**为什么普通两条命令会超卖:** 并发下两个请求都先读到"有库存",然后各自扣减,结果总卖出大于实际库存。 + +```mermaid +sequenceDiagram + participant A as 用户A + participant B as 用户B + participant R as Redis(单线程) + Note over A,R: 不用Lua, 用两set命令 + A->>R: GET 库存 + B->>R: GET 库存 + R-->>A: 10 + R-->>B: 10 (两个都读到10) + A->>R: DECR → 9 + B->>R: DECR → 8 (超卖!) +``` + +**使用 Lua 后:** "读库存→判断够不够→扣减"被封装成一个脚本,Redis 单线程执行,脚本期间不接管任何其他命令。 + +```mermaid +sequenceDiagram + participant A as 用户A + participant R as Redis(单线程) + participant C as 用户B,C(排队等待) + A->>R: EVAL Lua脚本 + Note over R: 锁定单线程
拿到stock
判断>=1
dec=9
return 1 + Note over C: B,C连接全部被阻塞
等其他每个完成 + R-->>A: 1 (成功) +``` + +```lua +-- 秒杀扣库存 Lua (原子) +-- KEYS[1] = 库存key, ARGV[1] = 扣减数量(默认1) +local stock = redis.call('get', KEYS[1]) + +-- 库存不存在或不足 → 失败 +if not stock or tonumber(stock) < tonumber(ARGV[1]) then + return 0 +end + +redis.call('decrby', KEYS[1], ARGV[1]) +return 1 +``` + +#### Lua 原子性的底层原理 + +Redis 处理命令的**主线程是单线程**的(event loop),一次只执行一条。当它在执行一个 Lua 脚本时,**整个执行期间不会被任何其他命令插入**,因为主线程只认当前一个处理单元。因此"读→判断→扣"是形成一个整体原子,不可能被切分——库存从 10 扣到 0 的过程中,第 11 个并发请求必然失败,**物理上不会超卖**。 + +#### Lua 与 MULTI/EXEC 对比 + +| 特性 | 普通两条命令 | MULTI/EXEC 事务 | **Lua** | +|------|------------|----------------|--------| +| 原子性 | ❌ 会被插队 | ✅ 连续执行 | ✅ 完全原子 | +| 中间插入其它命令 | 可能 | 不会 | 不会 | +| if/else 判断逻辑 | ❌ | ❌ | ✅ 可写 | +| 适合秒杀"够才扣" | ❌ | ❌ | ✅ | + +#### 数据库乐观锁兜底(双保险) + +Lua 保证 Redis 层不超卖,但仍建议在 DB 落库时用**乐观锁**兜底(防绕过 Redis 或 Redis 异常): + +```sql +-- 库存表(name, goods_id, stock, version) +UPDATE stock +SET stock = stock - 1, + version = version + 1 +WHERE goods_id = ? + AND stock > 0 -- 不够则返回0行 + AND version = ?; -- 与被并发抢过则返回0行 +``` + +```go +result, err := db.Exec(`UPDATE goods + SET stock = stock - 1, version = version + 1 + WHERE goods_id = ? AND stock > 0 AND version = ?`, + goodsID, oldVersion) +affected, _ := result.RowsAffected() +if affected == 0 { + return ErrSoldOut // 库存不够或版本冲突 +} +``` + +--- + +### 一人限购一件:Lua 内一并判断 + +**分"与库存"必须放同一个 Lua 一次性完成**,否则会出现"库存扣了但没标记"(能重复买)或"标了但没扣"(库存错乱)的不一致。 + +```lua +-- 限购 + 扣库存, 合一个原子脚本 +-- KEYS[1] = 库存key, KEYS[2] = 用户已购标记key +-- 返回: -1=已购过, 0=售罄, 1=成功 + +if redis.call('exists', KEYS[2]) == 1 then + return -1 -- 该用户已买过, 拦截 +end + +local stock = tonumber(redis.call('get', KEYS[1]) or '0') +if stock < 1 then + return 0 -- 库存不足 +end + +redis.call('decrby', KEYS[1], 1) +redis.call('set', KEYS[2], 1, 'EX', 86400) -- 标记已购, 有效期24h +return 1 +``` + +#### Go 侧调用 + +```go +const seckillScript = ` +if redis.call('exists', KEYS[2]) == 1 then return -1 end +local stock = tonumber(redis.call('get', KEYS[1]) or '0') +if stock < 1 then return 0 end +redis.call('decrby', KEYS[1], 1) +redis.call('set', KEYS[2], 1) +redis.call('expire', KEYS[2], 86400) +return 1 +` + +func Seckill(ctx context.Context, rdb *redis.Client, userID, goodsID string) (int, error) { + // hash tag 保证两个 key 在 Cluster 下同一槽位 + stockKey := "{seckill:" + goodsID + "}:stock" + userKey := "{seckill:" + goodsID + "}:user:" + userID + + res, err := rdb.Eval(ctx, seckillScript, []string{stockKey, userKey}).Result() + if err != nil { return 0, err } + + switch res.(int64) { + case 1: + return 1, nil // 抢购成功 → 发异步任务 + case -1: + return -1, ErrAlreadyBought + default: + return 0, ErrSoldOut + } +} +``` + +#### 数据库唯一约束兜底 + +Redis key 可能过期、可能宕机丢失,所以在 DB 增加硬约束,保证一人一件**物理不可能**被破坏: + +```sql +CREATE TABLE seckill_order ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + goods_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_goods_user (goods_id, user_id) -- 一人一件的保险丝 +); +``` + +```go +_, err := db.Exec(`INSERT INTO seckill_order(goods_id, user_id) VALUES(?,?)`, g, u) +if err != nil && strings.Contains(err.Error(), "Duplicate entry") { + return ErrAlreadyBought // 重复购买被唯一约束拦截 +} +``` + +--- + +### 异步削峰落库 + +秒杀扣库存成功后,**一定不要同步写库**(否则瞬时高并发打爆数据库),改由消息队列削峰,消费者批量写: + +```mermaid +graph LR + A[秒杀接口扣库存成功] --> B[[MQ 消息队列]] + B --> C[消费者 Goroutine] + C --> D{攒够一批?} + D -->|是| E[批量 INSERT 订单] + D -->|否, continue| B +``` + +```go +// 消费者攒批批量插入, 大幅降低 DB 交互 +func (w *Worker) Consume(ctx context.Context, msgs <-chan OrderMsg) { + batch := make([]OrderMsg, 0, 100) + for msg := range msgs { + batch = append(batch, msg) + if len(batch) >= 100 { + w.batchInsert(ctx, batch) // 批量INSERT + 乐观锁 + batch = batch[:0] + } + } +} +``` + +--- + +### Redis Key 设计(重要,避免 cluster 多键报错) + +**Redis 的 key 就是抽屉的标签**,底层是一个平坦的 dict(哈希表),**没有文件夹/层级结构**。`seckill:stock:1001` 里的冒号只是开发者的命名约定,对 Redis 来说就是一个普通字符串,用于整串匹配。 + +```mermaid +graph TD + subgraph Redis Dict 平坦字典 + E["key: seckill:stock:1001 → value: \"10\""] + F["key: seckill:bought:1001:张三 → \"1\""] + G["key: seckill:limit:李四 → \"3\""] + end +``` + +#### Key 命名与场景对应表 + +| 场景 | Key 模式 | value | 用途 | +|------|---------|-------|------| +| 不超卖 | `seckill:stock:{商品ID}` | 数字剩余库存 | 共享减量 | +| 一人一件 | `seckill:bought:{商品ID}:{用户ID}` | 1 | 每人独立标记 | +| 限流 | `seckill:limit:{用户ID或IP}` | 次数(INCR) | 控制频率 | + +#### Cluster 下的 hash tag 解法 + +**注意**:Lua 脚本同时操作 `stock` 和 `user` 两个 key 时,在 **Redis Cluster(集群)** 中若两个 key 分到不同节点,脚本会报错(跨节点无法原子执行)。**解法是用大括号 `{...}` hash tag 强制同一槽位**: + +``` +stock = {seckill:}:stock +user = {seckill:}:user: + ↑ 大括号里相同 → 两个键一定落在同一节点 +``` + +> 若架构就是单实例 Redis(秒杀常用),就没有这个跨节点限制,key 可自由命名。 + +--- + +## 常见陷阱 + +!!! warning "陷阱一:超卖(并发下"读+扣"被切割)" + 用两条独立命令 `GET` 再 `DECR`,并发时多个请求都读到旧库存各自扣减,酿成超卖。 + **解决**:把"读+判断+扣"封装进 Lua 脚本,靠 Redis 单线程保证原子。 + +!!! warning "陷阱二:429 与售罄混淆, 引发重试风暴" + 被限流返回错误码(如 500),前端会盲目重试放二次压力;统一返回 429 且必须带 `Retry-After`,让客户端平滑重试,同时用友好文案避免手动刷新。 + +!!! warning "陷阱三:Lua 操作多 key 在 cluster 崩溃" + Redis Cluster 下 Lua 一次操作分布在不同节点的 key 会报错,导致秒杀完全不可用。 + **解决**:用 `{hash-tag}` 强制相关 key 落同一槽,或直接用单实例 Redis。 + +!!! warning "陷阱四:脚本执行过久阻塞整个 Redis" + Lua 里写阻塞操作(死循环、文件IO、网络IO)会让单线程 Redis 全线卡死,且触发 `lua-time-limit`(默认5000ms)后需要人工干预。 + **解决**:脚本保持短小,只做"判断+扣减+标记"等几十毫秒内的操作。 + +--- + +## 练习题 + +??? question "题目一:秒杀为什么不能直接同步写数据库?" + ??? success "答案" + 瞬时高并发(上万 QPS)若全部同步写 MySQL,DB 的磁盘 IO 与行锁会瞬间被打爆,出现大量排队和超时。所以库存扣在 Redis 后放入消息队列,消费者再以批量、稳定的速率异步写入数据库,将数据库的瞬时冲击削峰为平稳流量。 + +??? question "题目二:Lua 脚本怎么保证"读-判-扣"不会被其他请求打断?" + ??? success "答案" + Redis 处理命令的主线程是单线程的 event loop,一次只执行一个命令。当执行 Lua 脚本时,主线程被脚本"占住",期间不会接管其他任何客户端命令;所以脚本内的"读取→判断→扣减"作为一个整体原子执行,不会发生并发读-扣冲突,也就不会超卖。 + +??? question "题目三:Redis Cluster 下,扣库存和限购标记两个 key 会有什么坑?怎么解?" + ??? success "答案" + Lua 脚本同时操作 `stock` 与 `user` 两个 key 时,若两个 key 按哈希落到不同节点,Redis Cluster 会因为无法跨节点原子执行而直接报错。解决办法是用 hash tag `{seckill:}` 给两个 key 做相同的前缀,让它们落在同一槽/同一节点;如果本身就是单实例 Redis 则没有此限制。 + +## 相关链接 + +- [Redis EVAL 官方文档](https://redis.io/docs/latest/commands/eval/) — Lua 脚本及其原子性的权威说明 +- [Redis 数据类型](https://redis.io/docs/latest/develop/data-types/) — Redis 五种数据类型官方文档 +- [Redis Cluster 键分发](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/) — 哈希槽与 hash tag 的原理说明 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index e80aa49..6ec1a66 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,6 +106,7 @@ nav: - 简历技术要点: project/thumbup/resume.md - 架构: - architecture/index.md + - 高并发秒杀系统设计: architecture/seckill-design.md - 缓存: - 缓存击穿: architecture/cache/cache-breakdown.md - 缓存雪崩: architecture/cache/cache-avalanche.md