【系列说明】 本文是《秒杀系统设计》系列的第 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
- 创建订单:生成一条待支付订单,记录用户和商品
- 返回结果:告诉用户"抢购成功,请尽快支付"
再配上"用户支付 / 超时未支付"的后续动作,完整流程如下:
#mermaid-svg-vXrgzrc8AdTS6LEZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vXrgzrc8AdTS6LEZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vXrgzrc8AdTS6LEZ .error-icon{fill:#552222;}#mermaid-svg-vXrgzrc8AdTS6LEZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vXrgzrc8AdTS6LEZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vXrgzrc8AdTS6LEZ .marker.cross{stroke:#333333;}#mermaid-svg-vXrgzrc8AdTS6LEZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vXrgzrc8AdTS6LEZ p{margin:0;}#mermaid-svg-vXrgzrc8AdTS6LEZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vXrgzrc8AdTS6LEZ .cluster-label text{fill:#333;}#mermaid-svg-vXrgzrc8AdTS6LEZ .cluster-label span{color:#333;}#mermaid-svg-vXrgzrc8AdTS6LEZ .cluster-label span p{background-color:transparent;}#mermaid-svg-vXrgzrc8AdTS6LEZ .label text,#mermaid-svg-vXrgzrc8AdTS6LEZ span{fill:#333;color:#333;}#mermaid-svg-vXrgzrc8AdTS6LEZ .node rect,#mermaid-svg-vXrgzrc8AdTS6LEZ .node circle,#mermaid-svg-vXrgzrc8AdTS6LEZ .node ellipse,#mermaid-svg-vXrgzrc8AdTS6LEZ .node polygon,#mermaid-svg-vXrgzrc8AdTS6LEZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vXrgzrc8AdTS6LEZ .rough-node .label text,#mermaid-svg-vXrgzrc8AdTS6LEZ .node .label text,#mermaid-svg-vXrgzrc8AdTS6LEZ .image-shape .label,#mermaid-svg-vXrgzrc8AdTS6LEZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-vXrgzrc8AdTS6LEZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vXrgzrc8AdTS6LEZ .rough-node .label,#mermaid-svg-vXrgzrc8AdTS6LEZ .node .label,#mermaid-svg-vXrgzrc8AdTS6LEZ .image-shape .label,#mermaid-svg-vXrgzrc8AdTS6LEZ .icon-shape .label{text-align:center;}#mermaid-svg-vXrgzrc8AdTS6LEZ .node.clickable{cursor:pointer;}#mermaid-svg-vXrgzrc8AdTS6LEZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vXrgzrc8AdTS6LEZ .arrowheadPath{fill:#333333;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vXrgzrc8AdTS6LEZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vXrgzrc8AdTS6LEZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vXrgzrc8AdTS6LEZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vXrgzrc8AdTS6LEZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vXrgzrc8AdTS6LEZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vXrgzrc8AdTS6LEZ .cluster text{fill:#333;}#mermaid-svg-vXrgzrc8AdTS6LEZ .cluster span{color:#333;}#mermaid-svg-vXrgzrc8AdTS6LEZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vXrgzrc8AdTS6LEZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vXrgzrc8AdTS6LEZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-vXrgzrc8AdTS6LEZ .icon-shape,#mermaid-svg-vXrgzrc8AdTS6LEZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vXrgzrc8AdTS6LEZ .icon-shape p,#mermaid-svg-vXrgzrc8AdTS6LEZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vXrgzrc8AdTS6LEZ .icon-shape .label rect,#mermaid-svg-vXrgzrc8AdTS6LEZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vXrgzrc8AdTS6LEZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vXrgzrc8AdTS6LEZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vXrgzrc8AdTS6LEZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未开始 / 已结束
进行中
已抢过
未抢过
否
是
支付
超时未支付
用户点击抢购
活动是否开始?
拒绝:活动未开始或已结束
该用户是否已抢过?
拒绝:请勿重复提交
查询商品库存
库存 > 0 ?
拒绝:已售罄
扣减库存
创建待支付订单
返回:抢购成功
用户是否支付?
订单状态:已支付
取消订单 + 回补库存
这张图是"理想状态"下的流程,每一步都按顺序执行、互不干扰。在单机、低并发下,它完全正确。 问题出在------秒杀场景下,这些步骤会被几十万个请求同时执行。
2.2 朴素实现的代码
把上面的流程翻译成代码,就是最常见的写法:
java
@Transactional(rollbackFor = Exception.class)
public SeckillResult seckill(Long userId, Long goodsId) {
// 1. 校验活动状态
SeckillActivity activity = activityMapper.selectById(goodsId);
if (activity == null || !activity.isOngoing()) {
return SeckillResult.fail("活动已结束");
}
// 2. 校验一人一单
Long count = orderMapper.countByUserAndGoods(userId, goodsId);
if (count > 0) {
return SeckillResult.fail("请勿重复提交");
}
// 3. 查询库存
Integer stock = goodsMapper.selectStock(goodsId);
if (stock == null || stock <= 0) {
return SeckillResult.fail("已售罄");
}
// 4. 扣减库存
goodsMapper.updateStock(goodsId, stock - 1);
// 5. 创建订单
orderMapper.insert(new Order(userId, goodsId, OrderStatus.UNPAID));
return SeckillResult.success();
}
对应的 SQL:
sql
-- 查库存
SELECT stock FROM goods WHERE id = 1;
-- 扣库存
UPDATE goods SET stock = #{stock} WHERE id = 1;
-- 查是否已下单
SELECT COUNT(*) FROM orders WHERE user_id = #{userId} AND goods_id = #{goodsId};
-- 创建订单
INSERT INTO orders (user_id, goods_id, status) VALUES (#{userId}, #{goodsId}, 0);
这段代码逻辑上完全正确,在单机、低并发环境下没有任何问题。但把它放到 50 万 QPS 的秒杀场景下,会暴露出三个高并发问题。
2.3 高并发问题一:数据库被打爆(流量问题)
这是最紧迫的问题------它决定了系统能不能活下来。
50 万 QPS 全部落到这段代码上,意味着:
- 50 万次查询活动:每次都要访问数据库
- 50 万次查询是否已下单 :每次都要走一遍
orders表的查询(这个表还是超大表) - 50 万次查询库存 + 50 万次更新库存:全部打在同一行上
- 50 万次插入订单:写操作,还要维护索引
而 MySQL 单机通常只能扛几千 QPS。50 万 QPS 打过来,直接被打爆。
更严重的是连锁反应:
#mermaid-svg-AHvNlgmYl5WQHfNL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AHvNlgmYl5WQHfNL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AHvNlgmYl5WQHfNL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AHvNlgmYl5WQHfNL .error-icon{fill:#552222;}#mermaid-svg-AHvNlgmYl5WQHfNL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AHvNlgmYl5WQHfNL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AHvNlgmYl5WQHfNL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AHvNlgmYl5WQHfNL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AHvNlgmYl5WQHfNL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AHvNlgmYl5WQHfNL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AHvNlgmYl5WQHfNL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AHvNlgmYl5WQHfNL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AHvNlgmYl5WQHfNL .marker.cross{stroke:#333333;}#mermaid-svg-AHvNlgmYl5WQHfNL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AHvNlgmYl5WQHfNL p{margin:0;}#mermaid-svg-AHvNlgmYl5WQHfNL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AHvNlgmYl5WQHfNL .cluster-label text{fill:#333;}#mermaid-svg-AHvNlgmYl5WQHfNL .cluster-label span{color:#333;}#mermaid-svg-AHvNlgmYl5WQHfNL .cluster-label span p{background-color:transparent;}#mermaid-svg-AHvNlgmYl5WQHfNL .label text,#mermaid-svg-AHvNlgmYl5WQHfNL span{fill:#333;color:#333;}#mermaid-svg-AHvNlgmYl5WQHfNL .node rect,#mermaid-svg-AHvNlgmYl5WQHfNL .node circle,#mermaid-svg-AHvNlgmYl5WQHfNL .node ellipse,#mermaid-svg-AHvNlgmYl5WQHfNL .node polygon,#mermaid-svg-AHvNlgmYl5WQHfNL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AHvNlgmYl5WQHfNL .rough-node .label text,#mermaid-svg-AHvNlgmYl5WQHfNL .node .label text,#mermaid-svg-AHvNlgmYl5WQHfNL .image-shape .label,#mermaid-svg-AHvNlgmYl5WQHfNL .icon-shape .label{text-anchor:middle;}#mermaid-svg-AHvNlgmYl5WQHfNL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AHvNlgmYl5WQHfNL .rough-node .label,#mermaid-svg-AHvNlgmYl5WQHfNL .node .label,#mermaid-svg-AHvNlgmYl5WQHfNL .image-shape .label,#mermaid-svg-AHvNlgmYl5WQHfNL .icon-shape .label{text-align:center;}#mermaid-svg-AHvNlgmYl5WQHfNL .node.clickable{cursor:pointer;}#mermaid-svg-AHvNlgmYl5WQHfNL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AHvNlgmYl5WQHfNL .arrowheadPath{fill:#333333;}#mermaid-svg-AHvNlgmYl5WQHfNL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AHvNlgmYl5WQHfNL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AHvNlgmYl5WQHfNL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AHvNlgmYl5WQHfNL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AHvNlgmYl5WQHfNL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AHvNlgmYl5WQHfNL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AHvNlgmYl5WQHfNL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AHvNlgmYl5WQHfNL .cluster text{fill:#333;}#mermaid-svg-AHvNlgmYl5WQHfNL .cluster span{color:#333;}#mermaid-svg-AHvNlgmYl5WQHfNL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AHvNlgmYl5WQHfNL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AHvNlgmYl5WQHfNL rect.text{fill:none;stroke-width:0;}#mermaid-svg-AHvNlgmYl5WQHfNL .icon-shape,#mermaid-svg-AHvNlgmYl5WQHfNL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AHvNlgmYl5WQHfNL .icon-shape p,#mermaid-svg-AHvNlgmYl5WQHfNL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AHvNlgmYl5WQHfNL .icon-shape .label rect,#mermaid-svg-AHvNlgmYl5WQHfNL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AHvNlgmYl5WQHfNL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AHvNlgmYl5WQHfNL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AHvNlgmYl5WQHfNL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 50 万 QPS 涌入应用
应用线程池被占满
数据库连接池耗尽
SQL 全部排队等待
接口 RT 从 10ms 涨到 10s
前端大量超时重试
流量进一步放大
数据库 CPU 打满 / 慢查询堆积
其它业务也查不了库
全站雪崩
这就是优先级最高的"雪崩"问题 。注意它的特点:不是因为某段代码写错了,而是因为流量太大,把下游依赖(数据库)压垮了 。所以它的解法不是"改逻辑",而是"削流量"------在请求到达数据库之前,把它挡掉。
2.4 高并发问题二:超卖(数据一致性问题)
即使数据库扛得住,还有第二个问题:超卖。
看第 3、4 步------"查询库存"和"扣减库存"是两条独立的 SQL,中间存在一个时间窗口。两个请求同时到达时:
MySQL 请求B 请求A MySQL 请求B 请求A #mermaid-svg-AqbnII1xbodE9CwN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AqbnII1xbodE9CwN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AqbnII1xbodE9CwN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AqbnII1xbodE9CwN .error-icon{fill:#552222;}#mermaid-svg-AqbnII1xbodE9CwN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AqbnII1xbodE9CwN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AqbnII1xbodE9CwN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AqbnII1xbodE9CwN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AqbnII1xbodE9CwN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AqbnII1xbodE9CwN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AqbnII1xbodE9CwN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AqbnII1xbodE9CwN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AqbnII1xbodE9CwN .marker.cross{stroke:#333333;}#mermaid-svg-AqbnII1xbodE9CwN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AqbnII1xbodE9CwN p{margin:0;}#mermaid-svg-AqbnII1xbodE9CwN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AqbnII1xbodE9CwN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-AqbnII1xbodE9CwN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-AqbnII1xbodE9CwN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-AqbnII1xbodE9CwN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-AqbnII1xbodE9CwN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-AqbnII1xbodE9CwN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-AqbnII1xbodE9CwN .sequenceNumber{fill:white;}#mermaid-svg-AqbnII1xbodE9CwN #sequencenumber{fill:#333;}#mermaid-svg-AqbnII1xbodE9CwN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-AqbnII1xbodE9CwN .messageText{fill:#333;stroke:none;}#mermaid-svg-AqbnII1xbodE9CwN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AqbnII1xbodE9CwN .labelText,#mermaid-svg-AqbnII1xbodE9CwN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-AqbnII1xbodE9CwN .loopText,#mermaid-svg-AqbnII1xbodE9CwN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-AqbnII1xbodE9CwN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-AqbnII1xbodE9CwN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-AqbnII1xbodE9CwN .noteText,#mermaid-svg-AqbnII1xbodE9CwN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-AqbnII1xbodE9CwN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AqbnII1xbodE9CwN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AqbnII1xbodE9CwN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AqbnII1xbodE9CwN .actorPopupMenu{position:absolute;}#mermaid-svg-AqbnII1xbodE9CwN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-AqbnII1xbodE9CwN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AqbnII1xbodE9CwN .actor-man circle,#mermaid-svg-AqbnII1xbodE9CwN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-AqbnII1xbodE9CwN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 库存只剩 1 件 两个请求都判断 1 > 0,都认为自己可以扣 ❌ 最终库存 = -1,卖出了 2 件 1. 查询库存 = 12. 查询库存 = 13. UPDATE stock = stock - 14. UPDATE stock = stock - 1
问题出在第 1、2 步和第 3、4 步之间:"读---判断---写"三步不是原子的 。两个请求都在窗口内读到了同一个库存值 1,都通过了 stock > 0 的判断,于是都执行了扣减。
这是本文反复出现的核心规律:凡是"读---判断---写"不是原子的地方,在并发下就会出错。 后面 Redis 用 Lua、数据库用原子 SQL,本质都是在修复这个问题。
2.5 高并发问题三:一人多单(幂等性问题)
第 2 步的"校验一人一单"同样不可靠------它和创建订单之间也有窗口期。
MySQL 应用 同一用户 MySQL 应用 同一用户 #mermaid-svg-MBr3Cy4blgO9wp6x{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MBr3Cy4blgO9wp6x .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MBr3Cy4blgO9wp6x .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MBr3Cy4blgO9wp6x .error-icon{fill:#552222;}#mermaid-svg-MBr3Cy4blgO9wp6x .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MBr3Cy4blgO9wp6x .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MBr3Cy4blgO9wp6x .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MBr3Cy4blgO9wp6x .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MBr3Cy4blgO9wp6x .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MBr3Cy4blgO9wp6x .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MBr3Cy4blgO9wp6x .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MBr3Cy4blgO9wp6x .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MBr3Cy4blgO9wp6x .marker.cross{stroke:#333333;}#mermaid-svg-MBr3Cy4blgO9wp6x svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MBr3Cy4blgO9wp6x p{margin:0;}#mermaid-svg-MBr3Cy4blgO9wp6x .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-MBr3Cy4blgO9wp6x text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-MBr3Cy4blgO9wp6x .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-MBr3Cy4blgO9wp6x .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-MBr3Cy4blgO9wp6x .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-MBr3Cy4blgO9wp6x .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-MBr3Cy4blgO9wp6x #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-MBr3Cy4blgO9wp6x .sequenceNumber{fill:white;}#mermaid-svg-MBr3Cy4blgO9wp6x #sequencenumber{fill:#333;}#mermaid-svg-MBr3Cy4blgO9wp6x #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-MBr3Cy4blgO9wp6x .messageText{fill:#333;stroke:none;}#mermaid-svg-MBr3Cy4blgO9wp6x .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-MBr3Cy4blgO9wp6x .labelText,#mermaid-svg-MBr3Cy4blgO9wp6x .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-MBr3Cy4blgO9wp6x .loopText,#mermaid-svg-MBr3Cy4blgO9wp6x .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-MBr3Cy4blgO9wp6x .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-MBr3Cy4blgO9wp6x .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-MBr3Cy4blgO9wp6x .noteText,#mermaid-svg-MBr3Cy4blgO9wp6x .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-MBr3Cy4blgO9wp6x .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-MBr3Cy4blgO9wp6x .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-MBr3Cy4blgO9wp6x .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-MBr3Cy4blgO9wp6x .actorPopupMenu{position:absolute;}#mermaid-svg-MBr3Cy4blgO9wp6x .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-MBr3Cy4blgO9wp6x .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-MBr3Cy4blgO9wp6x .actor-man circle,#mermaid-svg-MBr3Cy4blgO9wp6x line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-MBr3Cy4blgO9wp6x :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 两个请求都认为"没下过单" ❌ 同一用户生成了 2 条订单 请求1请求2(几乎同时)请求1:查询是否已下单 → 0 条请求2:查询是否已下单 → 0 条请求1:INSERT 订单请求2:INSERT 订单
用户用脚本刷、或者手速飞快,同时发出多个请求,就能绕过校验,抢到多件。这违反了 F1(一人一单)和 F8(防刷)。
根因和超卖完全一样 :又是"读---判断---写"不原子。所以它们的解法也相通------要么用原子操作 ,要么用唯一约束兜底。
2.6 问题梳理与解决顺序
把三个问题排个序,就得到了整个系列的技术路线:
| 问题 | 类型 | 危害 | 解法方向 | 对应章节 |
|---|---|---|---|---|
| 问题一:数据库被打爆 | 流量 / 可用性 | 全站雪崩 | 削流量:分层拦截 + 异步化 | 本篇第四 ~ 六章 |
| 问题二:超卖 | 数据一致性 | 资损 | 保证原子性:Lua / 原子 SQL / 锁 | 第二篇第一 ~ 二章 |
| 问题三:一人多单 | 幂等性 | 不公平、资损 | 幂等 + 唯一约束 | 第二篇第三章 |
为什么是这个顺序? 因为问题一是"生死问题"------数据库挂了,超卖不超卖都没意义了。所以必须先解决流量,再解决一致性。而问题二和问题三都属于"数据要正确",可以放在一起解决。
拓展点:当"单商品库存很大"或"同时秒杀大量商品"时,需要做库存分片。
小结一下:上面三个问题的解法,都建立在"一个商品、库存只有 100 件"这个前提上。那么如果这个前提被打破了呢?
- 如果单商品的库存很大(比如大促放量 10 万件),那么单个 Redis key、单行库存记录就会成为新的热点,扣减能力跟不上;
- 如果同时秒杀大量商品(比如一次活动开抢几百个商品),那么所有订单都写进同一个库,单库的写入能力和连接数会先撑不住。
这两种情况,都是在基础问题解决完之后才需要继续考虑的拓展场景 ,统一的解法方向是库存分片------把库存拆成多个桶、把不同商品拆到不同库,让热点分散开。这部分放在《秒杀系统设计(三):规模扩展》展开。
三、全局设计:漏斗模型与分层架构
先不要急着看具体方案。这一章先把全局看清楚,给出两样东西:一个是全文的核心认知------漏斗模型;一个是完整链路图与每层职责。
3.1 核心认知:漏斗模型
这是全文最重要的一张图,后面的所有章节都可以回看它:
#mermaid-svg-1AK31XZRmysmumVW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1AK31XZRmysmumVW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1AK31XZRmysmumVW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1AK31XZRmysmumVW .error-icon{fill:#552222;}#mermaid-svg-1AK31XZRmysmumVW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1AK31XZRmysmumVW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1AK31XZRmysmumVW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1AK31XZRmysmumVW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1AK31XZRmysmumVW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1AK31XZRmysmumVW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1AK31XZRmysmumVW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1AK31XZRmysmumVW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1AK31XZRmysmumVW .marker.cross{stroke:#333333;}#mermaid-svg-1AK31XZRmysmumVW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1AK31XZRmysmumVW p{margin:0;}#mermaid-svg-1AK31XZRmysmumVW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1AK31XZRmysmumVW .cluster-label text{fill:#333;}#mermaid-svg-1AK31XZRmysmumVW .cluster-label span{color:#333;}#mermaid-svg-1AK31XZRmysmumVW .cluster-label span p{background-color:transparent;}#mermaid-svg-1AK31XZRmysmumVW .label text,#mermaid-svg-1AK31XZRmysmumVW span{fill:#333;color:#333;}#mermaid-svg-1AK31XZRmysmumVW .node rect,#mermaid-svg-1AK31XZRmysmumVW .node circle,#mermaid-svg-1AK31XZRmysmumVW .node ellipse,#mermaid-svg-1AK31XZRmysmumVW .node polygon,#mermaid-svg-1AK31XZRmysmumVW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1AK31XZRmysmumVW .rough-node .label text,#mermaid-svg-1AK31XZRmysmumVW .node .label text,#mermaid-svg-1AK31XZRmysmumVW .image-shape .label,#mermaid-svg-1AK31XZRmysmumVW .icon-shape .label{text-anchor:middle;}#mermaid-svg-1AK31XZRmysmumVW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1AK31XZRmysmumVW .rough-node .label,#mermaid-svg-1AK31XZRmysmumVW .node .label,#mermaid-svg-1AK31XZRmysmumVW .image-shape .label,#mermaid-svg-1AK31XZRmysmumVW .icon-shape .label{text-align:center;}#mermaid-svg-1AK31XZRmysmumVW .node.clickable{cursor:pointer;}#mermaid-svg-1AK31XZRmysmumVW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1AK31XZRmysmumVW .arrowheadPath{fill:#333333;}#mermaid-svg-1AK31XZRmysmumVW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1AK31XZRmysmumVW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1AK31XZRmysmumVW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1AK31XZRmysmumVW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1AK31XZRmysmumVW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1AK31XZRmysmumVW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1AK31XZRmysmumVW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1AK31XZRmysmumVW .cluster text{fill:#333;}#mermaid-svg-1AK31XZRmysmumVW .cluster span{color:#333;}#mermaid-svg-1AK31XZRmysmumVW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1AK31XZRmysmumVW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1AK31XZRmysmumVW rect.text{fill:none;stroke-width:0;}#mermaid-svg-1AK31XZRmysmumVW .icon-shape,#mermaid-svg-1AK31XZRmysmumVW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1AK31XZRmysmumVW .icon-shape p,#mermaid-svg-1AK31XZRmysmumVW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1AK31XZRmysmumVW .icon-shape .label rect,#mermaid-svg-1AK31XZRmysmumVW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1AK31XZRmysmumVW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1AK31XZRmysmumVW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1AK31XZRmysmumVW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户请求 50 万 QPS
前端:静态化 / 按钮置灰 / 验证码
LVS + Nginx:IP 限流、URL 限流
应用层:令牌桶限流
Redis:原子预减库存(Lua)
MQ:异步削峰
消费者:DB 扣减 + 创建订单
DB:最终只有 100 次写入
沿着这条链路,流量是一层层衰减的:
- 前端:把静态资源甩给 CDN,把无效点击干掉
- 接入层:用 Nginx 限流,从 50 万砍到 20 万
- 应用层:用令牌桶,从 20 万砍到 10 万
- 缓存层 :Redis 里只有 100 个库存,最多只有 100 次扣减能成功
- 存储层:MQ 缓冲后,DB 只需要处理 100 次写入
最终结论 :数据库根本不需要扛 50 万 QPS,它只需要扛 100 次写入。真正需要扛住 50 万的,是最外层的接入能力。
这就是为什么秒杀不靠堆机器:节点数是逐层算出来的结果,不是拍脑袋定的手段。《秒杀系统设计(三):规模扩展》会给出具体算法。
3.2 完整链路与每层职责
看清楚漏斗之后,再给出完整链路图。本篇后四章,以及后面两篇的每一章,都对应这张图里的一个环节。
#mermaid-svg-qMYlt0oVZL8AVIyb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qMYlt0oVZL8AVIyb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qMYlt0oVZL8AVIyb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qMYlt0oVZL8AVIyb .error-icon{fill:#552222;}#mermaid-svg-qMYlt0oVZL8AVIyb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qMYlt0oVZL8AVIyb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qMYlt0oVZL8AVIyb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qMYlt0oVZL8AVIyb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qMYlt0oVZL8AVIyb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qMYlt0oVZL8AVIyb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qMYlt0oVZL8AVIyb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qMYlt0oVZL8AVIyb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qMYlt0oVZL8AVIyb .marker.cross{stroke:#333333;}#mermaid-svg-qMYlt0oVZL8AVIyb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qMYlt0oVZL8AVIyb p{margin:0;}#mermaid-svg-qMYlt0oVZL8AVIyb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qMYlt0oVZL8AVIyb .cluster-label text{fill:#333;}#mermaid-svg-qMYlt0oVZL8AVIyb .cluster-label span{color:#333;}#mermaid-svg-qMYlt0oVZL8AVIyb .cluster-label span p{background-color:transparent;}#mermaid-svg-qMYlt0oVZL8AVIyb .label text,#mermaid-svg-qMYlt0oVZL8AVIyb span{fill:#333;color:#333;}#mermaid-svg-qMYlt0oVZL8AVIyb .node rect,#mermaid-svg-qMYlt0oVZL8AVIyb .node circle,#mermaid-svg-qMYlt0oVZL8AVIyb .node ellipse,#mermaid-svg-qMYlt0oVZL8AVIyb .node polygon,#mermaid-svg-qMYlt0oVZL8AVIyb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qMYlt0oVZL8AVIyb .rough-node .label text,#mermaid-svg-qMYlt0oVZL8AVIyb .node .label text,#mermaid-svg-qMYlt0oVZL8AVIyb .image-shape .label,#mermaid-svg-qMYlt0oVZL8AVIyb .icon-shape .label{text-anchor:middle;}#mermaid-svg-qMYlt0oVZL8AVIyb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qMYlt0oVZL8AVIyb .rough-node .label,#mermaid-svg-qMYlt0oVZL8AVIyb .node .label,#mermaid-svg-qMYlt0oVZL8AVIyb .image-shape .label,#mermaid-svg-qMYlt0oVZL8AVIyb .icon-shape .label{text-align:center;}#mermaid-svg-qMYlt0oVZL8AVIyb .node.clickable{cursor:pointer;}#mermaid-svg-qMYlt0oVZL8AVIyb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qMYlt0oVZL8AVIyb .arrowheadPath{fill:#333333;}#mermaid-svg-qMYlt0oVZL8AVIyb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qMYlt0oVZL8AVIyb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qMYlt0oVZL8AVIyb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qMYlt0oVZL8AVIyb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qMYlt0oVZL8AVIyb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qMYlt0oVZL8AVIyb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qMYlt0oVZL8AVIyb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qMYlt0oVZL8AVIyb .cluster text{fill:#333;}#mermaid-svg-qMYlt0oVZL8AVIyb .cluster span{color:#333;}#mermaid-svg-qMYlt0oVZL8AVIyb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qMYlt0oVZL8AVIyb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qMYlt0oVZL8AVIyb rect.text{fill:none;stroke-width:0;}#mermaid-svg-qMYlt0oVZL8AVIyb .icon-shape,#mermaid-svg-qMYlt0oVZL8AVIyb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qMYlt0oVZL8AVIyb .icon-shape p,#mermaid-svg-qMYlt0oVZL8AVIyb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qMYlt0oVZL8AVIyb .icon-shape .label rect,#mermaid-svg-qMYlt0oVZL8AVIyb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qMYlt0oVZL8AVIyb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qMYlt0oVZL8AVIyb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qMYlt0oVZL8AVIyb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户
DNS 轮询
CDN
静态资源
LVS / F5
四层负载均衡
Nginx / OpenResty
七层 + 限流
应用集群
令牌桶 + 业务
Redis 集群
预减库存
MQ
削峰缓冲
消费者
下单
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 接入层的完整层次图
把上面的推演结果画成一张图:
#mermaid-svg-qThbizrFKOyJNf92{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qThbizrFKOyJNf92 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qThbizrFKOyJNf92 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qThbizrFKOyJNf92 .error-icon{fill:#552222;}#mermaid-svg-qThbizrFKOyJNf92 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qThbizrFKOyJNf92 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qThbizrFKOyJNf92 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qThbizrFKOyJNf92 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qThbizrFKOyJNf92 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qThbizrFKOyJNf92 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qThbizrFKOyJNf92 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qThbizrFKOyJNf92 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qThbizrFKOyJNf92 .marker.cross{stroke:#333333;}#mermaid-svg-qThbizrFKOyJNf92 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qThbizrFKOyJNf92 p{margin:0;}#mermaid-svg-qThbizrFKOyJNf92 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qThbizrFKOyJNf92 .cluster-label text{fill:#333;}#mermaid-svg-qThbizrFKOyJNf92 .cluster-label span{color:#333;}#mermaid-svg-qThbizrFKOyJNf92 .cluster-label span p{background-color:transparent;}#mermaid-svg-qThbizrFKOyJNf92 .label text,#mermaid-svg-qThbizrFKOyJNf92 span{fill:#333;color:#333;}#mermaid-svg-qThbizrFKOyJNf92 .node rect,#mermaid-svg-qThbizrFKOyJNf92 .node circle,#mermaid-svg-qThbizrFKOyJNf92 .node ellipse,#mermaid-svg-qThbizrFKOyJNf92 .node polygon,#mermaid-svg-qThbizrFKOyJNf92 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qThbizrFKOyJNf92 .rough-node .label text,#mermaid-svg-qThbizrFKOyJNf92 .node .label text,#mermaid-svg-qThbizrFKOyJNf92 .image-shape .label,#mermaid-svg-qThbizrFKOyJNf92 .icon-shape .label{text-anchor:middle;}#mermaid-svg-qThbizrFKOyJNf92 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qThbizrFKOyJNf92 .rough-node .label,#mermaid-svg-qThbizrFKOyJNf92 .node .label,#mermaid-svg-qThbizrFKOyJNf92 .image-shape .label,#mermaid-svg-qThbizrFKOyJNf92 .icon-shape .label{text-align:center;}#mermaid-svg-qThbizrFKOyJNf92 .node.clickable{cursor:pointer;}#mermaid-svg-qThbizrFKOyJNf92 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qThbizrFKOyJNf92 .arrowheadPath{fill:#333333;}#mermaid-svg-qThbizrFKOyJNf92 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qThbizrFKOyJNf92 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qThbizrFKOyJNf92 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qThbizrFKOyJNf92 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qThbizrFKOyJNf92 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qThbizrFKOyJNf92 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qThbizrFKOyJNf92 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qThbizrFKOyJNf92 .cluster text{fill:#333;}#mermaid-svg-qThbizrFKOyJNf92 .cluster span{color:#333;}#mermaid-svg-qThbizrFKOyJNf92 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qThbizrFKOyJNf92 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qThbizrFKOyJNf92 rect.text{fill:none;stroke-width:0;}#mermaid-svg-qThbizrFKOyJNf92 .icon-shape,#mermaid-svg-qThbizrFKOyJNf92 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qThbizrFKOyJNf92 .icon-shape p,#mermaid-svg-qThbizrFKOyJNf92 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qThbizrFKOyJNf92 .icon-shape .label rect,#mermaid-svg-qThbizrFKOyJNf92 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qThbizrFKOyJNf92 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qThbizrFKOyJNf92 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qThbizrFKOyJNf92 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户
DNS 轮询
LVS 集群 A
主 + 备(keepalived + VIP)
LVS 集群 B
主 + 备
Nginx 1
Nginx 2
Nginx N
Nginx ...
应用集群
回顾一下这条推演链:
| 层次 | 解决的问题 | 为什么需要它 |
|---|---|---|
| 静态化 / CDN | 卸载静态流量 | 从源头减少请求 |
| 单机 Nginx | 承接动态流量 | 流量小,单机够用 |
| Nginx 集群 | 突破单机能力上限 | 峰值超过单机几万 QPS |
| LVS / F5 | 给 Nginx 集群做负载均衡 | 集群后需要上游分发,而 Nginx 自己性能不够 |
| keepalived + VIP | LVS 高可用 | LVS 成了唯一入口,不能挂 |
| DNS 轮询 | LVS 集群扩容 | 单组 LVS 也扛不住全部流量 |
这就是"架构层次设计"的思维方式:每一层都不是凭空出现的,而是被上一层的问题逼出来的。
4.7 层次六:在 Nginx 上做限流
光有层次还不够,还要主动砍流量。Nginx 提供两个现成的限流模块。
(1)limit_req:基于漏桶算法的速率限流
nginx
http {
# 定义限流区:按 IP 限流,共享内存 10MB,速率 10 次/秒
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
server {
location /seckill/order {
# burst=20:允许突发 20 个请求排队
# nodelay:突发的请求立即处理,不排队延迟
limit_req zone=perip burst=20 nodelay;
limit_req_status 429; # 超限返回 429
proxy_pass http://app_cluster;
}
}
}
rate:平均速率,比如10r/s表示每个 IP 每秒 10 个请求burst:允许的突发队列长度。漏桶是"恒定速率漏水",burst相当于桶的容量nodelay:这 20 个突发请求不排队等待,直接放行;不写则会延迟处理(对秒杀不友好,用户等不起)
(2)limit_conn:并发连接数限流
nginx
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
location /seckill/ {
limit_conn addr 5; # 每个 IP 最多 5 个并发连接
}
}
坑点一:限流拿到的可能不是真实用户 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 就能直接返回"已售罄":
nginx
location /seckill/order {
access_by_lua_block {
local limit = require "resty.limit.req"
-- 用 Redis 做集中式计数,实现集群级限流(解决坑点二)
local lim, err = limit.new("redis_cluster", "seckill_limit", 10000, 5000)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate: ", err)
return ngx.exit(500)
end
local delay, err = lim:incoming(ngx.var.binary_remote_addr, true)
if not delay then
return ngx.exit(429)
end
}
proxy_pass http://app_cluster;
}
对照着看,这段代码正好把两个短板都补上了:计数器放在 Redis 里 (集群共享,不再是单机各自计数),判断逻辑用 Lua 写(不再局限于静态规则)。
坑点 :OpenResty 里做 Redis 调用,一旦 Redis 慢查询或抖动,Nginx 的 worker 会被阻塞,接入层反而成为瓶颈 。必须设置严格的 timeout 和降级策略(Redis 挂了就直接放行到应用层,而非拒绝所有请求)。
4.9 负载均衡算法怎么选
层次定了,接下来是"流量按什么规则分给下游节点"。
- 轮询 / 加权轮询:最简单,适合节点配置一致的场景
- 最少连接:适合请求耗时差异大的场景
- IP Hash / 一致性 Hash :让同一用户固定落到同一节点。秒杀场景下慎用 ------秒杀流量高度集中,一致性 Hash 容易造成热点倾斜,把压力堆到某几个节点上
坑点:健康检查不能只看端口是否存活。
秒杀场景下,应用可能"进程还活着,但线程池已经满了",此时端口探测依然是通的,负载均衡器会认为这个节点健康,继续往里灌流量。必须用业务级健康检查(比如检查线程池水位、接口 RT 指标),才能及时把异常节点摘掉。
五、应用层:令牌桶限流
经过接入层,流量从 50 万降到了 20 万。但 20 万打到应用层依然扛不住,需要第二道闸门:令牌桶。
5.1 令牌桶原理
令牌桶的模型很直观:
- 有一个固定容量的桶,按恒定速率往桶里放令牌(比如每秒放 1000 个)
- 桶满了,新令牌就丢弃(这是和漏桶的关键区别)
- 每个请求到来时,必须先拿到一个令牌才能被处理
- 桶里没令牌了,请求就被拒绝或排队
#mermaid-svg-lZ8FsvYzTl4C5E4d{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lZ8FsvYzTl4C5E4d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lZ8FsvYzTl4C5E4d .error-icon{fill:#552222;}#mermaid-svg-lZ8FsvYzTl4C5E4d .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lZ8FsvYzTl4C5E4d .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lZ8FsvYzTl4C5E4d .marker.cross{stroke:#333333;}#mermaid-svg-lZ8FsvYzTl4C5E4d svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lZ8FsvYzTl4C5E4d p{margin:0;}#mermaid-svg-lZ8FsvYzTl4C5E4d .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lZ8FsvYzTl4C5E4d .cluster-label text{fill:#333;}#mermaid-svg-lZ8FsvYzTl4C5E4d .cluster-label span{color:#333;}#mermaid-svg-lZ8FsvYzTl4C5E4d .cluster-label span p{background-color:transparent;}#mermaid-svg-lZ8FsvYzTl4C5E4d .label text,#mermaid-svg-lZ8FsvYzTl4C5E4d span{fill:#333;color:#333;}#mermaid-svg-lZ8FsvYzTl4C5E4d .node rect,#mermaid-svg-lZ8FsvYzTl4C5E4d .node circle,#mermaid-svg-lZ8FsvYzTl4C5E4d .node ellipse,#mermaid-svg-lZ8FsvYzTl4C5E4d .node polygon,#mermaid-svg-lZ8FsvYzTl4C5E4d .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lZ8FsvYzTl4C5E4d .rough-node .label text,#mermaid-svg-lZ8FsvYzTl4C5E4d .node .label text,#mermaid-svg-lZ8FsvYzTl4C5E4d .image-shape .label,#mermaid-svg-lZ8FsvYzTl4C5E4d .icon-shape .label{text-anchor:middle;}#mermaid-svg-lZ8FsvYzTl4C5E4d .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lZ8FsvYzTl4C5E4d .rough-node .label,#mermaid-svg-lZ8FsvYzTl4C5E4d .node .label,#mermaid-svg-lZ8FsvYzTl4C5E4d .image-shape .label,#mermaid-svg-lZ8FsvYzTl4C5E4d .icon-shape .label{text-align:center;}#mermaid-svg-lZ8FsvYzTl4C5E4d .node.clickable{cursor:pointer;}#mermaid-svg-lZ8FsvYzTl4C5E4d .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lZ8FsvYzTl4C5E4d .arrowheadPath{fill:#333333;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lZ8FsvYzTl4C5E4d .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lZ8FsvYzTl4C5E4d .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lZ8FsvYzTl4C5E4d .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lZ8FsvYzTl4C5E4d .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lZ8FsvYzTl4C5E4d .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lZ8FsvYzTl4C5E4d .cluster text{fill:#333;}#mermaid-svg-lZ8FsvYzTl4C5E4d .cluster span{color:#333;}#mermaid-svg-lZ8FsvYzTl4C5E4d div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lZ8FsvYzTl4C5E4d .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lZ8FsvYzTl4C5E4d rect.text{fill:none;stroke-width:0;}#mermaid-svg-lZ8FsvYzTl4C5E4d .icon-shape,#mermaid-svg-lZ8FsvYzTl4C5E4d .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lZ8FsvYzTl4C5E4d .icon-shape p,#mermaid-svg-lZ8FsvYzTl4C5E4d .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lZ8FsvYzTl4C5E4d .icon-shape .label rect,#mermaid-svg-lZ8FsvYzTl4C5E4d .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lZ8FsvYzTl4C5E4d .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lZ8FsvYzTl4C5E4d .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lZ8FsvYzTl4C5E4d :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 放令牌
消耗令牌
桶满溢出
无令牌
有令牌
令牌生成器
恒定速率 1000/s
令牌桶
容量 2000
请求
丢弃
拒绝 / 排队
放行到业务
两个参数很关键:
- 速率(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
#mermaid-svg-S9Hr4yiBLsqr4xlS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-S9Hr4yiBLsqr4xlS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-S9Hr4yiBLsqr4xlS .error-icon{fill:#552222;}#mermaid-svg-S9Hr4yiBLsqr4xlS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-S9Hr4yiBLsqr4xlS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-S9Hr4yiBLsqr4xlS .marker.cross{stroke:#333333;}#mermaid-svg-S9Hr4yiBLsqr4xlS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-S9Hr4yiBLsqr4xlS p{margin:0;}#mermaid-svg-S9Hr4yiBLsqr4xlS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-S9Hr4yiBLsqr4xlS .cluster-label text{fill:#333;}#mermaid-svg-S9Hr4yiBLsqr4xlS .cluster-label span{color:#333;}#mermaid-svg-S9Hr4yiBLsqr4xlS .cluster-label span p{background-color:transparent;}#mermaid-svg-S9Hr4yiBLsqr4xlS .label text,#mermaid-svg-S9Hr4yiBLsqr4xlS span{fill:#333;color:#333;}#mermaid-svg-S9Hr4yiBLsqr4xlS .node rect,#mermaid-svg-S9Hr4yiBLsqr4xlS .node circle,#mermaid-svg-S9Hr4yiBLsqr4xlS .node ellipse,#mermaid-svg-S9Hr4yiBLsqr4xlS .node polygon,#mermaid-svg-S9Hr4yiBLsqr4xlS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-S9Hr4yiBLsqr4xlS .rough-node .label text,#mermaid-svg-S9Hr4yiBLsqr4xlS .node .label text,#mermaid-svg-S9Hr4yiBLsqr4xlS .image-shape .label,#mermaid-svg-S9Hr4yiBLsqr4xlS .icon-shape .label{text-anchor:middle;}#mermaid-svg-S9Hr4yiBLsqr4xlS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-S9Hr4yiBLsqr4xlS .rough-node .label,#mermaid-svg-S9Hr4yiBLsqr4xlS .node .label,#mermaid-svg-S9Hr4yiBLsqr4xlS .image-shape .label,#mermaid-svg-S9Hr4yiBLsqr4xlS .icon-shape .label{text-align:center;}#mermaid-svg-S9Hr4yiBLsqr4xlS .node.clickable{cursor:pointer;}#mermaid-svg-S9Hr4yiBLsqr4xlS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-S9Hr4yiBLsqr4xlS .arrowheadPath{fill:#333333;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-S9Hr4yiBLsqr4xlS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S9Hr4yiBLsqr4xlS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-S9Hr4yiBLsqr4xlS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S9Hr4yiBLsqr4xlS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-S9Hr4yiBLsqr4xlS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-S9Hr4yiBLsqr4xlS .cluster text{fill:#333;}#mermaid-svg-S9Hr4yiBLsqr4xlS .cluster span{color:#333;}#mermaid-svg-S9Hr4yiBLsqr4xlS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-S9Hr4yiBLsqr4xlS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-S9Hr4yiBLsqr4xlS rect.text{fill:none;stroke-width:0;}#mermaid-svg-S9Hr4yiBLsqr4xlS .icon-shape,#mermaid-svg-S9Hr4yiBLsqr4xlS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S9Hr4yiBLsqr4xlS .icon-shape p,#mermaid-svg-S9Hr4yiBLsqr4xlS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-S9Hr4yiBLsqr4xlS .icon-shape .label rect,#mermaid-svg-S9Hr4yiBLsqr4xlS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S9Hr4yiBLsqr4xlS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-S9Hr4yiBLsqr4xlS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-S9Hr4yiBLsqr4xlS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
请求到来
读取桶状态
tokens / last_time
按时间补令牌
(now - last_time) × rate
令牌数封顶
min(capacity, tokens + 补发量)
tokens >= need ?
tokens = tokens - need
拒绝请求(429)
写回 tokens / last_time
放行到业务
注意第 1 步和第 5 步之间,又是一个"读---判断---写" ------这正是并发下会出问题的地方,所以这五步必须一次性原子执行完,也就是下面这段 Lua 脚本。
第四步:Lua 脚本实现。
核心 Lua 脚本:
lua
-- KEYS[1]: 令牌桶 key
-- ARGV[1]: 桶容量 capacity
-- ARGV[2]: 令牌生成速率 rate(个/秒)
-- ARGV[3]: 当前时间戳(秒,含小数)
-- ARGV[4]: 本次请求需要的令牌数(一般为 1)
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local need = tonumber(ARGV[4])
-- 读取桶的当前状态:tokens 令牌数,last_time 上次刷新时间
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_time')
local tokens = tonumber(bucket[1]) or capacity
local last_time = tonumber(bucket[2]) or now
-- 按时间差补充令牌
local delta = math.max(0, now - last_time)
local filled = delta * rate
tokens = math.min(capacity, tokens + filled) -- 桶满则丢弃多余令牌
-- 判断是否够用
local allowed = tokens >= need
if allowed then
tokens = tokens - need
end
-- 写回状态,并设置过期时间(避免冷 key 常驻内存)
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) * 2)
if allowed then
return 1
end
return 0
Java 侧调用:
java
public boolean tryAcquire(String key, int capacity, int rate, int need) {
String script =
"local capacity = tonumber(ARGV[1]) " +
"local rate = tonumber(ARGV[2]) " +
"local now = tonumber(ARGV[3]) " +
"local need = tonumber(ARGV[4]) " +
"local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_time') " +
"local tokens = tonumber(bucket[1]) or capacity " +
"local last_time = tonumber(bucket[2]) or now " +
"local delta = math.max(0, now - last_time) " +
"tokens = math.min(capacity, tokens + delta * rate) " +
"local allowed = tokens >= need " +
"if allowed then tokens = tokens - need end " +
"redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_time', now) " +
"redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) * 2) " +
"if allowed then return 1 end " +
"return 0";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key),
String.valueOf(capacity),
String.valueOf(rate),
String.valueOf(System.currentTimeMillis() / 1000.0),
String.valueOf(need)
);
return Long.valueOf(1L).equals(result);
}
5.4 为什么必须用 Lua
这是理解 Redis 限流的关键一步。
如果不用 Lua,把"读令牌数 → 算补充 → 判断 → 写回"分成四条命令:
java
Integer tokens = redis.get("tokens"); // 1. 读
// ... 应用层计算补充和判断 ...
redis.set("tokens", tokens - 1); // 4. 写回
在 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 串起来,用短路与连接:
java
// 多维度限流:任意一维超限即拒绝
public boolean multiDimensionLimit(Long userId, Long goodsId, String ip) {
return tryAcquire("limit:api:seckill", 10000, 5000, 1) // 接口全局:5000/s
&& tryAcquire("limit:goods:" + goodsId, 2000, 1000, 1) // 单商品:1000/s
&& tryAcquire("limit:ip:" + ip, 20, 10, 1) // 单 IP:10/s
&& tryAcquire("limit:user:" + userId, 5, 2, 1); // 单用户:2/s
}
各维度的阈值怎么定? 思路是从下游能力倒推,再按"单个用户/单个 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 完整流程
#mermaid-svg-RfOCsfBosdXBJQ4I{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RfOCsfBosdXBJQ4I .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RfOCsfBosdXBJQ4I .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RfOCsfBosdXBJQ4I .error-icon{fill:#552222;}#mermaid-svg-RfOCsfBosdXBJQ4I .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RfOCsfBosdXBJQ4I .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RfOCsfBosdXBJQ4I .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RfOCsfBosdXBJQ4I .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RfOCsfBosdXBJQ4I .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RfOCsfBosdXBJQ4I .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RfOCsfBosdXBJQ4I .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RfOCsfBosdXBJQ4I .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RfOCsfBosdXBJQ4I .marker.cross{stroke:#333333;}#mermaid-svg-RfOCsfBosdXBJQ4I svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RfOCsfBosdXBJQ4I p{margin:0;}#mermaid-svg-RfOCsfBosdXBJQ4I .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RfOCsfBosdXBJQ4I .cluster-label text{fill:#333;}#mermaid-svg-RfOCsfBosdXBJQ4I .cluster-label span{color:#333;}#mermaid-svg-RfOCsfBosdXBJQ4I .cluster-label span p{background-color:transparent;}#mermaid-svg-RfOCsfBosdXBJQ4I .label text,#mermaid-svg-RfOCsfBosdXBJQ4I span{fill:#333;color:#333;}#mermaid-svg-RfOCsfBosdXBJQ4I .node rect,#mermaid-svg-RfOCsfBosdXBJQ4I .node circle,#mermaid-svg-RfOCsfBosdXBJQ4I .node ellipse,#mermaid-svg-RfOCsfBosdXBJQ4I .node polygon,#mermaid-svg-RfOCsfBosdXBJQ4I .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RfOCsfBosdXBJQ4I .rough-node .label text,#mermaid-svg-RfOCsfBosdXBJQ4I .node .label text,#mermaid-svg-RfOCsfBosdXBJQ4I .image-shape .label,#mermaid-svg-RfOCsfBosdXBJQ4I .icon-shape .label{text-anchor:middle;}#mermaid-svg-RfOCsfBosdXBJQ4I .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RfOCsfBosdXBJQ4I .rough-node .label,#mermaid-svg-RfOCsfBosdXBJQ4I .node .label,#mermaid-svg-RfOCsfBosdXBJQ4I .image-shape .label,#mermaid-svg-RfOCsfBosdXBJQ4I .icon-shape .label{text-align:center;}#mermaid-svg-RfOCsfBosdXBJQ4I .node.clickable{cursor:pointer;}#mermaid-svg-RfOCsfBosdXBJQ4I .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RfOCsfBosdXBJQ4I .arrowheadPath{fill:#333333;}#mermaid-svg-RfOCsfBosdXBJQ4I .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RfOCsfBosdXBJQ4I .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RfOCsfBosdXBJQ4I .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RfOCsfBosdXBJQ4I .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RfOCsfBosdXBJQ4I .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RfOCsfBosdXBJQ4I .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RfOCsfBosdXBJQ4I .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RfOCsfBosdXBJQ4I .cluster text{fill:#333;}#mermaid-svg-RfOCsfBosdXBJQ4I .cluster span{color:#333;}#mermaid-svg-RfOCsfBosdXBJQ4I div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RfOCsfBosdXBJQ4I .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RfOCsfBosdXBJQ4I rect.text{fill:none;stroke-width:0;}#mermaid-svg-RfOCsfBosdXBJQ4I .icon-shape,#mermaid-svg-RfOCsfBosdXBJQ4I .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RfOCsfBosdXBJQ4I .icon-shape p,#mermaid-svg-RfOCsfBosdXBJQ4I .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RfOCsfBosdXBJQ4I .icon-shape .label rect,#mermaid-svg-RfOCsfBosdXBJQ4I .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RfOCsfBosdXBJQ4I .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RfOCsfBosdXBJQ4I .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RfOCsfBosdXBJQ4I :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
key 不存在
stock <= 0
扣减成功
否
是
活动开始前:库存预热
Redis 里 stock = 100
用户发起秒杀请求
令牌桶限流通过?
拒绝:活动太火爆
Redis 执行 Lua
原子扣减库存
扣减结果
拒绝:活动未开始
并触发预热告警
拒绝:已售罄
发送下单消息到 MQ
消息发送成功?
回补 Redis 库存
返回:系统繁忙
立即返回:排队中
MQ 投递消息
消费者:扣 DB 库存 + 创建订单
用户轮询查询结果
返回:下单成功
这条流程里藏着两个关键转变:
- 数据库的访问量从 10 万降到 100------因为 Redis 只放行 100 个
- 写库从同步变异步------请求立即返回"排队中",RT 从几百毫秒降到几毫秒
第 2 点尤其重要:它把"用户等待时间"和"数据库处理时间"解耦了。用户不再需要等订单真的创建完,接口可以极快地返回,吞吐能力随之提升(回忆 1.3 提到的利特尔法则)。
6.3 库存预热
活动开始前,把库存从 DB 加载到 Redis:
java
// 活动开始前 10 分钟执行
public void warmUp(Long goodsId) {
Integer stock = goodsMapper.selectStock(goodsId);
// key 命名统一,便于管理和监控
redisTemplate.opsForValue().set("seckill:stock:" + goodsId, stock);
}
坑点一:预热和开始之间要有时间缓冲。
如果预热失败(Redis 抖动、网络超时),活动开始后所有请求都会走到"Redis 里没有 key"的分支。必须做预热校验:定时任务检查 key 是否存在,不存在则重试;活动开始时做一次最终确认。
坑点二:预热后 DB 和 Redis 的库存必须"冻结"。
预热之后到活动结束前,任何人都不能直接改 DB 库存,否则两边数据不一致。
6.4 发送 MQ 与失败回补
Redis 扣减成功后,进入发消息环节:
java
public SeckillResult seckill(Long userId, Long goodsId) {
// 1. 校验活动状态(略)
// 2. 幂等校验(见第二篇)
// 3. Redis 原子扣减库存(见第二篇)
Long remain = deductStock(goodsId);
if (remain == -1) {
return SeckillResult.fail("已售罄");
}
if (remain == -2) {
return SeckillResult.fail("活动未开始");
}
// 4. 扣减成功,发消息异步下单
try {
mqProducer.send(new SeckillMessage(userId, goodsId), goodsId);
} catch (Exception e) {
// 发送失败必须回补库存,否则会少卖
redisTemplate.opsForValue().increment("seckill:stock:" + goodsId);
return SeckillResult.fail("系统繁忙,请重试");
}
// 5. 立即返回"排队中",前端轮询结果
return SeckillResult.queueing();
}
坑点一:发 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 万个并发,而是面对一个队列。消费者按数据库能承受的速度去取:
消费者并发度 = 20 个线程
单条消息处理耗时 = 10 ms
→ 消费速率 ≈ 20 ÷ 0.01s = 2000 QPS
这 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 侧的原子扣减,到数据库侧的并发控制与分布式锁,再到一人一单与幂等设计。