秒杀系统设计(一):需求拆解与流量治理
【系列说明】 本文是《秒杀系统设计》系列的第 1 篇,共 3 篇。系列主线只有一句话:秒杀的本质是漏斗,不是堆机器——让 99.99% 的流量在到达数据库之前就被挡掉。
- 第 1 篇(本文):需求拆解与流量治理——先想清楚要满足什么需求,再解决"数据库被打爆"
- 第 2 篇:数据正确性——解决"库存超卖"与"一人多单"
- 第 3 篇:规模扩展——大库存、多商品的分片方案,以及到底要几台机器
建议按顺序阅读,因为每一章都是被上一章的问题"逼"出来的。
【本篇导读】 秒杀系统看起来只是"扣个库存",但它考验的不是"堆多少台机器扛住峰值",而是"能不能逐层把流量削下去"。本篇先把需求和问题讲清楚(第一 ~ 三章:需求与难点、朴素实现暴露的三个问题、漏斗模型与分层架构),再完整展开流量治理的三道闸门(第四 ~ 六章:接入层、应用层令牌桶、Redis 挡量 + MQ 削峰)。这一篇的目标只有一个:让系统先活下来。
一、需求与难点
任何架构设计都应该从需求出发。秒杀系统看起来只是"扣个库存",但真正落地要满足的需求比想象中多,而且每一条需求背后都对应一个技术难点。先把它们列清楚,后面所有设计才有依据。
1.1 业务场景
先把场景量化,问题才有体感。假设一次活动:
- 商品库存:100 件
- 参与人数:100 万人
- 活动开始瞬间的峰值流量:50 万 QPS
这组数字说明一件事:绝大多数请求注定是失败的。100 万个人去抢 100 件商品,最终只有 100 个请求是"有效"的,其余 999900 个请求都应该被快速拒绝掉。
所以秒杀系统的核心目标不是"如何把 100 万请求都处理成功",而是"如何用最小的成本,把 999900 个无效请求挡在数据库之外,同时保证那 100 个有效请求绝对不出错"。
1.2 功能需求
从业务视角看,一个合格的秒杀系统必须满足以下需求:
| 编号 | 功能需求 | 具体含义 |
|---|---|---|
| F1 | 一人一单 | 同一用户对同一商品,只能成功抢购一次,无论他提交多少次请求 |
| F2 | 不超卖 | 库存 100 件,绝不能卖出 101 件,这是最硬的红线 |
| F3 | 不少卖 | 库存 100 件,在有人抢的情况下应尽量卖出 100 件,不能无故少卖 |
| F4 | 支持海量用户抢购 | 几十万甚至上百万人同时抢购,系统不能被冲垮 |
| F5 | 快速失败 | 抢不到的用户要在毫秒级收到"已售罄",而不是超时等待 |
| F6 | 活动时间严格 | 活动开始前一秒不能下单,结束后的请求要准确拒绝 |
| F7 | 超时未支付回补库存 | 抢到名额但超时未付款,库存要还给其他用户 |
| F8 | 防刷 | 脚本和机器人不能把库存刷走,保证普通用户公平参与 |
其中 F1(一人一单)和 F2(不超卖)是正确性需求,一旦违反就是线上事故;F4(海量用户抢购)是性能需求,做不到就整个系统崩溃。这两类需求恰好对应后文的两个解决主线。
1.3 非功能需求
| 编号 | 非功能需求 | 说明 |
|---|---|---|
| N1 | 高并发 | 峰值 QPS 可达几十万,是普通业务接口的数百倍 |
| N2 | 高可用 | 秒杀期间系统不可用,会造成严重的品牌和资损问题 |
| N3 | 低延迟 | 接口 RT 直接决定吞吐能力,必须尽可能短 |
| N4 | 最终一致 | 允许短暂中间状态(如"排队中"),但最终数据必须正确 |
| N5 | 可扩展 | 能通过加机器线性提升接入能力 |
这里有一个容易被忽略的点:N3 低延迟和 N1 高并发是强相关的。根据利特尔法则(并发数 = QPS × RT),在服务器线程数固定的情况下,RT 越长,能支撑的 QPS 越低。所以"缩短 RT"不只是体验问题,更是吞吐量问题——这也是后面要引入 MQ 异步的原因之一。
1.4 技术难点:需求背后的四个核心矛盾
把上面这些需求翻译成技术语言,就归结为四个核心矛盾:
| 矛盾 | 对应的需求 | 表现 | 后果 | 优先级 |
|---|---|---|---|---|
| 系统雪崩 | F4、N2 | 数据库被打爆,链路连锁崩溃 | 全站不可用 | ★★★★★ |
| 超卖 | F2 | 库存 100,卖出 120 件 | 资损,最严重 | ★★★★★ |
| 少卖 | F3、F7 | 库存 100,只卖出 80 件 | 流量浪费,业务不满 | ★★★ |
| 刷单 / 一人多单 | F1、F8 | 一人抢到 10 件,脚本刷走库存 | 活动失去公平性 | ★★★ |
说明:这张表列的是需求背后的技术矛盾,所以每一项都能对应到 1.2 里的需求编号。同一个问题(比如超卖)在"需求"和"难点"里各出现一次,是刻意的映射,不是写重了。
优先级排序是:雪崩 > 超卖 > 少卖 > 刷单。
这个排序很重要,它直接决定了后面的解决顺序:
- 雪崩(流量问题)必须最先解决——它是"能不能活"的问题。数据库被打爆,系统直接就没了,这时候讨论超卖不超卖毫无意义。所以本篇第四 ~ 六章先讲怎么削流量。
- 其次是超卖——它是"数据正不正确"的问题。系统先活下来,才有资格谈数据正确性,对应第二篇的 Redis 侧与数据库侧。
- 最后是少卖和刷单——属于体验与公平性问题,放在第二篇一起处理。
本篇第三章之后,正是严格按这个优先级展开的。
二、朴素实现暴露的三个问题
有了需求,接下来看最直接的实现。先不要急着优化,而是把"正常人会怎么写"写出来,然后逐一暴露它在高并发下的问题。这些问题就是后面每一章要攻克的靶子。
2.1 先描述一下业务逻辑
抛开所有分布式、高并发的东西,秒杀下单这件事本身的业务逻辑其实很朴素。用大白话讲一遍:
- 用户点击"立即抢购",请求打到下单接口,带上用户 ID 和商品 ID
- 校验活动状态:活动是否已开始、是否已结束?没开始或已结束都直接拒绝
- 校验购买资格(一人一单):这个用户是不是已经抢过了?抢过就直接拒绝
- 查询库存:读取当前商品的剩余库存
- 判断库存:如果库存小于等于 0,说明已售罄,直接拒绝
- 扣减库存:库存减 1
- 创建订单:生成一条待支付订单,记录用户和商品
- 返回结果:告诉用户"抢购成功,请尽快支付"
再配上"用户支付 / 超时未支付"的后续动作,完整流程如下:
flowchart TD
S["用户点击抢购"] --> A1{"活动是否开始?"}
A1 -->|未开始 / 已结束| R1["拒绝:活动未开始或已结束"]
A1 -->|进行中| A2{"该用户是否已抢过?"}
A2 -->|已抢过| R2["拒绝:请勿重复提交"]
A2 -->|未抢过| A3["查询商品库存"]
A3 --> A4{"库存 > 0 ?"}
A4 -->|否| R3["拒绝:已售罄"]
A4 -->|是| A5["扣减库存"]
A5 --> A6["创建待支付订单"]
A6 --> A7["返回:抢购成功"]
A7 --> P1{"用户是否支付?"}
P1 -->|支付| P2["订单状态:已支付"]
P1 -->|超时未支付| P3["取消订单 + 回补库存"]
style A5 fill:#fff3cd
style A6 fill:#fff3cd
style P3 fill:#ffe0e0这张图是"理想状态"下的流程,每一步都按顺序执行、互不干扰。在单机、低并发下,它完全正确。 问题出在——秒杀场景下,这些步骤会被几十万个请求同时执行。
2.2 朴素实现的代码
把上面的流程翻译成代码,就是最常见的写法:
1 |
|
对应的 SQL:
1 | -- 查库存 |
这段代码逻辑上完全正确,在单机、低并发环境下没有任何问题。但把它放到 50 万 QPS 的秒杀场景下,会暴露出三个高并发问题。
2.3 高并发问题一:数据库被打爆(流量问题)
这是最紧迫的问题——它决定了系统能不能活下来。
50 万 QPS 全部落到这段代码上,意味着:
- 50 万次查询活动:每次都要访问数据库
- 50 万次查询是否已下单:每次都要走一遍
orders表的查询(这个表还是超大表) - 50 万次查询库存 + 50 万次更新库存:全部打在同一行上
- 50 万次插入订单:写操作,还要维护索引
而 MySQL 单机通常只能扛几千 QPS。50 万 QPS 打过来,直接被打爆。
更严重的是连锁反应:
flowchart LR
A["50 万 QPS 涌入应用"] --> B["应用线程池被占满"]
B --> C["数据库连接池耗尽"]
C --> D["SQL 全部排队等待"]
D --> E["接口 RT 从 10ms 涨到 10s"]
E --> F["前端大量超时重试"]
F --> A2["流量进一步放大"]
A2 --> B
E --> G["数据库 CPU 打满 / 慢查询堆积"]
G --> H["其它业务也查不了库"]
H --> I["全站雪崩"]
style I fill:#ffe0e0这就是优先级最高的"雪崩"问题。注意它的特点:不是因为某段代码写错了,而是因为流量太大,把下游依赖(数据库)压垮了。所以它的解法不是"改逻辑",而是"削流量"——在请求到达数据库之前,把它挡掉。
2.4 高并发问题二:超卖(数据一致性问题)
即使数据库扛得住,还有第二个问题:超卖。
看第 3、4 步——"查询库存"和"扣减库存"是两条独立的 SQL,中间存在一个时间窗口。两个请求同时到达时:
sequenceDiagram
participant A as 请求A
participant B as 请求B
participant DB as MySQL
Note over A,DB: 库存只剩 1 件
A->>DB: 1. 查询库存 = 1
B->>DB: 2. 查询库存 = 1
Note over A,B: 两个请求都判断 1 > 0,都认为自己可以扣
A->>DB: 3. UPDATE stock = stock - 1
B->>DB: 4. UPDATE stock = stock - 1
Note over DB: ❌ 最终库存 = -1,卖出了 2 件问题出在第 1、2 步和第 3、4 步之间:"读—判断—写"三步不是原子的。两个请求都在窗口内读到了同一个库存值 1,都通过了 stock > 0 的判断,于是都执行了扣减。
这是本文反复出现的核心规律:凡是"读—判断—写"不是原子的地方,在并发下就会出错。 后面 Redis 用 Lua、数据库用原子 SQL,本质都是在修复这个问题。
2.5 高并发问题三:一人多单(幂等性问题)
第 2 步的"校验一人一单"同样不可靠——它和创建订单之间也有窗口期。
sequenceDiagram
participant U as 同一用户
participant APP as 应用
participant DB as MySQL
U->>APP: 请求1
U->>APP: 请求2(几乎同时)
APP->>DB: 请求1:查询是否已下单 → 0 条
APP->>DB: 请求2:查询是否已下单 → 0 条
Note over APP: 两个请求都认为"没下过单"
APP->>DB: 请求1:INSERT 订单
APP->>DB: 请求2:INSERT 订单
Note over DB: ❌ 同一用户生成了 2 条订单用户用脚本刷、或者手速飞快,同时发出多个请求,就能绕过校验,抢到多件。这违反了 F1(一人一单)和 F8(防刷)。
根因和超卖完全一样:又是"读—判断—写"不原子。所以它们的解法也相通——要么用原子操作,要么用唯一约束兜底。
2.6 问题梳理与解决顺序
把三个问题排个序,就得到了整个系列的技术路线:
| 问题 | 类型 | 危害 | 解法方向 | 对应章节 |
|---|---|---|---|---|
| 问题一:数据库被打爆 | 流量 / 可用性 | 全站雪崩 | 削流量:分层拦截 + 异步化 | 本篇第四 ~ 六章 |
| 问题二:超卖 | 数据一致性 | 资损 | 保证原子性:Lua / 原子 SQL / 锁 | 第二篇第一 ~ 二章 |
| 问题三:一人多单 | 幂等性 | 不公平、资损 | 幂等 + 唯一约束 | 第二篇第三章 |
为什么是这个顺序? 因为问题一是"生死问题"——数据库挂了,超卖不超卖都没意义了。所以必须先解决流量,再解决一致性。而问题二和问题三都属于"数据要正确",可以放在一起解决。
拓展点:当"单商品库存很大"或"同时秒杀大量商品"时,需要做库存分片。
小结一下:上面三个问题的解法,都建立在"一个商品、库存只有 100 件"这个前提上。那么如果这个前提被打破了呢?
- 如果单商品的库存很大(比如大促放量 10 万件),那么单个 Redis key、单行库存记录就会成为新的热点,扣减能力跟不上;
- 如果同时秒杀大量商品(比如一次活动开抢几百个商品),那么所有订单都写进同一个库,单库的写入能力和连接数会先撑不住。
这两种情况,都是在基础问题解决完之后才需要继续考虑的拓展场景,统一的解法方向是库存分片——把库存拆成多个桶、把不同商品拆到不同库,让热点分散开。这部分放在《秒杀系统设计(三):规模扩展》展开。
三、全局设计:漏斗模型与分层架构
先不要急着看具体方案。这一章先把全局看清楚,给出两样东西:一个是全文的核心认知——漏斗模型;一个是完整链路图与每层职责。
3.1 核心认知:漏斗模型
这是全文最重要的一张图,后面的所有章节都可以回看它:
flowchart TD
A["用户请求 50 万 QPS"] --> B["前端:静态化 / 按钮置灰 / 验证码"]
B --> C["LVS + Nginx:IP 限流、URL 限流"]
C --> D["应用层:令牌桶限流"]
D --> E["Redis:原子预减库存(Lua)"]
E --> F["MQ:异步削峰"]
F --> G["消费者:DB 扣减 + 创建订单"]
G --> H["DB:最终只有 100 次写入"]
style A fill:#ffe0e0
style E fill:#fff3cd
style H fill:#d4edda沿着这条链路,流量是一层层衰减的:
- 前端:把静态资源甩给 CDN,把无效点击干掉
- 接入层:用 Nginx 限流,从 50 万砍到 20 万
- 应用层:用令牌桶,从 20 万砍到 10 万
- 缓存层:Redis 里只有 100 个库存,最多只有 100 次扣减能成功
- 存储层:MQ 缓冲后,DB 只需要处理 100 次写入
最终结论:数据库根本不需要扛 50 万 QPS,它只需要扛 100 次写入。真正需要扛住 50 万的,是最外层的接入能力。
这就是为什么秒杀不靠堆机器:节点数是逐层算出来的结果,不是拍脑袋定的手段。《秒杀系统设计(三):规模扩展》会给出具体算法。
3.2 完整链路与每层职责
看清楚漏斗之后,再给出完整链路图。本篇后四章,以及后面两篇的每一章,都对应这张图里的一个环节。
flowchart LR
U["用户"] --> DNS["DNS 轮询"]
DNS --> CDN["CDN静态资源"]
DNS --> LVS["LVS / F5四层负载均衡"]
LVS --> NG["Nginx / OpenResty七层 + 限流"]
NG --> APP["应用集群令牌桶 + 业务"]
APP --> R["Redis 集群预减库存"]
APP --> MQ["MQ削峰缓冲"]
MQ --> CON["消费者下单"]
CON --> DB[("MySQL最终落库")]各层的职责如下:
| 层级 | 组件 | 核心职责 | 主要解决的问题 |
|---|---|---|---|
| 前端 | CDN + 浏览器 | 静态化、按钮置灰、验证码 | 干掉无效请求 |
| 四层接入 | LVS / F5 | 超高并发转发,不做业务逻辑 | 分摊全局流量 |
| 七层接入 | Nginx / OpenResty | 动静分离、限流、WAF | 从源头砍流量 |
| 应用层 | Java 集群 | 令牌桶、Redis 扣减、发 MQ | 业务级限流 |
| 缓存层 | Redis | 预减库存,挡住 DB | 挡流量 + 防超卖 |
| 消息层 | MQ | 削峰、解耦、异步 | 保护 DB |
| 存储层 | MySQL | 最终一致落库 | 只需处理少量写入 |
先解释一下表里的"四层接入"和"七层接入"是什么意思。
这里的"四层 / 七层"指的是 OSI 网络模型的层级,说的是这个组件工作在哪一层、能不能看懂 HTTP 内容:
- 四层接入:工作在传输层,只看得到 TCP/UDP 的 IP 和端口,看不懂 HTTP 内容。它干的事就是"把连接转发给后面的服务器",所以速度极快(LVS 单机可扛几十万 QPS)。代表组件是 LVS、F5。
- 七层接入:工作在应用层,能看懂 HTTP 协议——知道用户请求的是哪个 URL、带了什么 Header、用的什么方法。正因为能看懂内容,它才能做动静分离、按 URL 精确限流、请求改写。代价是要解析协议,性能比四层低一个数量级(Nginx 单机几万 QPS)。
一句话记忆:四层只转发连接,七层能看懂内容。 也正因如此,秒杀链路里通常是"四层扛量、七层做业务"的搭配——本篇第四章会完整推演这个结论是怎么来的。
3.3 分层防御的核心思想
很多人的第一反应是:"加一层就多一个故障点,是不是过度设计?"
恰恰相反,分层的目的就是为了限制故障的扩散范围。每一层的设计逻辑都是同一句话:
这一层的职责,是把上一层的流量削减到一个下一层能承受的量级。
- 如果 Nginx 不砍流量,应用层会被打挂
- 如果应用层不砍流量,Redis 会成热点
- 如果 Redis 不挡库存,DB 会被打爆
- 如果 DB 被打爆,整条链路雪崩
每一层都是下一层的保护伞,这就是"分层防御"。下面按"先流量、后一致性"的顺序,逐层展开。
四、接入层:从单机 Nginx 到多级负载均衡
问题一(数据库被打爆)的解法,核心思路是在请求到达数据库之前把它拦掉,而且拦截越靠前、成本越低。所以第一道防线就在接入层。
这一章不直接罗列"要上哪些组件",而是从一台机器开始,一层一层往上推:单机什么时候够用、什么时候必须集群、集群之后又引出什么新问题、这个问题该由谁来解决。推演出来的这套层次,就是接入层的架构设计。
4.1 层次一:动静分离——先把无效流量卸掉
秒杀页面通常是静态页面(商品信息、倒计时、按钮)。优化的第一步不是加机器,而是把根本不需要后端处理的请求摘出去:
- HTML、CSS、JS、图片全部走 CDN,Nginx 不参与
- 页面数据(库存、开始时间)在活动开始前静态化写入,避免每次请求都查库
- 动态请求只保留"点击下单"这一个接口
这一步的收益:原本 50 万 QPS 里可能有 90% 是刷新页面、加载资源,现在这部分完全不落到后端。
这就是架构设计的第一原则:能不在后端做的,就不在后端做。
4.2 层次二:一台 Nginx 能扛多少 QPS
卸掉静态流量后,剩下的动态请求由 Nginx 承接。但要不要集群,取决于单机能扛多少——所以先给出一个基线(参考值,实际必须压测):
| 场景 | 单机能力(4C8G 云服务器,参考值) |
|---|---|
| 纯 HTTP 反向代理 | 3 万 ~ 5 万 QPS |
| 反向代理 + HTTPS 卸载 | 1 万 ~ 2 万 QPS |
| 叠加 Lua 业务逻辑(OpenResty) | 几千 ~ 1 万 QPS |
有了这个基线,就能判断该用几台:
- 峰值 1 万 QPS → 一台 Nginx 就够,不需要集群,架构极简
- 峰值 10 万 QPS → 至少 2~3 台,还要考虑高可用,开始需要集群
- 峰值 50 万 QPS → 按 5 万单机能力算也要 10 台以上,必须集群
关键认知:架构不是一开始就上集群的,要不要集群要根据 QPS 来判断。 具体做法是:先压测出单机 Nginx 的实际 QPS 上限,再拿业务峰值 QPS 和它对比——峰值没有超过单机能力,一台 Nginx 就是整个接入层;超过了单机能力,才需要考虑集群。前面那张单机能力表,就是做这个判断的依据。
4.3 层次三:Nginx 集群——集群之后,谁来做负载均衡
单机不够,最自然的做法是横向扩展到 N 台 Nginx,每台都完整部署一份限流规则和转发配置。
但立刻出现一个新问题:这 N 台 Nginx,客户端该访问哪一台?
Nginx 是被访问的一方,它没法给自己分配流量。所以集群之后,必然需要一个上游的、专门做流量分发的角色——负载均衡器。
最直觉的想法是"从原来的机器里挑一台出来,专门做负载均衡"。但这会带来两个致命问题:
- 它成了单点:这台机器挂了,整个 Nginx 集群就全部不可用
- 它成了瓶颈:所有流量都要先经过它,它的处理能力就是整个系统的天花板。而 Nginx 本身是七层组件,要解析 HTTP 协议,单机也就几万 QPS,根本承接不了 50 万 QPS
也就是说:Nginx 集群之后,它自己无法承担给自己的负载均衡——必须引入一个更底层、性能更强的组件。 这就引出了四层负载均衡。
4.4 层次四:引入四层负载均衡 LVS / F5
四层负载均衡工作在传输层,它不解析 HTTP,只做 TCP 连接的转发,因此性能比 Nginx 高一个数量级。
| 维度 | 四层负载均衡(LVS / F5) | 七层负载均衡(Nginx) |
|---|---|---|
| 工作层次 | 传输层(TCP/UDP) | 应用层(HTTP) |
| 是否解析 HTTP | 不解析,只转发连接 | 解析,可改包、可重写 |
| 性能 | 极高(LVS 单机几十万 QPS,F5 可达百万级) | 较高(单机几万 QPS) |
| 能否做限流/路由 | 基本不能 | 可以,且能按 URL / Header |
| 典型用途 | 最外层承接海量流量 | 内层做业务路由与限流 |
常见的两种落地形态:
- LVS:开源的软件四层负载均衡,部署在普通服务器上,成本低,单机可扛几十万 QPS
- F5:硬件负载均衡设备,性能更强(百万级)、功能更全(SSL 卸载、防护等),但价格昂贵,一般用在超大规模或金融级场景
于是接入层形成了第二级结构:最外层用 LVS / F5 做四层分发,把流量打给下面的 Nginx 集群;Nginx 再做七层限流和业务路由。
这也回答了 4.3 的问题:给 Nginx 集群做负载均衡的,不是 Nginx 自己,而是更底层的 LVS / F5。
4.5 层次五:LVS 也不能是单点——主备与多集群
问题还没完。这里有两个坑点必须处理:LVS 成了整个系统的唯一入口,一是它挂了整个系统就挂了(单点),二是它的处理能力就是整个系统的天花板(瓶颈)。 所以需要纵横两个方向同时做:
- 纵向(高可用):用 keepalived + VIP 做 LVS 主备。对外只暴露一个虚拟 IP(VIP),请求实际由主节点处理;主节点宕机后,VIP 自动漂移到备节点,客户端无感知。
- 横向(扩容):如果单组 LVS 也扛不住全部流量,就在最外层加 DNS 轮询,把用户解析到不同的 LVS 集群。比如
seckill.example.com解析出多个 IP,用户被分散到 A、B 两个 LVS 集群,每个集群内部各自做主备。
至此,接入层的层次就成型了:DNS 轮询 → LVS(每组主备)→ Nginx 集群 → 应用集群。
4.6 接入层的完整层次图
把上面的推演结果画成一张图:
flowchart TD
U["用户"] --> DNS["DNS 轮询"]
DNS --> A1["LVS 集群 A主 + 备(keepalived + VIP)"]
DNS --> A2["LVS 集群 B主 + 备"]
A1 --> NG1["Nginx 1"]
A1 --> NG2["Nginx 2"]
A1 --> NG3["Nginx N"]
A2 --> NG4["Nginx ..."]
NG1 --> APP["应用集群"]
NG2 --> APP
NG3 --> APP
NG4 --> APP
style A1 fill:#e7f1ff
style A2 fill:#e7f1ff
style NG1 fill:#d4edda
style NG2 fill:#d4edda
style NG3 fill:#d4edda回顾一下这条推演链:
| 层次 | 解决的问题 | 为什么需要它 |
|---|---|---|
| 静态化 / CDN | 卸载静态流量 | 从源头减少请求 |
| 单机 Nginx | 承接动态流量 | 流量小,单机够用 |
| Nginx 集群 | 突破单机能力上限 | 峰值超过单机几万 QPS |
| LVS / F5 | 给 Nginx 集群做负载均衡 | 集群后需要上游分发,而 Nginx 自己性能不够 |
| keepalived + VIP | LVS 高可用 | LVS 成了唯一入口,不能挂 |
| DNS 轮询 | LVS 集群扩容 | 单组 LVS 也扛不住全部流量 |
这就是"架构层次设计"的思维方式:每一层都不是凭空出现的,而是被上一层的问题逼出来的。
4.7 层次六:在 Nginx 上做限流
光有层次还不够,还要主动砍流量。Nginx 提供两个现成的限流模块。
(1)limit_req:基于漏桶算法的速率限流
1 | http { |
- rate:平均速率,比如
10r/s表示每个 IP 每秒 10 个请求 - burst:允许的突发队列长度。漏桶是"恒定速率漏水",
burst相当于桶的容量 - nodelay:这 20 个突发请求不排队等待,直接放行;不写则会延迟处理(对秒杀不友好,用户等不起)
(2)limit_conn:并发连接数限流
1 | limit_conn_zone $binary_remote_addr zone=addr:10m; |
坑点一:限流拿到的可能不是真实用户 IP。$binary_remote_addr 取的是直接连到 Nginx 的那个 IP。但秒杀链路前面还有 LVS / CDN,所以 Nginx 看到的 remote_addr 其实是代理的 IP——这会导致所有用户共用同一个限流桶:阈值很快被少数用户打满,其余用户被集体误伤,限流形同虚设。应该改成取 X-Forwarded-For 里的第一个真实 IP,并且严格校验这个头,否则它可以被客户端伪造,直接绕过限流。
坑点二:Nginx 限流是"单机限流",不是"集群限流"。limit_req_zone 的计数只存在每台 Nginx 自己的共享内存里。10 台 Nginx 各算各的,实际放行量就是配置值的 10 倍,限流等于没做。
这两个坑点,恰好暴露了 Nginx 原生限流的两个根本短板:
- 计数是单机的——集群下无法做全局精确限流;
- 规则是静态的——
limit_req/limit_conn只能按 IP、URL 这类已有字段限流,做不了那种"必须查一次数据才能判断"的业务逻辑。
4.8 层次七:OpenResty——让 Nginx 具备业务判断能力
先说它是怎么来的。
上面那两个短板,其实指向了同一个诉求:能不能让 Nginx 自己也"跑一点代码"?
- 计数是单机的 → 那就让 Nginx 去读一个所有节点共享的计数器(比如 Redis);
- 规则是静态的 → 那就让 Nginx 能执行一段自定义逻辑,而不是只认配置文件里的固定指令。
把这两个诉求合起来,就需要一个"能在 Nginx 里编程、并且能访问外部存储"的能力。这个能力,就来自 OpenResty。
再说它是什么。
OpenResty 是一个基于 Nginx 与 LuaJIT 的 Web 平台。它把一个 Lua 运行时嵌进了 Nginx,让 Nginx 不再只是一台"靠配置文件驱动的代理",而是可以用 Lua 编写具体的处理逻辑,并把这段逻辑挂到 Nginx 的各个处理阶段上执行(最常用的是 access 阶段,也就是请求转发给后端之前那一刻)。
一句话概括:OpenResty = Nginx + Lua 编程能力。
最后看它怎么补上那两个短板。
有了 Lua 编程能力,秒杀里那些"必须查一次数据才能决定"的判断,就可以直接写在 Nginx 里了:
- "这个商品在 Redis 里的库存是不是已经空了?"
- "这个用户是不是已经抢过了?"
- "令牌桶的全局计数超了没有?"
这些判断如果放到 Java 应用里去做,请求就已经穿过 Nginx 了,Nginx 的拦截价值也就没了。而现在,请求连 Java 应用都不用进,在 Nginx 里查一次 Redis 就能直接返回"已售罄":
1 | location /seckill/order { |
对照着看,这段代码正好把两个短板都补上了:计数器放在 Redis 里(集群共享,不再是单机各自计数),判断逻辑用 Lua 写(不再局限于静态规则)。
坑点:OpenResty 里做 Redis 调用,一旦 Redis 慢查询或抖动,Nginx 的 worker 会被阻塞,接入层反而成为瓶颈。必须设置严格的 timeout 和降级策略(Redis 挂了就直接放行到应用层,而非拒绝所有请求)。
4.9 负载均衡算法怎么选
层次定了,接下来是"流量按什么规则分给下游节点"。
- 轮询 / 加权轮询:最简单,适合节点配置一致的场景
- 最少连接:适合请求耗时差异大的场景
- IP Hash / 一致性 Hash:让同一用户固定落到同一节点。秒杀场景下慎用——秒杀流量高度集中,一致性 Hash 容易造成热点倾斜,把压力堆到某几个节点上
坑点:健康检查不能只看端口是否存活。
秒杀场景下,应用可能"进程还活着,但线程池已经满了",此时端口探测依然是通的,负载均衡器会认为这个节点健康,继续往里灌流量。必须用业务级健康检查(比如检查线程池水位、接口 RT 指标),才能及时把异常节点摘掉。
五、应用层:令牌桶限流
经过接入层,流量从 50 万降到了 20 万。但 20 万打到应用层依然扛不住,需要第二道闸门:令牌桶。
5.1 令牌桶原理
令牌桶的模型很直观:
- 有一个固定容量的桶,按恒定速率往桶里放令牌(比如每秒放 1000 个)
- 桶满了,新令牌就丢弃(这是和漏桶的关键区别)
- 每个请求到来时,必须先拿到一个令牌才能被处理
- 桶里没令牌了,请求就被拒绝或排队
flowchart LR
T["令牌生成器恒定速率 1000/s"] -->|放令牌| BK["令牌桶容量 2000"]
BK -->|消耗令牌| RQ["请求"]
BK -->|桶满溢出| D["丢弃"]
RQ -->|无令牌| RJ["拒绝 / 排队"]
RQ -->|有令牌| OK["放行到业务"]
style BK fill:#fff3cd两个参数很关键:
- 速率(rate):决定长期平均放行速度,也就是系统真正要承载的 QPS
- 容量(capacity):决定能承受多大的瞬时突发。容量 2000、速率 1000/s,意味着活动刚开始的那一瞬间可以放 2000 个请求进来,之后按 1000/s 匀速放行
5.2 令牌桶 vs 漏桶
| 维度 | 令牌桶 | 漏桶 |
|---|---|---|
| 出水/放行速率 | 允许突发(有桶容量缓冲) | 恒定,严格平滑 |
| 桶的作用 | 存令牌,攒够了一次性放 | 存请求,匀速漏出 |
| 能否应对突发流量 | 能(这是最大优势) | 不能,突发请求排队或被拒 |
| 典型实现 | Redis + Lua、Guava RateLimiter | Nginx limit_req |
为什么秒杀选令牌桶:秒杀流量是典型的"脉冲式"——活动开始瞬间突然爆发,之后迅速回落。令牌桶允许"攒一波令牌应对突发",比漏桶的机械匀速更贴合实际。
5.3 Redis + Lua 实现分布式令牌桶
先讲清楚为什么要放到 Redis,以及每次请求是怎么算的,最后再看代码。
第一步:为什么单机令牌桶不行。
单机令牌桶(如 Guava RateLimiter)把令牌数存在本机内存里。应用部署成 10 台之后,每台机器各放各的令牌——配置的是 1000/s,实际放行量变成了 10000/s,限流直接失效。这和 4.7 讲的"Nginx 单机限流"是同一类问题。
第二步:把状态挪到所有节点都能看到的地方。
既然问题是"状态各自持有",那就把令牌桶的状态集中放到 Redis 里,让所有应用节点共享同一份:
- tokens:桶里当前还剩多少令牌
- last_time:上一次刷新令牌的时间
这样无论请求落到哪台机器上,操作的都是同一个桶。
第三步:每次请求到底怎么算?
令牌桶不能"真的"每秒往桶里丢令牌(那得跑一个定时任务),而是惰性计算——请求来了才根据"距离上次请求过了多久"补发令牌:
- 读状态:取出桶里的
tokens和last_time - 按时间补令牌:
(当前时间 - last_time) × 速率,就是这段时间该补的令牌数 - 封顶:补完之后如果超过桶容量,砍到容量上限(多出来的直接丢弃)
- 判断:令牌数 ≥ 本次需求(一般是 1)就放行,否则拒绝
- 扣减并写回:够的话扣掉令牌,把新的
tokens和当前时间写回 Redis
flowchart TD
S["请求到来"] --> R["读取桶状态tokens / last_time"]
R --> C["按时间补令牌(now - last_time) × rate"]
C --> M["令牌数封顶min(capacity, tokens + 补发量)"]
M --> J{"tokens >= need ?"}
J -->|是| P["tokens = tokens - need"]
J -->|否| RJ["拒绝请求(429)"]
P --> W["写回 tokens / last_time"]
W --> OK["放行到业务"]
style J fill:#fff3cd
style RJ fill:#ffe0e0注意第 1 步和第 5 步之间,又是一个"读—判断—写"——这正是并发下会出问题的地方,所以这五步必须一次性原子执行完,也就是下面这段 Lua 脚本。
第四步:Lua 脚本实现。
核心 Lua 脚本:
1 | -- KEYS[1]: 令牌桶 key |
Java 侧调用:
1 | public boolean tryAcquire(String key, int capacity, int rate, int need) { |
5.4 为什么必须用 Lua
这是理解 Redis 限流的关键一步。
如果不用 Lua,把"读令牌数 → 算补充 → 判断 → 写回"分成四条命令:
1 | Integer tokens = redis.get("tokens"); // 1. 读 |
在 1 和 4 之间存在时间窗口:两个线程可能同时读到 tokens = 1,都判断"够用",然后都执行扣减,最终放行了 2 个请求,但实际只该放行 1 个。
这和 2.4 的超卖本质上是同一个问题——"读—判断—写"不是原子的。
Lua 脚本在 Redis 中是原子执行的:Redis 执行 Lua 期间不会插入其他命令(命令处理本身是单线程的)。所以把"读—判断—写"整体塞进一个 Lua 脚本,就能彻底消除窗口期。
这条规律贯穿全文,请记住:凡是"读—判断—写"的复合操作,要么用 Lua,要么用锁。
5.5 令牌桶的局限与坑点
(1)它防不了超卖——这是最重要的一点。
令牌桶限制的是"速率",不是"总量"。它只能保证每秒只有 1000 个请求进入,但没法保证这 1000 个请求里只有 100 个能扣到库存。库存一致性必须在第二篇里解决。
(2)限流维度怎么选、怎么组合。
限流的本质就是"按某个 key 去计数",所以选什么 key,就决定了你能拦住谁。秒杀里常用的四个维度:
| 维度 | key 示例 | 能拦住什么 | 拦不住什么 |
|---|---|---|---|
| IP 维度 | limit:ip:{ip} |
单机脚本狂刷 | 代理 IP 池;NAT 出口(整栋楼共用一个 IP,会误伤) |
| 用户维度 | limit:user:{userId} |
单账号刷单 | 未登录用户、批量注册的小号 |
| 设备维度 | limit:device:{deviceId} |
同一台设备换号刷 | 模拟器可以改设备号 |
| 接口全局维度 | limit:api:seckill |
下游整体不被压垮 | 拦不住"是谁在刷",只保证总量 |
单用任何一个维度都有漏洞,所以实际做法是"多个维度叠加,各限各的量"。
具体怎么落地?以一次秒杀请求为例,按"由粗到细"的顺序依次判断,任意一层拒绝就立即返回:
- 接口全局维度(粗粒度,先兜底)——保证打到下游的总量不超过系统容量,这是"保命"的一层
- 商品维度——限制同一商品被访问的总速率
- IP 维度——限制单个 IP 的请求频率
- 用户维度(最细)——限制单个用户的请求频率
代码上就是把前面写好的 tryAcquire 串起来,用短路与连接:
1 | // 多维度限流:任意一维超限即拒绝 |
各维度的阈值怎么定? 思路是从下游能力倒推,再按"单个用户/单个 IP 的合理行为"分配:
- 全局阈值 → 由下游能承受的总 QPS 决定(比如令牌桶限流目标就是 5000/s)
- 单用户阈值 → 正常用户 1 秒最多点 2 次,那就给 2/s
- 单 IP 阈值 → 一个正常人不会同时开 20 个页面,那就给 10/s
坑点:维度不是越多越好。上面一次请求就要访问 4 次 Redis,这本身就是一笔不小的开销(见下一点)。实践中一般只保留 2~3 个核心维度(通常是"全局 + 用户"或"全局 + IP")。
(3)每个请求都访问 Redis,Redis 会成为新热点。
从上面的代码能看到,多维限流会让一次业务请求膨胀成好几次 Redis 调用。所以在应用本地要做二级限流:
- 第一级(本地):本机先用单机令牌桶挡掉绝大部分,阈值设成全局阈值的 1.2 倍左右
- 第二级(Redis):只有通过本地限流的请求,才去查 Redis 做全局精确限流
因为绝大多数超量请求在第一级就被挡住了,Redis 的压力能下降一个数量级。
(4)Redis 挂了会怎样?
先明确一点:限流用的 Redis 通常是集群部署(Cluster 分片)或主从 + 哨兵部署,而不是单机。主从 + 哨兵有自动故障转移,Cluster 本身是多分片多副本——所以"整个 Redis 全挂"的概率其实非常低,绝大多数情况只是个别分片抖动或短暂的主从切换,表现为部分请求超时,而不是彻底不可用。
但降级策略仍然要准备好,因为一旦真的不可用,必须有一个明确的决策:是"放行"(承担过载风险)还是"全拒"(业务直接中断)。秒杀场景一般选择本地兜底限流 + 放行:
- 本机令牌桶继续挡掉大部分流量
- Redis 查不通就直接放行
这样取舍的理由是:Redis 全挂的概率很低,而"全拒"会让整个活动立刻中断,业务损失更确定也更严重;即使放行导致一部分流量穿到下游,后面还有 Redis 库存扣减、MQ、DB 三道闸门兜着,不会立刻雪崩。
六、缓存与消息层:Redis 挡量 + MQ 削峰
经过前两层,流量从 50 万降到了 10 万。但 10 万 QPS 直接打数据库,依然是灾难。这一章要解决的是最后一段的流量问题:如何把 10 万 QPS 降到数据库能承受的量级。
这里用两把武器:Redis 当库存闸门(挡量)+ MQ 做异步削峰。
6.1 核心思路
- Redis 挡量:把库存放进 Redis,请求先在 Redis 里扣。库存只有 100 件,Redis 最多只放行 100 个请求往下走,其余 99.9% 的请求在 Redis 这一层就被拒绝。这是漏斗里最关键的一次收缩。
- MQ 削峰:通过 Redis 的这 100 个请求,不直接写库,而是扔进 MQ 排队,由消费者匀速消费。这样即使瞬间有 100 个并发写请求,数据库看到的也是平滑的写入流。
6.2 完整流程
flowchart TD
S["活动开始前:库存预热Redis 里 stock = 100"] --> A["用户发起秒杀请求"]
A --> B{"令牌桶限流通过?"}
B -->|否| R1["拒绝:活动太火爆"]
B -->|是| C["Redis 执行 Lua原子扣减库存"]
C --> D{"扣减结果"}
D -->|key 不存在| R2["拒绝:活动未开始并触发预热告警"]
D -->|stock <= 0| R3["拒绝:已售罄"]
D -->|扣减成功| E["发送下单消息到 MQ"]
E --> F{"消息发送成功?"}
F -->|否| G["回补 Redis 库存返回:系统繁忙"]
F -->|是| H["立即返回:排队中"]
H --> I["MQ 投递消息"]
I --> J["消费者:扣 DB 库存 + 创建订单"]
J --> K["用户轮询查询结果"]
K --> L["返回:下单成功"]
style C fill:#fff3cd
style R3 fill:#ffe0e0
style G fill:#ffe0e0
style L fill:#d4edda这条流程里藏着两个关键转变:
- 数据库的访问量从 10 万降到 100——因为 Redis 只放行 100 个
- 写库从同步变异步——请求立即返回"排队中",RT 从几百毫秒降到几毫秒
第 2 点尤其重要:它把"用户等待时间"和"数据库处理时间"解耦了。用户不再需要等订单真的创建完,接口可以极快地返回,吞吐能力随之提升(回忆 1.3 提到的利特尔法则)。
6.3 库存预热
活动开始前,把库存从 DB 加载到 Redis:
1 | // 活动开始前 10 分钟执行 |
坑点一:预热和开始之间要有时间缓冲。
如果预热失败(Redis 抖动、网络超时),活动开始后所有请求都会走到"Redis 里没有 key"的分支。必须做预热校验:定时任务检查 key 是否存在,不存在则重试;活动开始时做一次最终确认。
坑点二:预热后 DB 和 Redis 的库存必须"冻结"。
预热之后到活动结束前,任何人都不能直接改 DB 库存,否则两边数据不一致。
6.4 发送 MQ 与失败回补
Redis 扣减成功后,进入发消息环节:
1 | public SeckillResult seckill(Long userId, Long goodsId) { |
坑点一:发 MQ 失败必须回补库存。
上面第 4 步的 catch 是关键。如果消息发送失败但不回补,库存就被永久占用了——少卖。
坑点二:回补本身也可能失败。
如果回补时 Redis 正好挂了怎么办?所以还需要兜底对账:定时任务扫描"Redis 已扣减但 DB 无对应订单"的记录,做补偿。
坑点三:消息要按商品 ID 分区,保证顺序。
让同一商品的消息落到同一队列分区,避免消费乱序带来的额外复杂度。
6.5 削峰原理:数据库能扛多少,MQ 需要扛多少
先看数据库能扛多少。 MySQL 单机的写入能力大约在 3000 ~ 8000 QPS 这个量级(具体取决于表结构、索引数量和硬件)。这个数字是理解 MQ 价值的前提。
再看没有 MQ 会怎样。 假设 Redis 已经放行了 1 万个请求(对应 1 万件库存),这 1 万个请求要同时去扣 DB 库存、写订单:
- 单个商品:所有请求都抢同一行库存,行锁排长队,数据库被打到极限;
- 多个商品同时秒杀:情况更糟——所有商品的所有写请求都挤在同一个库上,而这个库的写入能力还是那 3000~8000,瞬间就被打爆。
打爆之后就是前面讲过的连锁反应:连接池耗尽 → 应用线程阻塞 → 整条链路雪崩。
MQ 的作用,就是把"瞬时冲进来的洪水"变成"匀速流出的细流"。
没有 MQ 时:1 万个请求在 1 秒内全部砸向数据库,数据库要在一瞬间处理 1 万次写入——它做不到,于是崩了。
加入 MQ 之后:这 1 万个请求先被"收进"队列,数据库不再是直面 1 万个并发,而是面对一个队列。消费者按数据库能承受的速度去取:
1 | 消费者并发度 = 20 个线程 |
这 2000 QPS 落在数据库 3000~8000 的能力区间内,数据库就稳了。而 1 万条消息只需要 5 秒就能消化完——用户多等几秒,但系统不会崩。
一句话概括这个动作:MQ 相当于在数据库前面装了一个可以调节流量的阀门,把瞬间冲进来的洪水,拧成一股稳定的细流。 数据库的瞬时压力从 1 万降到 2000,代价只是把处理时间拉长了几秒。
所以 MQ 在这里削的是两样东西:
- 削写入峰值:把"瞬间 1 万个并发写"变成"消费者按数据库能力匀速消费",保护数据库不被打爆;
- 削接口耗时:接口不再同步等待下单完成,RT 大幅下降,用户不用卡在转圈上。
但要注意——MQ 不减少总工作量,它只是把工作从"瞬时"摊到"一段时间"。如果消费者的总处理能力小于消息产生速度,消息会不断堆积,最终 MQ 也会被打满。所以消费者并发度必须按数据库的实际能力来配,并纳入《秒杀系统设计(三)》的容量校验。
MQ 实际需要扛多少 QPS?
MQ 位于 Redis 之后,它收到的消息量等于 Redis 实际放行成功的请求数,也就是成功扣减掉的库存量,而不是入口流量:
| 场景 | 入口峰值 QPS | Redis 放行(= MQ 消息量) |
|---|---|---|
| 库存 100 件 | 50 万 | 100 条 |
| 放量 1 万件 | 50 万 | 1 万条 |
| 几十个商品各放量几万件(罕见) | 50 万 | 累计才可能到十万级 |
MQ 的量级由"库存规模"决定,不由"入口流量"决定。 想让 MQ 真的达到十万级 QPS,只有"同时秒杀大量商品"这一种可能——几十个商品各放量几万件,加起来才够,而这种业务场景本身就很难组织。
所以正常情况下 MQ 不是瓶颈,真正的瓶颈永远在最外层(Nginx、应用层)。 但仍要注意两点:
- 瞬时集中:消息可能在一瞬间全部涌入,MQ 需要有基本的缓冲能力(这个量级对任何消息队列都很轻松);
- 异常放大:如果 Redis 预热失败导致扣减逻辑走错分支、或者代码有 bug,消息量可能失控,MQ 侧最好加一道发送速率限制作为兜底。
结论:MQ 的容量不要按入口峰值估算,而应该按"库存规模 ÷ 活动时长"估算,再纳入《秒杀系统设计(三)》的容量校验。
6.6 MQ 怎么选:Kafka / RocketMQ / RabbitMQ
先把秒杀对 MQ 的真实要求说清楚,这决定了怎么选型。
要求一:要有足够强的堆积(缓冲)能力。
前面为了好讲解,举的是"单商品、库存 100 件"的例子,所以 MQ 的消息量看起来只有百来条。但真实业务里的秒杀通常不会只有一件商品——一场大促活动往往同时开抢几十甚至几百个商品,每个商品的库存都可能是几万件,这些消息全部会涌向同一个 MQ。算下来瞬时涌入几万到几十万条消息是完全可能的。
而且 MQ 的价值不只是"把峰值削平",更是"扛住瞬时堆积":消费者短暂不可用、下游数据库慢查询、网络抖动……任何一个环节卡住,消息都会在 MQ 里越堆越多。能不能稳住这些堆积、不丢消息、不把内存撑爆,才是选型的第一个硬指标。
要求二:功能上必须对得上。
- 消息不能丢:秒杀是资损业务,丢一条消息就等于少卖一件
- 要支持延迟消息:15 分钟未支付自动取消订单
- 最好支持顺序消息:同一商品的消息有序,避免消费乱序带来的额外复杂度
把三款主流 MQ 放在一起对比:
| 维度 | RabbitMQ | RocketMQ | Kafka |
|---|---|---|---|
| 单机吞吐(消息/秒) | 3 万 ~ 5 万 | 约 10 万 | 10 万 ~ 百万级 |
| 延迟 | 微秒 ~ 毫秒(最低) | 毫秒级 | 毫秒级 |
| 延迟消息 | 插件支持 | 原生支持(固定延迟等级 1s ~ 2h) | 支持但不完善 |
| 事务消息 | 插件支持 | 原生支持 | 需自行实现 |
| 顺序消息 | 较弱 | 支持(分区顺序) | 支持(分区顺序) |
| 消息回溯 | 不支持 | 支持 | 支持 |
| 运维复杂度 | 低 | 中 | 高 |
| 典型场景 | 中小系统、灵活路由 | 电商 / 交易 / 秒杀 | 日志、大数据、流处理 |
表中的吞吐是量级参考,来自公开压测与社区实践。实际能力受消息大小、刷盘方式(同步/异步)、副本数影响很大,必须按自己的场景压测确认。
怎么选?
- RocketMQ:秒杀场景的默认答案。单机约 10 万条/秒的吞吐远超需求,更重要的是延迟消息、事务消息、顺序消息都原生支持——"15 分钟未支付自动取消订单"用它自带的延迟消息就能实现,不必自己写定时任务扫表。
- RabbitMQ:系统规模不大、团队想快速上手时选它。单机 3~5 万对秒杀已经够用,运维最简单;代价是延迟消息要靠插件,且要注意队列积压导致的内存问题。
- Kafka:只有当你已经有一套 Kafka 基建、或者秒杀只是整个事件流的一环时才考虑。它吞吐最高,但延迟消息/事务消息支持较弱、运维最重,为秒杀单独引入 Kafka 性价比不高。
一句话结论:秒杀优先 RocketMQ——它既能扛住几十万条的瞬时堆积,又原生支持延迟消息和事务消息,正好覆盖秒杀的两个核心要求。
为什么 Kafka 反而排在最后?不是它不够强(吞吐最高),而是它的强项和秒杀的需求不完全对齐:Kafka 擅长的是海量数据流与流式计算,但在延迟消息、事务消息上支持较弱,运维也最重。秒杀要的是"堆积稳、功能全",而不是"吞吐极致"。
【下一篇预告】 流量问题解决之后,系统算是"活下来了",但活下来不等于活得对——库存可能被扣成负数(超卖),同一个用户也可能抢到多单(一人多单)。下一篇《秒杀系统设计(二):数据正确性》就来解决这两件事:从 Redis 侧的原子扣减,到数据库侧的并发控制与分布式锁,再到一人一单与幂等设计。











