为什么你的站点会在某一刻被"打穿"
个人站长最常遇到的一种诡异现象:平时网站一切正常,某个下午忽然所有请求都变慢,PHP-FPM 队列堆到几百,Nginx 报 502,CPU 100%,等你登录服务器想排查时,攻击已经停了。翻日志才看到同一秒有上千个请求,全部来自几个 IP,路径集中在 /search、/?s= 这类耗费数据库资源的页面上。
这类流量不是"大流量攻击"那么夸张,很多时候只是一台 VPS 上跑了个目录扫描器,或者有人拿你的搜索框当练手靶子。但对于一台 2 核 4G 的小机器来说,几百并发就足以把后端拖垮。真正管用的做法不是事后加固,而是在 Nginx 入口处就设好限流闸门,让过量请求在到达 PHP 之前就被拒绝或排队。
本文讲清楚 Nginx 原生限流的三个指令——limit_req、limit_conn、limit_req_zone——的工作原理、参数含义和真实配置,并给出一套可以直接照抄、又不会误伤正常访客与搜索引擎爬虫的方案。这些配置我在自己的 Typecho 站上跑了很久,中间踩过的坑也会一并写出来。
先搞懂漏桶算法:rate 与 burst 到底是什么
Nginx 的 limit_req 基于漏桶算法(leaky bucket)。你可以把它想象成一个底部有小孔的水桶:水(请求)不断倒进来,从孔里匀速漏出去,孔的大小就是 rate。桶满了以后,再倒进来的水就溢出去(被拒绝)。
关键参数只有几个:
rate:漏出的速度,也就是每秒允许多少请求。写法可以是10r/s,也可以是60r/m(每分钟 60 次,等价于每秒 1 次)。注意 Nginx 内部对 r/s 的理解是"每 100 毫秒一个请求"的粒度,写小数如0.5r/s有时行为和直觉不同,建议直接用 r/m。burst:桶的额外容量,允许短时间内的突发请求被排队而不是立刻拒绝。桶里没位置的请求才会收到 503。nodelay:加上它,突发额度内的请求会被立刻放行,而不是按 rate 慢慢漏出去。不加时,即使桶里有位置,请求也会被延迟处理——这在浏览器看来就是"页面卡了几秒才出来"。
举个具体数字:rate=5r/s、burst=10、nodelay,意味着正常的每秒 5 个请求无延迟通过;如果突然来了 15 个请求,前 5 个走 rate,后 10 个走 burst 也立刻放行,第 16 个开始返回 503。这是最常见的"防突发但保体验"组合。
这里有一个容易忽略的点:漏桶是按 key 隔离的。key 通常取 $binary_remote_addr(客户端 IP 的二进制形式,比字符串省内存),所以每个 IP 各有一个独立的桶。这也是为什么限流配置写错了(比如 key 用了 $server_name)会导致全站共用一个大桶、把正常访客一起限死。
limit_req_zone 定义在哪、写几行
限流区域必须定义在 http 块里,也就是和 server 同级的地方。标准写法:
# /etc/nginx/nginx.conf 的 http {} 内
limit_req_zone $binary_remote_addr zone=req_perip:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=req_login:5m rate=10r/m;
limit_conn_zone $binary_remote_addr zone=conn_perip:10m;几个要点:
zone=名字:内存大小。10m 大约能记住 16 万个 IP 的状态(每个 IP 状态约 64 字节,加上哈希开销)。个人站 10m 完全够用,不需要开很大。- 同一台 Nginx 可以定义多个 zone,用于不同敏感度的位置。比如普通页面用
req_perip,登录/搜索用更严格的req_login。 - key 一定要用
$binary_remote_addr。用$remote_addr是字符串,占用内存大得多;用$http_x_forwarded_for则会被客户端伪造,等于没限流。 - 如果站点套了 CDN 或反向代理,
$binary_remote_addr拿到的是代理 IP,所有访客会共用一个桶。这时必须先做 real_ip 还原(set_real_ip_from+real_ip_header),再谈限流。
在 location 里启用:三种典型场景
定义好 zone 之后,在需要保护的 location 或 server 块里用 limit_req zone=名字 burst=N [nodelay] 启用。下面是我实际在用的三类配置。
场景一:全站基础限流
server {
listen 443 ssl http2;
server_name www.example.com;
# 全站每 IP 5r/s,突发 20,立刻放行
limit_req zone=req_perip burst=20 nodelay;
limit_req_status 429;
location / {
try_files $uri $uri/ /index.php?$args;
}
}注意 limit_req_status 429(Nginx 1.3.15+)。默认拒绝时返回 503,但从语义上讲,限流属于"请求太多",返回 429 Too Many Requests 更准确,搜索引擎也会把它当成临时性限流而不是服务器故障,不会误判为站点不可用。这一点对 SEO 很友好。
场景二:保护昂贵的搜索与登录接口
location = /search {
limit_req zone=req_login burst=3 nodelay;
limit_req_status 429;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
location = /admin/login.php {
limit_req zone=req_login burst=2;
limit_req_status 429;
# 登录接口不加 nodelay,让它排队,天然拖慢暴力破解
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}这里有个设计巧思:登录接口故意不加 nodelay。这样即使攻击者每秒发 100 个登录请求,Nginx 也只按每分钟 10 次的速度放行,其余的被延迟或拒绝,直接让暴力破解的时间成本爆炸。这是我在 limit_req 上最喜欢的一个用法。
场景三:限制单 IP 并发连接数
server {
server_name dl.example.com;
limit_conn conn_perip 8; # 单 IP 最多同时 8 个连接
limit_conn_status 429;
limit_rate 512k; # 每个连接限速 512KB/s
location /downloads/ {
root /var/www/files;
}
}limit_conn 和 limit_req 是互补的:前者管"同时挂着多少个连接",后者管"单位时间允许多少请求"。防下载器、防爬虫长连接,两者一起上效果最好。
排障:配了不生效、误伤爬虫、日志怎么看
限流上线后,最容易遇到三类问题。
问题一:配置了完全没反应
先确认 limit_req_zone 真的在 http 块里,而不是在 server 里——写在 server 里 nginx -t 会直接报 directive is not allowed here。其次确认 limit_req 所在的 location 真的匹配到了请求:location = /search 是精确匹配,如果实际路径是 /search/ 或带了查询串之外的前缀,就不会命中。用 curl -I 快速验证:
for i in $(seq 1 40); do
curl -s -o /dev/null -w "%{http_code} " "https://www.example.com/search?q=test"
done; echo如果全是 200,说明限流没生效;出现 429 就说明配置到位了。也可以在限流区域被触发后看 Nginx 日志,拒绝的记录会有 limiting requests 字样。
问题二:误伤了搜索引擎爬虫
这是限流最让人头疼的副作用。爬虫抓取频率完全可能超过你给普通访客设的阈值,一旦被 429 拦掉,收录就会掉。解决办法有两种:给爬虫单独放行、或者给爬虫单独的宽松 zone。更稳妥的是用 map 按 User-Agent 和 IP 段分配不同的 key:
map $http_user_agent $limit_key {
default $binary_remote_addr;
"~*Googlebot" ""; # 空 key = 不限流
"~*bingbot" "";
"~*Baiduspider" "";
}
limit_req_zone $limit_key zone=req_perip:10m rate=5r/s;当 $limit_key 为空字符串时,Nginx 不会对这类请求做限流计数。这是官方文档里明确的用法,比单独维护爬虫 IP 白名单省事得多。当然更严谨的做法是再用 geo 模块校验爬虫 IP 反查,防止有人伪造 UA 绕过——毕竟 UA 谁都能改。对个人站来说,UA 白名单足够挡住 99% 的脚本小子,真要防高手再上 IP 校验。
问题三:怎么知道限流到底拦了多少
建议在 log_format 里加上限流相关变量,方便统计:
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" req_time=$request_time '
'limit_req=$limit_req_status limit_conn=$limit_conn_status';
access_log /var/log/nginx/access.log main;这样每一行日志都带着该请求最终是否被限流的状态码。统计被拦请求排行:
grep -o 'limit_req=[0-9]*' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn
# 找出被拦最多的 IP
awk '$0 ~ /limit_req=429/ {print $1}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20有了这个,你就能判断阈值是不是设得太紧。如果被拦榜前面全是真实用户 IP、页面路径又很分散,说明阈值偏低;如果集中在少数几个 IP 和单一路径,说明限流正在正常工作。
一套可以直接用的个人站配置
把上面的东西整合起来,这是我目前在用的完整片段,可以直接放进站点配置:
# http {} 块内
limit_req_zone $limit_key zone=req_perip:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=req_sensitive:5m rate=10r/m;
limit_conn_zone $binary_remote_addr zone=conn_perip:10m;
map $http_user_agent $limit_key {
default $binary_remote_addr;
"~*Googlebot" "";
"~*bingbot" "";
"~*Baiduspider" "";
}
# server {} 块内
limit_req zone=req_perip burst=20 nodelay;
limit_req_status 429;
limit_conn conn_perip 16;
limit_conn_status 429;
location ~* \.(php|html)$ {
limit_req zone=req_sensitive burst=5 nodelay;
# ... 常规 PHP 处理
}上线顺序建议是:先只记录不拒绝。把 limit_req_status 设成 429 后,观察几天日志,确认被拦的是爬虫和扫描器而不是真人访客,再逐步收紧 rate。限流这东西宁可一开始松一点,也不要一上线就把自己站点的真实用户全挡在门外——那种"网站打不开"的锅,比被扫一次要难受得多。
最后提醒一句:限流只解决"单位时间请求过多",解决不了"单次请求太重"。如果你的页面本来就慢,那该做的是加缓存(FastCGI 缓存、对象缓存)而不是一味限流。限流是闸门,缓存才是把水库挖大,两者配合才是个人站扛流量的完整答案。