我给接口加了"每分钟最多 100 次"的限流,结果它还是在某个瞬间被 200 次请求打穿了,我对着固定窗口限流的临界问题排查了大半天的复盘
那是我给一个核心接口加的限流保护:我用了最直观的"固定时间窗口"算法——每分钟一个计数器,超过 100 次就拒绝。我以为这下"每分钟最多 100 次"稳了,系统再也不会被突发流量打垮。可一次流量高峰,系统还是被打出了问题。我查监控,发现一个诡异的现象:在某个时间点附近的一小段时间内,接口竟然处理了将近 200 次请求——远超我设的"每分钟 100 次"。我对着限流代码反复看:计数器没错、阈值也是 100 啊,怎么会放进来 200 个?排查了大半天,我才真正理解了"固定窗口限流"那个隐蔽的"临界问题(边界突刺)"。这篇就把这场"限了流还是被打穿"的事故,从头复盘一遍。
故障现场:限了"每分钟100",却放进了200
先看现场。问题就藏在"固定窗口"的时间边界上:
# 我的固定窗口限流: 每分钟一个计数器, 超100拒绝
def is_allowed(user_id):
minute = current_minute() # 当前是哪一分钟(如 "10:00", "10:01")
key = f"limit:{user_id}:{minute}"
count = redis.incr(key) # 这一分钟的计数 +1
redis.expire(key, 60)
return count <= 100 # 超100就拒绝
# 看起来很对: 每分钟计数, 超100拒绝。但有个致命的"临界问题":
# 问题: 在两个窗口的"交界处", 可能放进近2倍的请求!
# 假设阈值是 每分钟100:
# - 10:00:30 ~ 10:00:59 (第1分钟的后半段): 来了100个请求 → 全放行
# (这一分钟的计数器到100)
# - 10:01:00 ~ 10:01:30 (第2分钟的前半段): 又来了100个请求 → 全放行
# (新的一分钟, 计数器重置为0, 又能放100个)
# - 结果: 在 10:00:30 ~ 10:01:30 这【相邻的1分钟时间内】,
# 一共放进了【200个】请求! 远超"每分钟100"的本意!
# → 因为这两批请求, 分属两个"固定窗口"(10:00那分钟 和 10:01那分钟),
# 各自都没超100, 但它们在"时间上是连续的1分钟", 加起来200。
# 现象拼图:
# - 固定窗口: 按"自然分钟"切, 每个窗口独立计数、到点重置。
# - 临界问题: 在窗口交界处(如某分钟的后30秒 + 下一分钟的前30秒),
# 两个窗口的配额可以"叠加", 在连续的1分钟内放进近2倍的请求。
# - 我以为"每分钟100"是"任意连续1分钟≤100", 但固定窗口只保证
# "每个自然分钟≤100", 交界处会突刺到接近200。
# - ★ 根因: 固定窗口算法的"窗口是固定切分的", 导致窗口边界处
# 存在"配额叠加"的临界突刺, 实际限流效果在边界处翻倍。
看清这个"临界问题"后,我才明白为什么限了流还被打穿。问题的根源,是"固定窗口限流"在两个窗口的交界处,可能放进近 2 倍的请求。假设阈值是每分钟 100:在 10:00:30~10:00:59(第 1 分钟后半段)来 100 个全放行(这分钟计数到 100),在 10:01:00~10:01:30(第 2 分钟前半段)又来 100 个全放行(新的一分钟、计数器重置为 0、又能放 100);结果在 10:00:30~10:01:30 这相邻的 1 分钟内,一共放进了 200 个,远超"每分钟 100"的本意。因为这两批请求分属两个"固定窗口",各自都没超 100,但它们在时间上是连续的 1 分钟、加起来 200。我的认知错误是:我以为"每分钟 100"是"任意连续 1 分钟 ≤ 100",但固定窗口只保证"每个自然分钟 ≤ 100",交界处会突刺到接近 200。根因是:固定窗口算法的"窗口是固定切分的",导致窗口边界处存在"配额叠加"的临界突刺,实际限流效果在边界处翻倍。
第一件事:搞懂几种限流算法及其差异
要解决它,得先搞懂常见的几种限流算法,以及它们各自的特点和问题。
几种限流算法
# 一、固定窗口(Fixed Window): 简单, 但有临界突刺(本文)
# - 按固定时间(如每分钟)分窗口, 每窗口独立计数, 到点重置。
# - 优点: 实现简单(一个计数器)。
# - 缺点: 窗口交界处可能放进近2倍请求(临界问题, 本文)。
# 二、滑动窗口(Sliding Window): 解决临界问题
# - 不按固定时间切, 而是看"过去N秒内"的请求数(窗口随时间滑动)。
# - 如: 看"当前时刻往前1分钟"内的请求数, 超阈值就拒绝。
# - 优点: 没有临界突刺(任意连续1分钟都受限)。
# - 实现: 滑动窗口日志(记每个请求时间戳)或滑动窗口计数(分小格加权)。
# - 缺点: 比固定窗口复杂、占更多存储。
# 三、令牌桶(Token Bucket): 平滑限流 + 允许突发
# - 一个桶, 以固定速率往里放令牌, 桶满了不再放。
# - 每个请求要拿走一个令牌才能通过, 没令牌就拒绝/等待。
# - 优点: 平均速率受控, 又允许"短时突发"(桶里攒的令牌)。最常用。
# - 适合: 允许一定突发、但要控制平均速率的场景。
# 四、漏桶(Leaky Bucket): 恒定速率流出, 削峰
# - 请求进桶, 桶以恒定速率"漏出"(处理), 桶满了就拒绝。
# - 优点: 输出速率恒定、绝对平滑(强行削峰)。
# - 缺点: 不允许突发(即使桶空, 也只能恒定速率出)。
# - 适合: 要严格平滑、保护下游恒定处理能力的场景。
# 选择:
# - 简单够用、能容忍临界突刺: 固定窗口(但要知道它的临界问题)。
# - 要精确、无突刺: 滑动窗口。
# - 要平滑且允许突发: 令牌桶(最常用、最推荐)。
# - 要严格恒定速率: 漏桶。
# 核心: 固定窗口简单但有临界突刺(边界放近2倍); 滑动窗口解决突刺但复杂; 令牌桶平滑且允许突发
# (最常用); 漏桶恒定速率削峰不许突发; 按"要不要精确/要不要允许突发"选算法。
想透几种限流算法,这个坑就清楚了,选型也有了方向。一、固定窗口(Fixed Window):简单但有临界突刺(本文)——按固定时间分窗口、每窗口独立计数、到点重置;优点是实现简单,缺点是窗口交界处可能放进近 2 倍请求。二、滑动窗口(Sliding Window):解决临界问题——不按固定时间切,而是看"过去 N 秒内"的请求数(窗口随时间滑动),没有临界突刺(任意连续 1 分钟都受限),但比固定窗口复杂、占更多存储。三、令牌桶(Token Bucket):平滑限流 + 允许突发——以固定速率往桶放令牌、桶满不再放,每个请求拿走一个令牌才能通过;平均速率受控又允许短时突发,最常用。四、漏桶(Leaky Bucket):恒定速率流出、削峰——请求进桶、以恒定速率漏出处理、桶满拒绝;输出速率恒定绝对平滑,但不允许突发。选择:简单够用能容忍突刺用固定窗口、要精确无突刺用滑动窗口、要平滑且允许突发用令牌桶(最常用)、要严格恒定速率用漏桶。
第二件事:正解——用令牌桶或滑动窗口替代固定窗口
搞懂了原理,正解就清晰了:用令牌桶(平滑+允许突发)或滑动窗口(精确无突刺)替代固定窗口,用成熟的限流组件,并配合多层限流。
# ====== 正解一(推荐): 令牌桶限流(平滑, 允许合理突发)======
import time, redis
r = redis.Redis()
# 令牌桶: 以固定速率放令牌, 请求拿令牌(用Lua保证原子)
TOKEN_BUCKET_LUA = """
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒放多少令牌
local capacity = tonumber(ARGV[2]) -- 桶容量(允许的突发量)
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4]) -- 本次要几个令牌
local bucket = redis.call('hmget', key, 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or capacity
local ts = tonumber(bucket[2]) or now
-- 按经过的时间补充令牌(不超过容量)
tokens = math.min(capacity, tokens + (now - ts) * rate)
local allowed = tokens >= requested
if allowed then tokens = tokens - requested end
redis.call('hmset', key, 'tokens', tokens, 'ts', now)
redis.call('expire', key, math.ceil(capacity / rate) + 10)
return allowed and 1 or 0
"""
def allow(user_id, rate=2, capacity=10): # 平均每秒2个, 允许突发10个
return r.eval(TOKEN_BUCKET_LUA, 1, f"tb:{user_id}",
rate, capacity, time.time(), 1) == 1
# → 平均速率受控(rate), 又允许短时突发(capacity), 无固定窗口的临界突刺。
# ====== 正解二: 滑动窗口限流(精确, 无临界突刺)======
SLIDING_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- 窗口大小(秒)
local limit = tonumber(ARGV[3])
redis.call('zremrangebyscore', key, 0, now - window) -- 删掉窗口外的
local count = redis.call('zcard', key)
if count < limit then
redis.call('zadd', key, now, now .. math.random()) -- 记录本次请求时间
redis.call('expire', key, window)
return 1
else return 0 end
"""
# → 用zset记录每个请求的时间戳, 只算"过去window秒内"的, 任意连续window秒都受限。
# (精确, 但比令牌桶占更多存储——存了每个请求的时间戳)
# ====== 正解三(强烈推荐): 用成熟的限流组件/库 ======
# - 网关层限流: Nginx limit_req(漏桶)、API网关(Kong/APISIX)的限流插件。
# - 应用层: Guava RateLimiter(Java, 令牌桶)、Sentinel(阿里, 功能全)、
# Resilience4j 的 RateLimiter, redis-cell 模块(令牌桶)等。
# - 别自己手搓(限流要原子、要准、要分布式一致), 用成熟的。
# ====== 正解四: 多层限流(纵深防御)======
# - 网关层: 粗粒度限流(总QPS、按IP), 挡住大部分异常流量。
# - 应用层: 细粒度限流(按用户、按接口), 精细保护。
# - 不同维度: 全局限流 + 单用户限流 + 单接口限流, 组合使用。
# ====== 正解五: 限流后的"优雅拒绝" ======
# - 被限流的请求, 返回明确的 429 Too Many Requests + Retry-After 头。
# - 别直接超时/报错, 告诉调用方"被限流了, 多久后重试"(配合调用方退避)。
# 核心: 用令牌桶(平滑+允许突发, 最推荐)或滑动窗口(精确无突刺)替代固定窗口; 用成熟限流组件
# 别手搓; 多层限流(网关粗粒度+应用细粒度)纵深防御; 限流后优雅拒绝(429+Retry-After)。
修复的核心,是"用没有临界问题的限流算法,并用成熟组件、多层防御"。正解一(推荐):令牌桶限流——以固定速率放令牌、请求拿令牌(用 Lua 保证原子),平均速率受控(rate)又允许短时突发(capacity),无固定窗口的临界突刺。正解二:滑动窗口限流——用 zset 记录每个请求的时间戳、只算"过去 window 秒内"的,任意连续 window 秒都受限(精确,但比令牌桶占更多存储)。正解三(强烈推荐):用成熟的限流组件/库——网关层(Nginx limit_req、Kong/APISIX 插件)、应用层(Guava RateLimiter、Sentinel、Resilience4j、redis-cell);限流要原子、要准、要分布式一致,别自己手搓。正解四:多层限流(纵深防御)——网关层粗粒度(总 QPS、按 IP)挡大部分异常流量、应用层细粒度(按用户、按接口)精细保护。正解五:限流后的"优雅拒绝"——返回明确的 429 + Retry-After 头,告诉调用方"被限流了、多久后重试"(配合调用方退避)。归根结底:用令牌桶(最推荐)或滑动窗口替代固定窗口;用成熟限流组件别手搓;多层限流纵深防御;限流后优雅拒绝。
第三件事:限流的几个关键考量
排查后我把限流设计的几个关键考量系统梳理了一遍。
限流设计的关键考量
# 一、限什么维度? (粒度)
# - 全局: 整个服务的总QPS(保护整体)。
# - 单用户/单IP: 防单个用户/IP刷爆(防滥用、防攻击)。
# - 单接口: 不同接口不同阈值(贵的接口限严点)。
# - 通常多维度组合: 全局兜底 + 按用户/IP精细。
# 二、阈值怎么定?
# - 根据"系统能承受的容量"定(压测得出系统的安全QPS)。
# - 留余量(别卡在极限), 给突发和误差留空间。
# - 不同环境/时段可动态调整(如大促临时调高)。
# 三、分布式限流要"全局一致"
# - 多实例部署时, 限流计数要"全局共享"(如放Redis), 否则每个实例
# 各限各的, 总量就超了(N个实例 × 单实例阈值)。
# - 用Redis等集中存储 + 原子操作(Lua)保证分布式下的准确。
# 四、被限流后怎么办? (拒绝策略)
# - 直接拒绝(返回429): 最常见, 保护系统。
# - 排队等待: 漏桶式, 让请求排队慢慢处理(有等待上限)。
# - 降级: 返回兜底数据/简化响应。
# - 告诉调用方Retry-After, 引导其退避重试(别立即重试加剧, 见重试风暴篇)。
# 五、限流 vs 熔断 vs 降级(别混)
# - 限流: 控制"进来的流量"(超了就拒), 防自己被压垮(主动控量)。
# - 熔断: 下游故障时"快速失败"(别再调下游), 防被下游拖垮(见重试篇)。
# - 降级: 出问题时提供"兜底的简化服务", 保核心功能。
# - 三者配合, 是高可用的常见组合。
# 核心: 限流要考虑 限什么维度(全局/用户/接口多维组合)、阈值怎么定(按容量压测+留余量)、
# 分布式全局一致(Redis+原子)、被限后策略(拒绝/排队/降级/Retry-After); 区分限流熔断降级。
排查让我把限流设计的关键考量梳理清了。一、限什么维度(粒度):全局(总 QPS)、单用户/单 IP(防刷防攻击)、单接口(贵的限严点),通常多维度组合(全局兜底 + 按用户精细)。二、阈值怎么定?——根据系统能承受的容量定(压测得出安全 QPS)、留余量、不同时段可动态调。三、分布式限流要"全局一致"——多实例部署时限流计数要全局共享(放 Redis),否则每个实例各限各的、总量就超了(N 实例 × 单实例阈值);用 Redis + 原子操作(Lua)保证准确。四、被限流后怎么办?(拒绝策略)——直接拒绝(429)、排队等待、降级、告诉调用方 Retry-After 引导退避。五、限流 vs 熔断 vs 降级(别混)——限流控制"进来的流量"(防自己被压垮)、熔断下游故障时快速失败(防被下游拖垮)、降级提供兜底简化服务;三者配合是高可用常见组合。下面这张图,是这次固定窗口被打穿的成因与解法:
第四件事:四种限流算法对比速查
这次踩坑后,我把四种限流算法的对比整理成一张表,选限流方案时对照着来。
| 算法 | 临界突刺 | 允许突发 | 适用 |
|---|---|---|---|
| 固定窗口 | ✗ 有(边界近2倍) | 窗口内可 | 简单场景,能容忍突刺 |
| 滑动窗口 | ✓ 无 | 否(精确平滑) | 要精确限流 |
| 令牌桶 | ✓ 无 | ✓ 允许(桶容量) | 平滑+允许突发(最常用) |
| 漏桶 | ✓ 无 | ✗ 不允许 | 严格恒定速率,削峰 |
这张表,把四种限流算法的核心差异摆清了。选择的关键就两个问题:"要不要避免临界突刺"(要就别用固定窗口)和"要不要允许突发"(允许用令牌桶、不允许用滑动窗口/漏桶)。它给我的最大启发是:同样是"限流"这个目标,不同的算法在"如何分配和限制流量"上,做出了不同的取舍——而这些取舍,直接决定了它"适合什么场景、有什么副作用"。我之前的错,是只知道"要限流"这个目标,随手抓了个"看起来最简单"的算法(固定窗口),却没去了解它的特性和副作用(临界突刺)。这让我领悟到一个技术选型的道理:解决一个问题,往往有多种"算法/方案",它们看似都能达成目标,但各有各的"性格"(特点、副作用、适用边界);选型时,不能只看"能不能解决问题",更要了解每个方案的"性格",选那个"性格最匹配你场景"的。"随便抓一个能用的"和"选一个最合适的",差别就在于你有没有真正理解各个候选方案的特性和取舍——而这,正是技术深度的体现。
第五件事:限流是"高可用三板斧"之一
这次让我把限流放到"系统高可用保护"的全景里理解了。我把高可用的几个保护手段梳理了一下。
| 手段 | 解决的问题 | 方向 |
|---|---|---|
| 限流(本文) | 流量过大压垮自己 | 控制"进来的量" |
| 熔断 | 下游故障拖垮自己 | 切断"对故障下游的调用" |
| 降级 | 出问题时保核心功能 | 提供"兜底的简化服务" |
| 隔离 | 一个依赖拖垮整体 | 资源隔离(线程池/信号量) |
| 超时 | 请求无限等待堆积 | 给每次调用设上限 |
| 重试+退避 | 偶发失败 | 谨慎重试(防风暴) |
这张表,把"系统高可用"的保护手段串成了一个整体。它们各管一面:限流控制进来的量(防被流量压垮)、熔断切断对故障下游的调用(防被下游拖垮)、降级保核心功能、隔离防一个依赖拖垮整体、超时防请求堆积、重试+退避应对偶发失败。它给我的最大启发是:"高可用"不是靠某一个机制实现的,而是靠这一整套"保护机制"的协同——它们就像系统的"免疫系统",从不同方向(流量、依赖、资源、时间)保护系统不被各种异常情况击垮。而它们共同的设计哲学,是"假设异常一定会发生(流量会突增、下游会故障、请求会超时),并为之准备好保护措施";而不是"祈祷一切正常"。我这次的限流,正是这套免疫系统里的一环(防流量过载);而我把它"做得不够好(固定窗口有漏洞)",就相当于免疫系统有个缺口、被突发流量钻了空子。这让我领悟到:构建高可用系统,需要"系统性地、多管齐下地"部署这一整套保护机制(限流、熔断、降级、隔离、超时、重试),让它们互相补位、共同御敌;任何一环薄弱(如限流有临界漏洞),都可能成为系统在异常情况下的突破口。把这套"免疫系统"建全、建好,才是系统真正高可用的底气。
第六件事:要做限流时,我现在的决策习惯
现在每当我要给接口加限流,我都会按这张图先想清楚算法和维度:
这张图的精髓,是"加限流前,先按需求选算法、维度,用成熟组件"。先问 "需要避免临界突刺吗":能容忍求简单用固定窗口(但知道它的局限);要精确再问要不要允许突发——允许用令牌桶(最推荐)、严格平滑用滑动窗口/漏桶。然后:多实例部署要分布式限流(计数放 Redis + 原子、保全局一致);用成熟组件(Sentinel/Guava/网关)别手搓;多维度(全局+按用户+按接口);阈值按压测容量定;被限流优雅拒绝(429+Retry-After)。最后压测验证限流真的生效(这次的坑正是因为没压测验证固定窗口在边界的行为)。这套习惯,让我做限流时,从"随手抓个固定窗口就用"变成了"按需选算法、用成熟组件、压测验证"——核心始终是:限流要选对算法(令牌桶最常用、固定窗口有临界突刺),分布式要全局一致,用成熟组件并压测验证。
我立下的几条规矩
这场"限了流还被打穿"的事故,换来了我做限流时,刻进骨子里的几条铁律:
- 固定窗口限流有临界突刺。窗口边界处可放进近 2 倍请求,别以为"每分钟 N"就稳。
- 令牌桶是最常用的选择。平滑限流 + 允许合理突发,无临界突刺。
- 要精确无突刺用滑动窗口。看"过去 N 秒",任意连续窗口都受限。
- 分布式限流要全局一致。计数放 Redis + 原子操作,别每个实例各限各的。
- 用成熟限流组件,别手搓。Sentinel/Guava/网关限流,处理好了原子和分布式。
- 多维度 + 多层限流。全局+按用户+按接口,网关粗粒度+应用细粒度。
- 被限流优雅拒绝。返回 429 + Retry-After,引导调用方退避,别让它立即重试。
附:用 Lua 实现的原子令牌桶限流(分布式可用)
口说无凭。下面给一个完整的、用 Redis Lua 实现的令牌桶限流,分布式下原子、准确、无临界突刺:
import time, redis
class TokenBucketLimiter:
"""分布式令牌桶限流: 平滑限流 + 允许合理突发, 无固定窗口的临界问题。"""
# Lua脚本: 在Redis里【原子】地补充令牌 + 尝试取令牌
_LUA = """
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒放令牌数(平均速率)
local capacity = tonumber(ARGV[2]) -- 桶容量(允许的最大突发)
local now = tonumber(ARGV[3]) -- 当前时间(秒, 带小数)
local requested = tonumber(ARGV[4]) -- 本次请求要几个令牌
local data = redis.call('hmget', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then tokens = capacity; ts = now end -- 初始满桶
-- 按距上次的时间, 补充令牌(不超过容量)
local delta = math.max(0, now - ts)
tokens = math.min(capacity, tokens + delta * rate)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('hmset', key, 'tokens', tokens, 'ts', now)
redis.call('expire', key, math.ceil(capacity / rate) + 10)
return allowed
"""
def __init__(self, client, rate, capacity):
self.r = client
self.rate = rate # 平均每秒允许的请求数
self.capacity = capacity # 桶容量(允许突发到多少)
self.script = client.register_script(self._LUA)
def allow(self, key, n=1):
return self.script(keys=[f"rl:{key}"],
args=[self.rate, self.capacity, time.time(), n]) == 1
# ====== 用法 ======
# limiter = TokenBucketLimiter(redis.Redis(), rate=10, capacity=20)
# # 平均每秒10个请求, 允许突发到20个
# if limiter.allow(f"user:{user_id}"):
# handle_request() # 通过, 处理
# else:
# return Response(status=429, headers={"Retry-After": "1"}) # 限流, 优雅拒绝
# 核心: Lua脚本在Redis里原子地"按时间补令牌+取令牌", 分布式下多实例共享同一个桶、
# 计数全局一致; 平均速率rate受控、又允许突发到capacity; 无固定窗口的边界翻倍问题。
这个 TokenBucketLimiter,把令牌桶限流落成了一个分布式可用、生产可参考的实现。它的精妙,在于用一段 Redis Lua 脚本,把令牌桶的核心逻辑("按距上次的时间补充令牌 + 尝试取令牌")原子地在 Redis 里完成。这个"原子"至关重要:因为限流计数在分布式(多实例)下必须"全局一致"——多个实例共享 Redis 里同一个桶,而"补令牌 + 取令牌"必须是原子的(否则并发下会算错、放进超量请求);Lua 脚本在 Redis 里单线程原子执行,正好保证了这点。同时,这个令牌桶既能控制平均速率(rate)、又能允许合理突发(capacity),而且完全没有固定窗口的临界突刺问题。这,正是我想用这个实现,留给每个做限流的人的最后一课,也是对前面"别手搓限流"那条建议的补充说明:正确的分布式限流,需要同时处理好"算法正确(无临界突刺)"和"分布式一致(原子、全局共享)"两个难点;而这正是为什么"限流"这件看似简单的事,自己手搓很容易出错(要么算法有漏洞、要么分布式下不一致),最好用成熟组件。这个实现展示了"正确"需要多少功夫(原子 Lua、按时间补令牌、容量控制突发),也由此让你更理解:那些成熟的限流组件(Sentinel、redis-cell 等),封装的正是这些"看起来简单、做对很难"的细节;站在它们的肩膀上,远比自己重新趟一遍坑明智。理解原理(知道令牌桶怎么对、为什么要原子),但生产用成熟轮子——这,是驾驭限流(乃至一切分布式原语)的成熟之道。
写在最后
回头看,这场由"固定窗口临界问题"引发的、限了流还被打穿的事故,真正教给我的,远不止"用令牌桶替代固定窗口"这一个技巧。它让我对"看似简单的方案,可能有不简单的边界"有了又一次深刻的体会。我栽跟头,是因为我选了一个"看起来最直观、最简单"的限流方案(固定窗口),并想当然地以为它的行为就是我直觉里的样子("每分钟最多 100")。我满足于"它看起来能限流",却没有深究它在"边界情况(窗口交界处)"下的真实行为。而恰恰是这个我没深究的边界,成了限流被击穿的缺口。这让我领悟到一个工程上至关重要的道理:一个方案"在常规情况下能工作",和它"在所有情况下(尤其边界情况)都正确",是两回事;而很多方案的"缺陷"和"坑",恰恰藏在那些"边界、极端、交界"的情况里(固定窗口的窗口边界、深分页的深处、并发的临界点……)。所以,这件事给我的最大警示是:评估和选择一个方案时,不能只看它"在典型情况下的表现",更要追问"它在边界、极端、最坏的情况下,行为是怎样的?有没有漏洞?";尤其是限流、缓存、并发这类"本身就是为了应对极端情况(高流量、高并发)"的机制,它们的边界行为,恰恰是最该被仔细审视的(因为极端情况正是它们要面对的常态)。关注边界、审视极端情况下的行为——这,是我用一次"限流被打穿"的事故,换来的、关于架构、也关于"边界即陷阱"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次做限流时,优先选令牌桶、并思考它的边界行为,那我对着那个被突刺打穿的固定窗口熬的这大半天,就值了。
—— 别看了 · 2026