从 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 从足够,这个量级不需要分库分表
| 组件 | 峰值负载 | 单实例能力 | 实例数 | 余量 |
|---|---|---|---|---|
| CDN | 10 万 QPS / 8Gbps | 按套餐 | — | 静态页不回源 |
| 网关 | 10 万 QPS | 2.5 万/台 | 6 | 50% |
| 秒杀服务 | 2 万 QPS | 3,000/台 | 10 | 50% |
| Redis | 2 万 QPS | 5 万/分片 | 3 主 3 从 + 32 桶 | 60% |
| Kafka | 1,000 条/秒 | 5 万/分区 | 6 分区 | 充足 |
| 订单服务 | 1,000 TPS | 500/实例 | 3 | 50% |
| MySQL | 1,000 TPS | 3,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 步:
- 前端校验倒计时和按钮状态,带上活动开始才下发的一次性签名 token
- 网关校验签名、按 IP 和用户维度限流,超限直接 429,不放进下游
- 秒杀服务从本地缓存读活动状态(不查配置中心),校验用户资格后调 Redis
- Redis 在一个 Lua 脚本里判断买家标记、判断桶内库存、扣减、写标记,原子返回三种结果
- 预扣成功立刻发 Kafka 消息并返回"排队中",不让用户等 DB
- 订单服务幂等消费,一个事务里建订单、扣库存、写流水,提交后手动 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://www.jiangyi.space/articles/2026/11/02/1793464969441.html
公众号:服务端技术精选
评论