Nginx 限流误杀正常请求:阈值调优与复盘

上线 limit_req 限流后大量正常用户收到 503,排查为 burst 与 rate 设置过小且 nodelay 误用所致。

一、问题背景

我们的主站 www.example.com 使用 Nginx 1.24 作为七层接入网关,前面是 CDN,后面挂 8 个应用节点。由于此前遭遇过一次恶性 CC 攻击,安全团队要求对核心接口加限流。目标是:拦住异常高频的刷接口行为,但不能影响正常用户的浏览和下单

限流方案采用 Nginx 原生的 limit_req 模块(基于漏桶算法),按客户端 IP 做粒度。上线时间是周一下午,由运维在网关配置中新增了一段 limit_req_zone + limit_req 规则,并 nginx -s reload 平滑生效。本以为只是"加一道保险",没想到立刻引发客诉。

二、故障现象

reload 后大约 10 分钟,客服群开始炸:大量用户反馈"页面打开就提示 503 Service Temporarily Unavailable",且不是刷子,是普通用户,包括公司内网同事自己访问也中招。

监控侧:

  • Nginx 503 返回码占比从 0.1% 飙到 18%;
  • limit_req 拒绝计数($limit_req_status)持续高位:
1
limit_req_status  rejected: 12453  (last 1 min)
  • 被拒的 IP 高度分散,并非集中在少数异常 IP,说明是正常流量被误杀
  • 应用节点 CPU、连接数都很低,证明后端完全健康,问题 100% 出在网关限流层。

这与"防 CC"的目标完全背离:攻击没拦住多少,正常用户先被砍了一半。

三、排查过程

第一步,定位是哪条 limit_req 规则在拒绝。打开 Nginx 的 limit_req_status 日志字段,确认返回 503 的请求都带 limit_req_status=503,且命中的 zone 是 perip

第二步,看配置。问题一目了然:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
http {
    limit_req_zone $binary_remote_addr zone=perip:10m rate=2r/s;

    server {
        location / {
            limit_req zone=perip burst=3 nodelay;
            proxy_pass http://backend;
        }
    }
}

三个致命点:

  1. rate=2r/s 太小:每个 IP 平均每秒只允许 2 个请求。现代网页一次打开就并发数十个请求(html + css + js + 图片 + api),瞬时远超 2/s。
  2. burst=3 太小:只缓冲 3 个超出请求就拒绝,正常用户的突发并发根本装不下。
  3. nodelay 误用nodelay 让 burst 内的请求不排队立即处理,等于把限流瞬时阈值直接抬到 rate+burst=5,超出立刻 503,没有平滑过渡,正常用户的并发瞬间被打挂。

第三步,验证判断。用正常浏览器行为模拟单 IP 的首屏请求数:

1
2
3
4
5
6
$ curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/
200
$ # 并发模拟首屏 20 个资源请求
$ seq 20 | xargs -P 20 -I{} curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/assets/{} 2>/dev/null | sort | uniq -c
  17 503
   3 200

单 IP 并发 20 个请求,17 个被 503——完美复现用户"打开就报错"。这正是 rate+burst=5nodelay 下的直接后果。

第四步,确认 CDN 是否放大问题。由于前面是 CDN,回源到 Nginx 时客户端 IP 已被 X-Forwarded-For 携带,但 limit_req_zone 用的是 $binary_remote_addr(即 CDN 边缘节点 IP)。这意味着所有经同一 CDN 节点回源的用户被当成同一个 IP 限流,进一步把正常流量成片误杀。这是第二个隐藏坑。

四、解决方案

应急先回退,再给出正确配置:

  1. 立即 nginx -s reload 回退到无 limit_req 的版本,503 比例 1 分钟内归零,客诉停止。

  2. 修正限流参数,按"正常用户首屏并发约 20~30 个请求、且允许一定突发"来设计:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
http {
    # 用真实客户端 IP(CDN 场景取 XFF 最左)
    map $http_x_forwarded_for $client_real_ip {
        default $binary_remote_addr;
        "~^(?P<first>\d+\.\d+\.\d+\.\d+)" $first;
    }
    limit_req_zone $client_real_ip zone=perip:20m rate=20r/s;

    server {
        location / {
            limit_req zone=perip burst=40 delay=20;
            limit_req_status 429;
            proxy_pass http://backend;
        }
    }
}

要点:

  • rate=20r/s 容纳正常浏览;
  • burst=40 给足突发缓冲;
  • 去掉 nodelay,改用 delay=20:前 20 个超出的请求不延迟,其余进队列平滑处理,既保体验又防刷;
  • 限流拒绝码改为 429(语义更准确),并基于 X-Forwarded-For 取真实客户端 IP,避免 CDN 合并误杀。
  1. 对静态资源(/assets/、图片)不限流,只对 API 和登录等敏感路径限流,进一步降低误杀面。

灰度上线后观察 30 分钟:503/429 比例稳定在 0.3%,且被拒 IP 确为少数高频异常来源,正常用户零投诉。

五、根因分析

根因是 limit_req 阈值设计与实际流量模型严重错配,叠加两个误用:

  • rate/burst 按"单请求流"想象设置(2r/s、burst 3),但浏览器首屏天然并发几十请求,正常行为就被判为"超限";
  • nodelay 把漏桶本该的"平滑排队"变成"瞬间砍杀",剥夺了缓冲;
  • CDN 场景下用 $binary_remote_addr 取到的是 CDN 节点 IP,等于把整片用户合并成一个被限流对象,放大误杀。

本质上,限流是保护手段,但阈值必须建立在对正常用户行为基线的量化之上,不能拍脑袋。

六、预防措施

  1. 先度量,再限流:上线前用日志统计真实用户 P95/P99 的每 IP 请求速率与并发,阈值取正常峰值的 1.5~2 倍。

  2. 慎用 nodelay:除非明确要"超 burst 立即拒绝",否则用 delay= 让超出请求排队,保住体验。

  3. CDN 后取真实 IP:务必 real_ip / X-Forwarded-For 解析真实客户端地址,避免按边缘节点聚合限流。

  4. 分级限流:静态资源不限,敏感/写接口严限,做到"该拦的拦、该放的放"。

  5. 灰度 + 监控:新限流规则先 1 台网关灰度,盯 limit_req_status 与 4xx 比例,确认无误再全量;拒绝码用 429 便于区分。

  6. 返回友好提示:对限流返回 429 + Retry-After 头,前端做退避重试,避免用户看到生硬 503。

七、总结

这次故障是典型的"安全策略反噬业务"。limit_req 本身没错,错在阈值是凭直觉设的、且 nodelay 用错了地方。排查关键三步:limit_req_status 日志确认拒绝来源 → 看 rate/burst/nodelay 组合 → 用真实并发复现 503。记住三条铁律:限流阈值必须基于真实流量基线;nodelay 不是默认选项;CDN 后面一定要还原真实客户端 IP。限流的目标是"拦坏人",不是"误伤好人",上线前做一次灰度观测,能省掉一次客诉危机。

使用 Hugo 构建
主题 StackJimmy 设计