【系列说明】 本文是《秒杀系统设计》系列的第 3 篇,共 3 篇。系列主线只有一句话:秒杀的本质是漏斗,不是堆机器。

  • 第 1 篇:需求拆解与流量治理——解决"数据库被打爆"
  • 第 2 篇:数据正确性——解决"库存超卖"与"一人多单"
  • 第 3 篇(本文):规模扩展——大库存、多商品的分片方案,以及到底要几台机器

【承接上文】 前两篇的所有方案,都建立在"一个商品、库存 100 件"这个前提上。本篇先讨论这个前提被打破时怎么办——大库存、多商品要用库存分片(第一章);再回答那个最实际的问题——到底要几台机器(第二章,逐层容量估算);最后收口整个系列(第三章)。

一、库存分片

前面所有讨论都基于一个前提:库存只有 100 件,且只秒杀一个商品。但真实业务里还有两种更复杂的情况:

  1. 秒杀库存很大:比如大促放量 10 万件、优惠券发放 50 万张
  2. 同时秒杀大量商品:比如"品类日"同时开抢几百个商品,全部写进同一个库

这两种情况都会让单点(单 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 原来:一行库存,所有请求抢这一行
-- goods_stock(id, goods_id, stock)

-- 现在:拆成 N 行,用 bucket_no 区分
CREATE TABLE goods_stock_bucket (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
goods_id BIGINT NOT NULL,
bucket_no INT NOT NULL, -- 桶编号 0 ~ N-1
stock INT NOT NULL, -- 该桶的库存
UNIQUE KEY uk_goods_bucket (goods_id, bucket_no)
);

-- 库存 10 万、分 100 桶,就插入 100 行,每行 1000
INSERT INTO goods_stock_bucket (goods_id, bucket_no, stock) VALUES
(1, 0, 1000), (1, 1, 1000), ..., (1, 99, 1000);
1
2
3
4
5
6
-- 每个桶独立扣减,不同桶之间互不阻塞
UPDATE goods_stock_bucket
SET stock = stock - 1
WHERE goods_id = #{goodsId}
AND bucket_no = #{bucketNo}
AND stock > 0;

收益到底在哪?关键不在"线程数",而在"同一时刻有多少请求在抢同一把行锁"。

假设峰值有 5 万个扣减请求同时打到数据库:

  • 不分桶:这 5 万个请求全部去抢同一行的锁,行锁排成一条极长的队列,绝大多数请求卡在锁等待上、还一直占着数据库连接 → 数据库被打爆;
  • 分 100 桶:这 5 万个请求按 userId % 100 被均摊到 100 行上,每行大约只有 500 个请求在抢锁。

也就是说,单行的锁竞争压力从 5 万降到了 500,下降了 100 倍——这才是分桶真正的收益。

换个说法:分桶就是把"一个滚烫的热点行"变成"100 个温度低得多的行"。

但要注意——它并没有让总压力消失,只是分摊了。如果总共只有几百个请求,分桶毫无意义(每行也就几个请求);只有并发高到"单行扛不住"时,分桶才值得做。这就是 10.1 强调"不要一上来就分片"的原因。

Redis 分桶:

1
2
3
// 根据用户 ID 计算桶编号,保证同一用户总是落到同一桶
int bucketNo = (int) (userId % BUCKET_COUNT); // BUCKET_COUNT = 100
String key = "seckill:stock:" + goodsId + ":" + bucketNo;

Lua 脚本的逻辑完全不变,只是传入的 key 变了(从 seckill:stock:1 变成 seckill:stock:1:73 这样)。

⚠️ 这里有一条必须遵守的铁律:Redis 和 DB 的路由算法必须完全一致。

1
2
3
4
5
6
两边的桶编号,必须由同一个算法算出来:

bucketNo = userId % BUCKET_COUNT

Redis 扣减:seckill:stock:{goodsId}:{bucketNo}
DB 扣减:... WHERE bucket_no = {bucketNo}

为什么必须一致?因为"Redis 里扣减的桶"和"DB 里扣减的桶"必须是同一个桶。

举个反例:如果 Redis 用 userId % 100 算桶,而 DB 用 userId % 50 算桶,那么一次请求可能"在 Redis 的桶 73 扣掉了一件",转头却"要求 DB 从桶 23 扣一件"——而桶 23 很可能早就空了,于是这次下单直接失败。更糟的是,两边各桶的余额从此彻底错乱,连对账都对不平。

所以工程上要把路由算法抽成一个公共方法,两边调同一份代码:

1
2
3
4
5
6
7
8
public class BucketRouter {
public static final int BUCKET_COUNT = 100;

// Redis 扣减和 DB 扣减都必须调用这一个方法,绝不允许各写一份
public static int route(Long userId) {
return (int) (userId % BUCKET_COUNT);
}
}

另外两个必须守住的点:

  • BUCKET_COUNT 上线后不要改:改了之后老用户会被路由到新桶,之前扣的库存就"找不回来"了;
  • 回补库存必须回原桶:超时未支付要还库存,必须还到"订单当初扣的那个桶",所以订单表要记录 bucket_no,否则各桶余额会漂移。

Redis Cluster 还能再放大收益:这 100 个 key 是 100 个独立热点,配合 Redis Cluster 可以把它们分布到不同节点上,把压力从"单个 Redis 节点"进一步分散到"多个节点"。

1.3 情况二:多商品 → 分库分表 / 按商品分片

如果同时秒杀几百个商品,所有订单都写进同一个库,单库的写入能力和连接数都会成为瓶颈。这时要在商品维度做分片:

  • 按商品 ID 分库分表:goods_id % N 决定订单落到哪个库
  • 按商品 ID 分区消息:MQ 的队列分区也按商品切分,保证同一商品的消息有序
  • 库存表同步分片:商品落在哪个库,它的库存行就在哪个库,避免跨库事务

核心原则:让"同一个商品的所有操作"落在同一个分片上,这样既保证了单商品的串行正确性,又把不同商品的压力分散开了。

1.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%)

一次请求的完整执行过程:

  1. 用 BucketRouter.route(userId) 算出用户自己的桶 bucketNo;
  2. 先在自己的桶里扣——扣成功就正常返回,这条路覆盖了绝大多数请求;
  3. 自己的桶空了 → 不立即返回售罄,而去兜底桶再试一次;
  4. 兜底桶还有货 → 从兜底桶扣一件,本次请求算成功;
  5. 兜底桶也空了 → 才真正返回"已售罄"。

对应代码(在原来那段 Lua 上加一个分支即可):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- KEYS[1]: 用户自己的桶   seckill:stock:{goodsId}:{bucketNo}
-- KEYS[2]: 兜底桶 seckill:stock:{goodsId}:fallback

-- 第 1 步:先看自己的桶
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then
return -2 -- key 不存在:预热失败,要告警
end

-- 第 2 步:自己的桶有货,直接扣
if stock > 0 then
redis.call('DECR', KEYS[1])
return stock - 1
end

-- 第 3 步:自己的桶空了,去看兜底桶
local fallback = tonumber(redis.call('GET', KEYS[2]))
if fallback == nil then
return -2
end

-- 第 4 步:兜底桶有货,借一件
if fallback > 0 then
redis.call('DECR', KEYS[2])
return 0 -- 返回 0 表示"从兜底桶成交"
end

-- 第 5 步:兜底桶也空了,才是真的售罄
return -1

这样做的代价:

  • 兜底桶自己可能变成新热点——所有"本桶已空"的请求都会涌向它;
  • 多一次 Redis 访问——本桶有货时只查 1 次,本桶空了要查 2 次,RT 会略升;
  • 必须记录成交来源——订单里要标明"这次是从业务桶扣的还是从兜底桶扣的",否则超时回补时还不回去。

所以这个方案适合流量分布大体均匀、只是偶有偏差的场景。

解决方案二:桶间借调(异步再平衡)

方案一有个隐患:如果大部分桶都空了,那些请求会全部涌向兜底桶——而兜底桶本来只是个 1 万件的小池子,瞬间被挤爆,"热点"又回来了。

核心思路:与其让所有空桶的请求去挤同一个池子,不如让库存主动流动——后台任务定期扫描各桶余量,把"富余桶"的库存搬一部分到枯竭桶去。

具体做法:

  1. 起一个低频定时任务(比如每 5 秒一次),扫描该商品所有桶的剩余库存;
  2. 找出枯竭桶(余量为 0 或低于阈值)和富余桶;
  3. 从富余桶转移一部分库存到枯竭桶——先减源桶、再加目标桶,把两边拉平;
  4. 转移完成后,用户再去自己的桶扣,就有货了。

和方案一的本质区别:

方案一:预留兜底桶 方案二:桶间借调
库存怎么分 预留 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 完整链路回顾

3.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 压力

最后留一句话作为整个系列的收尾:秒杀系统的设计,本质上是在回答"如何让一个注定失败的请求,以最快的速度、最低的成本失败",同时让那少数成功的请求绝对不出错。 想清楚这句话,前面所有的分层、限流、异步、分片,就都有了统一的解释。