从 0 设计 10 万 QPS 秒杀系统:10 万请求进来,只有 1000 个能碰到数据库

引言

去年大促,1,000 件商品的秒杀刚开抢,监控上三条曲线同时变形:

  • 入口 QPS 冲到 12 万
  • 库存服务的扣减请求 3 万 QPS
  • MySQL 连接池 100%,等待队列堆到 2,000+

结果库存被扣成 -37,超卖 37 单;数据库 CPU 打满,连商品详情查询都开始超时;Redis 上那个 stock:1001 的单分片 CPU 100%——3 万个扣减请求全砸在同一个 key 上。

当时的代码用的是最直觉的写法:UPDATE stock SET count = count - 1 WHERE sku_id = ? AND count > 0。这条 SQL 看着无懈可击,它的真实含义却是 3 万个请求在数据库行锁上排队。单行更新的上限就在那,连接池先满,超时、重试、更多请求涌进来,雪崩不到 10 秒。

这篇文章不讨论"库存怎么扣得快",只回答三个问题:10 万 QPS 该被拆成哪几条流量、每一层要多少机器、架构为什么长成这个形状。 读完你能带走一套可复用的容量推导方法,以及一张能直接照着画的架构图。

一、需求拆解:先问清"10 万 QPS 是谁的 10 万"

"设计一个 10 万 QPS 秒杀系统"这句话的信息量接近于零。它没说 10 万打在哪一层,也没说库存多少。这两个数字不定,后面的架构全是猜。

问题为什么关键本文假设
几个 SKU?单 SKU 是热 key 问题,多 SKU 是分布问题1 个爆款
库存多少?直接决定 DB 写入量和成功订单数1,000 件
10 万 QPS 是哪一层?详情页和下单接口差一个数量级点"立即抢购"
活动持续多久?决定峰值长度和削峰策略10 分钟,峰值在前 10 秒
能否接受少卖?决定库存分桶与回滚方式允许少量,事后补偿
是否要求严格公平?决定排队、抽签还是纯先到先得先到先得 + 防刷

第三个问题最容易被跳过。10 万 QPS 如果是商品详情页,CDN 静态化就能扛住,根本不需要后面这套漏斗;只有打在"下单"这个接口上,才有容量评估的必要。面试时先把这句问出来,比直接背架构图值钱。

需求拆完,只剩三条硬约束:不超卖(卖出数 ≤ 库存)、不重复(一人最多一单)、可降级(任一依赖挂掉不能雪崩)。其余都是"尽量"——尽量少卖、尽量公平、尽量低延迟。硬约束和软约束分开摆,架构取舍才有依据。

二、容量评估:把每个数字算出来

2.1 漏斗:一场活动只卖出 1,000 单

入口 10 万 QPS,先过前端答题和验证码,按 50% 通过率剩 5 万到网关;网关令牌桶只放行 2 万;秒杀服务里做资格校验和 Redis 预扣,库存只有 1,000 件,全场最多 1,000 次成功;这 1,000 条消息进 Kafka,DB 峰值写入 1,000 TPS。

这里有个反直觉的点:DB 要按 1,000 TPS 设计,而不是按 2 万设计。真正到达数据库的写请求,被库存总量天然封顶了。想清楚这一点,机器数量会比"按 QPS 直接等比放大"少一个数量级。

顺带说清怎么反推这个 10 万。容量评估不能拍脑袋,通常从预约量倒推:假设活动预约 300 万人,历史经验是开抢后首秒有 3%~5% 的人同时点,峰值就落在 9 万~15 万之间,取 10 万做设计值;如果只有 30 万预约,峰值 1 万就够了,整套架构可以砍掉一半。先有预约量这个输入,再有 QPS 这个输出,反过来直接给一个 QPS 数字,等于没有需求。

2.2 每个组件怎么算

  • 带宽:入口 10 万 × 4KB ≈ 400MB/s ≈ 3.2Gbps;经过漏斗后回源只有 2 万 × 4KB ≈ 0.64Gbps;静态页 2 万 × 50KB ≈ 8Gbps,这部分必须走 CDN,否则源站光拉页面就要 40Gbps
  • 网关:10 万 ÷ 单机 2.5 万 × 1.5 余量 = 6 台(2.5 万是含 TLS 卸载和限流的保守值)
  • 秒杀服务:2 万 ÷ 单机 3,000 × 1.5 = 10 台;3,000 的前提是接口只做内存判断 + 一次 Redis 调用,多查一次商品库,单机立刻掉到几百 QPS
  • Redis:2 万 ÷ 32 个桶 = 单桶 625 QPS,单分片 5 万的量级毫无压力;32 个桶不是为了容量,是为了打散热 key
  • Kafka:峰值 1,000 条/秒,6 分区够用;分区数是为了消费并行和故障隔离,不是为吞吐
  • MySQL:峰值 1,000 TPS,单库 3,000 TPS 的量级,1 主 1 从足够,这个量级不需要分库分表
组件峰值负载单实例能力实例数余量
CDN10 万 QPS / 8Gbps按套餐—静态页不回源
网关10 万 QPS2.5 万/台650%
秒杀服务2 万 QPS3,000/台1050%
Redis2 万 QPS5 万/分片3 主 3 从 + 32 桶60%
Kafka1,000 条/秒5 万/分区6 分区充足
订单服务1,000 TPS500/实例350%
MySQL1,000 TPS3,000/库1 主 1 从3 倍

2.3 并发不等于 QPS

按 Little's Law 换算真正同时停在系统里的请求:并发 ≈ QPS × 响应时间。

网关 10 万 × 20ms ≈ 2,000 并发;应用 2 万 × 50ms ≈ 1,000 并发;Redis 2 万 × 0.5ms ≈ 10 并发;DB 1,000 × 5ms ≈ 5 并发。

所以线程池和连接池按"并发"配,不是按"QPS"配。10 万 QPS 听着吓人,真正压在线程上的可能只有几千。

压测分三段走,别一上来就 10 万:单机基线(1 台打满,看单实例 QPS 和 P99)→ 集群线性(2 万 QPS,看是否线性扩展)→ 全链路峰值(10 万 QPS,重点看每一层的拒绝行为和降级行为)。压测口径和工具选型见《性能压测工具横评:JMeter vs wrk vs Gatling vs k6》。

三、架构全景图

用户 10 万 QPS 点击
        │
        ▼
① CDN + 前端    静态页 / 倒计时 / 按钮置灰 / 答题验证
        │  ← 拒绝:重复点击、脚本、无效流量
        ▼
② 网关          令牌桶 / 黑名单 / 签名 / 灰度开关
        │  ← 拒绝:刷子、超量请求,直接 429
        ▼
③ 秒杀服务      活动状态 / 资格校验 / 幂等判断(无状态,可水平扩)
        │
        ▼
④ Redis 集群    32 个库存桶 + 买家标记 + Lua 原子预扣
        │  ← 拒绝:库存不足、重复购买
        ▼  只有预扣成功才发消息
⑤ Kafka         6 分区,削峰 / 解耦 / 失败重试
        │
        ▼
⑥ 订单服务      幂等消费 / 建订单 / 扣库存 / 写流水
        │
        ▼
⑦ MySQL         订单表 / 库存表 / 流水表 + 唯一索引

旁路:配置中心(开关)· Prometheus 监控 · 对账任务 · 补偿队列

一次请求走完这条路是 6 步:

  1. 前端校验倒计时和按钮状态,带上活动开始才下发的一次性签名 token
  2. 网关校验签名、按 IP 和用户维度限流,超限直接 429,不放进下游
  3. 秒杀服务从本地缓存读活动状态(不查配置中心),校验用户资格后调 Redis
  4. Redis 在一个 Lua 脚本里判断买家标记、判断桶内库存、扣减、写标记,原子返回三种结果
  5. 预扣成功立刻发 Kafka 消息并返回"排队中",不让用户等 DB
  6. 订单服务幂等消费,一个事务里建订单、扣库存、写流水,提交后手动 ack

这套分层只有一个原则:每一层都只做自己最便宜的那件事。CDN 做静态化,网关做快速拒绝,应用做判断不做存储,Redis 做原子扣减,Kafka 做削峰,DB 做最终账本。任何一层越界去干别人的活,容量就会断在那里。

四、核心设计:三个不能省的环节

4.1 Redis 原子预扣

为什么不在 DB 扣?单行更新的能力上限约 1,000 TPS,2 万 QPS 进来就是 20 秒量级的排队,连接池一满全线雪崩。Redis Lua 单分片 5 万 QPS,2 万 QPS 只用到 40%,而且失败的请求毫秒级返回,不会占着连接。

-- KEYS[1] = 库存桶 key,如 {seckill:1001}:stock:3
-- KEYS[2] = 买家标记 key,如 {seckill:1001}:buyers:3
-- ARGV[1] = userId,ARGV[2] = 购买数量
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
    return -1                       -- 重复购买
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
if stock < tonumber(ARGV[2]) then
    return 0                        -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[2])
redis.call('SADD', KEYS[2], ARGV[1])
return 1                            -- 预扣成功

三个细节不能错:判断和扣减必须在一个脚本里,拆成 GET + 判断 + DECRBY 三步,两个请求会同时读到 1、同时扣成功;两个 key 必须用同一个 hash tag,否则在 Cluster 下会落到不同分片,Lua 直接报跨槽错误;脚本里只做判断和扣减,别顺带查其他 key,Redis 主线程是单线程,重逻辑会拖慢整个分片。

4.2 库存分桶

单个 stock:1001 key 的问题不是容量,是热 key。2 万 QPS 全打在一个分片上,单线程模型下这个分片 CPU 先到 100%,然后所有请求延迟一起上升。

按用户 ID 固定映射到 32 个桶,压力被摊到多个分片:

// 同一个用户永远打到同一个桶,保证重试时能命中自己的买家标记
public int bucket(long userId) {
    return Math.floorMod(Long.hashCode(userId), 32);
}

分桶有一个必须承认的代价:少卖。桶 0 卖空了,桶 1 还有货,映射到桶 0 的用户会失败。三种处理方式——接受少卖、活动后统一补偿;定时任务把富余桶的库存搬到空桶;或者干脆按应用实例预分配本地库存。秒杀场景通常选第一种,因为超卖是事故,少卖只是收益损失。

4.3 落账与对账闭环

预扣成功后为什么不直接写 DB,而要绕一圈 Kafka?三个理由:建单 + 扣库存 + 写流水是一个 5~10ms 的事务,1,000 条并发写就能把连接池占满;DB 抖一下消息还在 Kafka 里,恢复后继续消费,不用补偿脚本;1,000 条消息在 1 秒内产生,消费者按自己的节奏取,DB 压力被摊平。库存 10 万件时这个差距会放大 100 倍,所以架构按可扩展设计,不按当前最小值设计。

Redis 预扣成功也不代表订单一定落库。消费失败、事务回滚、消息丢失,都会造成"Redis 扣了、DB 没扣"。幂等的最终防线放在数据库,而不是 Redis 的买家标记:标记会过期、会随重启丢失,唯一索引不会。

-- 幂等保底:同一个用户对同一个 SKU 只能有一单
CREATE UNIQUE INDEX uk_user_sku ON t_seckill_order (user_id, sku_id);

-- 最终扣减:带条件更新,防止超卖的最后一道闸
UPDATE t_stock
SET count = count - 1
WHERE sku_id = 1001 AND count > 0;

消费端遇到 DuplicateKeyException 直接当"已处理"跳过并 ack,不要重试:Redis 标记挡活动期的重复点击,Kafka 幂等挡重复投递,唯一索引挡最终结果,三层各管一段。之后就是闭环的最后一步,对账公式只有一行——Redis 剩余库存 + DB 成功订单数 = 初始库存。差 2 单就意味着有两条消息丢了或消费失败了,补偿要么重放消息,要么回滚 Redis 库存;对账任务每 5 分钟跑一次,差异超阈值立刻告警。

五、避坑清单

  • 别在网关里查数据库。网关单机 2.5 万 QPS 的前提是纯内存逻辑,一次 DB 查询就能把它打回几百 QPS,6 台瞬间变 60 台。
  • 别用 DB 行锁扛扣减。WHERE count > 0 只保证不超卖,不保证扛得住;乐观锁在 2 万 QPS 下冲突率接近 100%,重试把压力翻倍还回去。
  • 别用单个库存 key。热 key 会把一个分片打爆,而其他分片闲着,集群扩容也救不了。
  • 别在降级时回退到 DB。Redis 挂了要快速失败、返回"系统繁忙",甚至关掉活动入口;把 2 万 QPS 放回只有 1,000 TPS 的库,等于用一次秒杀事故换一次全站事故。
  • 别只在前端限流。前端置灰挡不住脚本,网关、应用、Redis、DB 每一层都要保护下一层。

收尾

10 万 QPS 秒杀的答案不在"库存怎么扣得快",而在"怎么让 99% 的请求到不了库存"——三层漏斗把 10 万压成 2 万、再压成 1,000,DB 只做最终账本。

你们线上的秒杀,库存扣减用的是 DB 条件更新、Redis Lua 还是分桶方案?有没有遇到过"Redis 扣成功、订单没落库"的差账?评论区说说现场,我挑一个最典型的拆成下一篇。

关注我,每周拆一个真实生产故障。


标题:从 0 设计 10 万 QPS 秒杀系统:10 万请求进来,只有 1000 个能碰到数据库
作者:jiangyi
地址:http://jiangyi.space/articles/2026/11/02/1793464969441.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消