秒杀系统设计(三):规模扩展——库存分片与容量规划
【系列说明】 本文是《秒杀系统设计》系列的第 3 篇,共 3 篇。系列主线只有一句话:秒杀的本质是漏斗,不是堆机器。
- 第 1 篇:需求拆解与流量治理——解决"数据库被打爆"
- 第 2 篇:数据正确性——解决"库存超卖"与"一人多单"
- 第 3 篇(本文):规模扩展——大库存、多商品的分片方案,以及到底要几台机器
【承接上文】 前两篇的所有方案,都建立在"一个商品、库存 100 件"这个前提上。本篇先讨论这个前提被打破时怎么办——大库存、多商品要用库存分片(第一章);再回答那个最实际的问题——到底要几台机器(第二章,逐层容量估算);最后收口整个系列(第三章)。
一、库存分片
前面所有讨论都基于一个前提:库存只有 100 件,且只秒杀一个商品。但真实业务里还有两种更复杂的情况:
- 秒杀库存很大:比如大促放量 10 万件、优惠券发放 50 万张
- 同时秒杀大量商品:比如"品类日"同时开抢几百个商品,全部写进同一个库
这两种情况都会让单点(单 key / 单行 / 单库)成为瓶颈,解法就是分片。这一章讲清楚什么时候分、怎么分、以及分片的坑。
1.1 什么时候需要分片
先明确边界,不要一上来就分片。
不需要分片的场景:
- 库存只有几百件 → Redis 预减库存之后,真正放行到 DB 的请求也就几百个,单行锁完全扛得住
- 并发不高(放行到 DB 的请求只有几百个) → 单个热点行不会成为瓶颈
- 只秒杀少量商品 → 单库完全能承载
需要分片的场景:
- 库存量大:单 key 的扣减能力成为瓶颈
- 扣减 QPS 极高:即使库存不多,但放行到 DB 层的请求非常多
- 商品数量多:几百个商品同时开抢,订单表写入集中在同一个库
判断标准:单 key / 单行 / 单库的写入能力是否小于业务需要的 QPS。单 Redis key 的写入能力大约在 1 万 ~ 5 万 QPS 量级,超过这个数就该考虑分片。
1.2 情况一:大库存 → 库存分桶
先说清楚"分桶"到底在分什么。
原来的设计是"一个商品对应一行库存记录"——DB 里是 goods_stock 表的一行,Redis 里是 seckill:stock:1 一个 key。所有请求都去抢这同一行 / 同一个 key。
分桶的做法是:把这一行拆成 N 行(Redis 侧则是拆成 N 个 key),每行存一部分库存,N 行加起来等于总库存。以 10 万件库存、分 100 个桶为例:
| 分桶前 | 分桶后 | |
|---|---|---|
| DB | goods_stock 里 1 行,stock = 100000 |
goods_stock_bucket 里 100 行,每行 stock = 1000 |
| Redis | seckill:stock:1 1 个 key |
seckill:stock:1:0 ~ seckill:stock:1:99,100 个 key |
这里要澄清一个容易误解的点:表还是那一张表,不是建 100 张表。 分桶是靠"同一张表里的多行"来实现的——用 bucket_no 字段区分不同的桶,行数变多了,表结构没变。Redis 侧同理,是 key 变多了,不是 Redis 实例变多了。
数据库分桶:
1 | -- 原来:一行库存,所有请求抢这一行 |
1 | -- 每个桶独立扣减,不同桶之间互不阻塞 |
收益到底在哪?关键不在"线程数",而在"同一时刻有多少请求在抢同一把行锁"。
假设峰值有 5 万个扣减请求同时打到数据库:
- 不分桶:这 5 万个请求全部去抢同一行的锁,行锁排成一条极长的队列,绝大多数请求卡在锁等待上、还一直占着数据库连接 → 数据库被打爆;
- 分 100 桶:这 5 万个请求按
userId % 100被均摊到 100 行上,每行大约只有 500 个请求在抢锁。
也就是说,单行的锁竞争压力从 5 万降到了 500,下降了 100 倍——这才是分桶真正的收益。
换个说法:分桶就是把"一个滚烫的热点行"变成"100 个温度低得多的行"。
但要注意——它并没有让总压力消失,只是分摊了。如果总共只有几百个请求,分桶毫无意义(每行也就几个请求);只有并发高到"单行扛不住"时,分桶才值得做。这就是 10.1 强调"不要一上来就分片"的原因。
Redis 分桶:
1 | // 根据用户 ID 计算桶编号,保证同一用户总是落到同一桶 |
Lua 脚本的逻辑完全不变,只是传入的 key 变了(从 seckill:stock:1 变成 seckill:stock:1:73 这样)。
⚠️ 这里有一条必须遵守的铁律:Redis 和 DB 的路由算法必须完全一致。
1 | 两边的桶编号,必须由同一个算法算出来: |
为什么必须一致?因为"Redis 里扣减的桶"和"DB 里扣减的桶"必须是同一个桶。
举个反例:如果 Redis 用 userId % 100 算桶,而 DB 用 userId % 50 算桶,那么一次请求可能"在 Redis 的桶 73 扣掉了一件",转头却"要求 DB 从桶 23 扣一件"——而桶 23 很可能早就空了,于是这次下单直接失败。更糟的是,两边各桶的余额从此彻底错乱,连对账都对不平。
所以工程上要把路由算法抽成一个公共方法,两边调同一份代码:
1 | public class BucketRouter { |
另外两个必须守住的点:
BUCKET_COUNT上线后不要改:改了之后老用户会被路由到新桶,之前扣的库存就"找不回来"了;- 回补库存必须回原桶:超时未支付要还库存,必须还到"订单当初扣的那个桶",所以订单表要记录
bucket_no,否则各桶余额会漂移。
flowchart TD
RQ["请求userId=1001"] --> H["BucketRouter.route(userId)= userId % 100"]
H --> B0["stock:goods1:0"]
H --> B1["stock:goods1:1"]
H --> BN["stock:goods1:99"]
B0 --> DB0[("bucket 0")]
B1 --> DB1[("bucket 1")]
BN --> DBN[("bucket 99")]
style H fill:#e7f1ff
style B0 fill:#fff3cd
style B1 fill:#fff3cd
style BN fill:#fff3cdRedis Cluster 还能再放大收益:这 100 个 key 是 100 个独立热点,配合 Redis Cluster 可以把它们分布到不同节点上,把压力从"单个 Redis 节点"进一步分散到"多个节点"。
1.3 情况二:多商品 → 分库分表 / 按商品分片
如果同时秒杀几百个商品,所有订单都写进同一个库,单库的写入能力和连接数都会成为瓶颈。这时要在商品维度做分片:
- 按商品 ID 分库分表:
goods_id % N决定订单落到哪个库 - 按商品 ID 分区消息:MQ 的队列分区也按商品切分,保证同一商品的消息有序
- 库存表同步分片:商品落在哪个库,它的库存行就在哪个库,避免跨库事务
核心原则:让"同一个商品的所有操作"落在同一个分片上,这样既保证了单商品的串行正确性,又把不同商品的压力分散开了。
flowchart TD
P["商品 1"] --> S0["分片 0库存 + 订单"]
P2["商品 2"] --> S1["分片 1库存 + 订单"]
P3["商品 3"] --> S2["分片 2库存 + 订单"]
P4["商品 N"] --> S3["分片 N库存 + 订单"]
style S0 fill:#d4edda
style S1 fill:#d4edda
style S2 fill:#d4edda
style S3 fill:#d4edda1.4 分片最大的坑:分桶不均导致少卖
这是分片绕不开的问题,必须重点讲。
假设库存 100,分 10 个桶,每桶 10。如果流量集中在少数几个桶(hash 不均、或者某类用户 ID 集中),可能出现:
- 桶 1、2、3 已经扣光(共 30 件)
- 桶 4~10 还有 70 件
- 但用户 hash 到桶 1 后直接返回"售罄",尽管总库存还有
这就是"总量还有、单桶已空"的少卖问题。
再想深一层——为什么会出现"有的桶空了、有的桶还满着"?
因为桶的库存是预先固定分配的,但流量是动态且不均匀的。分桶只能保证"每个桶初始库存一样",保证不了"每个桶收到的请求一样多"。只要 hash 分布有偏,或者某一类用户(比如同一批推广渠道进来的用户)ID 集中,就一定会有的桶先被抢光。这是分桶的固有缺陷,只能缓解、无法根除。
既然它必然会发生,就得有应对方案。下面两种是工程上常用的。
解决方案一:预留兜底桶(同步兜底)
核心思路:不指望每个桶永远有货,而是一开始就留出一部分库存当作公共备用金。当用户发现自己的桶空了,就去这个公共池里借一件。
库存这样分配(总库存 10 万、100 个业务桶为例):
| 用途 | 对应的 key / 行 | 分配到的库存 |
|---|---|---|
| 业务桶 | bucket 0 ~ 99 |
9 万件,每桶 900 件 |
| 兜底桶 | fallback |
1 万件(约 10%) |
一次请求的完整执行过程:
- 用
BucketRouter.route(userId)算出用户自己的桶bucketNo; - 先在自己的桶里扣——扣成功就正常返回,这条路覆盖了绝大多数请求;
- 自己的桶空了 → 不立即返回售罄,而去兜底桶再试一次;
- 兜底桶还有货 → 从兜底桶扣一件,本次请求算成功;
- 兜底桶也空了 → 才真正返回"已售罄"。
对应代码(在原来那段 Lua 上加一个分支即可):
1 | -- KEYS[1]: 用户自己的桶 seckill:stock:{goodsId}:{bucketNo} |
这样做的代价:
- 兜底桶自己可能变成新热点——所有"本桶已空"的请求都会涌向它;
- 多一次 Redis 访问——本桶有货时只查 1 次,本桶空了要查 2 次,RT 会略升;
- 必须记录成交来源——订单里要标明"这次是从业务桶扣的还是从兜底桶扣的",否则超时回补时还不回去。
所以这个方案适合流量分布大体均匀、只是偶有偏差的场景。
解决方案二:桶间借调(异步再平衡)
方案一有个隐患:如果大部分桶都空了,那些请求会全部涌向兜底桶——而兜底桶本来只是个 1 万件的小池子,瞬间被挤爆,"热点"又回来了。
核心思路:与其让所有空桶的请求去挤同一个池子,不如让库存主动流动——后台任务定期扫描各桶余量,把"富余桶"的库存搬一部分到枯竭桶去。
具体做法:
- 起一个低频定时任务(比如每 5 秒一次),扫描该商品所有桶的剩余库存;
- 找出枯竭桶(余量为 0 或低于阈值)和富余桶;
- 从富余桶转移一部分库存到枯竭桶——先减源桶、再加目标桶,把两边拉平;
- 转移完成后,用户再去自己的桶扣,就有货了。
flowchart TD
T["定时任务每 5s 触发"] --> S["扫描所有桶的剩余库存"]
S --> J{"存在'枯竭桶'且存在'富余桶'?"}
J -->|否| E["本轮结束"]
J -->|是| M["从富余桶减 N 件DECRBY source N"]
M --> N["给枯竭桶加 N 件INCRBY target N"]
N --> S
style J fill:#fff3cd
style M fill:#e7f1ff
style N fill:#d4edda和方案一的本质区别:
| 方案一:预留兜底桶 | 方案二:桶间借调 | |
|---|---|---|
| 库存怎么分 | 预留 10% 做公共池 | 不预留,全靠动态搬 |
| 由谁来兜 | 用户的请求同步去兜底桶借 | 后台任务异步把库存搬过来 |
| 兜底桶会成热点吗 | 会(空桶请求全涌向它) | 不会(库存被搬过来了,请求仍在自己的桶扣) |
| 实时性 | 立刻生效 | 有延迟(最长一个扫描周期) |
| 实现复杂度 | 低 | 中(要写扫描 + 转移任务) |
转移过程中要注意什么?
- 用
DECRBY/INCRBY,不要先读后写——搬库存这个动作本身也要原子,否则并发下会把库存搬丢; - 每次转移设上限——比如每次只搬富余桶的一半,避免"刚搬过去又被搬回来"的来回震荡;
- 任务频率不能太高——它是兜底机制,不是主流程,跑太勤反而给 Redis 增加无谓压力。
实际怎么选?
- 流量分布大体均匀 → 方案一就够了,简单直接;
- 流量分布可能严重倾斜(比如某些渠道的用户 ID 高度集中) → 用方案二,或者两者叠加:先预留兜底桶保证实时兜底,再用借调任务把水分抹平。
1.5 分片的坑点总结
| 坑点 | 说明 | 应对 |
|---|---|---|
| 分桶不均 | 总量还有但单桶空了,导致少卖 | 预留兜底桶 + 桶间借调 |
| 总量统计复杂 | 超卖判定从"单 key"变成"跨桶求和" | 定期对账 + 各桶独立判定 |
| 回补回哪一桶 | 超时未支付回补库存,必须回原桶 | 订单记录桶编号 |
| 对账困难 | 多桶数据分散,问题定位难 | 全桶扫描 + 汇总校验 |
| 分桶数怎么定 | 太少没效果,太多管理复杂 | 按"单桶竞争压力"估算 |
关于分桶数的估算:
1 | 分桶数 N ≥ 预期扣减 QPS / 单桶可承受 QPS |
举例:预期 5 万 QPS,单 Redis key 可承受 5000 QPS,则 N ≥ 50000 / 5000 = 10。实际要留余量,取 20~30。
重要提醒:分片是为了"分散热点",不是为了"增加库存"。它的收益只有在单 key / 单行 / 单库真的成为瓶颈时才能体现,否则只是徒增复杂度。
二、容量规划:到底要几台机器
前面讲了各种方案,但还有那个最实际的问题没回答:这些节点数是怎么算出来的? 这一章给出完整方法。
2.1 第一步:压测得出单机基线
所有估算的前提,是先知道自己一台机器能扛多少。不能拍脑袋。
需要针对每一层分别压测:
| 层级 | 压测目标 | 典型单机能力(参考值,需实测) |
|---|---|---|
| Nginx | 纯转发 QPS | 3 万 ~ 5 万 |
| 应用服务 | 含业务的接口 QPS | 2000 ~ 5000(取决于业务复杂度) |
| Redis | 单节点读写 QPS | 8 万 ~ 10 万 |
| MySQL | 单机写入 QPS | 3000 ~ 8000 |
压测方法:用 JMeter / wrk / Gatling 逐层压测,逐步加压直到 RT 超过可接受阈值或错误率上升,记录此时 QPS 作为基线。
坑点:压测结果受数据量、索引、连接池配置、JVM 参数影响极大,必须在贴近生产的配置下压测,否则基线是假的。
2.2 第二步:留安全水位
压测出的基线是"极限值",绝不能按极限值部署。必须在极限值上乘一个安全系数:
1 | 有效单机能力 = 压测极限 × 安全水位 |
- 安全水位 α 通常取 0.3 ~ 0.5
- 为什么留这么多?因为:
- 压测环境和生产环境有差异
- 流量不是均匀的,会有局部热点
- 需要预留容量应对节点故障(N+1 冗余)
- 突发流量可能超过预估
坑点:安全水位不是拍脑袋定的经验值,应该结合历史故障复盘和业务容错度调整。核心链路(如秒杀下单)应取更低的水位。
2.3 第三步:逐层估算节点数
公式:
1 | 该层所需节点数 N = ⌈ 该层峰值 QPS / (单机极限 QPS × 安全水位) ⌉ |
关键认知:每一层的"峰值 QPS"不是同一个数,而是漏斗衰减后的值。
这就是为什么不能简单地说"50 万 QPS 需要多少台机器"——你必须逐层算。
2.4 漏斗衰减表
还是用第一篇的场景:库存 100 件,峰值 50 万 QPS。
| 层级 | 进入 QPS | 拦截手段 | 流出 QPS | 衰减比 |
|---|---|---|---|---|
| 前端 / CDN | 50 万 | 静态化、按钮置灰 | 50 万(有效请求↓) | — |
| LVS(四层) | 50 万 | 转发(不限流) | 50 万 | — |
| Nginx(七层) | 50 万 | limit_req IP 限流 |
20 万 | 60% |
| 应用层 | 20 万 | 令牌桶 | 10 万 | 50% |
| Redis | 10 万 | 库存仅 100 | 仅 100 次扣减成功 | 99.9% |
| MQ | 100 | 削峰缓冲 | 100 | — |
| MySQL | 100 | — | 100 | — |
注意最后两行:经过 Redis 之后,流量直接从 10 万掉到 100。这意味着:
- MySQL 只需要处理 100 次写入,完全不需要扩容
- MQ 只需要缓冲 100 条消息,压力极小
- 真正的压力全在前四层
2.5 示例计算
按 11.3 的公式,逐层算一遍:
接入层(Nginx):
- 进入峰值:50 万 QPS
- 单机能力:5 万 QPS(纯转发)
- 安全水位:0.5
N = 500000 / (50000 × 0.5) = 20 台
应用层:
- 进入峰值:10 万 QPS(Nginx 限流后的值,不是 50 万)
- 单机能力:5000 QPS(轻量化后,只做限流 + Redis 扣减 + 发 MQ,不做重业务)
- 安全水位:0.5
N = 100000 / (5000 × 0.5) = 40 台
Redis:
- 进入峰值:10 万 QPS
- 单节点:8 万 QPS
- 安全水位:0.5
N = 100000 / (80000 × 0.5) = 3 台(主从 + 哨兵,或直接用 Cluster)
MySQL:
- 进入峰值:100 QPS(只有 100)
- 单机:3000 QPS
N = 1 台,还有巨大余量
结论:
整个系统真正需要"堆机器"的只有 Nginx(20 台)和应用层(40 台)。Redis 3 台、MySQL 1 台就够了。
这就是漏斗模型带来的成本优势:把所有资源都压在最外层,而不是均匀地堆在所有层。
2.6 木桶效应:逐层校验
估算完之后,必须做一次木桶效应校验:整体性能取决于最短的那块板。
校验清单:
- 接入层带宽够不够?(20 台 Nginx 的出口带宽)
- 应用层之后连的 Redis,QPS 是否匹配?(3 台 Redis 能否扛 10 万 QPS)
- MQ 的写入吞吐是否匹配?
- 消费者并发度是否和 DB 能力匹配?
- DB 连接池是否够用?(每个应用节点都要连 DB,40 个节点 × 连接数)
坑点一:只算 QPS 不算连接数。
40 个应用节点,每个配 20 个 DB 连接,就是 800 个连接打到 MySQL。MySQL 默认 max_connections 是 151,直接被打爆。必须同步核算连接数。
坑点二:忽略带宽。
秒杀接口的响应体(含商品信息)可能有几 KB,50 万 QPS × 2KB = 1GB/s,带宽先于 CPU 成为瓶颈。所以推动静化、减小响应体才是关键。
坑点三:忘了预留冗余。
算出来 40 台,实际要按 N+1 或 N+20% 部署。因为节点会故障、会有滚动发布、会有扩容需求。
三、总结
3.1 完整链路回顾
flowchart TD
A["前端静态化 + 按钮置灰"] --> B["Nginxlimit_req 限流"]
B --> C["应用层令牌桶(Redis+Lua)"]
C --> D["RedisLua 原子预减库存"]
D --> E["MQ异步削峰"]
E --> F["消费者分布式锁 + 事务"]
F --> G[("MySQL唯一索引兜底")]
G --> H["对账任务最终一致"]
style D fill:#fff3cd
style F fill:#ffe0e0
style G fill:#d4edda3.2 各方案定位与问题(总表)
| 方案 | 解决的问题 | 核心实现 | 主要问题 / 坑点 |
|---|---|---|---|
| Nginx 限流 | 削减入口流量 | limit_req / limit_conn |
单机限流不精确;X-Forwarded-For 可伪造;OpenResty 查 Redis 会阻塞 worker |
| 负载均衡 | 分摊流量 | LVS + Nginx | LB 自身是单点,需 keepalived;LB 是全局天花板;健康检查不能只看端口 |
| 令牌桶限流 | 业务级限速 | Redis + Lua | 防不了超卖;key 维度难选;Redis 成新热点;需降级策略 |
| Redis 预减库存 | 挡流量 + 防超卖 | Lua 原子扣减 | 预热失败风险;不能用 DECR 后判断;发 MQ 失败必须回补 |
| MQ 异步 | 削峰、解耦 | 生产者 + 消费者 | 重复消费(需幂等);消息丢失;消费并行度要压测确定 |
| CAS 原子 SQL | DB 层防超卖 | UPDATE ... WHERE stock > 0 |
无(首选方案);但跨多步操作时不够用 |
| DB 乐观锁 | 并发控制 | WHERE version = 旧值 |
秒杀场景不可用:冲突极高,大量更新失败并重试,形成活锁 |
| 分布式锁 | 跨节点互斥 | Redisson | 锁必须在事务外;锁过期、误删、脑裂;粒度要细;性能开销 |
| DB 行锁 | 并发控制 | SELECT ... FOR UPDATE |
性能远不如 Redis 锁;持锁范围是整个事务;占连接;无索引会锁全表 |
| 库存分片 | 分散单 key / 单行 / 单库热点 | 分桶 + hash 路由 | 分桶不均导致少卖;总量统计复杂;回补必须回原桶;分桶数需估算 |
3.3 三条最重要的结论
第一,秒杀靠"漏斗",不靠"堆机器"。
真正需要扩容的是最外层(Nginx、应用层),因为 99.9% 的流量在 Redis 那一层就被拦掉了。DB 几乎不用扩容。
第二,解决顺序是"先流量,后一致性"。
数据库被打爆是"生死问题",必须最先解决;超卖是"正确性问题",排在第二位;一人一单和分片是进一步扩展。顺序错了,方案就会东一榔头西一棒子。
第三,能用原子操作,就不要用锁。
- Redis 层:用 Lua 替代锁
- DB 层:用 CAS 原子 SQL(
UPDATE ... WHERE stock > 0)替代锁 - 只有当操作跨越多个资源、无法用一个原子操作表达时(比如桶间借调、跨表复合操作),才引入分布式锁
- 而一旦用锁,必须保证锁包住事务,并用唯一索引兜底
3.4 延伸方向
整个系列覆盖了秒杀系统的主干设计,还可以继续深挖的方向:
- 缓存预热与热点探测:如何在大促前自动完成预热,如何识别突发热点 key(京东 hotkey、有赞 TMC 思路)
- 库存超卖的业务风控:技术防超卖之外,业务层面的限购、黑名单、设备指纹
- 全链路压测:如何在不污染生产数据的前提下做全链路压测
- 多级缓存:JVM 本地缓存 + Redis 二级缓存,进一步降低 Redis 压力
最后留一句话作为整个系列的收尾:秒杀系统的设计,本质上是在回答"如何让一个注定失败的请求,以最快的速度、最低的成本失败",同时让那少数成功的请求绝对不出错。 想清楚这句话,前面所有的分层、限流、异步、分片,就都有了统一的解释。







