我给接口加了限流、限定每分钟最多 600 次以为稳了,大促时下游服务还是被瞬间打爆雪崩,我反复确认限流配置数字没错、监控里平均 QPS 也没超,百思不得其解,最后才发现是我用的固定窗口计数器在每分钟切换的那一瞬间放进了两倍的流量
这是一次让我把"限流"这件事,从"设个数字就完事",重新理解成"到底在约束哪个时间尺度上的速率"的事故。我一直以为,给接口加上限流、限定"每分钟最多 600 次",下游就稳如泰山了。直到一次大促,下游服务在限流明明开着的情况下,还是被瞬间的流量洪峰打爆、雪崩。我反复确认限流的配置数字没填错、监控里每分钟的平均 QPS 也确实没超过阈值,百思不得其解。最后扒开限流的实现才恍然——我用的是最朴素的固定窗口计数器,而它在每分钟切换的那一瞬间,会放进近乎两倍的流量。这篇就把这次"限了流却没限住瞬时突刺"的事故,从头到尾复盘一遍。
故障现场:限流开着、平均没超,下游却被瞬间打爆
我的服务前面挡着一层限流,逻辑朴素得很:统计每分钟进来多少个请求,超过 600 个就拒绝,下一分钟清零重新计数。上线后平时风平浪静,我对它很放心。可大促当晚,下游的数据库连接池瞬间被占满、响应超时、连锁雪崩,而我的限流"看起来"一直在工作。
我盯着监控:每分钟的请求量统计,几乎每个点都贴着 600 的线,没有哪一分钟显著超过。限流的拒绝计数也在涨,说明它确实在拦。可下游就是被打爆了。我先怀疑是下游自己的容量缩水,查了半天没发现异常;又怀疑是限流配置在某个时刻被人改大了,翻配置历史也没有。直到我把下游被打爆那一刻、精确到秒的请求时间戳拉出来,按秒画了一张图,才倒吸一口凉气——在某一分钟的最后一两秒和下一分钟的最前一两秒之间,挤进来的请求量,几乎是我设定限额的两倍。
监控按"每分钟"聚合看到的(平静):
10:00~10:01 请求 598 ✓ 没超 600
10:01~10:02 请求 600 ✓ 没超 600
10:02~10:03 请求 599 ✓ 没超 600
→ 平均 QPS ≈ 10/s, 看着稳如泰山
按"秒"拉出来真实发生的(惊魂):
10:01:58 12 次
10:01:59 588 次 ← 这一分钟的额度, 全堆在了最后一秒附近
10:02:00 590 次 ← 新一分钟刚开始, 计数清零, 又瞬间灌进来
10:02:01 10 次
→ 10:01:59~10:02:00 这一两秒内, 涌进近 1200 次!
下游能扛每分钟 600 均匀流量, 扛不住一两秒内的 1200 次突刺 → 打爆
我的困惑: "我明明限了每分钟 600, 监控每分钟也没超, 怎么会被打爆?"
问题被钉死在那个跨越分钟边界的瞬间:固定窗口计数器,在窗口切换的临界点上,允许前一个窗口的末尾和后一个窗口的开头的流量,叠加在一起,形成一个最高可达两倍限额的瞬时突刺。我那句"每分钟限 600"约束住的,是每个离散的、对齐到整分钟的"桶"里的总数;它压根没有约束任意一个连续的 60 秒窗口里的流量——而下游被打爆,恰恰是因为某个跨越边界的连续 60 秒里,涌进了远超 600 的请求。我用一个"按整分钟分桶计数"的粗糙尺度,去近似"限制瞬时速率"这个真正的目标,而这个近似,在窗口边界处彻底失效了。
第一件事:想明白"固定窗口限流"为什么会在边界放进两倍流量
把这次事故彻底想清楚,关键是理解"限流"真正想约束的,是"瞬时速率"(任意一小段连续时间里的请求量),而固定窗口计数器约束的,只是"对齐到整点的离散窗口里的总数"——这两者在窗口边界处会严重背离。
固定窗口的做法是:把时间切成对齐的固定窗口(比如每分钟),为当前窗口维护一个计数器,来一个请求加一,超过阈值就拒绝,到下个窗口边界就把计数器清零重新开始。它的致命弱点就在这个"到边界清零":它只保证"每个对齐窗口内不超额",却完全不管"跨越窗口边界的那段连续时间"。于是,如果前一个窗口的请求都集中在窗口末尾(比如最后一秒灌进 600 个),计数器达到上限;窗口边界一到,计数器立刻清零;后一个窗口的请求又集中在开头(再灌进 600 个)——这前后两批请求,在物理时间上只隔了极短的一瞬,加起来接近 1200 个,但因为它们分属两个不同的窗口,各自都"没超额",于是全部被放行。这一两倍于限额的瞬时突刺,就是固定窗口的边界效应。
# 我最初的限流: 固定窗口计数器 —— 边界处会放进两倍流量
import time
class FixedWindowLimiter:
def __init__(self, limit, window_sec=60):
self.limit = limit
self.window = window_sec
self.count = 0
self.window_start = int(time.time()) // window_sec # 当前窗口编号
def allow(self):
now_window = int(time.time()) // self.window
if now_window != self.window_start: # 跨到新窗口了
self.window_start = now_window
self.count = 0 # ★ 致命: 边界处计数器清零
if self.count < self.limit:
self.count += 1
return True
return False
# 问题: 窗口 A 的最后一刻放进 limit 个, 计数器满;
# 一过边界立刻清零, 窗口 B 开头又放进 limit 个;
# 这两批在物理时间上紧挨着 → 瞬时涌入接近 2*limit, 打爆下游。
# 我以为"每分钟 600"是在限速率, 其实只是在"每个整分钟桶里数数",
# 桶与桶交界的那条缝, 完全没人管。
想通这一层,我才明白自己错在哪:我把"限制瞬时速率"这个连续的、对任意时间窗口都该成立的目标,用"对齐整分钟分桶计数"这个离散的、只在桶内成立的手段去近似了,而我忽略了桶的边界恰恰是这个近似失效的地方。下游关心的从来不是"某个整分钟里来了多少",而是"任意一两秒里来了多少"——它能不能扛得住,取决于真实的瞬时洪峰,而不取决于我怎么对齐我的统计窗口。固定窗口让我在监控上看到一片"每分钟都没超"的虚假平静,却把真正的双倍突刺,藏在了我视线之外的窗口接缝里。限流的本质是约束速率,而速率是个连续的概念,不能用一个会在边界清零的离散计数器来糊弄。
第二件事:正解——用令牌桶/滑动窗口,约束"连续时间窗口"而非"对齐桶"
找到根因,正解就清晰了:别用"对齐到整点、边界清零"的固定窗口计数器,改用真正约束"任意一段连续时间内速率"的算法——令牌桶(token bucket,匀速补充令牌、有令牌才放行,天然平滑且允许可控突发)、漏桶(leaky bucket,匀速漏出、强制恒定速率),或滑动窗口(sliding window,统计"此刻往前数 60 秒"这个滑动区间,而非对齐的整分钟)。
# 正解1: 令牌桶 —— 匀速补令牌, 有令牌才放行; 平滑且突发可控
import time
class TokenBucket:
def __init__(self, rate, capacity):
self.rate = rate # 每秒补充多少令牌 (= 长期平均速率)
self.capacity = capacity # 桶容量 (= 允许的最大突发量, 自己定)
self.tokens = capacity
self.last = time.time()
def allow(self, n=1):
now = time.time()
# 按流逝的真实时间, 匀速补令牌(不会在"整分钟"突然清零/暴涨)
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= n:
self.tokens -= n
return True
return False
# 关键: 令牌按物理时间匀速补充, 没有"边界清零"这种突变,
# 所以任意一两秒内能放行的量, 被令牌补充速率牢牢摁住, 不会双倍突刺。
# 正解2: 滑动窗口计数 —— 统计"此刻往前 60 秒"的滑动区间, 而非对齐整分钟
from collections import deque
class SlidingWindowLimiter:
def __init__(self, limit, window=60):
self.limit, self.window = limit, window
self.q = deque() # 存每个请求的时间戳
def allow(self):
now = time.time()
while self.q and now - self.q[0] > self.window:
self.q.popleft() # 踢掉滑出窗口的旧请求
if len(self.q) < self.limit:
self.q.append(now)
return True
return False
# 它约束的是"任意连续 60 秒", 跨越整分钟也照样算在一起 → 没有边界突刺。
这套做法的精髓,是让限流约束的对象,从"对齐到整点的离散桶"变回"任意一段连续时间里的真实速率"。令牌桶靠"按物理时间匀速补令牌"——没有"到整分钟突然清零"这种突变,所以任意一两秒内能放行多少,被令牌补充速率牢牢摁住;桶容量则是你主动设定的、允许的最大突发,而不是固定窗口那种意外送出的两倍突刺。滑动窗口则直接统计"此刻往前数 60 秒"这个会随时间平滑滑动的区间,跨越整分钟的请求照样被算在同一个窗口里。不是把限流的数字调小去硬扛突刺,而是换一个真正盯住"连续时间速率"的算法,从根上消掉边界突刺。
【做限流, 我现在认死的几条】
1. 想清楚要限的是"瞬时速率"(任意一小段连续时间的量), 不是"整桶总数"
2. 固定窗口计数器最简单, 但边界处会放进近两倍流量, 突发场景别用
3. 令牌桶: 匀速补令牌、有令牌才放行 —— 平滑, 且突发量由桶容量【主动控制】
4. 漏桶: 匀速漏出 —— 强制恒定速率, 削峰, 适合保护脆弱下游
5. 滑动窗口: 统计"此刻往前 N 秒"的滑动区间 —— 没有对齐桶的边界缝隙
6. 限流值要按"下游能扛的瞬时速率"来定, 不是按"平均能接受多少"
7. 监控别只看"每分钟聚合", 要能看到更细粒度(每秒)的瞬时峰值
第三件事:其他"用粗粒度周期统计、去近似细粒度瞬时约束"的同类坑
顺着"用对齐的离散周期去近似连续的瞬时速率,会在周期边界留下盲区"这条线,我把系统里同类的坑都排查了一遍,它们都源于"把一个连续的量,用粗粒度的、对齐的周期去切桶统计":
第一个,按"每分钟"算的监控告警,漏掉秒级毛刺。监控按分钟聚合算平均,一两秒的尖峰被平均稀释掉,告警永远不触发,可下游就是被那一两秒的尖峰打死的。要保留更细粒度或看 P99/最大值。
第二个,按"自然日"算的配额,在跨天那一刻翻倍。每天限 1000 次、零点清零,用户可以在 23:59 用掉 1000、00:00 再用 1000,两小时不到用掉两天的量——和分钟窗口一模一样的边界效应。
第三个,按"整点"对齐的定时任务扎堆。一堆任务都设成"每小时第 0 分执行",整点一到全部同时启动,瞬间打爆共享资源。要加抖动(jitter)把它们错开。
第四个,计费/统计按"账单周期"切,跨周期的连续行为被割裂。一段连续的使用被账单日切成两半分别统计,各自都不显眼,合起来才是异常——和窗口切流量同理。
第四件事:四种常见限流算法对比——别再默认用固定窗口
我把几种主流限流算法摆在一起对比了一遍,核心看它们"约束的是不是连续时间的速率、有没有边界突刺、能不能控突发":
| 算法 | 怎么工作 | 边界突刺 | 突发处理 | 适用场景 |
|---|---|---|---|---|
| 固定窗口计数 | 对齐整点窗口计数, 边界清零 | 有, 最高约两倍 | 不可控, 边界处意外放行突发 | 实现最简单, 但突发场景慎用 |
| 滑动窗口日志 | 记每个请求时间戳, 统计往前 N 秒 | 无 | 严格按真实窗口拒绝 | 要精确, 量不大时 |
| 令牌桶 | 匀速补令牌, 有令牌才放行 | 无 | 允许可控突发(=桶容量) | 最常用, 平滑且容忍合理突发 |
| 漏桶 | 请求入桶, 匀速漏出处理 | 无 | 强制恒定速率, 削峰 | 保护脆弱下游, 要求平稳 |
看清这张表,选型就有谱了:固定窗口图的是实现简单,代价是边界双倍突刺;要平滑又想容忍合理突发,首选令牌桶;要把流量削成恒定速率保护脆弱下游,用漏桶;要绝对精确且量不大,用滑动窗口日志。我这次踩坑,就是因为图省事默认选了固定窗口,却没意识到它的边界突刺会要了下游的命。在大促这种突发场景里,令牌桶才是更稳妥的默认选项。
第五件事:我曾经对"限流"想当然的几个误区
这次事故也把我对限流的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| 限了"每分钟 600"就限住了速率 | 只限住了"每个对齐整分钟桶里的总数", 桶交界处可放进近两倍 |
| 监控每分钟没超阈值就安全 | 分钟聚合会把秒级突刺平均掉, 真正打爆下游的尖峰你根本看不见 |
| 限流就是设个数字, 算法无所谓 | 算法决定了边界行为、突发处理、平滑度, 选错就等于没限 |
| 被打爆就是限流值设大了, 调小即可 | 固定窗口边界突刺是算法缺陷, 调小只是治标, 换算法才治本 |
| 限流是一劳永逸的保护 | 限流值要按下游真实瞬时承载力定, 下游变了要跟着调, 还得配熔断降级 |
这些误区的根子是同一个:我把"限流"理解成了"设一个数字、按一个方便的周期数数",而没理解它真正要约束的是"任意一段连续时间内的瞬时速率"。当我用"对齐整分钟数数"这个方便的离散视角,去代替"盯住任意连续时间窗口"这个真实目标时,我就在桶与桶的接缝处,给自己埋下了一个看不见的双倍突刺的雷。很多对"工具/机制"的误用,都源于没看清它真正约束的是什么、又在什么前提下会失效。
第六件事:做限流、排查"限了却没限住"时,我现在的自检习惯
现在每当我做限流、或排查"限流开着下游却被打爆",我都会先按这张图问自己:
这张图的精髓,是"先看清要约束的是连续时间的瞬时速率;固定窗口在边界会放进两倍,要换令牌桶等盯住连续窗口的算法"。设计就用令牌桶/滑动窗口约束连续时间速率、限流值按下游瞬时承载力定、排查就按秒拉真实时间戳看边界有没有突刺,而不是只看分钟聚合。这套习惯,让我从"限流就是设个每分钟的数字"变成了"限流是约束任意连续时间窗口里的瞬时速率"——核心始终是:限流的目的是保护下游不被超过其承载力的瞬时流量打垮,而承载力是对"任意一小段连续时间内涌入多少"的约束;固定窗口计数器用"对齐到整点、边界清零的离散桶"去近似这个连续约束,它只保证每个对齐桶内不超额,却放任前一桶末尾与后一桶开头的流量在边界处叠加成接近两倍的瞬时突刺,而这突刺恰恰能打爆只按平均容量评估的下游;正解是改用真正盯住连续时间速率的机制——令牌桶按物理时间匀速补令牌、突发量由桶容量主动控制,漏桶匀速漏出强制恒定速率,滑动窗口统计"此刻往前 N 秒"的滑动区间——它们都没有对齐桶的边界缝隙,并把限流值按下游能扛的瞬时速率来定、监控到秒级峰值、再配熔断降级兜底。
我立下的几条规矩
这场"限了流却没限住瞬时突刺"的事故,换来了我做限流时,刻进骨子里的几条铁律:
- 限流约束的是"任意一段连续时间内的瞬时速率",不是"对齐整点桶里的总数"。
- 固定窗口计数器最简单,但边界处会放进近两倍流量,突发场景别用。
- 默认首选令牌桶:匀速补令牌、平滑,突发量由桶容量主动控制而非意外送出。
- 要削峰成恒定速率保护脆弱下游用漏桶;要精确无突刺用滑动窗口。
- 限流值按"下游能扛的瞬时速率"定,不是按"平均能接受多少"定。
- 监控别只看每分钟聚合,要能看到秒级峰值;否则突刺对你是隐形的。
- 限流不是万能,要配熔断、降级、下游自我保护一起兜底。
附:我现在落地限流的"令牌桶 + 秒级监控 + 熔断兜底"骨架
这是我现在做接口限流固定套的骨架——把这次踩坑的教训(用令牌桶约束连续速率、按下游瞬时承载力定值、秒级监控、熔断兜底)固化成一套结构,让"固定窗口边界双倍突刺"那种隐形雷再不会埋进系统:
import time, threading
class RateGuard:
"""令牌桶限流 + 自适应熔断, 约束的是连续时间速率, 无边界突刺"""
def __init__(self, rate, burst, downstream_safe_qps):
# rate=长期平均速率, burst=桶容量(=主动设定的最大允许突发)
# burst 必须 <= 下游能扛的瞬时承载力, 而不是拍脑袋
assert burst <= downstream_safe_qps, "突发上限不能超过下游瞬时承载力!"
self.rate, self.capacity = rate, burst
self.tokens, self.last = burst, time.time()
self.lock = threading.Lock()
self.rejected = 0
def allow(self):
with self.lock:
now = time.time()
# 按真实流逝时间匀速补令牌 —— 没有"整分钟清零"的突变
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= 1:
self.tokens -= 1
return True
self.rejected += 1 # 拒绝量是关键监控指标
return False
# 1) 限流值按下游瞬时承载力定, 不是按平均
guard = RateGuard(rate=10, burst=15, downstream_safe_qps=20)
# 2) 接入: 没令牌直接快速失败 / 降级, 别排队拖垮自己
def handle(req):
if not guard.allow():
return degrade_response() # 降级兜底, 不是硬等
return call_downstream(req)
# 3) 监控: 上报秒级峰值 + 拒绝量, 别只看分钟平均(会藏住突刺)
# metrics.gauge("rate.tokens", guard.tokens)
# metrics.counter("rate.rejected", guard.rejected)
# 4) 再叠一层熔断: 下游错误率/RT 飙高就直接断开, 给它喘息
这套骨架把我这次的教训钉死在了结构里:用令牌桶按物理时间匀速补令牌(根除边界清零突刺),桶容量(最大突发)用断言卡死在下游瞬时承载力之内,没令牌就快速降级而非排队拖垮自己,监控上报秒级峰值和拒绝量而非只看分钟平均,最外层再叠一层熔断兜底。这样,限流约束的就真正是"任意连续时间里的瞬时速率",而不再是当初那个"对齐整分钟数数、却在桶交界处偷偷放进两倍"的固定窗口。把"约束连续过程的瞬时真实、别让对齐的离散桶在边界留盲区"这个道理,沉淀成限流的固定骨架,这是我对这次大促被打爆最实在的交代——毕竟,真正能保护下游的,是摁住那一两秒的洪峰,而不是让监控上的每分钟看起来都很太平。
写在最后
回头看,这场由"固定窗口限流"引发的"下游被瞬间打爆"事故,真正教给我的,远不止"换用令牌桶"这一个技巧。它让我对"当我们想约束或度量一个连续变化的量(速率、流量、用量)时,我们往往会图方便,把它切成一个个对齐的、离散的周期(每分钟、每天、每整点)去数数;而这种'用离散周期近似连续过程'的做法,会在周期与周期的边界处,留下一条没人看管的缝——连续的现实并不会在我们划定的边界上乖乖断开",有了一次刻骨的体会。我栽跟头,是因为我用一个"对齐到整点、到边界就清零的离散桶",去近似一个"对任意连续时间窗口都该成立的瞬时约束"——我图的是实现简单:数到整分钟就清零重来,多干脆;我没意识到,真实的流量是连续流淌的,它不认识我划的整分钟边界,完全可以把前一分钟的额度堆在末尾、把后一分钟的额度堆在开头,在那条边界缝上叠成两倍的洪峰;而我的监控又恰好也按整分钟聚合,于是这个双倍突刺,在我的视野里被那条同样对齐的统计边界,完美地藏了起来。这让我领悟到一个关于"连续与离散、整体约束与分段统计"的深刻认知:当我们用"分段、对齐、到边界清零"的离散方式,去约束或度量一个连续的量时,我们真正约束住的,只是"每一段内部"的情况,而段与段的交界处,几乎总是一个被忽略的盲区;连续的现实不会在我们为了方便而划定的边界上中断,它会跨过边界、把相邻两段的极端情况叠加在那条缝上,制造出单看每一段都"合规"、合起来却"爆表"的局面;更隐蔽的是,如果我们的观测尺度和约束尺度用了同样的对齐边界,那这个边界处的问题,连监控都会替我们一起掩盖。这给了我一种看待"一切'用离散周期去处理连续过程'之事"时的清醒:每当我要用"每 X 时间"这样的离散周期去限制、统计、调度一个连续的量时,要追问"周期与周期的边界处会发生什么?连续的现实跨过这条边界叠加起来,会不会突破我以为约束住了的上限?我的观测,能不能看穿这条边界、还是和它一起把问题藏了起来"——用真正盯住"连续窗口/瞬时速率"的机制(滑动窗口、匀速令牌、抖动错峰)去约束,而不是用对齐的离散桶图省事;"看穿离散周期的边界、约束连续过程的瞬时真实,别让对齐的桶在交界处留下双倍的盲区",是做对限流、也是做对一切'用周期处理连续量'之事的关键。认清限流约束的是连续时间速率、固定窗口边界会放进两倍、要用令牌桶等盯住连续窗口——这,是我用一次下游被打爆的事故,换来的、关于架构、也关于如何用离散手段诚实地对待连续现实的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次随手写下"每分钟限 N 次"的固定窗口限流时,先想想"那两个分钟交界的一瞬,会不会挤进 2N?我是不是该换成令牌桶?",并把监控的尺度也调细一档,那我对着那张按秒画出来、在边界处陡然冲高近两倍的流量图发懵的那个大促夜晚,就值了。
—— 别看了 · 2026