一波流量把服务冲垮:一次接口限流改造的复盘

对外接口被一波突发流量冲垮,明明"有限流"却没拦住——固定窗口在窗口切换的临界点放进了 2 倍阈值的流量。几天重做限流体系:固定窗口临界缺陷、滑动窗口、漏桶与令牌桶、单机与分布式限流、Sentinel 调用方隔离与熔断降级。

2024 年我们一个对外开放的查询接口被一波突发流量冲垮了。事后看流量曲线,平时这个接口每秒几百次调用,那天某个合作方的程序出了 bug,在一分钟内疯狂重试,把 QPS 顶到了平时的几十倍。我们当时其实"有"限流——用的是一个最朴素的"每分钟计数"的固定窗口限流,可它不仅没拦住这波流量,反而在窗口切换的临界点上,放进来的瞬时流量比设定的阈值还高一倍。这件事逼着我们把"限流"这件事认真做了一遍:固定窗口为什么不靠谱、滑动窗口、漏桶、令牌桶分别解决什么问题、单机限流和分布式限流的区别。投了几天把限流体系重做了一遍,本文复盘这次实战。

问题背景

业务:对外开放查询接口,正常 QPS 几百,有简单限流
事故现象:
- 合作方程序 bug,一分钟内疯狂重试,QPS 冲到平时几十倍
- 接口"有限流"却没拦住,服务被打到 CPU 满载、大量超时
- 复盘发现:限流阈值是 1000/分钟,但实测瞬时放进了近 2000

现场排查:
# 1. 看现有的限流代码
public boolean tryAcquire(String api) {
    String key = "limit:" + api + ":" + currentMinute();  // 按分钟
    Long count = redis.increment(key);
    if (count == 1) redis.expire(key, 60);
    return count <= 1000;       // 每分钟最多 1000 次
}

# 2. 临界点问题暴露:
# 12:00:59 涌入 1000 次请求 -> 刚好不超阈值,全放行
# 12:01:00 窗口切换,计数清零
# 12:01:01 又涌入 1000 次 -> 又全放行
# -> 在 12:00:59 ~ 12:01:01 这 2 秒里,实际放进了 2000 次

根因:
1. 用的是"固定窗口"限流,有天然的临界点缺陷
2. 窗口切换瞬间计数清零,相邻两窗口的流量可以叠加
3. 限流只控了"一分钟的总量",完全没控"瞬时速率"
4. 没有针对单个调用方的限流,一个合作方就能打垮整个接口

修复 1:固定窗口——最简单,但有临界缺陷

// === 固定窗口计数法 ===
// 把时间切成固定长度的窗口(如每分钟),
// 每个窗口内计数,超过阈值就拒绝,窗口结束计数清零。
public boolean fixedWindow(String api) {
    long window = System.currentTimeMillis() / 60000;  // 当前分钟窗口
    String key = "limit:" + api + ":" + window;
    Long count = redis.increment(key);
    if (count == 1) redis.expire(key, 60);
    return count <= 1000;
}

// === 它的致命缺陷:临界点问题 ===
// 假设阈值是 1000/分钟。考虑这样一个序列:
// 第 1 分钟的最后 1 秒:涌入 1000 个请求 -> 全部放行
// 第 2 分钟的最前 1 秒:计数清零,又涌入 1000 个 -> 又全放行
// 结果:在跨越窗口边界的【2 秒】内,实际放行了 2000 个请求,
//       是设定速率的【2 倍】!
// 固定窗口只保证"每个完整窗口内不超量",
// 但它管不住"跨窗口边界的那一小段"。

// === 适用与不适用 ===
// 适用:对精度要求不高、能接受临界抖动的粗粒度限流
// 不适用:对瞬时流量敏感的场景(我们的事故正是栽在这)
// 优点:实现极简、内存占用小
// 缺点:临界点流量可达 2 倍阈值

修复 2:滑动窗口——解决临界点问题

// === 滑动窗口的思路 ===
// 固定窗口的问题在于"窗口是跳变的"。
// 滑动窗口让"窗口"跟着当前时刻平滑移动:
// 任何时刻,都只统计"从现在往前推一个窗口长度"内的请求数。
// 这样就没有"边界清零"这回事了,临界点问题自然消失。

// === 用 Redis 的 ZSet 实现滑动窗口 ===
// member = 请求的唯一标识,score = 请求的时间戳(毫秒)
// 用一段 Lua 脚本保证"清理 + 计数 + 写入"的原子性。
private static final String SLIDING_LUA =
    "local key = KEYS[1] " +
    "local now = tonumber(ARGV[1]) " +
    "local window = tonumber(ARGV[2]) " +   // 窗口长度(毫秒)
    "local limit = tonumber(ARGV[3]) " +
    // 1. 移除窗口之外的旧请求记录
    "redis.call('ZREMRANGEBYSCORE', key, 0, now - window) " +
    // 2. 统计当前窗口内还剩多少个请求
    "local count = redis.call('ZCARD', key) " +
    "if count < limit then " +
    // 3. 没超限:把本次请求记进去
    "  redis.call('ZADD', key, now, ARGV[4]) " +
    "  redis.call('PEXPIRE', key, window) " +
    "  return 1 " +
    "else return 0 end";

public boolean slidingWindow(String api) {
    long now = System.currentTimeMillis();
    Long ok = redis.execute(new DefaultRedisScript<>(SLIDING_LUA, Long.class),
        Collections.singletonList("limit:slide:" + api),
        String.valueOf(now), "60000", "1000", UUID.randomUUID().toString());
    return ok != null && ok == 1;
}

// === 滑动窗口的特点 ===
// 优点:消除了固定窗口的临界点问题,限流更精确平滑
// 代价:要存下窗口内每个请求的记录,内存占用比固定窗口大
// 窗口切得越细,越接近"真实的瞬时速率",但成本也越高

修复 3:漏桶算法——强制匀速

=== 漏桶算法(Leaky Bucket)的形象理解 ===
想象一个底部有个小孔的桶:
- 请求,就是往桶里【倒水】
- 桶底的小孔,以【恒定速率】往外漏水(漏水 = 处理请求)
- 桶有容量上限,水倒得太快、桶满了,
  再倒进来的水就【溢出】(请求被拒绝)

=== 漏桶的核心特性:输出速率恒定 ===
不管请求来得多猛、多不规律,
漏桶"漏出去"的速率永远是平稳的、固定的。
它把"忽高忽低的入口流量",整形成了"匀速的出口流量"。

=== 漏桶适合什么 ===
适合"需要保护下游、要求请求绝对匀速"的场景。
比如下游是一个脆弱的、只能承受固定速率的老系统,
用漏桶做整形,能保证打给它的流量永远平稳。

=== 漏桶的局限 ===
它【不允许突发流量】。
即使系统当前很空闲、明明有能力一下子多处理一些,
漏桶也只会按那个恒定速率慢慢漏 ——
这在很多场景下是一种浪费:系统有余力却不让用。
这个局限,正是令牌桶要去改进的地方。

修复 4:令牌桶算法——允许一定突发

// === 令牌桶(Token Bucket)的思路 ===
// 反过来想:
// - 有一个桶,系统以【恒定速率】往桶里【放令牌】
// - 每个请求来了,要先从桶里【拿走一个令牌】才能通过
// - 桶里没令牌了,请求就被限流
// - 桶有容量上限,令牌放满了就不再放

// === 它和漏桶最大的区别:允许突发 ===
// 系统空闲一段时间后,桶里会【攒下一批令牌】。
// 这时如果突然来一波流量,它们可以一下子取走这批攒下的令牌,
// 快速通过 —— 这就是"允许一定程度的突发"。
// 突发的上限,就是桶的容量。
// 突发过后,又回到"按放令牌的速率"稳定限流。

// === 用 Guava 的 RateLimiter(令牌桶实现)做单机限流 ===
// 创建一个每秒放 1000 个令牌的限流器
private final RateLimiter rateLimiter = RateLimiter.create(1000.0);

public boolean tryAcquire() {
    // 尝试拿一个令牌,拿不到立即返回 false(不阻塞)
    return rateLimiter.tryAcquire();
}
// 也可以带超时:最多等 100ms 来拿令牌
public boolean tryAcquireWithWait() {
    return rateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS);
}

// === 漏桶 vs 令牌桶,怎么选 ===
// 漏桶:出口绝对匀速,不容突发 —— 适合"保护脆弱下游"
// 令牌桶:平均速率受控,但容许短时突发 —— 适合"应对真实流量
//        (真实流量本就是有波峰的)、又想限住平均速率"
// 大多数面向用户的接口限流,选【令牌桶】更合理:
// 它既守住了平均速率,又不会把正常的小波峰也误杀。

修复 5:单机限流 vs 分布式限流

// === 一个关键问题:Guava RateLimiter 是【单机】的 ===
// RateLimiter.create(1000) 限的是【这一个 JVM 进程】1000/s。
// 如果服务部署了 10 个实例,每个实例都限 1000/s,
// 整个集群实际上放行了 10000/s —— 阈值被悄悄放大了 10 倍。

// === 单机限流:够用的场景 ===
// 目的是"保护单个实例不被自己这台机器上的流量打垮"时,
// 单机限流就够了,简单、无网络开销、不依赖外部组件。
// 阈值要按"单实例能力 / 实例数"来算。

// === 分布式限流:控制整个集群总量 ===
// 当你要限的是"整个服务对外的总 QPS"(比如对外开放的
// 配额、对下游的总调用量),就必须用分布式限流 ——
// 让所有实例共享【同一个】计数器。
// Redis + Lua 是最常见的实现(前面滑动窗口那段就是)。

// === 分布式限流的代价 ===
// 1. 每次限流判断都要访问一次 Redis -> 有网络开销和延迟
// 2. 强依赖 Redis -> Redis 挂了,限流逻辑要有降级:
//    是"放行所有"(fail-open)还是"拒绝所有"(fail-close)?
//    一般选 fail-open:限流组件挂了,不该把业务也带挂,
//    但要配合其他保护(如单机限流兜底)。

// === 实战中的组合:两层限流 ===
// 第 1 层:分布式限流,控住集群对外的总量/配额
// 第 2 层:单机限流,每个实例再兜一道,
//          防止流量分布不均时,个别实例被局部热点打垮
// 两层各司其职,比只用一层更稳。

修复 6:用 Sentinel 做生产级限流

// === 自己写限流够用吗 ===
// 上面的算法是为了讲清原理。但生产环境真正要的,
// 远不止"算法"本身,还包括:
// - 规则能动态配置(不重启就能改阈值)
// - 多维度限流(按接口、按调用方、按参数)
// - 限流之外还要有熔断、降级、热点参数防护
// - 实时的监控大盘
// 这些自己全做,成本很高。成熟方案如 Sentinel 已经做好了。

// === Sentinel:定义资源 + 配置规则 ===
@SentinelResource(value = "queryOrder",
                  blockHandler = "handleBlock")
public Order queryOrder(Long id) {
    return orderMapper.selectById(id);
}
// 被限流 / 熔断时,自动走这个降级方法
public Order handleBlock(Long id, BlockException ex) {
    log.warn("queryOrder 被限流, id={}", id);
    return Order.unavailable();   // 返回兜底结果,而不是报错
}

// === 配置一条限流规则(也可在控制台动态配)===
FlowRule rule = new FlowRule();
rule.setResource("queryOrder");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);  // 按 QPS 限流
rule.setCount(1000);                          // 阈值 1000 QPS
// 流控效果:快速失败 / Warm Up(冷启动预热)/ 排队等待
rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER);
FlowRuleManager.loadRules(Collections.singletonList(rule));

// === 针对调用方限流(我们事故的关键)===
// 我们这次被打垮,根因之一是"一个合作方就能占满整个接口"。
// Sentinel 支持按来源(origin)限流:
// 给每个合作方分配独立的配额,A 方把自己的配额打满,
// 只会限到 A 方自己,不影响 B、C 方 —— 这就是
// "调用方隔离",是对外接口必须要做的。

优化效果

指标                      治理前              治理后
=============================================================
限流算法                  固定窗口             滑动窗口 + 令牌桶
临界点流量                可达阈值的 2 倍       平滑,稳定在阈值附近
限流维度                  仅接口总量           接口 + 调用方多维度
调用方隔离                无,一方可占满接口   按来源分配独立配额
限流范围                  单机各算各的         分布式总量 + 单机兜底
限流规则调整              改代码重新发布       控制台动态配置
被限流后的行为            直接报错             降级返回兜底结果
熔断降级                  无                   Sentinel 熔断 + 降级
限流可观测                无                   实时监控大盘

治理过程:
- 定位固定窗口临界点缺陷:0.5 天
- 滑动窗口 / 令牌桶限流改造:1.5 天
- 分布式限流 + 单机兜底两层:1 天
- 接入 Sentinel,配调用方隔离规则:1.5 天
- 监控大盘 + 压测验证:0.5 天

避坑清单

  1. 固定窗口限流有临界点缺陷,跨窗口边界的瞬时流量最高可达阈值的 2 倍
  2. 滑动窗口让统计区间随当前时刻平滑移动,消除了固定窗口的临界点问题
  3. 滑动窗口要存窗口内每个请求记录,内存占用比固定窗口大,窗口越细成本越高
  4. 漏桶强制输出匀速、不容突发,适合保护只能承受固定速率的脆弱下游
  5. 令牌桶限平均速率但允许一定突发,适合面向用户的接口,不会误杀正常小波峰
  6. Guava RateLimiter 是单机限流,集群部署时实际阈值会被实例数放大
  7. 要控集群对外总量必须用分布式限流,让所有实例共享同一个计数器(Redis+Lua)
  8. 分布式限流强依赖 Redis,要设计降级,一般 fail-open 并用单机限流兜底
  9. 对外接口必须做调用方隔离,给每个调用方独立配额,避免一方占满拖垮所有人
  10. 生产级限流用 Sentinel 等成熟方案,要的是动态规则、多维限流、熔断降级与监控

总结

这次限流的事故,给我上的第一课是:"有限流"和"限流有用"是两回事。出事之前,如果有人问我们这个对外接口有没有限流,我们会很有底气地回答"有",代码里确实白纸黑字写着一个每分钟最多放行多少次的判断。可正是这个让我们有安全感的限流,在真正的流量冲击面前几乎没起作用,甚至帮了倒忙。原因就是我们用的那个最朴素的"固定窗口"算法。固定窗口的逻辑非常直观:把时间切成一分钟一分钟的格子,每个格子里数数,数满了就拒绝,到下一分钟格子清零重新数。它的问题不在于"数错了",而在于它只承诺了一件事——每一个完整的、对齐的一分钟之内,放行的总数不超过阈值;但它对"跨越格子边界的那一小段时间"完全失控。设想流量恰好卡在格子的交界处:这一分钟的最后一秒涌进来一批请求,刚好不超阈值,全部放行;时钟一跳进下一分钟,计数瞬间清零,紧接着的第一秒又涌进来同样多的一批,因为是新格子,又全部放行。于是在这短短两秒钟的真实时间里,放进系统的请求量是阈值的整整两倍。我们设的阈值,本意是给系统的瞬时承压能力留好余量的,可固定窗口的这个临界点缺陷,悄悄地把这个余量吃掉了一半。理解了这个缺陷,后面那几个限流算法的存在意义就都清晰了——它们本质上都是在固定窗口的不足之上,各自往前走了一步。滑动窗口针对的就是"临界点"这个病:它不再用一格一格跳变的格子,而是让统计的区间像一把尺子一样,跟着当前时刻连续地、平滑地往前滑,任何一个时刻,它统计的都是"从此刻往前回溯一整个窗口长度"内的请求数,既然窗口不再跳变、不再有"清零"这个动作,临界叠加的问题也就从根上消失了。而漏桶和令牌桶,则是从另一个维度去思考问题——它们关心的不只是"一段时间内的总量",更是"流量的形状"。漏桶的思路是把忽高忽低的入口流量,强行整形成一条恒定平直的出口流量,它适合用来保护那些脆弱的、只能匀速接收请求的下游系统。令牌桶则更聪明也更贴近现实一点:它承认真实世界的流量天然就是有波峰波谷的,所以它在守住"平均速率"的前提下,允许系统在空闲时把令牌攒下来、在小波峰来临时把这批存货放出去,从而容忍一定程度的突发,不会把正常业务的小高峰也当成攻击误杀掉。对我们这种面向用户的接口来说,令牌桶显然是更合身的选择。除了算法本身,这次复盘还纠正了我两个认知盲区。一个是限流的"作用范围":我们一直用的 Guava RateLimiter 限的其实只是单个 JVM 进程,而我们的服务有十个实例,十个实例各限各的,集群对外的真实总量是阈值的十倍——要真正控制住对外开放的总配额,必须用 Redis 这样的共享存储做分布式限流,让所有实例去抢同一个计数器。另一个、也是最关键的一个,是限流的"维度":我们这次被打垮,直接导火索是某一个合作方的程序出了 bug,可一个调用方的异常,凭什么能把整个接口、把所有其他正常合作方的服务全部拖垮?这说明我们的限流只有"接口总量"这一个维度,是远远不够的,必须做到"调用方隔离"——给每一个调用方分配它自己独立的配额,某一方把自己的配额打满,限流就只会限到它自己头上,其余调用方安然无恙。这次治理之后,我对限流的理解彻底变了:限流不是在接口上随手加一个计数判断就算完事的"开关",它是一套需要认真设计的体系——你要想清楚自己究竟想限住的是总量还是速率、是要绝对匀速还是允许突发、限的范围是单机还是集群、维度上要不要按调用方隔离。把这些问题一个个想透,选出的算法和方案才真正配得上"限流"这两个字,才能在那波你迟早会遇到的异常流量真正砸下来的时候,稳稳地把它挡在门外。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

改了数据库,查出来还是旧的:一次 MyBatis 缓存踩坑的复盘

2026-5-20 16:31:19

技术教程

下了单库存没扣:一次分布式事务踩坑的复盘

2026-5-20 16:38:15

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索