秒杀系统设计(一):需求拆解与流量治理

【系列说明】 本文是《秒杀系统设计》系列的第 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 里的需求编号。同一个问题(比如超卖)在"需求"和"难点"里各出现一次,是刻意的映射,不是写重了。

优先级排序是:雪崩 > 超卖 > 少卖 > 刷单。

这个排序很重要,它直接决定了后面的解决顺序:

  1. 雪崩(流量问题)必须最先解决------它是"能不能活"的问题。数据库被打爆,系统直接就没了,这时候讨论超卖不超卖毫无意义。所以本篇第四 ~ 六章先讲怎么削流量。
  2. 其次是超卖------它是"数据正不正确"的问题。系统先活下来,才有资格谈数据正确性,对应第二篇的 Redis 侧与数据库侧。
  3. 最后是少卖和刷单------属于体验与公平性问题,放在第二篇一起处理。

本篇第三章之后,正是严格按这个优先级展开的。


二、朴素实现暴露的三个问题

有了需求,接下来看最直接的实现。先不要急着优化,而是把"正常人会怎么写"写出来,然后逐一暴露它在高并发下的问题。这些问题就是后面每一章要攻克的靶子。

2.1 先描述一下业务逻辑

抛开所有分布式、高并发的东西,秒杀下单这件事本身的业务逻辑其实很朴素。用大白话讲一遍:

  1. 用户点击"立即抢购",请求打到下单接口,带上用户 ID 和商品 ID
  2. 校验活动状态:活动是否已开始、是否已结束?没开始或已结束都直接拒绝
  3. 校验购买资格(一人一单):这个用户是不是已经抢过了?抢过就直接拒绝
  4. 查询库存:读取当前商品的剩余库存
  5. 判断库存:如果库存小于等于 0,说明已售罄,直接拒绝
  6. 扣减库存:库存减 1
  7. 创建订单:生成一条待支付订单,记录用户和商品
  8. 返回结果:告诉用户"抢购成功,请尽快支付"

再配上"用户支付 / 超时未支付"的后续动作,完整流程如下:
#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 原生限流的两个根本短板:

  1. 计数是单机的------集群下无法做全局精确限流;
  2. 规则是静态的 ------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:上一次刷新令牌的时间

这样无论请求落到哪台机器上,操作的都是同一个桶。

第三步:每次请求到底怎么算?

令牌桶不能"真的"每秒往桶里丢令牌(那得跑一个定时任务),而是惰性计算------请求来了才根据"距离上次请求过了多久"补发令牌:

  1. 读状态 :取出桶里的 tokens 和 last_time
  2. 按时间补令牌 :(当前时间 - last_time) × 速率,就是这段时间该补的令牌数
  3. 封顶:补完之后如果超过桶容量,砍到容量上限(多出来的直接丢弃)
  4. 判断:令牌数 ≥ 本次需求(一般是 1)就放行,否则拒绝
  5. 扣减并写回 :够的话扣掉令牌,把新的 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 下游整体不被压垮 拦不住"是谁在刷",只保证总量

单用任何一个维度都有漏洞,所以实际做法是"多个维度叠加,各限各的量"。

具体怎么落地?以一次秒杀请求为例,按"由粗到细"的顺序依次判断,任意一层拒绝就立即返回:

  1. 接口全局维度(粗粒度,先兜底)------保证打到下游的总量不超过系统容量,这是"保命"的一层
  2. 商品维度------限制同一商品被访问的总速率
  3. IP 维度------限制单个 IP 的请求频率
  4. 用户维度(最细)------限制单个用户的请求频率

代码上就是把前面写好的 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 库存 + 创建订单
用户轮询查询结果
返回:下单成功

这条流程里藏着两个关键转变:

  1. 数据库的访问量从 10 万降到 100------因为 Redis 只放行 100 个
  2. 写库从同步变异步------请求立即返回"排队中",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. 削写入峰值:把"瞬间 1 万个并发写"变成"消费者按数据库能力匀速消费",保护数据库不被打爆;
  2. 削接口耗时:接口不再同步等待下单完成,RT 大幅下降,用户不用卡在转圈上。

但要注意------MQ 不减少总工作量 ,它只是把工作从"瞬时"摊到"一段时间" 。如果消费者的总处理能力小于消息产生速度,消息会不断堆积,最终 MQ 也会被打满。所以消费者并发度必须按数据库的实际能力来配,并纳入《秒杀系统设计(三)》的容量校验。

MQ 实际需要扛多少 QPS?

MQ 位于 Redis 之后,它收到的消息量等于"Redis 实际放行成功的请求数",也就是成功扣减掉的库存量,而不是入口流量:

场景 入口峰值 QPS Redis 放行(= MQ 消息量)
库存 100 件 50 万 100 条
放量 1 万件 50 万 1 万条
几十个商品各放量几万件(罕见) 50 万 累计才可能到十万级

MQ 的量级由"库存规模"决定,不由"入口流量"决定。 想让 MQ 真的达到十万级 QPS,只有"同时秒杀大量商品"这一种可能------几十个商品各放量几万件,加起来才够,而这种业务场景本身就很难组织。

所以正常情况下 MQ 不是瓶颈,真正的瓶颈永远在最外层(Nginx、应用层)。 但仍要注意两点:

  1. 瞬时集中:消息可能在一瞬间全部涌入,MQ 需要有基本的缓冲能力(这个量级对任何消息队列都很轻松);
  2. 异常放大 :如果 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 侧的原子扣减,到数据库侧的并发控制与分布式锁,再到一人一单与幂等设计。

相关推荐
lusklusklusk1 小时前
Oracle数据库基础之8_闪回
数据库·oracle
卓怡学长1 小时前
w195基于ssm“Fitfrend”个性化健身
java·intellij-idea
喵了几个咪1 小时前
Go 写业务,Rust 扛底盘:一套可落地的混合架构
微服务·架构·golang·rust·多租户·gowind·rushwind
沫璃染墨1 小时前
《从零入门Linux系统篇(五十三):线程篇·六——线程互斥详解:从并发问题到mutex互斥锁》
linux·运维·服务器·开发语言·后端·架构·系统架构
lusklusklusk1 小时前
Oracle数据库基础之10_性能优化
数据库·oracle·性能优化
Omics Pro1 小时前
预测性虚拟细胞中显式机理算子
大数据·数据库·人工智能·python·算法·机器学习·自然语言处理
看浪的路人1 小时前
第5讲:Prompt 管理与版本追踪
大数据·数据库·elasticsearch
万联WANFLOW1 小时前
从工具协同到智能体驱动:Meta Muse 小型企业版的架构演进与行业启示
网络·架构·业界资讯
驭渊的小故事1 小时前
算法实战:滑动窗口、素数枚举与字符串模拟(三道经典题解)
java·算法