Nginx限流实战:令牌桶、漏桶与429

限流这件事我拖了很久没做。原因很实在:小站流量不大,看起来没必要;另一方面限流的方案太多,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 限流完全无效。

对应的补偿手段,按投入产出比排序:

  1. 给已登录用户更高的额度。用 map 判断 Cookie 里有没有会话标识,有的话走另一个 zone,rate 放宽三到五倍。这是最容易实现、误伤最小的一招。
  2. 对静态资源不限流。location ~* \.(js|css|jpg|png|woff2)$ 单独处理,这些请求由 nginx 直接返回文件,不消耗后端,限了纯属自伤。
  3. 对明显恶意的直接封。UA 为空、UA 含已知采集器名字、请求 /.env 之类的路径,用 map 判到 1 之后 return 444(444 是 nginx 特有状态,直接断连不回响应,对扫描器最省资源)。
  4. 爬虫白名单要慎重。只按 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」。

七、上线顺序与验证

我的上线顺序是三阶段,每阶段至少观察一天:

  1. 只记录不拦截。把 limit_req 的 rate 配成一个极端值(比如 1r/s)+ 巨大 burst,或者用 limit_req_dry_run on(Nginx 1.17.1+ 支持)。看日志里 limiting requests 的量,确认被限的都是哪些 IP、哪些路径。
  2. 只对贵接口开。先搜素页、导出、评论提交这些「真贵」的地方开限流,普通页面暂时不动。这一步能挡掉大部分实际伤害,误伤面最小。
  3. 全站开 + 观察 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 和封禁,限流只该负责「让正常流量在高峰期仍然可用」这一件事。

Last modification:October 30th, 2026 at 08:24 pm

Leave a Comment