2021 年我做一个对外开放的 API,谁都能调,所以得防着被刷——不能让某个调用方一秒钟打几千个请求,把我的服务和数据库拖垮。怎么挡住这种过量调用?这件事我没多想,就有了方案:数个数。第一版我做得很顺手——我做了一个计数器,每分钟开头清零,每来一个请求就加一,一旦这一分钟的计数超过设定的上限,后面的请求就全部拒绝。本地一测真不错,我写了个脚本疯狂打接口,计数到了上限,后面的请求果然被干净利落地挡了回去。我心里很笃定:限流不就是数个数嘛,数到上限就拒绝,这套限流稳了。可等这个 API 真正上线,面对真实世界里花样百出的调用方,一串问题冒了出来。第一种最先把我打懵:我明明限了每分钟一万,可监控上某些时刻的瞬时流量,是这个数的两倍还多。第二种最难缠:我把服务部署成了多个实例,结果每个实例各数各的,实际放进来的总量是我设定上限的好几倍。第三种最头疼:有个正常的调用方,平时很乖,偶尔因为业务高峰会有一小阵突发,这一小阵突发被我一刀切地拒了,对方投诉体验太差。第四种最莫名其妙:被我拒绝的请求,我只是冷冰冰地返回一个失败,调用方根本不知道该等多久再试,于是它立刻重试、再被拒、再立刻重试,反而把我打得更狠。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为限流就是数个数,在一个时间窗口里数到上限就拒绝,简单直接。可"数个数"只是限流最表层的动作,它真正难的地方,全藏在那几个我没想过的问题里:在"什么样的时间窗口"里数?这个窗口是固定的、还是跟着当前时刻滑动的?多个服务实例的计数怎么合到一起算?限流是要把流量削成一条死板的直线,还是要允许正常的、短暂的突发?被拦下的请求,该怎么体面地打发它?要把限流做扎实,根上要明白:限流不是一个计数器,而是一组各有取舍的算法,你要根据自己想要的"流量形状",去选对的那一个,并且让它在分布式环境下依然成立。本文从头梳理:为什么固定窗口计数会被突发击穿,滑动窗口怎么修掉这个问题,漏桶和令牌桶分别塑造出什么样的流量形状,分布式环境下限流的计数该放在哪,以及一些把它做扎实要避开的工程坑。
问题背景
先把限流这件事的目标说清楚。限流,是给一段时间内能通过的请求数量设一个上限,超过的部分拒绝掉,目的是保护后端的服务和数据库不被过量的流量冲垮。它是系统稳定性的一道基础闸门。
错误认知是:限流等于"计数 + 阈值",维护一个计数器,到点清零,超阈值就拒。真相是:"计数 + 阈值"只描述了限流最朴素的一种实现,而这种实现有明确的缺陷;限流真正要考虑的,是计数所依附的时间窗口怎么定义、计数在多实例间怎么共享、以及你希望放行的流量呈现什么形状。把这几点摊开,第一版的几类问题就有了来处:
- 窗口边界被击穿:固定窗口计数器在两个窗口的交界处,短时间内能放进接近两倍上限的请求,瞬时流量远超预期。
- 多实例各自为政:计数器存在每个实例的内存里,N 个实例就有 N 份独立计数,实际通过量是设定上限的 N 倍。
- 一刀切误伤正常突发:简单计数无法区分"恶意持续高频"和"正常业务的短暂突发",对后者也粗暴拒绝。
- 被拒请求处理不当:只返回一个失败、不告诉调用方何时可重试,会引发立即重试的风暴,雪上加霜。
所以限流要做对,不是把计数器写得更精巧,而是要理解几种经典算法各自塑造的流量形状和各自的取舍,再把它正确地落到分布式环境里。下面六节,就从第一版的固定窗口讲起,一种一种往下走。
一、为什么"每分钟数个数"这种限流会被突发流量击穿
第一版用的算法,有个正式名字叫固定窗口计数器。它把时间切成一段一段固定的窗口(比如每分钟一段),每段窗口内维护一个计数,请求来了就加一,超过阈值就拒,窗口一到下一段就把计数清零重来。它的优点是实现极简、内存极省——只要存一个计数和一个窗口起点。
# 固定窗口计数器:实现最简单,但有致命的边界问题
import time
class FixedWindowLimiter:
def __init__(self, limit: int, window_seconds: int):
self.limit = limit
self.window = window_seconds
self.count = 0
self.window_start = time.time()
def allow(self) -> bool:
now = time.time()
# 已经跨进新窗口:计数清零,窗口起点重置
if now - self.window_start >= self.window:
self.count = 0
self.window_start = now
# 当前窗口内还没到上限就放行
if self.count < self.limit:
self.count += 1
return True
return False
它的致命缺陷在窗口的边界上。假设限流是"每 60 秒最多 100 个请求"。设想一个调用方,在第一个窗口的最后 1 秒里集中打了 100 个请求——全部放行,因为这个窗口的计数刚好用满。紧接着,时间跨进第二个窗口,计数清零;它在第二个窗口的最初 1 秒里又集中打了 100 个请求——同样全部放行。结果就是:在跨越窗口边界的这短短 2 秒之内,实际有 200 个请求通过了,是限流值的两倍。监控上看到的那个"两倍于上限的瞬时流量",就是这么来的。
# 演示固定窗口的临界突发:边界两秒放进两倍流量
import time
limiter = FixedWindowLimiter(limit=100, window_seconds=60)
# 模拟:在第 59 秒打满 100 个
limiter.window_start = time.time() - 59
passed_a = sum(1 for _ in range(100) if limiter.allow())
# 1 秒后跨入新窗口,计数清零,再打 100 个
limiter.window_start = time.time() - 60 # 触发跨窗口清零
passed_b = sum(1 for _ in range(100) if limiter.allow())
print(f"边界前后两秒共放行 {passed_a + passed_b} 个")
# 输出 200:窗口一清零,上一窗口的余量就被"忘记"了
这一节要建立的认知是:固定窗口计数器的问题,不在"计数"本身,而在它对时间的切分方式——它用的是一组对齐到固定时刻的、互不相干的窗口。清零这个动作,本质是"把过去一刀切断、完全忘记"。可真实的流量是连续的,它不会迁就你窗口的边界。当一段密集的流量恰好骑在两个窗口的接缝上,前一个窗口已经放行的那一批,在后一个窗口眼里完全不存在,于是后一个窗口又允许放进同样多的一批。问题的根子,是"过去 60 秒到底来了多少请求"这个真正该被限制的量,和"当前这个固定窗口里数了多少"这个被实际限制的量,在窗口边界附近严重对不上。要修这个问题,就得换一种对时间的看法——这就是下一节的滑动窗口。
二、滑动窗口:让计数窗口跟着当前时刻滑动
滑动窗口的思路,是把"固定的、对齐到整分钟的窗口",换成"永远以当前时刻为终点、向前回溯固定时长的窗口"。无论现在是几点几分几秒,它关心的永远是"从现在往前数 60 秒,这段时间里来了多少请求"。这个窗口的边界跟着时间连续地滑动,没有"清零"这个突变动作,自然就没有了边界突发问题。最直接的实现,是把每个请求的时间戳都记下来,每次判断时,把滑出 60 秒之外的旧时间戳丢掉,再数还剩多少。
# 滑动窗口日志:记录每个请求的时间戳,精确但占内存
import time
from collections import deque
class SlidingWindowLog:
def __init__(self, limit: int, window_seconds: int):
self.limit = limit
self.window = window_seconds
self.timestamps = deque() # 存放近期请求的时间戳
def allow(self) -> bool:
now = time.time()
boundary = now - self.window
# 把滑出窗口的旧时间戳从队头清掉
while self.timestamps and self.timestamps[0] <= boundary:
self.timestamps.popleft()
# 剩下的就是"过去 window 秒内"的真实请求数
if len(self.timestamps) < self.limit:
self.timestamps.append(now)
return True
return False
滑动窗口日志精确,但它要为每个请求存一个时间戳,流量大时内存开销不小。一个常用的折中,是滑动窗口计数器:它不存每个请求,而是保留固定窗口的计数,再用"上一个窗口的计数"按重叠比例做一个加权估算,用很小的内存近似出滑动窗口的效果。
# 滑动窗口计数器:用上一窗口的计数加权,内存小、近似精确
import time
class SlidingWindowCounter:
def __init__(self, limit: int, window_seconds: int):
self.limit = limit
self.window = window_seconds
self.cur_count = 0
self.prev_count = 0
self.cur_start = time.time()
def allow(self) -> bool:
now = time.time()
elapsed = now - self.cur_start
if elapsed >= self.window:
# 跨窗口:当前窗口变成"上一个",而非直接丢弃
self.prev_count = self.cur_count if elapsed < 2 * self.window else 0
self.cur_count = 0
self.cur_start = now
elapsed = 0
# 上一窗口还有多大比例落在"当前滑动窗口"内
prev_weight = (self.window - elapsed) / self.window
estimated = self.cur_count + self.prev_count * prev_weight
if estimated < self.limit:
self.cur_count += 1
return True
return False
这一节的认知是:限流算法之间的差别,很大程度上是"如何看待时间"的差别。固定窗口把时间看成一格一格、彼此割裂的抽屉,滑动窗口把时间看成一条连续流动的河,永远只看离当前最近的那一段。看待时间的方式不同,放行的决策就不同。而在两种滑动窗口的实现里,又藏着一个更普遍的工程权衡:滑动窗口日志精确但费内存,滑动窗口计数器省内存但只是近似。没有哪个绝对更好——流量不大、要求精确,用日志;流量很大、能接受一点点近似,用计数器。能看清"我在用什么方式对待时间""我在拿精度换什么",才算真正理解了限流,而不只是会调一个限流库。
三、漏桶算法:把请求强行整成恒定速率
滑动窗口解决了"窗口边界"的问题,但它和固定窗口一样,本质还是"在一段时间里数个数"。有时候我们要的不是"一段时间内总量不超标",而是"流出的速率必须恒定平稳"——比如下游是一个吞吐能力固定的系统,你必须以稳定的节奏喂给它,不能时快时慢。这就是漏桶算法的用武之地。漏桶的形象很直观:请求像水一样倒进一个桶,桶底有个洞,以恒定的速率漏水(漏出的水就是被放行、交给下游处理的请求);桶有固定容量,水位满了还往里倒,溢出的水(请求)就被丢弃。
# 漏桶:无论流入多突兀,流出永远是恒定速率
import time
class LeakyBucket:
def __init__(self, capacity: int, leak_rate: float):
self.capacity = capacity # 桶容量
self.leak_rate = leak_rate # 每秒漏出(放行)多少个
self.water = 0.0 # 当前水位
self.last_leak = time.time()
def allow(self) -> bool:
now = time.time()
# 按距离上次的时间,先漏掉应漏的水
leaked = (now - self.last_leak) * self.leak_rate
self.water = max(0.0, self.water - leaked)
self.last_leak = now
# 桶还没满,这个请求就能进桶(即被接纳)
if self.water + 1 <= self.capacity:
self.water += 1
return True
# 桶满了,溢出,拒绝
return False
漏桶的关键特性是:它彻底抹平了流入的波动。无论请求是均匀来的,还是一下子涌来一大批,经过漏桶之后,交给下游的速率永远是那个恒定的 leak_rate。这是它的优点,在某些场景下也是它的缺点——它不允许任何突发,哪怕下游此刻明明很空闲、完全有能力处理一波突发,漏桶也只会一滴一滴地漏。
这一节的认知是:漏桶限流的本质,不是"限制总量",而是"塑形速率"——它强行把任意形状的输入流量,整形成一条恒定速率的输出直线。这就引出一个选型上的关键问题:你到底想要什么形状的流量?如果你的下游是一个对速率波动很敏感、必须匀速喂食的系统,漏桶那种"绝对平稳"正是你要的。但如果你的下游其实有一定的弹性,偶尔能扛一下突发,那么漏桶"一律平滑、绝不允许突发"的刚性,反而会让系统的吞吐能力被白白浪费——下游空闲着,请求却还在桶里一滴一滴地排队。限流算法的选择,从来不是"哪个最先进",而是"哪个塑造出的流量形状,最匹配你下游的承受特性"。想要弹性、想要允许突发,就要看下一节的令牌桶。
四、令牌桶算法:控平均速率,又允许一定突发
令牌桶,是工程上用得最广的限流算法,因为它在"控制平均速率"和"允许短暂突发"之间取得了很好的平衡。它的模型和漏桶相反:桶里装的不是请求,而是令牌;系统以恒定速率往桶里放令牌,桶有容量上限,放满了就不再放。每个请求要通过,必须先从桶里拿走一个令牌;桶里有令牌就放行,没有就拒绝。
# 令牌桶:匀速发放令牌,桶里存量允许一定突发
import time
class TokenBucket:
def __init__(self, capacity: int, refill_rate: float):
self.capacity = capacity # 桶容量,决定能突发多大
self.refill_rate = refill_rate # 每秒放入多少令牌
self.tokens = float(capacity) # 初始装满
self.last_refill = time.time()
def allow(self, need: int = 1) -> bool:
now = time.time()
# 按流逝的时间补充令牌,但不超过桶容量
delta = (now - self.last_refill) * self.refill_rate
self.tokens = min(self.capacity, self.tokens + delta)
self.last_refill = now
# 令牌够就扣减放行,不够就拒绝
if self.tokens >= need:
self.tokens -= need
return True
return False
令牌桶的精妙之处,全在那个"桶容量"上。如果一段时间没有请求,令牌会在桶里积攒,最多攒到桶满。这时来了一波突发,只要不超过桶里积攒的令牌数,这波突发就能一次性全部通过——这就是它允许突发的来源。而长期来看,因为令牌是恒定速率放入的,通过的请求的平均速率,不可能超过这个放入速率。一句话:令牌桶限制的是平均速率,桶容量则决定了它能容忍多大的瞬时突发。漏桶整形输出,令牌桶约束平均、放行突发——这是两者最本质的区别。
把令牌桶的工作过程画出来,就是下面这张图:
[mermaid]
flowchart TD
A[请求到来] --> B[按距上次的时间补充令牌]
B --> C[令牌数不超过桶容量]
C --> D{桶里令牌是否足够}
D -->|足够| E[扣掉令牌 放行请求]
D -->|不足| F[拒绝请求 或排队等待]
G[系统以恒定速率持续投放令牌] --> B
这一节的认知是:令牌桶之所以成为工程上的默认选择,是因为它对真实流量的假设最贴合现实——真实的正常流量,本来就是"平均速率平稳、但带有自然的小突发"的。第一版固定窗口的一个隐藏毛病,是它把"突发"一概当成坏事来打击,可现实里,一个正常用户偶尔的、短暂的请求小高峰,是完全合理的。漏桶则走到另一个极端,把所有突发都抹平。令牌桶找到了中间那个最符合现实的位置:它用"放令牌的速率"守住了长期平均的底线,又用"桶容量"给正常突发留了一个明确的、可控的余地。选限流算法时,先问自己一句"我要不要允许突发、允许多大的突发"——大多数面向真实用户的场景,答案是"要,适度允许",而这正是令牌桶的主场。
五、分布式限流:计数必须放在共享存储里
到这里,四种算法都是"单机"的——计数器、水位、令牌数,都存在单个进程的内存里。这就埋着第一版那个"多实例各自为政"的雷。当你的服务部署成 N 个实例,每个实例进程里都有一份独立的限流状态,它们互相看不见对方。你设了"每秒 100",每个实例就各自放行 100,合起来实际是 100 乘以 N。限流值在多实例下被悄悄放大了 N 倍。
根因很清楚:限流的状态,本该是"整个服务全局共享"的一份,却被存成了"每个实例私有"的 N 份。解法也很直接:把这份状态从进程内存里挪出来,放进一个所有实例都能访问的共享存储,通常就是 Redis。但这里有个坑——限流的判断包含"读取当前状态、计算、写回新状态"这一连串操作,在多实例并发访问同一个 Redis key 时,这几步必须原子地完成,否则会有竞态。在 Redis 上保证原子性的标准做法,是把这套逻辑写成一段 Lua 脚本,由 Redis 单线程地、不可分割地执行它。
-- Redis 令牌桶限流脚本:读取、补充、扣减,原子完成
-- KEYS[1] 令牌桶的 key
-- ARGV[1] 桶容量 ARGV[2] 每秒放入速率 ARGV[3] 当前时间戳 ARGV[4] 本次需要的令牌数
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local need = tonumber(ARGV[4])
-- 取出上次记录的令牌数与时间戳
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local last = tonumber(data[2])
if tokens == nil then
tokens = capacity
last = now
end
-- 按流逝时间补充令牌,不超过桶容量
local delta = math.max(0, now - last) * rate
tokens = math.min(capacity, tokens + delta)
local allowed = 0
if tokens >= need then
tokens = tokens - need
allowed = 1
end
-- 写回新的令牌数与时间戳,并设置过期防止冷 key 堆积
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], 120)
return allowed
应用侧把这段脚本加载进 Redis,每次限流判断就调用它。所有实例调的是同一个 Redis、同一个 key、同一段原子脚本,于是无论有多少个实例,它们看到的都是同一份全局令牌桶状态。
# 应用侧:所有实例共用同一个 Redis 令牌桶
import time
import redis
class DistributedTokenBucket:
def __init__(self, redis_client, script_sha,
capacity: int, rate: float):
self.redis = redis_client
self.sha = script_sha # 预加载 Lua 脚本得到的 SHA
self.capacity = capacity
self.rate = rate
def allow(self, key: str, need: int = 1) -> bool:
now = time.time()
# evalsha 执行那段原子的限流脚本
allowed = self.redis.evalsha(
self.sha, 1, key,
self.capacity, self.rate, now, need,
)
return allowed == 1
# 启动时加载脚本,拿到 SHA 复用
# script_sha = redis_client.script_load(LUA_SCRIPT_TEXT)
# limiter = DistributedTokenBucket(redis_client, script_sha, 100, 100.0)
# limiter.allow("api:user:8848") # 按用户维度限流
这一节的认知是:一个限流算法在单机上成立,不代表它在分布式下还成立——决定它成立与否的,是"限流状态存在哪里"。限流状态必须有唯一的一份,且对所有实例可见。一旦它被复制成每个实例一份,限流的语义就破了,而且破得很隐蔽:每个实例自己看自己,都觉得限流工作得好好的,只有把全局流量加起来看,才发现总量是 N 倍。还要记住,把状态挪到共享存储,会引出一个新约束:涉及"读—改—写"的限流判断必须保证原子性,这就是为什么分布式限流几乎都要靠 Redis Lua 脚本这类机制。这是一个普遍的道理——很多在单机上理所当然成立的东西(计数、累加、状态机),一旦进入分布式,都要重新审视它的状态放在哪、并发访问安不安全。
六、把限流做扎实,要避开的工程坑
前面五节讲清了算法的选型和分布式落地。但要真正在生产里把限流用好,还有几个坑得专门讲。第一个,也是第一版踩过的:被限流拒绝的请求,要怎么体面地打发。只甩一个失败状态码,调用方不知道该等多久,往往会立刻重试,被拒的请求于是变成一轮接一轮的重试风暴,把本就紧张的服务压得更狠。正确做法是返回标准的 429 状态码,并带上 Retry-After 头,明确告诉调用方"过多少秒再来"。
# 被限流的请求:返回 429 并明确告知何时可重试
import math
def handle_request(request, limiter):
key = f"api:user:{request.user_id}"
if limiter.allow(key):
return process(request) # 正常处理
# 被限流:估算需要等待的秒数
retry_after = max(1, math.ceil(1.0 / limiter.rate))
return {
"status": 429, # Too Many Requests
"headers": {"Retry-After": str(retry_after)},
"body": {"error": "请求过于频繁,请稍后再试",
"retry_after": retry_after},
}
# 配合调用方:收到 429 应按 Retry-After 等待,
# 并叠加随机抖动错开重试,避免大家在同一刻一起重试
第二个坑是限流的粒度。一个"全局每秒一万"的限流,挡得住总量,却挡不住"某一个调用方占掉九千、把其他人挤死"的情况。所以限流通常要分维度:全局有一个总闸,同时按用户、按 IP、按 API 接口各有各的限流 key。粒度越细,越能精准地隔离"坏邻居",但需要的 key 也越多。第三个坑是限流组件自身的可用性:如果限流依赖的 Redis 挂了,你的限流判断该怎么办?是"一律放行"(fail-open,保业务可用但失去保护),还是"一律拒绝"(fail-closed,保护后端但业务不可用)?这要根据业务重要性提前想清楚、配置好,不能等 Redis 真挂了才现想。下面把四种算法的取舍放在一起对比:
四种限流算法的对比
算法 限制的是 允许突发 实现复杂度 典型场景
------------------------------------------------------------
固定窗口计数器 窗口内总量 边界处会 最低 要求不高的简单场景
滑动窗口 滑动段总量 否 中 要求平稳且较精确
漏桶 输出速率 否 中 下游需恒定速率喂食
令牌桶 平均速率 是 可控 中 面向用户的通用限流
选型口诀:多数面向用户的场景选令牌桶;下游要匀速选漏桶;
要精确控总量选滑动窗口;固定窗口仅用于要求很粗的场合。
还有几个坑值得点一下。其一,限流加在哪一层要想清楚——网关层限流能在请求进入业务系统前就挡掉,保护范围大;应用层限流能拿到更细的业务信息,粒度更准;两者常常配合使用。其二,限流的各项参数(阈值、桶容量)最好做成可动态调整的配置,而不是写死在代码里,这样大促前临时放宽、被攻击时紧急收紧,都不用重新发版。这几个坑串起来是同一个意思:限流不是一段"写完就不用管"的算法代码,它是一个需要被运营的系统能力。它要有合理的粒度划分,要对被拒的请求负责,要考虑自己依赖项挂掉时的退路,还要能被动态地调参。把算法选对只是开头,把这些工程上的事一并做到位,限流才真正成为一道靠得住的闸门。
关键概念速查
| 概念 | 说明 |
|---|---|
| 限流 | 给单位时间内通过的请求数设上限,保护后端不被过量流量冲垮 |
| 固定窗口计数器 | 把时间切成对齐的固定窗口分别计数,实现最简但有边界突发问题 |
| 临界突发 | 固定窗口边界两侧短时间内放进近两倍上限请求的现象 |
| 滑动窗口 | 窗口以当前时刻为终点连续滑动,消除了固定窗口的边界突发 |
| 滑动窗口日志 | 记录每个请求时间戳,精确但内存开销随流量增长 |
| 滑动窗口计数器 | 用上一窗口计数加权近似,内存小、精度略损 |
| 漏桶 | 把任意输入整形为恒定输出速率,不允许任何突发 |
| 令牌桶 | 匀速发放令牌,桶容量决定可容忍的突发,控平均速率又允许突发 |
| 分布式限流 | 限流状态放共享存储,多实例共用一份,判断须原子完成 |
| 429 与 Retry-After | 限流拒绝应返回的标准状态码与可重试时间提示 |
避坑清单
- 不要直接用固定窗口计数器扛重要流量:它在窗口边界会被放进接近两倍上限的请求。
- 不要忽视窗口边界:限流真正该约束的是"过去一段连续时间"的量,不是某个对齐窗口里的量。
- 不要把限流状态放进程内存:多实例部署下每个实例各算各的,实际通过量被放大 N 倍。
- 不要在分布式限流里忽略原子性:读改写必须原子完成,用 Redis Lua 脚本保证。
- 不要一概打击突发:正常用户的短暂突发是合理的,需要允许突发就选令牌桶。
- 不要拿令牌桶当漏桶用:要恒定输出速率喂下游用漏桶,要控平均又放突发用令牌桶。
- 不要只甩一个失败给被拒请求:返回 429 并带 Retry-After,否则会引发立即重试风暴。
- 不要只做全局限流:按用户、IP、接口分维度,才能隔离掉抢占资源的坏邻居。
- 不要忽略限流组件自身故障:提前定好 Redis 挂掉时是放行还是拒绝。
- 不要把限流参数写死:阈值和桶容量做成动态配置,才能不发版地放宽或收紧。
总结
回头看第一版那个"每分钟数个数"的限流,它的错误很典型。它不在某一行代码,而在一个对限流的根本误解:以为限流就是计数加阈值,数到上限就拒绝。真相是,"计数"只是限流最表层的动作,它真正的难点在于——计数依附的时间窗口怎么定义、计数怎么在多实例间共享成全局唯一的一份、以及你想要放行的流量呈什么形状。第一版只做了表层的计数,把底下这几层全漏了,所以一上线就被窗口边界击穿、被多实例稀释、被一刀切的拒绝伤到正常用户。
而把限流做对,工程量并不小。它不是选一个算法那么简单,而是要看懂固定窗口的边界缺陷、理解滑动窗口对时间的连续看法、分清漏桶整形速率和令牌桶约束平均的区别,再把选定的算法用 Redis 和 Lua 正确地落到分布式环境里,最后还要补上粒度划分、被拒请求的处理、组件故障的退路、参数的动态调整。一道靠得住的限流闸门,是这些环节一个不少地拼起来的。
这件事其实很像一个热门景点的入园管理。最笨的办法,是每小时整点开闸,这一小时放进一万人就关闸——可整点前后那一小段,前一小时的末尾和后一小时的开头挤在一起,门口能瞬间涌进两万人,这就是固定窗口的临界突发。聪明的景点用的是令牌桶式的管理:按一个恒定的节奏发放门票,平时没人来,门票可以稍微攒一点,于是一个旅行团忽然到了,凭着攒下的票能顺畅地一次放进去(允许突发);但长期看,放进园的人的平均速度,不会超过发票的速度(控平均)。而如果这个景点有好几个入口,那发票这件事必须由一个中央票务系统统一管,绝不能每个入口自己印自己的票——否则总人数就失控了,这就是分布式限流。
这类问题还有一个共同的麻烦:它在本地几乎暴露不出来。你在开发机上写个脚本测限流,用单实例、用对齐的时间、用均匀的请求,固定窗口看起来工作得好好的——因为你恰好没去踩窗口边界,恰好只有一个实例,恰好没有真实的突发。真正会把这些裂缝撑开的,是上线后多实例的部署、是真实调用方那种不迁就你窗口边界的连续流量、是花样百出的突发和恶意刷量。所以如果你正在给一个服务加限流,别等线上出现两倍流量、别等多实例把限流值悄悄放大,才回头补这一课。在写下限流的第一行代码时,就把"窗口怎么算、状态放哪里、要不要允许突发"这三个问题想清楚——这是这篇文章最想留给你的一句话。
—— 别看了 · 2026