限流这件事我拖了很久没做。原因很实在:小站流量不大,看起来没必要;另一方面限流的方案太多,Nginx 的 limit_req、应用层令牌桶、Redis 计数器、CDN 自带规则,不知道该从哪个下手。真正让我决定动手的是一次被采集:某个爬虫用多线程把全站文章列表扫了一遍,PHP-FPM 进程池被占满,正常用户访问全部 502。
后来把限流做下来,发现它其实不复杂,难的是「在哪一层限、按什么维度限、限到什么程度」。这篇文章就是把这三个问题按实际顺序讲清楚,包括我最后为什么选了 Nginx 的漏桶、令牌桶和漏桶有什么区别、以及上线后怎么验证它确实在起作用。
一、先想清楚:限流要防的到底是什么
「限流」这个词被用得很泛。实际场景里至少有四类不同的东西混在一起,处理手段完全不同:
- 恶意采集与暴力扫描。特征是从同一个 IP 高频请求、UA 可疑、路径集中。这类要的是一次性封禁,不是匀速限流。
- 单用户过度使用。比如某个用户开了十个标签页同时刷列表页,或者调用了搜索接口。这类要按会话/用户维度限,不能按 IP(同办公室共享出口会连累别人)。
- 突发流量。文章被转到某个大社群、被搜索引擎集中抓取。这类不是恶意的,限流的目的只是保护后端不被打穿,应该「排队」而不是「拒绝」。
- 资源消耗型接口。搜索页、导出、图片缩略图生成。这类单个请求就贵,限流的重点是并发数而不是 QPS。
我的实际结论是:限流只解决第二、第三类。第一类交给 WAF 或 fail2ban 做封禁,第四类靠缓存和并发控制解决。把「防恶意」和「保稳定」混在一个配置里,结果往往是正常用户被误伤,恶意流量毫发无损(它换 IP 的成本比换 UA 低得多)。
二、令牌桶与漏桶:差别比名字重要
这是理解限流的基础,但网上讲得太玄。用一句话记住:
| 模型 | 存的是什么 | 行为特征 | 适合场景 |
|---|---|---|---|
| 令牌桶 | 令牌,按固定速率生成,桶有上限 | 允许突发:桶里攒了 20 个令牌就允许瞬间过 20 个 | API 调用、允许短时突发的接口 |
| 漏桶 | 队列,请求排队,按固定速率流出 | 平滑输出:无论进来多快,出去都是匀速 | 保护后端、页面请求、不允许突发的场景 |
Nginx 的 limit_req 实际实现是漏桶 + 可选突发额度:默认情况下它按 rate 匀速放行,超过的直接拒绝(503);加了 burst 之后,超出的请求进入队列等待,队列满了才拒绝。所以它的行为介于两者之间——可以理解为「带缓冲区的漏桶」。
这个区别在实际配置里的意义是:burst 不是「额外允许的请求数」,而是「允许排队的请求数」。这是我的第一个误区。我一开始把 burst=100 理解成「允许瞬间 100 个请求都正常响应」,实际上这 100 个请求会排进队列,按 rate 一个一个放行。如果浏览器等不了那么久(一般 10 秒就超时了),排在后面的请求对用户来说就是失败。
所以配置时有个经验公式:burst 应该在 rate × 可接受的排队时间 左右。rate=10 请求/秒、能接受排 3 秒,burst 就配 30。配太大会让排队时间变长,用户那边表现为「页面转圈很久最后失败」,比直接 503 更难受。
三、Nginx 漏桶的落地配置
两个指令必须成对出现:limit_req_zone 定义在 http 块(记录状态和计数),limit_req 写在 location 或 server 里(应用规则)。
http {
# 按 IP 限:每个 IP 10 请求/秒,10MB 共享内存约能存 16 万个 IP 状态
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
# 按站点整体限:整站 200 请求/秒(保护 PHP-FPM 进程池)
limit_req_zone $server_name zone=perserver:10m rate=200r/s;
# 降低被限流时的日志噪音
limit_req_log_level warn;
server {
listen 443 ssl;
server_name example.com;
location / {
limit_req zone=perip burst=20 nodelay;
limit_req zone=perserver burst=50;
limit_req_status 429;
try_files $uri $uri/ /index.php?$query_string;
}
# 搜索页最贵,单独收紧
location = /search {
limit_req zone=perip burst=5 nodelay;
limit_req_status 429;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
}
}几个关键点逐个说:
$binary_remote_addr而不是$remote_addr。前者是二进制格式,IPv4 只占 4 字节;后者是字符串,最长 15 字节。同样 10MB 内存,用 binary 能存的 IP 数是后者的三倍多。这在被大规模扫描时是决定性的差别。nodelay到底做什么。不加 nodelay 时,突发的 20 个请求会按 10r/s 的速率排队放行,第 20 个要等 2 秒。加了 nodelay,burst 额度内的请求立即放行,但它们仍然消耗令牌——也就是说 nodelay 让 burst 变成了「允许的瞬时突发量」,同时把速率限制保留给了后续请求。对页面请求,一般建议加 nodelay;对 API 或需要平滑输出的场景,不加。limit_req_status 429。Nginx 默认返回 503,语义上是「服务不可用」,会让搜索引擎以为站点挂了。改成 429(Too Many Requests)更准确,而且配合Retry-After头能告诉客户端什么时候再来。- 真实 IP 问题。如果站点在 CDN 后面,
$remote_addr全是 CDN 节点的 IP,按 IP 限流等于把全站限成一类。必须先把real_ip配好——set_real_ip_from+real_ip_header X-Forwarded-For+real_ip_recursive on,而且set_real_ip_from必须只写 CDN 的回源网段,不能写0.0.0.0/0(否则攻击者伪造 XFF 就能绕过限流)。这一步没做,后面配什么都是白配。
四、按 IP 限流的固有缺陷,以及怎么补
按 IP 限流有两个绕不过去的问题,值得在动手前就想明白:
问题一:共享出口误伤。学校、公司、运营商 NAT 后面可能有几千个用户共用一个出口 IP。按 IP 限 10r/s,他们的正常访问就会被限。这类流量的特征是多 UA、多 Cookie、分散的会话。
问题二:IP 池绕过。有预算的采集者用代理池,几万个 IP 每个只请求几次,按 IP 限流完全无效。
对应的补偿手段,按投入产出比排序:
- 给已登录用户更高的额度。用
map判断 Cookie 里有没有会话标识,有的话走另一个 zone,rate 放宽三到五倍。这是最容易实现、误伤最小的一招。 - 对静态资源不限流。
location ~* \.(js|css|jpg|png|woff2)$单独处理,这些请求由 nginx 直接返回文件,不消耗后端,限了纯属自伤。 - 对明显恶意的直接封。UA 为空、UA 含已知采集器名字、请求
/.env之类的路径,用map判到 1 之后return 444(444 是 nginx 特有状态,直接断连不回响应,对扫描器最省资源)。 - 爬虫白名单要慎重。只按 UA 里的 Googlebot 放行是不安全的(谁都能伪造),必须做反向 DNS 验证。小站可以用现成的
ngx_http_realip+ 官方 IP 段列表,或者干脆不做白名单——搜索引擎的抓取速率通常不会触发 10r/s。
第 1 条的配置大概长这样:
map $http_cookie $limit_key {
default $binary_remote_addr;
"~*PHPSESSID=" "user:$binary_remote_addr";
}
limit_req_zone $limit_key zone=perip:10m rate=10r/s;
# 已登录走独立 zone,额度放宽
map $http_cookie $is_logged_in {
default 0;
"~*PHPSESSID=" 1;
}
limit_req_zone $binary_remote_addr zone=loggedin:5m rate=30r/s;严格说,map 只能改 key 不能改 rate,要分档就得建两个 zone,在 location 里根据标志位选择——用一个 if 包 limit_req 是不行的(limit_req 不能在 if 里)。实际可行的做法是用 map 生成不同的 key,把「已登录」的 key 前缀成 user:,然后接受同一 rate —— 但这只解决了「已登录和匿名互不影响」,没解决「已登录额度更大」。真正要分档,最干净的方式是拆两个 server 块或用 OpenResty 写 Lua,别在 Nginx 原生指令上硬凑。
五、比限流更该先做的两件事
这一节可能是全文最重要的。限流是「拒绝请求」,而拒绝请求对用户来说永远是负体验。在限流之前,有两件事的性价比高得多:
第一,缓存。列表页、首页、文章页做好缓存(Nginx 的 proxy_cache 或 fastcgi_cache),爬虫扫一百次也只有第一次打到 PHP。一个 60 秒的页面缓存能扛掉 90% 的重复请求,这比任何限流阈值都有效。我这次被采集打穿,根因其实不是没限流,是首页和列表页没有缓存。
第二,并发控制。limit_conn 比 limit_req 更适合防「慢速攻击」和「资源占用」。它限制的是同一时刻的活跃连接数,而不是单位时间的请求数:
limit_conn_zone $binary_remote_addr zone=connperip:10m;
limit_conn_zone $server_name zone=connperserver:10m;
server {
limit_conn connperip 20; # 单 IP 最多 20 个并发连接
limit_conn connperserver 500; # 整站最多 500 个
limit_conn_status 429;
}这两个指令是互补的:limit_req 管「来得太快」,limit_conn 管「挂得太久」。很多「服务器被压垮」的案例是慢连接占满 worker_connections,光配 limit_req 完全无效。
六、阈值怎么定:别拍脑袋,看现有数据
限流配错方向的代价很大:太松等于没做,太紧把正常用户挡在外面。定阈值最靠谱的依据是现有日志,不是猜。
先统计「正常用户」的请求频率分布:
awk '{print $1}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -30再看整站的请求速率峰值(按分钟聚合):
awk -F'[][]' '{print $2}' /var/log/nginx/access.log \
| cut -d: -f1-3 | sort | uniq -c | sort -rn | head -5判断标准:正常用户的单 IP 峰值通常不超过 2~3 请求/秒(浏览器打开一个页面会并发拉十几个资源,但这些多半走静态缓存、不该计入限流)。所以 10r/s 对页面请求是很宽松的值,被误伤的只有「开了很多标签页疯狂刷新」这种确实该限的行为。整站阈值则看你的 PHP-FPM pm.max_children:整站 rate 乘以平均响应时间,大致要小于 max_children,否则队列还没限住,进程池已经满了。
另外要记住:限流阈值跟着业务变。做活动、发新文章、被转载的那几天,正常流量本来就会翻倍,这时候如果阈值还是平时的数,等于自己打自己。至少在日志里留个记录,事后能解释「那天为什么有大量 429」。
七、上线顺序与验证
我的上线顺序是三阶段,每阶段至少观察一天:
- 只记录不拦截。把
limit_req的 rate 配成一个极端值(比如 1r/s)+ 巨大 burst,或者用limit_req_dry_run on(Nginx 1.17.1+ 支持)。看日志里limiting requests的量,确认被限的都是哪些 IP、哪些路径。 - 只对贵接口开。先搜素页、导出、评论提交这些「真贵」的地方开限流,普通页面暂时不动。这一步能挡掉大部分实际伤害,误伤面最小。
- 全站开 + 观察 429 曲线。看每天 429 的数量是否稳定在预期范围内,突然升高说明阈值需要调,或者有人在扫。
验证命令(一次跑完看四项):
# 1) 快速打 30 个请求,看有多少被拒
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code} " "https://example.com/"
done; echo
# 2) 确认 429 是 nginx 返回的(有 Server 头和 nginx 特有的 body)
curl -sI "https://example.com/" | grep -iE 'server|retry-after'
# 3) 看限流是否真的在记(zone 状态)
# 需要在 server 块加:limit_req_status 429; 并在 http 块加 limit_req_log_level warn;
grep -c 'limiting requests' /var/log/nginx/error.log
# 4) 确认静态资源没被限(应为 200,不是 429)
curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/style.css"第 1 条里如果 30 个请求全部 200,说明限流没生效(常见原因是 limit_req_zone 写在了 server 块里,那是无效位置,必须在 http 块)。如果全部 429,说明 burst 太小或 rate 写错了单位(rate=10r/m 是每分钟 10 个,不是每秒)。
八、几处必须知道的边界
- worker 之间共享 zone。多个 worker 进程读同一块共享内存,所以阈值是全站统一的,不会被放大成 worker 数倍。但如果开了多个 nginx 实例(比如蓝绿部署各跑一套),各自独立计数,实际额度会翻倍。
- reload 会重置计数。
nginx -s reload后共享内存 zone 会被重建,刚上线的瞬间所有计数清零。频繁发版的时候,限流形同虚设——这是很多人没意识到的漏洞。 - 队列内存也要算。每个 zone 的 10MB 里既有 key 状态也有队列,burst 配得很大时,实际能存的 IP 数会下降。被大规模扫描时可能出现「老 IP 的状态被挤掉」,表现为限流时灵时不灵。
- 别忘 IPv6。
$binary_remote_addr对 IPv6 占 16 字节,同样内存存的数量是 IPv4 的四分之一。开了 IPv6 的站点,zone 大小要相应放大。
九、小结
限流的正确顺序是:先缓存、再并发控制、最后才是速率限制。速率限制里,先用日志算出正常用户的真实频率,再定阈值;先 dry-run 只记录,再灰度到贵接口,最后全站开。令牌桶允许突发、漏桶要求匀速,而 Nginx 的 limit_req 是带队列的漏桶,burst 是排队长度而非额外额度——这一点搞错方向,配置就会全反。
最后提醒一句:限流不是安全措施。它保护的是后端容量,不是站点安全。恶意采集要交给 WAF 和封禁,限流只该负责「让正常流量在高峰期仍然可用」这一件事。