跳转至

高并发秒杀系统设计(Go)

一句话摘要

秒杀的核心矛盾是「极小并发窗口 vs 极大瞬时流量」。设计的本质是**分层削峰**:把海量请求挡在各层之外,只让极少数"真买家"穿透到后端,再用 Redis 原子扣减保证不超卖、不重复买,最后异步落库保护数据库。


核心概念

  1. 分层削峰 — 从 CDN → Nginx → 网关 → 秒杀服务 → Redis → 消息队列 → 数据库,逐层把绝大多数请求挡在外层,只有真买家透传。
  2. 限流(返回 429) — 网关/服务层用限流算法控制 QPS,超限返回 429 Too Many Requests + Retry-After,与"售罄"严格区分。
  3. Redis 原子性 — Redis 单线程串行执行,Lua 脚本中"读→判→扣"是一个不可分割的整体,这是不超卖的底层保证。
  4. 限购幂等 — 用 Redis 已购标记 + 数据库唯一约束,保证同一用户对同一商品只能买一件。
  5. 异步削峰落库 — 扣库存成功后只写消息队列,由消费者批量异步写数据库,避免瞬时写爆 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 强制同一槽位:

stock = {seckill:<goodsID>}:stock
user  = {seckill:<goodsID>}:user:<userID>
                      ↑ 大括号里相同 → 两个键一定落在同一节点

若架构就是单实例 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 则没有此限制。

相关链接