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