我给接口加了"每分钟最多 100 次"的限流,结果它还是在某个瞬间被 200 次请求打穿了,我对着固定窗口限流的临界问题排查了大半天的复盘

给核心接口加限流保护,用了最直观的固定时间窗口算法——每分钟一个计数器超100就拒绝,以为每分钟最多100稳了。可一次流量高峰系统还是被打出问题,查监控发现诡异现象:某时间点附近一小段时间内接口竟处理了近200次,远超每分钟100。对着限流代码反复看计数器没错阈值也是100怎么放进200个?排查大半天才理解固定窗口限流隐蔽的临界问题(边界突刺):在两个窗口交界处可放进近2倍请求——某分钟后30秒来100个(这分钟计数到100)、下分钟前30秒又来100个(新分钟计数重置又能放100),这两批分属两个窗口各没超100但连续1分钟内放进了200。我以为每分钟100是任意连续1分钟≤100,但固定窗口只保证每个自然分钟≤100。这篇从四种限流算法(固定窗口有临界突刺/滑动窗口精确/令牌桶平滑允许突发最常用/漏桶恒定速率)、用令牌桶或滑动窗口替代/用成熟组件别手搓/多层限流/优雅拒绝的正解、限流关键考量(维度/阈值/分布式一致/拒绝策略)、四算法对比、高可用三板斧(限流熔断降级)、决策图与铁律,到附上一个Redis Lua原子令牌桶实现。核心领悟:方案在常规情况能工作和在所有情况尤其边界都正确是两回事,缺陷常藏在边界极端交界处;限流缓存并发这类应对极端情况的机制其边界行为最该审视;随便抓个能用的和选最合适的差别在于有没有理解各方案的特性取舍。

我给接口加了"每分钟最多 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)最后压测验证限流真的生效(这次的坑正是因为没压测验证固定窗口在边界的行为)。这套习惯,让我做限流时,从"随手抓个固定窗口就用"变成了"按需选算法、用成熟组件、压测验证"——核心始终是:限流要选对算法(令牌桶最常用、固定窗口有临界突刺),分布式要全局一致,用成熟组件并压测验证。

我立下的几条规矩

这场"限了流还被打穿"的事故,换来了我做限流时,刻进骨子里的几条铁律:

  1. 固定窗口限流有临界突刺。窗口边界处可放进近 2 倍请求,别以为"每分钟 N"就稳。
  2. 令牌桶是最常用的选择。平滑限流 + 允许合理突发,无临界突刺。
  3. 要精确无突刺用滑动窗口。看"过去 N 秒",任意连续窗口都受限。
  4. 分布式限流要全局一致。计数放 Redis + 原子操作,别每个实例各限各的。
  5. 用成熟限流组件,别手搓。Sentinel/Guava/网关限流,处理好了原子和分布式。
  6. 多维度 + 多层限流。全局+按用户+按接口,网关粗粒度+应用细粒度。
  7. 被限流优雅拒绝。返回 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我让大模型生成 JSON,它经常生成到一半就断了、JSON 解析失败,内容长的时候尤其频繁,我对着 max_tokens 排查了大半天的复盘

2026-6-2 9:11:44

技术教程

我遍历一个 struct 列表挨个改字段,代码跑完一看列表里的值竟然一个都没变,我对着 C# 值类型处处是拷贝这个坑排查了大半天的复盘

2026-6-2 9:25:47

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