每个教程都从同一个套路开始:一个存 IP 的 dict,一句 if hits > N: reject,然后宣称问题解决了。直到有一天,一个两百人的办公室因为其中一个人在爬站,集体登录不上,我才明白限流不是一道计数题。它问的是:你在保护什么、谁和你共用同一个身份、以及你做错的时候该怎么办。令牌桶处理的是计数器处理不了的突发,nginx limit_req 解决的是「用最便宜的方式拦截」,而一个诚实的 HTTP 429 回答的是「我是不是把真人挡在外面了」。这三样,没有一个是计数器。
限流到底在防什么
挑算法之前,先给敌人起个名字。这几件事根本不是同一个问题。
- 滥用。 撞库、垃圾注册、爆破 token。特征是大量廉价请求来自大量来源;要按身份卡紧。
- 爬虫。 客户端以远超人类的速度抓你的目录。信号是单账号数小时内的总量,按秒的计数器永远看不见。
- 昂贵接口。 一次
/export就跑 30 秒查询。正确的防护是限制并发,而不是限制请求速率。 - 意外的循环。 没有退避的重试、
while true里轮询的客户端、对 500 无限重试的合作方。这里的「攻击者」就是你自己的用户,而一个硬限制反而是善意。
「每 IP 每分钟 100 次」是一个数字假装覆盖了上面四种情况。它对 /export 太松,对分页太严,还对一个刻意压在线下的爬虫完全瞎眼。
为什么「一个 IP 一个计数器」会失手
按 IP 计数之所以是默认做法,只是因为它简单,不是因为它对。它会在三个地方可预测地崩掉:
- 共享 NAT。 一家公司、一所学校、运营商的 CGNAT——几千个真人共用一个出口 IP。你为了一个坏家伙,把所有人一起限了。
- 分布式客户端。 有点动机的人都备着代理池;一万个 IP 各发一次请求毫不费力。按 IP 限流对攻击者毫无成本。
- 合法的突发。 一个仪表盘加载时打出二十个 XHR,就是一个人在正常使用;按秒的计数器看到尖峰,拒掉一半。
还有第四个更隐蔽的:IPv6。一个用户往往掌控一整个 /64——几十亿个地址——在 IPv4 下很紧的按 IP 限制,一放到 IPv6 就形同虚设。
各种算法:固定窗口、滑动窗口、令牌桶、漏桶
固定窗口
把时间切成桶(比如每分钟一个),每个桶计数。便宜——一个整数、一个 TTL。毛病在边界:12:59:59 来 100 次、13:00:00 再来 100 次,就是两秒内 200 次的突发。
滑动窗口
两种修法,都在拿精度换内存。
- 滑动窗口日志记录每个请求的时间戳,统计最近 N 秒内的次数。精确,但内存随上限增长——用在少数昂贵接口上还行,用在几百万个 key 上就是浪费。
- 滑动窗口计数保留当前和上一个窗口的计数再做插值:
prev * (1 - elapsed/window) + current。常见的折中:每个 key 几字节、没有边界悬崖、一点点你根本察觉不到的近似误差。
令牌桶
桶里最多装 capacity 个令牌,以恒定的 rate 补充。每个请求花掉一个令牌;没有令牌,就没有请求。一个旋钮变两个:rate 是你允许的长期均值,capacity 是你容忍的突发。这才符合现实,因为真实流量本来就是突发的。这是我默认的选择。
rate 决定长期均值,capacity 决定你容忍多大的突发。漏桶
镜像版本:请求以固定速率滴出,超过容量的要么丢弃、要么排队。你用排队请求的延迟换来完全平滑的输出速率。当下游很慢、需要被削平的时候用它,而不是在你关心响应延迟的时候。
在哪里拦截:边缘、应用,还是按 key
你有三个地方可以限,而每一层看到的东西都不一样。
- 在边缘(nginx、CDN、负载均衡器)。 最便宜——应用还没醒就被拒了——但它只看得到
$binary_remote_addr和可能的一个 header。适合粗暴地挡按 IP 的洪峰,对「按用户的公平」无能为力,因为它不知道谁登录了。 - 在应用里。 慢一些,但它知道账号、接口、套餐和成本。
免费版 60/分钟、付费版 6000/分钟就住在这里。 - 按 key、按 IP 还是按用户。 优先用你手上最强的身份。
$binary_remote_addr是传输层事实,API key 或 user ID 是业务事实。按业务事实限流,你才能扛过 NAT、CGNAT 和 IPv6 轮换。只有登录这类未认证路由才退回按 IP。
分层是很正常的:边缘一道松的按 IP 限制,应用里一道严的按用户限制,再给真正烧钱的接口单独加一个并发上限。
nginx limit_req:粗暴、便宜,能顶很多事
limit_req 默认就是一个按 $binary_remote_addr 的漏桶。两个参数承担了全部工作:burst(允许超出速率多少)和 nodelay(突发立即放行,而不是摊开处理——摊开会给真人的页面加载增加延迟)。
# /etc/nginx/conf.d/rate-limit.conf
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=expensive:10m rate=1r/s;
limit_req_status 429;
server {
location /api/ {
limit_req zone=perip burst=20 nodelay;
proxy_pass http://app;
}
# 给那个要烧 30 秒 CPU 的接口单独一个桶。
location /api/export/ {
limit_req zone=expensive burst=2 nodelay;
proxy_pass http://app;
}
}
10m 的 zone 大约能记住 16 万个 IP;满了会淘汰旧条目,而不是直接丢弃。注意 nginx 不会做什么:它自己的 429 不带 Retry-After。你要么在应用里算一个,要么在边缘用 add_header Retry-After 1 always; 近似一下——诚实的猜测总好过没有。
小服务不用 Redis 也能限流
提到共享计数器,第一反应是 Redis;对一个集群来说这没错。但对一个单进程小服务,它就是一份依赖、一种故障模式,以及一个你可能根本不需要的延迟跳。我默认的顺序是:
- 先用进程内。 一个进程、一个存令牌桶的
dict。没有网络、没有单点。你一旦跑第二个实例它就错了——但如果你只跑一个,它就完全正确,而且只有十行。 - 扩展时才上共享存储。 只有确实多于一个进程才加 Redis:用它做全局上限,同时保留进程内的桶当第一层便宜过滤。只有当一个请求已经可疑时,才去查那个更贵的存储。
import time
class TokenBucket:
"""惰性补充:没有后台定时器,每次调用只做算术。"""
def __init__(self, capacity, refill_per_sec):
self.capacity = capacity
self.rate = refill_per_sec
self.tokens = capacity
self.updated = time.monotonic()
def allow(self, cost=1.0):
now = time.monotonic()
# 按流逝时间补充,上限就是 capacity —— 这个上限正是突发额度。
self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= cost:
self.tokens -= cost
return True, 0.0
# 令牌不够:告诉调用方还要等多久这个请求才放得下。
wait = (cost - self.tokens) / self.rate
return False, wait
allow() 同时返回判定结果和一个 Retry-After 值——这正是偏爱令牌桶而不是计数器的理由:等待时间是从算术里推出来的,而不是编出来的。
真要加共享存储时,让它保持笨:用一个原子的固定窗口计数器当粗略的全局上限,只在本地桶放行之后才检查。它挂了就 fail open——全局限制丢一分钟,好过让整个服务跟着 Redis 一起挂。
class Limiter:
def __init__(self, shared=None):
self.shared = shared # redis 客户端,或 None
self.local = {} # key -> TokenBucket,每进程一份
def allow(self, key, cost=1.0):
bucket = self.local.setdefault(key, TokenBucket(20, 10))
ok, wait = bucket.allow(cost)
if not ok:
return False, wait
if self.shared is None:
return True, 0.0
# 只在便宜的本地检查通过之后,才碰粗略的全局上限。
window = int(time.time()) // 60
k = f"rl:{key}:{window}"
count = self.shared.incr(k)
if count == 1:
self.shared.expire(k, 120) # 窗口结束就忘掉它
if count > 600:
return False, 60 - (time.time() % 60)
return True, 0.0
返回一个正确的 HTTP 429,带上 Retry-After
一个不带任何指示的 429 是个负载放大器:客户端拿到它就立刻重试,洪峰更糟。HTTP 429 存在的意义,就是让你能叫客户端退后。
HTTP/1.1 429 Too Many Requests
Retry-After: 3
RateLimit-Limit: 10
RateLimit-Remaining: 0
RateLimit-Reset: 3
Content-Type: application/json
{"error": "rate_limited", "retry_after": 3}
Retry-After 可以填秒数或一个 HTTP 日期;短窗口下填秒数更简单也更对。RateLimit-* 那组字段(IETF 草案的惯例)能让一个好客户端在被拒之前就主动降速。把你发出的等待时间对齐到 allow() 算出的等待时间,并且让边缘和应用保持一致——同一个限制给出两个不同的 Retry-After,比不给我还糟。
失败模式:限流限到自己,把真人挡在外面
危险的失败不是限得太松,而是一道悄悄拒掉你想要的那些人的限制。我确实这么干过的几种方式:
- 在 CDN 后面按 IP 限流。 所有请求都来自 CDN 的边缘 IP,于是你把自己的代理限了。只信任 CDN 的网段,用它的真实客户端 IP header,绝不无脑信
X-Forwarded-For。 - 一条路由一个桶。 登录和读取共用一个桶,会让只是开着页面的人被登出。把限制范围收敛到路由。
- 给「重试是安全的」请求限流。 在一次失败扣款之上再叠一个
429是最糟的答复——那正是幂等键要解决的问题。 - 忘了健康检查和内网流量。 你自己的监控撞上限流,就把一个健康的服务变成了「宕机」告警。
- 没有可观测性。 如果你看不到自己多频繁地返回
429、返回给了谁,那你就在猜。盯拒绝比例,而不是原始计数。
还有个更阴的:限流限到你自己。 对一个内部会扇出的服务下激进进程内限制——一个调用自己 API 的 worker——会掐死你自己的流量。把 loopback 调用方加白,并用真实的突发去压测。
我现在怎么做
- 先命名威胁,再定数字:滥用、爬虫、成本、循环,各自要不同的限制。
- 默认用令牌桶——
rate管均值,capacity管突发——并且优先按用户或 API key,而不是按 IP。 - 只有必须按单位时间计数时才用滑动窗口,接受计数版比日志版省内存、但有近似。
- 用 nginx limit_req 在边缘挡粗暴洪峰,在应用里做按用户的公平;在只有一个实例之前,保持进程内即可。
- 永远返回带
Retry-After的 429,那个值是拒绝这个请求的同一套算术推出来的,并把拒绝率当成一等指标盯着。
一道把真人挡住的限流,就是一场多绕了几步的宕机。如果你想知道一个受保护的接口在被顶回来之前能被打多快,那正是逆向一个私有 API教的东西——搞懂它,你就知道自己的限制是一堵墙还是一句建议。按 IP 的计数器两者都不是。去建那个真正匹配威胁的吧。