2022 年我负责一个商品详情接口。平时流量平稳,这个接口扛得很轻松。直到一次运营搞促销,活动开始的那一瞬间,流量几十倍地涌了进来。我盯着监控,看着接口的响应时间从几十毫秒一路飙到几秒,然后是大面积超时,最后整个服务挂了——不只是这一个接口,连带着同一个服务里别的功能也全部不可用。复盘时我才看清:促销的流量本身不是错,错的是我的服务对"一瞬间会涌进来多少请求"毫无防备。每个请求进来都老老实实地去查数据库,几万个请求同时砸过来,数据库连接池一瞬间被占满,后面的请求开始排队、等待、超时,工作线程一个接一个被拖死,服务雪崩。我第一反应是加机器,可运营的促销说来就来,我不可能为了几分钟的峰值常年养着十倍的机器。正确的思路是限流:在请求真正打到数据库之前,先挡住超过系统承载能力的那一部分。于是我加了一个最朴素的"固定窗口计数器"——每秒最多放 1000 个请求进来。本地一测,完美。可下一次促销,系统还是在某个瞬间被打出了问题。我又懵了:我明明限了每秒 1000,怎么还会被冲垮?盯着日志看了很久才想明白:固定窗口在两个窗口的交界处,会放进将近两倍的请求。我一直以为"限流就是数个数,够了就拒绝",而真相是——限流是一整套关于时间和速率的算法,固定窗口、滑动窗口、漏桶、令牌桶,每一种都在用不同的方式回答同一个问题:怎样才算"超速"。算法选错了,你的限流器在 Demo 里看着稳如泰山,在真实的流量尖峰面前却照样会漏。那次之后我才认真把限流这件事从头搞明白。这篇文章就把它梳理一遍:为什么需要限流、固定窗口错在哪、滑动窗口怎么补、漏桶和令牌桶差在哪,以及把限流真正做进生产要避开的那些坑。
问题背景
先把那次事故的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:促销活动带来几十倍的流量尖峰,没有限流时,请求直接打满数据库连接池,引发请求排队、超时、线程耗尽,整个服务雪崩。加了"固定窗口每秒 1000"之后,系统仍在某个瞬间被冲垮。
我当时的错误认知:"限流就是数个数——开一个计数器,每来一个请求加一,到了上限就拒绝,过一秒清零重来,就这么简单。"
真相:限流真正的难点,根本不在"数个数"那一下,而在"怎么数":时间窗口怎么划分、速率怎么定义、要不要允许突发。固定窗口实现最简单,但它在窗口边界会放进近两倍流量;滑动窗口消除了边界突刺,但两种实现各有内存与精度的代价;漏桶强制请求匀速流出;令牌桶则在限速的同时允许短暂突发。再加上分布式部署下,计数器还必须放进所有实例共享的存储里。选错算法、或在分布式下用了进程内计数器,你的限流器就会在某个并发的瞬间悄悄失效。
要把限流做稳,需要几块认知:
- 为什么没有限流的系统会被流量尖峰直接打垮;
- 固定窗口计数器为什么会在边界放进两倍流量;
- 滑动窗口怎么消除边界突刺,它的两种实现各有什么代价;
- 漏桶和令牌桶的根本区别——一个强制匀速,一个允许突发;
- 分布式限流、限流维度、被限流后的响应这些工程坑怎么处理。
一、为什么需要限流:系统的承载上限是真实存在的
先想清楚一件事:任何系统的处理能力都有一个上限,这个上限是物理存在的、绕不过去的。
你的服务能开多少工作线程、数据库连接池有多少个连接、下游接口每秒能扛多少调用——这些都是有限的数字。平时流量在上限以下,系统跑得好好的,你甚至感觉不到上限的存在。可一旦流量越过这条线,情况不是"变慢一点"那么温和:请求开始抢占有限的连接和线程,抢不到的就排队,排队又进一步拉长每个请求的耗时,耗时拉长导致连接占用更久、释放更慢,于是队列越堆越长……这是一个正反馈,它不会停在某个"慢但能用"的稳态,而是一路滑向彻底崩溃。下面这个接口,就是完全没有这条防线的样子:
from fastapi import FastAPI
app = FastAPI()
@app.get("/product/{pid}")
def get_product(pid: int):
# 反面教材:接口对"一瞬间涌进来多少请求"毫无防备。
# 每个请求进来都直接去查数据库 —— 平时流量低时毫无问题,
# 可一旦促销流量几十倍涌入,数据库连接池瞬间被占满,
# 后面的请求排队、超时,工作线程被一个个拖死,服务雪崩。
return query_db("SELECT * FROM product WHERE id = %s", pid)
限流要做的事,本质上很简单:给系统画一条线,守住它。在请求真正消耗那些有限资源(连接、线程、下游配额)之前,先判断当前的请求速率有没有越线;越线了,就把多出来的请求快速拒绝掉,而不是放它们进来一起排队等死。
这里有个反直觉但极其重要的点:限流的目的不是让所有请求都成功,而是在过载时保住系统不崩。被限流拒绝的那部分请求,确实失败了——但代价是清晰的、可控的:一部分人收到"请稍后重试",而系统整体still活着,另一大部分人的请求正常完成。反过来,如果不限流,放任所有请求涌进来,结局是所有人的请求都因为雪崩而失败。限流是一种主动的、有损的取舍:用"拒绝一部分"换"保住全部"。想清楚这个取舍,后面那些算法才有意义——它们争论的,无非是"怎样判断越线"才最准。
二、固定窗口计数器:最简单,也最容易被击穿
最直观的限流算法,就是我当初写的那个:把时间切成一段段固定长度的"窗口"(比如每 1 秒一个窗口),给每个窗口配一个计数器,请求来了就加一,超过上限就拒绝,进入下一个窗口时计数清零。
import time
class FixedWindowLimiter:
"""固定窗口计数器:把时间切成定长窗口,每个窗口内独立计数。"""
def __init__(self, limit: int, window: float):
self.limit = limit # 每个窗口最多放行多少请求
self.window = window # 窗口长度(秒)
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
这段代码逻辑清晰、实现简单,平时跑也确实有效。但它藏着一个致命的边界问题,正是它让我第二次促销又翻了车。
假设限额是"每秒 1000"。窗口在每个整秒清零。现在设想这样一种流量分布:有 1000 个请求集中在第一个窗口的最后半秒(比如 0.5 秒到 1.0 秒)到达,全部放行;紧接着又有 1000 个请求集中在第二个窗口的前半秒(1.0 秒到 1.5 秒)到达,因为窗口刚清零,也全部放行。于是在 0.5 秒到 1.5 秒这横跨边界的整整 1 秒之内,系统实际放行了 2000 个请求——是你设定的限额的两倍。你的下游,正是被这"任意 1 秒内的真实峰值"打垮的,而固定窗口计数器对此一无所知,它只会在每个整秒骄傲地汇报"本窗口放行 1000,没超"。
这个边界突刺的根源是:固定窗口把时间切成了互不相干的段,它只统计"某一个窗口内"的量,却管不住"任意一个滑动的 1 秒内"的量——而下游承受的,恰恰是后者。要堵上这个洞,就得让"窗口"本身能跟着当前时刻平滑地移动。
三、滑动窗口:把窗口做得更平滑
滑动窗口的核心思想,是让统计的窗口永远是"此刻往前回看固定一段时间",而不是被切死在整秒上。它有两种典型实现,精度和成本各不相同。
第一种是滑动窗口日志:把每一个放行请求的时间戳都记下来。每来一个新请求,先把"已经超出回看范围"的旧时间戳全部丢掉,再看剩下的数量有没有到上限。它统计的就是不折不扣的"最近 window 秒内的真实请求数",精度最高:
import collections
import time
class SlidingWindowLog:
"""滑动窗口日志:记下每个请求的时间戳,窗口永远是此刻往前推 window 秒。"""
def __init__(self, limit: int, window: float):
self.limit = limit
self.window = window
self.timestamps = collections.deque()
def allow(self) -> bool:
now = time.time()
# 把窗口左边界之外(太旧)的时间戳从队头全部清掉
while self.timestamps and now - self.timestamps[0] >= self.window:
self.timestamps.popleft()
# 剩下的就是"最近 window 秒内"的真实请求数
if len(self.timestamps) < self.limit:
self.timestamps.append(now)
return True
return False
滑动窗口日志精确,但它有个代价你必须看见:它要为每一个放行的请求存一个时间戳。如果限额是"每秒十万",这个 deque 里随时躺着十万个时间戳,内存开销和清理开销都不小。在超高 QPS 的场景下,这个代价会变得难以接受。
于是有了第二种实现——滑动窗口计数器。它是一个聪明的折中:不存每个请求的时间戳,只保留当前窗口和上一个窗口两个计数;判断时,根据当前时刻在窗口里的位置,按重叠比例去加权估算"最近一个窗口"的请求量:
import time
class SlidingWindowCounter:
"""滑动窗口计数器:只存当前和上一个窗口的计数,按重叠比例加权估算。"""
def __init__(self, limit: int, window: float):
self.limit = limit
self.window = window
self.prev_count = 0
self.curr_count = 0
self.curr_start = time.time()
def allow(self) -> bool:
now = time.time()
elapsed = now - self.curr_start
if elapsed >= self.window:
# 滚动一格:相邻则当前窗口降级为"上一个",否则太久远直接清零
self.prev_count = self.curr_count if elapsed < 2 * self.window else 0
self.curr_count = 0
self.curr_start = now
elapsed = 0.0
# 上一个窗口还有多大比例"压"在当前的滑动窗口里
ratio = 1 - elapsed / self.window
estimated = self.prev_count * ratio + self.curr_count
if estimated < self.limit:
self.curr_count += 1
return True
return False
这个加权估算的妙处在于:窗口刚滚动时,上一个窗口的计数几乎全额计入(ratio 接近 1),随着时间推进它的权重平滑地衰减到 0。固定窗口那个"边界一过、上一秒的量瞬间归零"的突变被抹平了,所以边界突刺自然就没了。它的代价是结果是个估算值——它假设请求在窗口内均匀分布,这个假设不总成立,所以会有微小误差。但用两个整数的存储换掉十万个时间戳,再换来一个没有突刺的限流曲线,这个折中在绝大多数生产场景里都非常划算,它也是工业界用得最多的限流算法之一。
到这里,"窗口"这条路已经走得比较完整了。但窗口类算法有一个共同的特点:它们只回答"过去这段时间放行了多少",并不主动控制请求流出的节奏。下面两种算法,换了一个完全不同的视角。
四、漏桶:以恒定速率处理请求
漏桶算法换了个思路:它不去数"过去放行了多少",而是关心"请求流出去的节奏"。
想象一个底部有个小孔的水桶。请求就是往桶里倒的水,桶底以一个恒定的速率漏水——漏出去的水,就是被真正处理的请求。桶有一个固定的容量:水没满,新请求就能倒进来排队;水满了,新倒进来的水直接溢出,也就是请求被拒绝。它的关键特性是:无论你往桶里倒水的速度多猛多不均匀,桶底漏水的速率永远是恒定的。
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。如果你的下游极其脆弱、对速率波动零容忍(比如一个老旧的、严格按 QPS 收费的第三方接口),漏桶是最稳的选择。
但漏桶的优点反过来也是它的缺点:它不允许任何突发。哪怕系统此刻明明很空闲、完全有余力一口气多处理一些请求,漏桶也铁面无私地按恒定速率慢慢漏。对很多业务来说这是一种浪费——真实世界的流量本来就是一阵一阵的,用户也期望"平时点一下立刻就有响应"。我们往往希望:平均速率受控,但允许在系统有余力时短暂地爆发一下。这正是令牌桶要解决的。
五、令牌桶:既能限流,又允许突发
令牌桶把漏桶的桶"反过来用了"。漏桶装的是请求、控制的是出水;令牌桶装的是令牌,控制的是令牌的生成。
规则是这样的:系统以一个恒定速率往桶里放令牌,桶有容量上限,放满了就不再放。每个请求要被放行,必须先从桶里取走一个令牌;取得到就放行,取不到就拒绝。
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) -> bool:
now = time.time()
# 按流逝的时间补充令牌,但总量不超过桶容量
self.tokens = min(
self.capacity,
self.tokens + (now - self.last_refill) * self.refill_rate,
)
self.last_refill = now
# 桶里有令牌就取走一个、放行;一个都没有就拒绝
if self.tokens >= 1:
self.tokens -= 1
return True
return False
令牌桶的精妙,全在桶容量这一个参数上。设想系统空闲了一阵子:这期间没有请求来取令牌,令牌就一直在按速率往桶里攒,直到攒满 capacity 个。这时候突然来了一波流量,它可以一口气取走桶里攒下的全部令牌——这就是"突发":短时间内放行的请求数,可以达到 capacity。而一旦桶里攒的令牌被取空,后续请求就只能等令牌按 refill_rate 一个个慢慢生成,速率被拉回到了 refill_rate。
所以令牌桶一个算法,同时给了你两个旋钮:refill_rate 控制长期平均速率,capacity 控制允许多大的瞬间突发。这种"平时限速、闲时攒额度、忙时可短暂爆发"的特性,非常贴合真实业务的流量形态,所以令牌桶是目前应用最广的限流算法——你熟悉的很多网关、框架(如 Guava 的 RateLimiter、Nginx 的 limit_req)底层用的都是它或它的变体。把漏桶和令牌桶放一起记最清楚:要绝对平滑、零突发,用漏桶;要限住均值、又给突发留口子,用令牌桶。
六、工程坑:分布式限流、限流维度、被限流后怎么办
算法本身搞懂了,但要把限流真正用进生产,还有几个绕不开的工程坑。
坑 1:进程内的限流器,在多实例部署下会失效。前面所有那些类,计数器、桶,都是存在单个进程的内存里的。可现代服务几乎都是多实例部署的:你部署了 10 个实例,每个实例各自跑一个"每秒 1000"的限流器,那么整个系统实际放行的就是每秒 10000——限流额度被实例数悄悄放大了 10 倍。要做对,限流状态必须放进一个所有实例共享的存储里,通常是 Redis。而且"取令牌"这组"读令牌数、算补充、扣减、写回"的操作必须是原子的,否则多个实例并发取令牌会互相覆盖。标准做法是用 Lua 脚本,让 Redis 把整段逻辑作为一个不可分割的整体执行:
import time
# 分布式限流:限流状态放进所有实例共享的 Redis,
# 且"取令牌"这组读改写必须原子 —— 用 Lua 脚本一次完成。
TOKEN_BUCKET_LUA = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local tokens = tonumber(redis.call('hget', key, 'tokens') or capacity)
local last = tonumber(redis.call('hget', key, 'last') or now)
tokens = math.min(capacity, tokens + (now - last) * rate)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('hset', key, 'tokens', tokens, 'last', now)
redis.call('expire', key, 60)
return allowed
"""
def allow(redis, key: str, capacity: int, rate: float) -> bool:
# 整段 Lua 在 Redis 里原子执行,多实例并发也不会算错
return redis.eval(TOKEN_BUCKET_LUA, 1, key,
capacity, rate, time.time()) == 1
坑 2:限流维度——你到底在对"谁"限流?限流器的 key 怎么定,直接决定了限流的效果。如果用一个全局的 key 对所有流量限流,那一个恶意用户疯狂刷接口,就会把额度占满,正常用户全被误伤。通常要按更细的维度来:按用户 ID 限,防的是单个用户刷爆;按 IP 限,兜底匿名请求;还可以按"用户 + 接口"再细分,因为查询接口和下单接口的承受力完全不同。维度的粗细是个权衡——太粗会误伤,太细则限不住真正的攻击者:
def limiter_key(request) -> str:
# 限流维度:对谁限流?key 构造方式不同,效果天差地别。
user_id = request.headers.get("X-User-Id")
if user_id:
return f"ratelimit:user:{user_id}" # 按用户:防单个用户刷爆
return f"ratelimit:ip:{request.client.host}" # 匿名请求兜底到按 IP
# 还可按"用户 + 接口"再细分,如 ratelimit:user:123:/api/order
# —— 不同接口的承受力不同,该分开限,别用一把尺子量到底
坑 3:被限流的请求,要好好"拒绝"。限流拒绝一个请求时,绝不能简单粗暴地断开连接、或返回一个含糊的 500。规范的做法是返回 HTTP 状态码 429(Too Many Requests)——这个状态码有明确语义,客户端一看就知道"我被限流了,不是服务器坏了"。更进一步,要在响应里带上 Retry-After 头,明确告诉客户端"过多少秒之后再来",这样客户端就能据此退避重试,而不是立刻又发一遍、把限流器继续往死里撞:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
bucket = TokenBucket(capacity=200, refill_rate=100)
@app.middleware("http")
async def rate_limit(request: Request, call_next):
if not bucket.allow():
# 被限流:返回 429,并用 Retry-After 告诉客户端多久后再来
return JSONResponse(
status_code=429,
content={"error": "请求过于频繁,请稍后重试"},
headers={"Retry-After": "1"},
)
return await call_next(request)
下面这张图,把一个请求从到达、过限流器、到被处理或被拒的完整路径串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 限流 | 在请求消耗有限资源前,挡住超过系统承载能力的那部分流量 |
| 固定窗口 | 时间切成定长窗口逐窗计数,实现最简单,但边界处会放行两倍流量 |
| 边界突刺 | 固定窗口两窗交界的任意 1 秒内可能涌入近 2 倍限额,下游被它打垮 |
| 滑动窗口日志 | 记录每个请求时间戳,精度最高,但高 QPS 下内存开销大 |
| 滑动窗口计数器 | 只存两个窗口计数按比例加权估算,抹平突刺,精度与开销的折中 |
| 漏桶 | 请求以恒定速率流出,输出绝对平滑,但完全不允许突发 |
| 令牌桶 | 按速率攒令牌,refill_rate 控均值、capacity 控突发,应用最广 |
| 分布式限流 | 状态放共享 Redis,取令牌用 Lua 脚本保证原子,避免按实例数翻倍 |
| 限流维度 | 按用户 / IP / 接口构造 key,太粗会误伤、太细则限不住攻击者 |
| 429 / Retry-After | 被限流返回 429,用 Retry-After 头让客户端退避重试 |
避坑清单
- 任何系统的连接、线程、下游配额都有物理上限,流量越线后是雪崩式崩溃而非平缓变慢,必须用限流守住这条线。
- 限流的目的不是让所有请求都成功,而是过载时主动拒绝一部分、保住系统整体可用——是"拒绝一部分换保住全部"的取舍。
- 固定窗口计数器实现最简单,但两个窗口交界处会放行近两倍限额,下游承受的是"任意滑动 1 秒内"的峰值。
- 滑动窗口日志精度最高,但要为每个请求存时间戳,高 QPS 下内存与清理开销大。
- 滑动窗口计数器只存两个窗口计数、按重叠比例加权估算,抹平了边界突刺,是工业界最常用的折中方案。
- 漏桶以恒定速率流出、输出绝对平滑,但完全不允许突发;下游对速率波动零容忍时才选它。
- 令牌桶用 refill_rate 控长期均值、capacity 控突发上限,贴合真实流量形态,是应用最广的算法。
- 进程内限流器在多实例部署下会被实例数放大,必须把状态放进共享 Redis,且取令牌操作要用 Lua 脚本保证原子。
- 限流维度决定效果:全局一把尺子会误伤正常用户,要按用户 / IP / 接口构造 key,粗细之间做权衡。
- 被限流的请求要返回 429 并带 Retry-After 头,让客户端明确知道原因并退避重试,别用含糊的 500 或直接断连。
总结
回头看那次促销把服务打挂的事故,以及我加了限流之后第二次又翻车,最该记住的不是某一段令牌桶代码,而是我动手前那个想当然的判断——"限流就是数个数,够了就拒绝"。这句话错在它把限流当成了一个计数问题,而它其实是一个时间和速率的问题。"这一秒放了多少个"听起来天经地义,可下游根本不按你的整秒来承受流量,它承受的是任意一个滑动的时间窗里的真实峰值。你用整秒切出来的计数器去对账,边界那一下的两倍突刺,你永远也看不见。
所以做限流,真正的功夫不在"写一个计数器"。计数器谁都会写,Demo 里它也确实能拦住流量。真正的功夫在于看清每种算法到底在用什么方式定义"超速",以及这个定义会在什么地方漏:固定窗口漏在边界,所以有了滑动窗口;滑动窗口日志漏在内存,所以有了滑动窗口计数器;窗口类算法都不控制流出节奏,所以有了漏桶;漏桶不给突发留余地,所以有了令牌桶。这篇文章的几节,其实就是顺着这条"一个算法补上一个算法的洞"的链条展开的——你理解了这条链,才不会在选型时拍脑袋,而是能对着自己的业务问:我的下游能不能容忍突发?我的 QPS 有多高?我是不是多实例部署?
你会发现,这套思路和我们处理任何"过载"问题的工程经验是相通的。我们给数据库连接池设上限,给线程池设队列长度,给消息队列设积压告警——本质上都是承认"资源是有限的",并且主动决定"超出部分怎么办",而不是被动地等系统自己崩给你看。限流只是把这种"为有限资源主动设防"的纪律,用在了入口流量这件事上。它不创造处理能力,它做的是更诚实的一件事:在系统还清醒的时候,替它说出那句"我今天最多只能接这么多"。
最后想说,限流做没做对,差距永远不会在 Demo 里暴露——Demo 的流量是你自己手点出来的,平缓、均匀、量也小,固定窗口、令牌桶随便挑一个都跑得好好的。它只在真实的促销洪峰、真实的恶意刷量、真实的多实例部署面前才显形。那时候它会用最难看的方式给你结账:一次边界突刺让下游瞬间过载,一个没做分布式的限流器让额度悄悄翻了十倍。所以别等运营拿着"服务又挂了"的截图来找你,在你写下第一个限流器的时候就该想清楚:它在窗口边界会不会漏?它在多实例下还准不准?它拒绝请求时,客户端知道该过多久再来吗?这几个问题都有了答案,你的限流器才不只是 Demo 里那个看着安心的计数器,而是一道在洪峰面前真正守得住的闸门。
—— 别看了 · 2026