Nginx limit_req 限流防 CC 实战:漏桶参数、burst 突发值计算与 429 误伤排查

为什么你的 Nginx 一被压就崩:从 limit_req 的漏桶说起

很多个人站长都有过这样的经历:网站平时跑得好好的,某天突然被告知 CPU 打满、负载飙到几十,登录服务器一看 Nginx 的 access.log 里全是同一个 IP 在刷某个动态页面——可能是搜索接口,可能是带参数的列表页,也可能是 WordPress 的 wp-login.php。这类请求既不是真正的高并发业务,也不是有意义的蜘蛛抓取,它只是在消耗你的带宽、内存和数据库连接。

最省钱的应对方式不是加机器,而是在 Nginx 层把入口卡住。ngx_http_limit_req_module 提供了基于漏桶算法(leaky bucket)的请求速率限制,配合 limit_conn 做并发连接限制,基本可以挡住绝大多数低成本的刷站行为。但它有几个极易踩错的细节,配置不对要么形同虚设,要么把真实用户一起误伤。这篇文章把参数含义、突发值计算和误伤排查一次讲清。

漏桶算法到底在做什么

limit_req 的核心模型是:你声明一个桶,桶以固定速率漏水(也就是放行请求),水龙头往桶里灌水(也就是进来的请求)。桶的容量由 burst 决定。

关键在于理解三种结果:

  • 桶里有空间、漏水速率跟得上:请求立刻被放行。
  • 瞬时流量超过漏水速率、但桶还没满:请求被延迟处理(排队),最终仍然会被放行。这是很多人没意识到的地方——burst 带来的是延迟,不是拒绝。
  • 桶满了:新请求直接返回 503(默认)。

所以 rate=1r/s burst=5 的含义并不是"每秒允许 5 个请求",而是"平均每秒放行 1 个请求,最多允许 5 个请求排队等待"。如果你希望突发请求被立即处理而不是排队,需要加上 nodelay。

最小可用配置

先在 http 块里声明共享内存区域(注意:limit_req_zone 只能放在 http 块,不能放 server 或 location):

http {
    # $binary_remote_addr 取客户端 IP,比 $remote_addr 省内存(4/16 字节 vs 字符串)
    limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;

    # 同时对单 IP 的并发连接数做限制
    limit_conn_zone $binary_remote_addr zone=conn_perip:10m;

    # 记录被限流的请求,便于事后统计
    limit_req_log_level warn;
    limit_req_status 429;
    limit_conn_status 429;
}

然后按场景在 location 里引用。这里的关键是分场景设置不同的速率,而不是全站一刀切:

server {
    # 静态资源几乎不需要限流,限了反而拖慢正常访问
    location ~* \.(jpg|jpeg|png|gif|webp|css|js|woff2)$ {
        root /www/wwwroot/zz1984;
        expires 30d;
    }

    # 动态页面适度限流
    location / {
        limit_req zone=perip burst=20 nodelay;
        limit_conn conn_perip 20;
        try_files $uri $uri/ /index.php?$args;
    }

    # 搜索、登录这类高成本入口严管
    location = /wp-login.php {
        limit_req zone=perip burst=3 nodelay;
        limit_conn conn_perip 3;
    }

    location /search {
        limit_req zone=perip burst=3 nodelay;
    }
}

注意 limit_req_status 429:默认返回 503 会让搜索引擎和监控把限流误判成服务故障,429(Too Many Requests)语义更准确,也方便你在日志里用状态码统计误伤量。

burst 到底该设多少:一个可计算的方法

很多人凭感觉写个 burst=5 就上线了,结果正常用户打开一个含 30 个静态请求的页面时,某些请求被延迟几百毫秒。正确做法是先估峰值:

  1. 打开浏览器开发者工具 Network 面板,记录一个典型页面完整加载的请求数,记为 N。
  2. 记录这些请求集中在多少秒内发出,记为 T。现代浏览器并发数一般是 6,所以大致 T ≈ N / 6。
  3. 突发值至少应该是 N,否则单个用户打开一个页面就会被限流。

举例:一个页面 40 个请求,浏览器 6 并发,约 7 秒内发完,那么瞬时峰值可能达到每秒 10 个以上。这时候 rate=10r/s 加上 burst=40 nodelay 才是合理的,而不是 burst=5。记住一条经验:若真实用户会被限流,说明 burst 给小了,而不是限流本身有问题。

另外,很多站长的静态资源走了 CDN。这种情况下 Nginx 看到的 $binary_remote_addr 可能全是 CDN 回源节点 IP,限流会变成"所有用户共用一个桶",后果严重。必须先从 X-Forwarded-For 或 CDN 提供的真实 IP 头里取值:

# 前提:用 set_real_ip_from 信任 CDN 回源段,set_real_ip_from 之后 $remote_addr 已是真实 IP
set_real_ip_from 172.64.0.0/13;    # Cloudflare 示例段,按自己 CDN 文档替换
real_ip_header CF-Connecting-IP;

# 这样 $binary_remote_addr 才是访客 IP
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;

如果漏了这一步,限流不仅挡不住刷站,还会把正常用户的请求一起 429,是线上最常见的隐形事故。

限流生效了却还在被打:三类典型漏判

第一类:攻击者用 IP 池。单一 $binary_remote_addr 键对分布式小流量无效。可以叠加维度,比如对特定 location 用 $server_name$request_uri 做键,限制同一路径被疯狂请求:

limit_req_zone $binary_remote_addr$request_uri zone=uri:10m rate=2r/s;
location /api/list {
    limit_req zone=uri burst=5 nodelay;
}

第二类:限流规则没生效,因为你忘了 reload。改完配置必须 nginx -t 校验再 nginx -s reload。nginx -t 会检查语法,但不会告诉你 zone 名字拼错——zone 引用不到的 location 会直接报错,这反而容易发现;真正难查的是规则写在了 if 块里。Nginx 官方文档明确说明 limit_req 在 if 上下文中的行为不符合直觉,应当避免。

第三类:静态资源也被限流。如果你在 location / 里加了限流,而静态资源没有单独 location 匹配,那么它们会落入 / 的规则里。一个含 40 张图的页面会立刻触发 429。一定要给静态资源单独开 location 并不加 limit_req。

如何判断是"攻击"还是"误伤"

上线限流后,别急着庆祝,先观察一周。用 Nginx 日志统计 429 的来源分布:

# 统计被限流的 IP 及次数(取 status=429 的前 20 个来源)
awk '$9==429 {print $1}' /www/wwwlogs/access.log | sort | uniq -c | sort -rn | head -20

# 看被限流的请求路径集中在哪
awk '$9==429 {print $7}' /www/wwwlogs/access.log | sort | uniq -c | sort -rn | head -20

判读方法:

  • 429 高度集中在少数几个 IP、且路径集中在搜索/登录/带参列表 → 正常防护,继续。
  • 429 分散在大量不同 IP,但路径都是 / 或 /index.php → 极可能是 burst 太小,真实用户在挨刀。先调大 burst,再重新观察。
  • 429 集中在图片、CSS 路径 → 静态资源被误纳入限流,立刻加静态 location 排除。

还有一个容易忽略的信号:如果 429 的 IP 恰好是 CDN 回源段或监控探针 IP(Uptime Kuma、阿里云拨测等),说明你的真实 IP 还原没配好,或者该给探针加白名单:

# 监控探针白名单,不参与限流
geo $limit_exempt {
    default 0;
    10.0.0.1/32 1;      # 你的监控机 IP
}
map $limit_exempt $limit_key {
    0 $binary_remote_addr;
    1 "";               # 空键不会被 limit_req 计数
}
limit_req_zone $limit_key zone=perip:10m rate=10r/s;

用 map 把白名单 IP 的键置空,是官方推荐的做法——空键的请求不参与计数,比写 if 优雅得多。

限流之外的补强:连接数与日志分析

limit_req 管速率,limit_conn 管并发。两者配合才完整:一个慢速攻击者可以用极低速率占满你的连接数,这时 limit_req 是看不出来的。limit_conn conn_perip 20 表示单 IP 最多 20 个并发连接,超过的请求直接 429。

要定期统计,才能发现趋势。一个简单的日报脚本:

#!/bin/bash
LOG=/www/wwwlogs/access.log
DATE=$(date -d yesterday +%d/%b/%Y)
echo "=== 限流统计 $DATE ==="
echo -n "429 总数: "; awk -v d="$DATE" '$4 ~ d && $9==429' "$LOG" | wc -l
echo -n "被限流独立 IP: "
awk -v d="$DATE" '$4 ~ d && $9==429 {print $1}' "$LOG" | sort -u | wc -l

最后:限流不是终点

限流只能抬高攻击成本,不能根治。真正有效的防刷是分层:

  1. Cloudflare 免费版挡掉大部分自动化工具。开启 Security Level 到 Medium,加一条 Rate Limiting 规则(免费版有额度)。
  2. Nginx 限流兜底,处理穿透 CDN 的请求。
  3. fail2ban 做长效封禁,把持续刷站的 IP 直接丢进防火墙,而不是每次都给 429。
  4. 应用层验证:搜索、评论加验证码或频率检查,防止单 IP 通过换 IP 绕过前面的层。

个人站长没有安全团队,能依靠的就是这套低成本组合。把 limit_req 的 burst 算对、把真实 IP 还原做对、把静态资源和探针排除掉,你的服务器在最便宜的那一档 VPS 上也能扛住绝大多数骚扰。记住验证顺序:nginx -t → nginx -s reload → 观察 429 分布 → 调整 burst,循环两三轮,基本就稳定了。

Last modification:September 29th, 2026 at 07:24 pm

Leave a Comment