为什么每个站长迟早都要面对限流
站点上线之后,你大概率会遇到这样一天:服务器 CPU 突然飙到 100%,带宽跑满,MySQL 连接数告急,网站打开变成 502 或者一直转圈。打开日志一看,某个 IP 在一分钟内请求了八千次,路径全是 /search?q=xxx 或者干脆是随机字符串。这就是最典型的 CC 攻击或者恶意爬虫采集。
很多站长的第一反应是装一堆安全插件、防火墙软件,甚至上 Cloudflare 的付费套餐。但说实话,对于绝大多数个人站点,Nginx 自带的限流模块就足够挡住九成以上的无效流量,而且零成本、无依赖、不增加任何请求链路。这篇文章把 limit_req(请求速率限制)和 limit_conn(并发连接限制)讲透,包括参数含义、真实配置模板,以及新手最容易踩的坑。
限流的两块基石:漏桶算法与共享内存区
Nginx 的 limit_req 用的是漏桶算法(Leaky Bucket)。你可以把它想象成一个底部有个小孔的水桶:请求就是倒进去的水,小孔的流速就是 rate,桶的容量就是 burst。水倒得太快,桶满了,多余的水就溢出去——溢出去的那部分就是被拒绝的请求,返回 503。
关键在于,漏桶允许多少「突发」被缓存在桶里等着排队处理,而不是直接拒绝。这就是 burst 参数存在的意义,也是很多人配错的地方。
而 limit_conn 是另一维度:它不看你请求多快,只看同一个 key 同时有多少个连接还没结束。慢速攻击(Slowloris,一个连接慢慢发 header 拖住 worker)就得靠它来防。
两者都必须先在 http 块里声明一个共享内存区(shared memory zone),否则 nginx -t 会直接报错。共享内存区的作用是让所有 worker 进程共享计数器——因为 Nginx 是多进程模型,没有共享区的话每个 worker 各算各的,限流就形同虚设。
http {
# 第一个参数是 zone 名字,第二个是内存大小
# 10m 大约可以存 16 万个 IP 的状态,个人站 10m 完全够用
limit_req_zone $binary_remote_addr zone=req_per_ip:10m rate=10r/s;
# 限制单 IP 并发连接数,同样是 10m
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
# 全局每秒请求数上限,防止整体被压垮(备用兜底)
limit_req_zone $server_name zone=req_global:10m rate=500r/s;
}注意这里用的是 $binary_remote_addr 而不是 $remote_addr。binary 版本用 4 字节存 IPv4(而不是 15 字节的字符串),内存利用率高得多。所以网上有人说「1m 只能存 8000 个 IP」——那是用 $remote_addr 的算法,用 binary 版本 1m 能存 16000 个左右。
limit_req 参数逐条拆解
先看一个可以直接抄进 server 块的配置:
location / {
limit_req zone=req_per_ip burst=20 nodelay;
limit_req_status 429;
limit_conn conn_per_ip 20;
limit_conn_status 429;
# 命中限流时记录日志,便于排查(可选)
limit_req_log_level warn;
try_files $uri $uri/ /index.php?$query_string;
}逐个说:
burst=20:允许突发 20 个请求排队。也就是说,如果某个 IP 在一瞬间发来 30 个请求,前 10 秒平均速率没超的前提下,Nginx 会立刻处理 1 个,把剩下 19 个放进队列等,第 30 个才会被拒绝并返回limit_req_status。nodelay:关键参数。不加它,排队里的请求会被按 rate 的速度慢慢放行——你写 10r/s,那第 20 个排队请求要等 2 秒才处理。对于正常用户,这意味着打开一个引用了 20 个静态资源的页面,图片会一张张挤出来,体验极差。nodelay的含义是「桶里的排队请求立刻放行,只是占了桶的位置」,既保住了平均值限制,又不影响突发体验。limit_req_status 429:默认限流返回 503 Service Unavailable。这有两个坏处:一来搜索引擎会认为你的站点在宕机,二来监控告警会误报。改成 429(Too Many Requests)语义准确得多。同时记得在站点错误页里给 429 也配一个友好页面。limit_conn conn_per_ip 20:单 IP 最多 20 个并发连接。正常浏览器打开一个页面大概并发 6 个连接,20 是相当宽松的值。如果有人用 ab / wrk 压你,或者用多线程采集器,这个数字会立刻生效。
按路径做差异化限流
整站一刀切是不合理的。静态资源应该给得很宽松,而搜索、登录、评论、API 这些「重」接口必须收紧。做法是给不同 location 配不同的 zone:
http {
limit_req_zone $binary_remote_addr zone=static_zone:10m rate=100r/s;
limit_req_zone $binary_remote_addr zone=search_zone:10m rate=2r/s;
limit_req_zone $binary_remote_addr zone=login_zone:10m rate=1r/m;
}
server {
# 静态资源:宽松,几乎不会误伤
location ~* \.(jpg|jpeg|png|gif|webp|css|js|woff2)$ {
limit_req zone=static_zone burst=200 nodelay;
expires 30d;
add_header Cache-Control "public, immutable";
}
# 搜索接口:最容易被打,严格
location = /search {
limit_req zone=search_zone burst=5 nodelay;
limit_req_status 429;
limit_conn conn_per_ip 5;
}
# 登录:防暴力破解,一分钟只允许一次
location = /admin/login.php {
limit_req zone=login_zone burst=3 nodelay;
limit_req_status 429;
}
}这里的 rate=1r/m 是「每分钟 1 次」,Nginx 支持 r/s 和 r/m 两种单位。登录接口用 1r/m 看起来夸张,但实际上正常用户登录一次成功后就不会反复提交,而暴力破解脚本会被死死卡住。配合 burst=3 给用户留一点重试余地(输错密码重试 2 次完全够)。
白名单:别把搜索引擎和监控打出去
限流最怕的就是误伤。搜索引擎爬虫如果被 429 拦住,收录会立刻掉;你自己的监控探针被拦住,告警就不准了。Nginx 提供了 geo 模块配合 map 来做白名单:
http {
# 先把 UA 归类
map $http_user_agent $limit_bot {
default "";
# 已知搜索引擎爬虫不限制
~*Googlebot "";
~*Baiduspider "";
~*bingbot "";
~*YandexBot "";
~*Bytespider "";
# 可疑 UA 单独给一个严格 zone
~*(python-requests|curl|wget|Scrapy|Go-http-client) "abuser";
}
limit_req_zone $limit_bot zone=ua_zone:10m rate=1r/s;
}不过更稳妥、更常用的做法是基于 IP 白名单。用 geo 把可信网段标记为 0,然后 map 它们为空字符串——因为 Nginx 的限流 key 为空时不参与计数,等于直接放行:
geo $limit_key {
default 1;
127.0.0.1 0;
10.0.0.0/8 0;
172.16.0.0/12 0;
192.168.0.0/16 0;
# 你的 CDN 回源 IP 段一定要加进来!
# 否则所有流量都来自 CDN 节点,限流会把整个 CDN 打死
}
map $limit_key $limit_key_final {
0 "";
1 $binary_remote_addr;
}
limit_req_zone $limit_key_final zone=req_per_ip:10m rate=10r/s;最后这一条是使用 CDN 的站点最容易翻车的地方。如果你的站前面挂了 CDN,Nginx 看到的 $remote_addr 全是 CDN 边缘节点的 IP。此时限流等于在限制 CDN 节点本身,几分钟内整站就可能对部分用户不可用。正确做法是用 real_ip 模块还原真实客户端 IP:
# 必须放在 http 或 server 块
set_real_ip_from 23.225.146.0/24; # 你的 CDN 回源网段
real_ip_header X-Forwarded-For;
real_ip_recursive on;配置完之后 $remote_addr 就变成了真实访客 IP,限流才是有意义的。验证方法很简单:把 $remote_addr 加到 access log 格式里,看日志里的 IP 是访客的还是 CDN 的。
排查:为什么限流没生效?
配完限流最常见的三个问题:
第一,限流完全没反应,被压测时照样全放行。九成的原因是 zone 定义在了 server 块里或者被 include 到了错误的位置。limit_req_zone 只能出现在 http 上下文,limit_req 才可以在 http/server/location 里用。执行 nginx -t 确认没报错。
第二,误伤了正常用户。症状是用户抱怨「网站偶尔打不开」「刷新一下又好了」。先看 burst 是不是太小(建议至少 10),再看有没有加 nodelay。如果站点目录里图片特别多、页面引用了上百个静态文件,那静态资源的 rate 一定要给大(100r/s 起步)。
第三,想确认限流真的在拦截,看日志数 429:
# 统计最近 1000 条日志里被限流的请求
tail -n 1000 /var/log/nginx/access.log | awk '$9 == 429' | wc -l
# 看是哪些 IP 在被限流
tail -n 5000 /var/log/nginx/access.log | awk '$9 == 429 {print $1}' \
| sort | uniq -c | sort -rn | head -20
# 实时观察限流日志(需要 limit_req_log_level warn)
tail -f /var/log/nginx/error.log | grep "limiting requests"error.log 里会输出类似 limiting requests, excess: 12.345 by zone "req_per_ip", client: 1.2.3.4 的行,excess 就是超出速率限制的倍率,直接告诉你某个 IP 超了多少。
被封的 IP 要不要永久拉黑?
限流是「软防御」——它只是减速,不封禁。但如果你发现某个 IP 反复触发限流(比如一个小时内被限流上千次),那就该上黑名单了。最简单的方式是用 deny,或者用 fail2ban 自动从日志里提取 IP 并写入 iptables:
# 手动封禁
location / {
deny 1.2.3.4;
limit_req zone=req_per_ip burst=20 nodelay;
}
# fail2ban 自动封禁(/etc/fail2ban/jail.local)
[nginx-limit-req]
enabled = true
filter = nginx-limit-req
logpath = /var/log/nginx/error.log
maxretry = 20
findtime = 600
bantime = 3600fail2ban 内置了 nginx-limit-req 这个 filter,直接匹配 error.log 里的 limiting requests 日志,10 分钟内触发 20 次就封 1 小时。这是我最推荐的组合:Nginx 限流负责减速,fail2ban 负责对屡教不改者上手段。
小结
把整篇文章压缩成一份可以直接抄的清单:
- 在
http块声明limit_req_zone/limit_conn_zone,key 用$binary_remote_addr。 - 正常页面
rate=10r/s burst=20 nodelay,静态资源放宽到 100r/s,搜索/登录收紧到 2r/s 甚至 1r/m。 - 限流状态码一律改成
429,避免 503 被搜索引擎误判为宕机。 - 挂 CDN 的站必须先配
set_real_ip_from+real_ip_header,否则限流会打到 CDN 自己身上。 - 用
geo/map把搜索引擎爬虫、监控探针、内网网段排除在计数之外。 - 配合 fail2ban 的
nginx-limit-reqjail,自动封禁反复触发限流的 IP。 - 上线后看
access.log里 429 的数量和error.log里的 excess 值来验证效果。
限流从来不是一道「配了就完事」的开关,而是一组需要根据自己站点真实流量特征反复微调的旋钮。建议上线初期把 rate 设得宽松一些(观察一周 429 的数量),确认没有误伤之后再逐步收紧。宁可慢一点收紧,也不要一夜之间把真实用户和爬虫一起拒之门外。