为什么你的 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 个静态请求的页面时,某些请求被延迟几百毫秒。正确做法是先估峰值:
- 打开浏览器开发者工具 Network 面板,记录一个典型页面完整加载的请求数,记为 N。
- 记录这些请求集中在多少秒内发出,记为 T。现代浏览器并发数一般是 6,所以大致 T ≈ N / 6。
- 突发值至少应该是 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最后:限流不是终点
限流只能抬高攻击成本,不能根治。真正有效的防刷是分层:
- Cloudflare 免费版挡掉大部分自动化工具。开启 Security Level 到 Medium,加一条 Rate Limiting 规则(免费版有额度)。
- Nginx 限流兜底,处理穿透 CDN 的请求。
- fail2ban 做长效封禁,把持续刷站的 IP 直接丢进防火墙,而不是每次都给 429。
- 应用层验证:搜索、评论加验证码或频率检查,防止单 IP 通过换 IP 绕过前面的层。
个人站长没有安全团队,能依靠的就是这套低成本组合。把 limit_req 的 burst 算对、把真实 IP 还原做对、把静态资源和探针排除掉,你的服务器在最便宜的那一档 VPS 上也能扛住绝大多数骚扰。记住验证顺序:nginx -t → nginx -s reload → 观察 429 分布 → 调整 burst,循环两三轮,基本就稳定了。