2023 年我做一个对外开放的 API 服务。为了不被人滥用,产品要求给每个用户限流:每分钟最多 100 次请求,超了就拒绝。第一版我做得很直接:用一个计数器,记下"这一分钟"这个用户调了多少次,到 100 就拒绝;等下一分钟,计数器清零,重新开始数。本地一测——很完美:一分钟内连发 100 次正常,第 101 次准确地被拒掉;过了这一分钟,又能正常发了。我心里很踏实:"限流不就是数个数嘛,这不就做完了。"可上线之后,服务还是三番五次地被某些用户打到过载,监控上的请求曲线时不时窜起一根尖刺。我盯着日志查了很久,终于定位到那个诡异的现象:某个用户,在 10:00:59 这一秒,把"10:00 这一分钟"的 100 次额度用满;紧接着 10:01:00,新的一分钟开始、计数器清零,他立刻又发了 100 次。结果就是——在 10:00:59 到 10:01:00 这短短两秒里,他合法地发出了 200 次请求。我的"每分钟 100 次"限流,在窗口的临界点上,被轻松突破成了"两秒 200 次"。我盯着这根尖刺想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"限流就是按整分钟数个数"。可这个想法有个致命的破绽:它把时间切成了一段段互不相干的"格子",每个格子各数各的。可用户的请求不认识你的格子——他完全可以骑在两个格子的接缝上,在接缝的左边用满一个格子,在接缝的右边立刻用满下一个格子。限流真正要管的,是"任意一个 60 秒的滑动区间里不超过 100 次",而不是"对齐到整分钟的那个格子里不超过 100 次"。这两件事听起来一样,差别却是致命的。这篇文章就把限流梳理一遍:为什么固定窗口会在临界点漏进双倍流量、滑动窗口怎么把这个洞补上、漏桶和令牌桶各自怎么工作、它们之间到底怎么选,以及分布式限流、限流维度、被限流之后怎么办这些把限流真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个开放 API,限流规则是"每个用户每分钟 100 次"。第一版用固定窗口计数器实现:按整分钟数数,到点清零。本地测试一切正常,但上线后服务仍被打到过载,监控曲线上时不时窜起尖刺。排查发现:某用户在某一分钟的最后一秒发满 100 次,又在下一分钟的第一秒发满 100 次,两秒内涌入 200 次请求。
我当时的错误认知:"限流就是按整分钟数个数,数到 100 就拒绝、到点清零,这就够了。"
真相:把时间对齐成固定的整分钟格子分别计数,会在相邻格子的接缝处留下一个洞——用户骑在接缝上,能在极短时间里发出接近两倍的流量。限流真正要保证的是"任意一个 60 秒滑动区间内不超限"。要做到这一点,需要滑动窗口;而如果你还想控制请求的速率、平滑突发,就要用到漏桶和令牌桶。四种算法,精度、内存、是否允许突发各有取舍。
要把限流做对,需要几块认知:
- 为什么固定窗口会在临界点漏进双倍流量——格子是死的,流量是活的;
- 滑动窗口日志与滑动窗口计数器——怎么补上临界点的洞;
- 漏桶算法——把忽快忽慢的流量整成恒定速率;
- 令牌桶算法——既限平均速率,又允许一定的突发;
- 分布式限流、限流维度、被限流后怎么办这些工程坑。
一、为什么固定窗口会在临界点漏进双倍流量
先把这件最根本的事钉死:固定窗口把时间切成对齐整点的、互不相干的格子,每个格子各数各的;用户只要骑在两个格子的接缝上,就能在接缝两侧各用满一个格子,瞬间发出接近两倍的流量。
下面这段代码,就是我那个"会在临界点漏流量"的第一版——固定窗口计数器:
import time
class FixedWindowLimiter:
"""固定窗口计数器:按对齐整点的窗口计数,到点清零。"""
def __init__(self, limit: int, window: int = 60):
self.limit = limit # 每个窗口允许的次数
self.window = window # 窗口长度(秒)
self.counts = {} # {窗口编号: 该窗口已用次数}
def allow(self, user: str) -> bool:
now = time.time()
# 关键:窗口编号 = 当前时间整除窗口长度。
# 它把时间【对齐】成一个个固定的格子。
win_id = int(now // self.window)
key = (user, win_id)
used = self.counts.get(key, 0)
if used >= self.limit:
return False # 这个格子用满了,拒绝
self.counts[key] = used + 1
return True
# 破绽:win_id 是【跳变】的。10:00:59 和 10:01:00
# 属于两个不同的 win_id,各自有满额的 limit 次。
# 用户在接缝两侧各打满,2 秒就能发出 2 倍流量。
这段代码没有任何语法错误,逻辑也自洽——对任何一个对齐整分钟的窗口,它确实把次数卡在了 100 以内。它的问题不在代码本身,而在一个错误的时间观:它默认"一分钟"是从整点开始的那个固定区间。可对用户来说,"一分钟"是从他第一次请求那一刻起往后的 60 秒——这两个"一分钟"根本不是同一个东西。win_id 在整点那一瞬间啪地跳变,上一个窗口的额度瞬间作废、下一个窗口的额度瞬间满血。一个聪明的(或恶意的)用户,只要盯着这个跳变点,就能在跳变前的一瞬和跳变后的一瞬各打满一次。问题的根子清楚了:你不该问"这个对齐整点的格子里用了多少次",你该问"从现在往前数 60 秒,一共用了多少次"。
二、滑动窗口日志:精确地数清最近 60 秒
上一节的死结是:固定窗口的格子是跳变的。要补这个洞,思路其实很朴素:既然要管的是"最近 60 秒",那就把每一次请求的时间戳都记下来;新请求来时,把 60 秒之前的旧记录全部丢掉,再看剩下的记录有多少条。这就是滑动窗口日志(Sliding Window Log)。
from collections import deque
class SlidingWindowLog:
"""滑动窗口日志:记下每次请求的时间戳,精确统计最近 window 秒。"""
def __init__(self, limit: int, window: int = 60):
self.limit = limit
self.window = window
self.logs = {} # {user: deque([时间戳, ...])}
def allow(self, user: str) -> bool:
now = time.time()
dq = self.logs.setdefault(user, deque())
# 关键:把 window 秒之前的旧时间戳【全部清掉】。
# 剩下的,就是"最近 window 秒"内的真实请求。
while dq and dq[0] <= now - self.window:
dq.popleft()
if len(dq) >= self.limit:
return False # 最近 60 秒已满,拒绝
dq.append(now) # 记下这一次
return True
# 它没有"格子"了 —— 窗口随每个 now 平滑地往前滑,
# 不存在跳变点,临界突刺的洞被彻底堵上。
滑动窗口日志彻底消灭了临界点的洞:它的窗口不是跳变的格子,而是随每一次请求平滑往前滑动的 60 秒。无论用户在什么时刻发请求,它统计的永远是"截至此刻、往前 60 秒"——接缝不存在了。但它有个明显的代价:它要给每一次请求都存一个时间戳。如果限流是"每分钟一万次",那它就得同时存一万个时间戳;用户一多,内存蹭蹭涨。它精确,但不便宜。有没有办法,既堵住临界点的洞,又不用存这么多东西?
三、滑动窗口计数器:用一点精度换大量内存
滑动窗口日志贵在"每个请求存一个时间戳"。滑动窗口计数器(Sliding Window Counter)的思路是:退回去用计数器(只存一个数,不存每个时间戳),但不止看当前窗口——它同时看当前窗口和上一个窗口,再按当前时刻在窗口里的位置,给上一个窗口的计数打一个折,加权估算出"最近 60 秒"的近似值。
class SlidingWindowCounter:
"""滑动窗口计数器:当前窗口 + 上一窗口加权,估算最近 window 秒。"""
def __init__(self, limit: int, window: int = 60):
self.limit = limit
self.window = window
self.counts = {} # {(user, 窗口编号): 次数}
def allow(self, user: str) -> bool:
now = time.time()
win_id = int(now // self.window)
# elapsed:当前时刻已经走进当前窗口多深(0.0 ~ 1.0)
elapsed = (now % self.window) / self.window
cur = self.counts.get((user, win_id), 0)
prev = self.counts.get((user, win_id - 1), 0)
# 核心公式:上一窗口的计数,按"还没滑出去的比例"加权计入。
# 刚进入新窗口时 elapsed≈0,上一窗口几乎全额计入;
# 快走完当前窗口时 elapsed≈1,上一窗口几乎不计入。
estimated = prev * (1 - elapsed) + cur
if estimated >= self.limit:
return False
self.counts[(user, win_id)] = cur + 1
return True
这个加权公式,是滑动窗口计数器的灵魂。还拿 10:01:00 那个临界点说:此刻 elapsed 几乎是 0,于是上一分钟(10:00)那 100 次会几乎全额地被算进来——estimated 直接逼近 100,用户立刻就被拦住了,临界突刺不再发生。它的代价是精度上的近似:这个公式假设上一窗口的请求是均匀分布的,如果实际分布很不均匀,估算值会略有偏差。但它只需存两个计数器,内存极省。这是一笔非常划算的交易,也是工业界用得最多的限流算法之一。不过,前面这三种算法都只解决"数量"问题。还有一类需求它们答不了——我不光要限总数,我还想让请求匀速地被处理。
四、漏桶算法:把忽快忽慢的流量整成恒定速率
设想一个场景:你的下游是一个很娇贵的服务,它受不了流量忽高忽低,你希望无论上游怎么抖,转发给它的请求都是平稳、恒定速率的。滑动窗口做不到这件事——它只管"60 秒内别超 100",至于这 100 次是均匀来的还是挤在 1 秒里来的,它不管。能管这个的,是漏桶算法(Leaky Bucket)。
它的比喻就是字面意思:一只底部有个小孔的桶。请求像水一样从上面倒进来(速率忽快忽慢),而水只能从小孔以恒定速率漏出去。桶满了,再倒进来的水就溢出(请求被拒)。
class LeakyBucket:
"""漏桶:水以恒定速率漏出,桶满则溢出(拒绝)。"""
def __init__(self, capacity: int, leak_rate: float):
self.capacity = capacity # 桶容量
self.leak_rate = leak_rate # 漏出速率(个/秒)
self.water = 0.0 # 当前水量
self.last = time.time()
def allow(self) -> bool:
now = time.time()
# 先按流逝的时间,把这段时间该漏掉的水漏掉
leaked = (now - self.last) * self.leak_rate
self.water = max(0.0, self.water - leaked)
self.last = now
if self.water + 1 > self.capacity:
return False # 桶要溢出了,拒绝这个请求
self.water += 1 # 请求作为"一滴水"进桶
return True
# 注意:漏桶严格按 leak_rate 出水。哪怕桶里有水,
# 也得等"漏"的节奏 —— 它牺牲了突发,换来绝对平稳的输出。
漏桶的性格很鲜明:它极度追求平稳。出水速率是铁打的恒定值,哪怕桶里积了很多水,也一滴一滴匀速地往外漏,绝不会因为水多就漏快。这让它非常适合"保护娇贵下游"。但它的固执也是缺点:假设系统很闲、桶是空的,这时突然来了一小批请求——理论上系统完全有能力立刻处理,可漏桶偏不,它还是按那个恒定速率慢慢漏。它不允许任何突发。可现实里,大多数业务是希望"系统闲时,允许用户痛快地突发一下"的。这个需求,要靠下一个算法。
五、令牌桶算法:限平均速率,又允许突发
漏桶不近人情:系统再闲也不让你突发。令牌桶算法(Token Bucket)把这件事反过来想,因而成了最受欢迎的限流算法。它也有一只桶,但桶里装的不是水(请求),而是令牌。系统以恒定速率往桶里放令牌;每个请求来,要先从桶里拿走一个令牌才能通过,拿不到就被拒。
class TokenBucket:
"""令牌桶:以恒定速率添加令牌,请求需取走令牌才放行。"""
def __init__(self, capacity: int, refill_rate: float):
self.capacity = capacity # 桶最多存多少令牌
self.refill_rate = refill_rate # 每秒补充多少令牌
self.tokens = float(capacity) # 初始装满
self.last = time.time()
def allow(self, need: int = 1) -> bool:
now = time.time()
# 按流逝的时间补充令牌,但不超过桶容量
delta = (now - self.last) * self.refill_rate
self.tokens = min(self.capacity, self.tokens + delta)
self.last = now
if self.tokens >= need:
self.tokens -= need # 取走令牌,放行
return True
return False # 令牌不够,拒绝
# 妙处:系统闲时令牌【攒着】,最多攒满 capacity 个。
# 这时来一批请求,可以【一次性取走】这堆攒下的令牌 ——
# 这就是"允许突发",突发上限正好是 capacity。
令牌桶的巧妙,全在那句"令牌会攒着"上。系统闲的时候,令牌没人取,就一直累积,直到把桶攒满(capacity 个)。这时如果突然来一批请求,它们可以一口气把攒下的令牌全取走——于是用户体验到的是"系统闲时,我可以痛快地爆发一下"。而一旦桶里的令牌取空,后续请求就只能等令牌按 refill_rate 慢慢补,速率被拉回到长期平均值。所以令牌桶同时管住了两件事:长期平均速率由 refill_rate 决定,瞬时突发上限由 capacity 决定。这个"平时严格、闲时宽容"的性格,恰好契合绝大多数真实业务。它和漏桶正好相反:漏桶整输出(出水恒定),令牌桶整输入(发令牌恒定,但允许攒)。把它包成一个限流器拿来用,通常长这样:
_buckets = {} # {user: TokenBucket},每个用户一个独立的桶
def rate_limit(user: str) -> bool:
"""对外的限流入口:每个用户一个令牌桶。
capacity=100 意味着突发最多 100,refill=100/60 即每分钟 100。"""
bucket = _buckets.get(user)
if bucket is None:
bucket = TokenBucket(capacity=100, refill_rate=100 / 60)
_buckets[user] = bucket
return bucket.allow()
# 用在接口里(以 Flask 为例)
def handle_request(user: str):
if not rate_limit(user):
return {"error": "too many requests"}, 429
return {"data": "..."}, 200
到这里,四种算法各就各位了。但这些代码都有一个共同的隐患:它们的计数器、桶,全都存在单个进程的内存里。一旦你的服务部署了多个实例,麻烦就来了——这正是第六节要面对的工程坑。
六、工程坑:分布式限流、限流维度与被限流后怎么办
四种算法讲完了,但要把限流真正用在生产上,还有几个绕不开的坑。坑 1:多实例部署时,内存版限流会失效。前面所有代码的计数器都在进程内存里。假设你的服务部署了 5 个实例,负载均衡把请求分摊到它们身上——每个实例各自维护一份"每分钟 100 次",合起来用户实际能发 500 次。限流被实例数悄悄放大了 5 倍。解法是把限流状态挪到一个共享存储里,通常是 Redis,并且用 Lua 脚本保证原子性(否则"读计数、判断、写计数"这几步之间会有并发竞争):
import redis
r = redis.Redis()
# 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)
-- 按流逝时间补令牌,不超过容量
local filled = math.min(capacity, tokens + (now - last) * rate)
local allowed = 0
if filled >= 1 then
filled = filled - 1
allowed = 1
end
redis.call('hset', key, 'tokens', filled, 'last', now)
redis.call('expire', key, 120)
return allowed
"""
_script = r.register_script(TOKEN_BUCKET_LUA)
def distributed_allow(user: str) -> bool:
"""分布式令牌桶:状态存 Redis,Lua 保证整段逻辑原子执行。"""
ok = _script(keys=[f"rl:{user}"],
args=[100, 100 / 60, time.time()])
return ok == 1
用 Lua 脚本的关键,是原子性:补令牌、判断、扣令牌这三步必须作为一个整体执行,中间不能被其它请求插队。否则两个并发请求同时读到"还剩 1 个令牌",会双双放行,令牌被超扣。坑 2:限流维度要选对。"每分钟 100 次"——这个"每"到底是每用户、每 IP、还是每接口?按 IP 限,同一个公司出口 NAT 后共用一个 IP 的用户会互相挤占;按用户限,没登录的请求没有用户 ID可用。通常要组合:登录用户按 user_id、匿名请求按 IP,核心接口和普通接口用不同的额度。维度选错,限流要么误伤好人,要么拦不住坏人。
坑 3:被限流的请求,不能只是粗暴地丢掉。当你拒绝一个请求,要给客户端足够的信息,让它知道该怎么办。标准做法是返回 HTTP 429(Too Many Requests),并在响应头里带上 Retry-After(过多久可以再试)和限流额度信息:
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/api/data")
def api_data():
user = request.headers.get("X-User-Id") or request.remote_addr
if not distributed_allow(user):
# 关键:不是丢掉就完了,要告诉客户端【多久后重试】
resp = jsonify({"error": "rate limit exceeded"})
resp.status_code = 429
resp.headers["Retry-After"] = "5" # 5 秒后再来
resp.headers["X-RateLimit-Limit"] = "100"
return resp
return jsonify({"data": "your data here"})
带上 Retry-After,规矩的客户端会乖乖等待再重试,而不是立刻、疯狂地重试——后者会让本已过载的系统雪上加霜。坑 4:限流不是越严越好。限流的目的是保护系统,不是为难用户。阈值定得太低,正常用户动不动被拦,体验极差;定得太高,又起不到保护作用。合理的做法是看真实流量的分位数来定(比如取 P99 用户用量的若干倍),并留好监控,根据"被限流的比例"持续调整。坑 5:核心接口和非核心接口要分开限。下单、支付这种核心接口,和某个边角的查询接口,不该共用一个限流池——否则边角接口被刷,会连累核心接口也没额度了。下面这张图,把一次带分布式限流的请求处理串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 固定窗口计数器 | 按对齐整点的格子计数到点清零,实现最简单但临界点会漏进双倍流量 |
| 临界突刺问题 | 用户骑在两个固定窗口接缝上,各打满一个窗口,极短时间内发出近两倍流量 |
| 滑动窗口日志 | 记录每个请求时间戳,清掉过期的再计数,绝对精确但每请求都耗内存 |
| 滑动窗口计数器 | 当前窗口加上一窗口按比例加权估算,只存两个计数器,精度略近似但极省内存 |
| 漏桶算法 | 水恒定速率漏出桶满即溢出,输出绝对平稳但完全不允许突发 |
| 令牌桶算法 | 恒定速率发令牌请求取令牌通过,限平均速率又允许攒下令牌做突发 |
| 漏桶 vs 令牌桶 | 漏桶整输出节奏,令牌桶整输入节奏并允许突发,后者更契合多数业务 |
| 分布式限流 | 多实例下内存计数器会被放大实例倍,需把状态挪到 Redis 并用 Lua 保原子 |
| 限流维度 | 按用户还是 IP 还是接口要选对,通常组合维度,选错会误伤或拦不住 |
| 429 与 Retry-After | 被限流应返回 429 并告知多久后重试,引导客户端理性退避而非疯狂重试 |
避坑清单
- 别用固定窗口计数器,它按对齐整点的格子计数,用户骑在接缝上能瞬间发出近两倍流量。
- 限流真正要保证的是任意一个滑动区间内不超限,而不是对齐整点的那个格子不超限。
- 滑动窗口日志精确无突刺,但要给每个请求存时间戳,限流额度大时内存开销很高。
- 滑动窗口计数器用当前加上一窗口加权估算,只存两个计数器,是工业界最常用的折中。
- 漏桶算法输出恒定速率适合保护娇贵下游,但它完全不允许突发,系统再闲也不放行快。
- 令牌桶算法限平均速率又允许突发,闲时攒令牌忙时按速率补,契合绝大多数真实业务。
- 漏桶整的是输出节奏,令牌桶整的是输入节奏,选算法先想清楚要不要允许突发。
- 多实例部署时进程内存版限流会被实例数放大,要把状态挪到 Redis 并用 Lua 保证原子。
- 限流维度要选对,按用户按 IP 按接口各有坑,通常要组合,选错会误伤好人或拦不住坏人。
- 被限流的请求要返回 429 并带 Retry-After,引导客户端理性退避,别让它疯狂重试雪上加霜。
总结
回头看那次"每分钟 100 次的限流,被两秒 200 次轻松突破"的事故,以及我后来在限流上接连踩的坑,最该记住的不是某一个算法的代码,而是我动手前那个想当然的判断——"限流就是按整分钟数个数"。这句话错在它偷偷把"一分钟"理解成了"从整点开始的那个固定格子"。可"每分钟 100 次"这个承诺,要管的从来不是某个对齐整点的格子,而是"任意一个 60 秒的区间"。固定窗口把连续流动的时间,粗暴地剁成了一段段互不相干的格子,而它没料到:用户的请求不认识你的格子。用户可以精准地骑在两个格子的接缝上,在接缝左边把上一个格子用光,在接缝右边把下一个格子用光——你以为你设了一道"每分钟 100 次"的闸,实际上在每一个整点,这道闸都会瞬间松开一次,放进一波双倍流量。限流这件事想清楚的,正是这个:时间是连续滑动的,你的窗口也必须是连续滑动的;一旦你的窗口是跳变的格子,跳变的那一瞬,就是你防线的缺口。
所以做限流,真正的工程量不在"数个数"那一下。数个数,固定窗口十几行就写完了。真正的工程量,在于你要在四种算法之间,按你的真实诉求做出选择——而这四种算法,其实是在回答四个层层递进的问题。固定窗口回答"怎么数",但它在临界点漏。滑动窗口日志回答"怎么数准",但它太耗内存。滑动窗口计数器回答"怎么又准又省",它用一点精度换来了内存——这是数量维度的终点。可数量管住了,还有速率没管:漏桶回答"怎么让输出绝对平稳",代价是牺牲突发;令牌桶回答"怎么既限平均速率、又留出突发空间",于是成了最通用的那个。这篇文章的几节,其实就是顺着这条问题链展开的:先看固定窗口错在哪,再看滑动窗口怎么补精度,再看漏桶和令牌桶怎么管速率,最后是分布式、维度、429 这些把限流真正落地的工程细节。
你会发现,限流的思路,和现实里一个热门场馆怎么控制入场完全相通。一个笨拙的场馆,会说"每个整点放 100 人"——结果聪明的人都掐着 9:59 和 10:00 的接缝来,两分钟挤进 200 人(这就是固定窗口)。一个较真的场馆,会给每个人发一张带时间戳的票,精确数清"最近一小时进了多少人"(这是滑动窗口日志,准但费事)。一个聪明的场馆,会在闸机口匀速放人,无论门外排得多长,里面始终是平稳的人流(这是漏桶)。而一个最体贴的场馆会这么做:它按固定速率发号,没人来的时候号攒着,于是一大群人同时到达时,能用攒下的号一次性进去一批,之后再恢复匀速(这就是令牌桶——它既守住了长期的平均人流,又给了人群一点痛快)。限流算法的演进,本质上就是一个管理者从"机械数数"到"理解流量的脾气"的过程。
最后想说,限流做没做扎实,差距永远不会在测试环境暴露——测试时你自己手动点几下,流量温和而规律,固定窗口也好、令牌桶也罢,看起来都一样。它只在真实的、有恶意刷量、有突发热点、服务还多实例部署的生产环境里才显形。那时候它会用最直接的方式给你结账:做不好,你会像我一样,看着监控曲线在每个整点窜起一根尖刺,看着多实例把你的限流额度悄悄放大了五倍,看着核心接口因为和边角接口共用限流池而被一起拖垮——你明明设了限流,系统却照样被打挂。而做对了,无论外面的流量怎么抖、怎么刷、怎么突发,你的系统始终把进来的请求稳稳地控制在它能承受的范围内,该放行的痛快放行,该拒绝的礼貌地回一个 429 和"请 5 秒后再试"。所以别等监控上那根尖刺、等服务被刷挂的告警找上门,在你写下第一行"数个数"的限流代码时就该想清楚:我要管的,是"对齐整点的格子",还是"任意一段滑动的时间"?我要不要允许用户突发?我的服务有几个实例,这个计数器放在哪?这几个问题都有了答案,你的限流才不只是测试环境里那个"数到 100 就拒绝"的玩具,而是一道无论流量怎么冲都能稳稳替系统扛住的可靠防线。
—— 别看了 · 2026