高并发秒杀系统设计(Go)¶
一句话摘要
秒杀的核心矛盾是「极小并发窗口 vs 极大瞬时流量」。设计的本质是**分层削峰**:把海量请求挡在各层之外,只让极少数"真买家"穿透到后端,再用 Redis 原子扣减保证不超卖、不重复买,最后异步落库保护数据库。
核心概念¶
- 分层削峰 — 从 CDN → Nginx → 网关 → 秒杀服务 → Redis → 消息队列 → 数据库,逐层把绝大多数请求挡在外层,只有真买家透传。
- 限流(返回 429) — 网关/服务层用限流算法控制 QPS,超限返回
429 Too Many Requests+Retry-After,与"售罄"严格区分。 - Redis 原子性 — Redis 单线程串行执行,Lua 脚本中"读→判→扣"是一个不可分割的整体,这是不超卖的底层保证。
- 限购幂等 — 用 Redis 已购标记 + 数据库唯一约束,保证同一用户对同一商品只能买一件。
- 异步削峰落库 — 扣库存成功后只写消息队列,由消费者批量异步写数据库,避免瞬时写爆 DB。
分层架构(削峰金字塔)¶
秒杀系统设计的精髓:请求在每一层被越削越少,真正打到数据库的只有个位数 QPS。
graph TD
A[海量用户<br/>瞬时 1w+ 并发点击] --> B
subgraph 接入层
B[CDN / Nginx 静态资源<br/>只返回页面, 不碰后端]
C[网关 限流·限购·风控<br/>超限返回 429]
end
B --> C
C --> D[秒杀服务 Go<br/>按用户维度限流 检查幂等]
D --> E{Redis 原子扣减 Lua}
E -->|成功| F[消息队列 MQ<br/>削峰异步]
E -->|库存不足| G[返回 无库存/已抢光]
E -->|已购过| H[返回 已抢购过 拦截]
F --> I[消费者批量异步写库<br/>订单表 唯一约束兜底]
关键认知:数据库只接待"削峰后极少量"的写入,绝不让瞬时高并发直接压到 MySQL 上。
分层防护冲次表¶
| 层级 | 承接请求 | 拦掉的方式 | 返回 |
|---|---|---|---|
| CDN/Nginx | 全量页面 | 静态资源缓存 | 页面本身 |
| 网关 | 剩余真实点击 | IP/总 QPS 限流 | 429 |
| 秒杀服务 | 更少 | 用户维度限流、幂等 | 429 / 已购 |
| Redis Lua | 少数真买家 | 原子扣库存 | 成功/售罄/已购 |
| 消息队列 | 抢到的人 | 削峰排队 | 异步受理 |
| 数据库 | 极少量 | 乐观锁 + 唯一约束 | 最终落库 |
详解¶
限流返回 429(答用户:被限流启返回什么)¶
限流 ≠ 售罄 ≠ 故障。必须用不同状态码和业务码让客户端区分"还有机会"和"已经没了",否则会产生重试风暴或误导用户。
返回码策略¶
| 场景 | 状态码 | 语义 |
|---|---|---|
| 被限流 | 429 Too Many Requests | 别急,还有机会,稍后再试 |
| 商品售罄 | 404 或业务码 |
真没了,别刷了 |
| 无法流通 | 503 Service Unavailable |
系统忙,稍缓 |
响应头携带重试信息(关键)¶
HTTP/1.1 429 Too Many Requests
Retry-After: 5 # 告诉客户端 5 秒后再重试
X-RateLimit-Remaining: 60 # 剩余配额
X-RateLimit-Reset: 1712330 # 配额重置时间(unix)
Retry-After 头尤其重要:能让流量平滑,避免客户端无视间隔疯狂重试,形成二次"重试风暴"。
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 限流中间件(令牌桶)¶
// 令牌桶限流, 每秒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 原子扣减¶
为什么普通两条命令会超卖: 并发下两个请求都先读到"有库存",然后各自扣减,结果总卖出大于实际库存。
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 单线程执行,脚本期间不接管任何其他命令。
sequenceDiagram
participant A as 用户A
participant R as Redis(单线程)
participant C as 用户B,C(排队等待)
A->>R: EVAL Lua脚本
Note over R: 锁定单线程<br/>拿到stock<br/>判断>=1<br/>dec=9<br/>return 1
Note over C: B,C连接全部被阻塞<br/>等其他每个完成
R-->>A: 1 (成功)
-- 秒杀扣库存 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 异常):
-- 库存表(name, goods_id, stock, version)
UPDATE stock
SET stock = stock - 1,
version = version + 1
WHERE goods_id = ?
AND stock > 0 -- 不够则返回0行
AND version = ?; -- 与被并发抢过则返回0行
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 一次性完成,否则会出现"库存扣了但没标记"(能重复买)或"标了但没扣"(库存错乱)的不一致。
-- 限购 + 扣库存, 合一个原子脚本
-- 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 侧调用¶
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 增加硬约束,保证一人一件**物理不可能**被破坏:
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) -- 一人一件的保险丝
);
_, 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 // 重复购买被唯一约束拦截
}
异步削峰落库¶
秒杀扣库存成功后,一定不要同步写库(否则瞬时高并发打爆数据库),改由消息队列削峰,消费者批量写:
graph LR
A[秒杀接口扣库存成功] --> B[[MQ 消息队列]]
B --> C[消费者 Goroutine]
C --> D{攒够一批?}
D -->|是| E[批量 INSERT 订单]
D -->|否, continue| B
// 消费者攒批批量插入, 大幅降低 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 来说就是一个普通字符串,用于整串匹配。
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 强制同一槽位:
若架构就是单实例 Redis(秒杀常用),就没有这个跨节点限制,key 可自由命名。
常见陷阱¶
陷阱一:超卖(并发下"读+扣"被切割)
用两条独立命令 GET 再 DECR,并发时多个请求都读到旧库存各自扣减,酿成超卖。
解决:把"读+判断+扣"封装进 Lua 脚本,靠 Redis 单线程保证原子。
陷阱二:429 与售罄混淆, 引发重试风暴
被限流返回错误码(如 500),前端会盲目重试放二次压力;统一返回 429 且必须带 Retry-After,让客户端平滑重试,同时用友好文案避免手动刷新。
陷阱三:Lua 操作多 key 在 cluster 崩溃
Redis Cluster 下 Lua 一次操作分布在不同节点的 key 会报错,导致秒杀完全不可用。
解决:用 {hash-tag} 强制相关 key 落同一槽,或直接用单实例 Redis。
陷阱四:脚本执行过久阻塞整个 Redis
Lua 里写阻塞操作(死循环、文件IO、网络IO)会让单线程 Redis 全线卡死,且触发 lua-time-limit(默认5000ms)后需要人工干预。
解决:脚本保持短小,只做"判断+扣减+标记"等几十毫秒内的操作。
练习题¶
题目一:秒杀为什么不能直接同步写数据库?
答案
瞬时高并发(上万 QPS)若全部同步写 MySQL,DB 的磁盘 IO 与行锁会瞬间被打爆,出现大量排队和超时。所以库存扣在 Redis 后放入消息队列,消费者再以批量、稳定的速率异步写入数据库,将数据库的瞬时冲击削峰为平稳流量。
题目二:Lua 脚本怎么保证"读-判-扣"不会被其他请求打断?
答案
Redis 处理命令的主线程是单线程的 event loop,一次只执行一个命令。当执行 Lua 脚本时,主线程被脚本"占住",期间不会接管其他任何客户端命令;所以脚本内的"读取→判断→扣减"作为一个整体原子执行,不会发生并发读-扣冲突,也就不会超卖。
题目三:Redis Cluster 下,扣库存和限购标记两个 key 会有什么坑?怎么解?
答案
Lua 脚本同时操作 stock 与 user 两个 key 时,若两个 key 按哈希落到不同节点,Redis Cluster 会因为无法跨节点原子执行而直接报错。解决办法是用 hash tag {seckill:<goodsID>} 给两个 key 做相同的前缀,让它们落在同一槽/同一节点;如果本身就是单实例 Redis 则没有此限制。
相关链接¶
- Redis EVAL 官方文档 — Lua 脚本及其原子性的权威说明
- Redis 数据类型 — Redis 五种数据类型官方文档
- Redis Cluster 键分发 — 哈希槽与 hash tag 的原理说明