删掉几条 limit_req 之后,网站反而被打挂了
去年我做过一次「优化」,理由很充分:Nginx 的 limit_req 日志里天天刷 503,我以为是误封正常用户,于是把 burst 从 20 调到了 200,limit_conn 从 10 调到了 50。改完当天没事,第二天早上站点整站 502,MySQL 的连接数打满,PHP-FPM 的进程池全部堵在等待数据库。
原因很直接:被限速挡住的那些请求,本来大多是采集器和扫描器。放宽限制之后它们畅通无阻,把数据库连接池吃干了——真正受害的是正常访客。这次事故让我彻底改变了对「限速」的理解:限速不是惩罚坏人,而是给系统留出可承受的余量。参数调松的代价不是「多放几个请求进来」,而是整条链路的容量假设全部失效。
这篇文章复盘那次事故,并给出一套可复用的容量核算方法:limit_req、limit_conn、PHP-FPM 进程数、MySQL 连接数这四个数字之间到底该是什么关系。
事故现场:四个数字互相打架
事后把参数摊开看,问题一眼就出来了。这台机器 4 核 8G,跑 Nginx + PHP-FPM + MySQL 8.0:
# 事故前的配置(改动后)
limit_req_zone $binary_remote_addr zone=api:10m rate=50r/s;
limit_conn_zone $binary_remote_addr zone=conn:10m;
server {
limit_req zone=api burst=200 nodelay;
limit_conn conn 50;
location ~ \.php$ { fastcgi_pass unix:/run/php/php8.2-fpm.sock; }
}
# PHP-FPM
pm = dynamic
pm.max_children = 25
pm.start_servers = 5
pm.max_spare_servers = 10
# MySQL
max_connections = 100
innodb_buffer_pool_size = 4G算一下最坏情况:50 个连接 × 每个连接每秒 50 个请求 = 2500 QPS 的合法入口。每个 PHP 请求持有一个 MySQL 连接,25 个 FPM 子进程就是 25 个并发数据库连接——这个数字看起来还有余量。问题在于慢请求。max_execution_time=60,一个卡了 30 秒的搜索查询会占住一个 FPM 进程 30 秒。25 个进程里只要有 20 个卡在慢查询上,剩下的 5 个就要承接全部流量,队列瞬间堆满,Nginx 报 502。
而放宽限速之前,限速层本身就在做「流量整形」:它把突发的采集流量削平,让 FPM 的输入是平稳的。限速一放宽,尖峰直接灌进 FPM,容量假设崩塌。
先从后端算起:容量倒推法
限速参数不该凭感觉拍,应该从后端能承受的并发倒推。顺序是:MySQL 连接数 ← FPM 进程数 ← 限速值。反过来推一定会出事,因为前端永远是「更便宜」的一层,扩容没有成本感。
第一步:PHP-FPM 的 max_children 怎么算
公式是 可用内存 / 单个进程峰值内存。峰值要用 pm.status 里的真实数据,不能拍脑袋:
# 开启 status(仅在 127.0.0.1 暴露,别对公网开)
# /etc/php/8.2/fpm/pool.d/www.conf
pm.status_path = /fpm-status
# 从 Nginx 只允许本地访问
location = /fpm-status {
allow 127.0.0.1; deny all;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
# 读真实峰值
curl -s http://127.0.0.1/fpm-status?full | head -20
# 关注三行:
# active processes / max active processes / max children reached我在事故后重跑了一次压测,单进程峰值 RSS 约 120M(Typecho 主题加载后)。8G 内存里 MySQL 要 4G、系统和其他进程约 1G,留给 FPM 约 3G → max_children = 3000 / 120 ≈ 25。也就是说原来的 25 已经是内存上限,不能再加。真正该改的是慢查询和超时,而不是进程数。
如果 max children reached 大于 0,说明进程池被打满过——但在加进程之前,先看 max active processes 和慢请求日志。进程池打满十次有八次是慢请求占坑,不是并发太高。
第二步:request_terminate_timeout 要设
默认 FPM 没有 request_terminate_timeout,一个卡死的请求可以一直占着进程,直到 max_execution_time 生效——而 max_execution_time 在 sleep()、数据库阻塞、file_get_contents 等场景下并不生效。结果就是进程永久占用。
; 强制杀掉超过 45 秒的请求,比 max_execution_time 更可靠
request_terminate_timeout = 45s
; 慢日志:超过 5 秒的请求记下来,含调用栈
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
; 每个子进程处理 500 个请求后重启,规避内存泄漏累积
pm.max_requests = 500request_terminate_timeout 是那次事故之后我加的最有价值的一行配置。它保证「最坏情况下每个进程最多被占用 45 秒」,于是容量模型变成可计算的:25 个进程 × 45 秒 = 每秒可周转约 0.55 个慢请求,剩下的容量留给正常请求。如果没有这个上限,容量模型里就有一个「无穷大」,任何核算都没意义。
第三步:max_connections 与 FPM 的关系
MySQL 的 max_connections=100 对 25 个 FPM 进程看起来绰绰有余,但实际上连接来源不止 FPM:cron 脚本、备份任务、监控采集、SSH 手工查询,还有长连接(pconnect 或连接池)。我在事故时查到的分布是:FPM 25 个 + 长连接 30 个 + 各种定时任务 15 个 = 70,余量只有 30。
-- 按用户和 host 统计当前连接分布
SELECT user, host, COUNT(*) AS conns,
SUM(command = 'Sleep') AS sleeping,
MAX(time) AS max_idle_sec
FROM information_schema.processlist
GROUP BY user, host ORDER BY conns DESC;
-- 连接数峰值与拒绝次数(判断 max_connections 是否真的不够)
SHOW GLOBAL STATUS WHERE Variable_name IN
('Threads_connected','Threads_running','Max_used_connections',
'Aborted_connects','Connection_errors_max_connections');Sleeping 连接过多是典型的应用层问题——PHP 应该用完即断,长连接在 FPM 上反而会放大连接数。我在事故当天把应用的 pconnect 全部关掉,sleeping 连接从 30 降到 4。这比调 max_connections 有效得多,因为增加连接数只会让内存更紧张(每个连接约 1M 起,加上排序缓冲)。
回到前端:limit_req 参数的正确算法
有了后端容量,前端限速就是「别让输入超过后端吞吐」。这里最容易混淆的是 rate 和 burst 的语义。
rate 是令牌桶的填充速率,单位是「每秒允许的平均请求数」。burst 是桶的容量,即允许的瞬时突发量。nodelay 表示突发请求立刻处理而不排队,代价是它们会在瞬间全部打到后端。
关键点:burst=200 nodelay 意味着单个 IP 可以在 1 秒内打 200 个并发请求直达 FPM。25 个进程的池子,一个 IP 就能把它全部占满。这就是事故的直接原因。
# 按路径分级的正确写法:不同资源不同预算
# 静态资源几乎不吃后端,宽松;动态页面严格;搜索页最严格
limit_req_zone $binary_remote_addr zone=static:10m rate=100r/s;
limit_req_zone $binary_remote_addr zone=page:10m rate=8r/s;
limit_req_zone $binary_remote_addr zone=search:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=conn:10m;
# 搜索页:桶容量 5,不 nodelay → 超出的排队,排队超时返回 429
location = /index.php {
if ($arg_s) { return 302 /search/$arg_s; }
limit_req zone=page burst=20 nodelay;
limit_conn conn 20;
limit_req_status 429;
limit_conn_status 429;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
location ^~ /search/ {
limit_req zone=search burst=5; # 注意:不加 nodelay,超出部分排队后拒绝
limit_req_status 429;
...
}
# 静态资源:走 Nginx 直接返回,限速只是为了挡扫描
location ~* \.(jpg|png|css|js|woff2)$ {
limit_req zone=static burst=200 nodelay;
expires 30d;
}搜索页的 burst=5 不加 nodelay,是有意为之。不加 nodelay 时,超出速率的请求进入队列,按 rate 的节奏慢慢放行;队列满了才返回 429。这样即使来个每秒 100 次的采集器,后端每秒也只收到 1 个搜索请求。加了 nodelay 就变成「一次放 5 个进去,剩下的直接 429」,对这些慢查询来说,5 个并发也可能超预算。
还有一点:limit_req_status 429 比默认的 503 好,因为 429 是「限流」的标准语义,Googlebot 会降低抓取频率而不是标记站点不可用。limit_conn 同理。
real_ip 没配好,限速等于没开
事故后复查发现一个更严重的遗留问题:这台机器的 Nginx 前面有 CDN,但 set_real_ip_from 配的是 0.0.0.0/0。这意味着任何客户端只要自己发一个 X-Forwarded-For 头,就能把自己的 IP 伪装成任意值——限速的 key 变成攻击者可控的,换个头就绕过限速。
# 只信任 CDN 节点段(从 CDN 服务商文档取,定期更新)
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 104.16.0.0/13;
real_ip_header X-Forwarded-For;
# 只取最后一个可信代理写入的地址,不信任客户端自带的值
real_ip_recursive on;real_ip_recursive on 配合可信段列表,会从右往左找第一个不属于可信段的地址作为客户端 IP。这是防伪造的关键——如果 set_real_ip_from 写成了全网,X-Forwarded-For 最左边的值就是攻击者随便填的。
验证方法很简单:curl -H "X-Forwarded-For: 1.2.3.4" https://your.site/,然后在日志里看 $remote_addr。如果记成了 1.2.3.4,说明被伪造成功了。
调参之后必须压测验证
改完配置不压测,等于换了个数字继续猜。我用的最小验证集是三条命令:
# 1) 单 IP 突发:确认 burst 真的生效(预期看到 429,且不影响其他 IP)
ab -n 200 -c 50 https://www.example.com/ 2>&1 | grep -E 'Non-2xx|Requests per second'
# 2) 并发连接:确认 limit_conn 生效
hey -z 10s -c 60 https://www.example.com/ | tail -5
# 3) 压测期间同时观察后端(这是最关键的一步)
watch -n 1 'curl -s http://127.0.0.1/fpm-status | grep -E "active|max children"; \
mysql -uroot -p*** -e "SHOW GLOBAL STATUS LIKE \"Threads_running\""'第 3 步是很多人漏掉的。压测工具只告诉你「前端返回了什么」,不告诉你「后端撑不撑得住」。如果压测时 Threads_running 冲到 25 以上、max children reached 开始增长,那说明限速还是太松,或者慢查询没解决——光靠限速掩盖慢查询,只是把问题推到更高的成本上。
我最终的参数:page 页 rate=8r/s burst=20,搜索页 rate=1r/s burst=5(无 nodelay),静态资源 rate=100r/s burst=200,limit_conn 20。压测结果显示:模拟 200 并发单 IP 攻击时,FPM 的 active processes 稳定在 6~8,正常访客的响应时间没有变化。
这次事故的三条结论
第一,限速参数是容量参数,不是安全参数。把它当「阻挡坏人」的工具时,你会为了「少误伤」而放松它;把它当「系统容量的守门人」时,你会先算后端能吃多少,再决定放多少进来。
第二,四个数字必须成体系。MySQL max_connections ≥ FPM max_children + 其他来源连接数;FPM max_children ≤ 可用内存 / 单进程峰值;限速的「最坏并发」≤ FPM max_children。任何一环单独调,都会打破另外两环的假设。
第三,慢查询才是根因。限速只是止血。那次事故真正的根因是一条没有走索引的 LIKE '%关键词%' 搜索,单次执行 8 秒。加了索引之后,搜索页的 95 分位从 6.2 秒降到 40 毫秒,FPM 的进程周转率提高了两个数量级——这时候再回头看限速,它终于回到了「挡扫描器」的本职,而不是「替慢查询兜底」。
顺序永远是:先修慢的,再算容量,最后调限速。反过来做,就是给一条漏水的船加高栏杆。