高防CDN回源尖峰把 PHP-FPM 池打穿,`max_execution_time` 却一声不吭:`setitimer(ITIMER_PROF)` 量的是 CPU 钟,不是墙钟

现象:清洗完成后回注的那 30 秒,502 和 active processes 一起飙

凌晨两点,攻击停了,CDN 控制台的"已清洗"绿灯亮起,回注流量在 30 秒内从 4000 QPS 冲到 3.2 万 QPS。源站 Nginx 的 error.log 在这个窗口里刷出两类错误,必须分开看:

upstream timed out (110: Connection timed out) while connecting to upstream
connect() failed (111: Connection refused) while connecting to upstream

同时 FPM status 页的 active processes 顶死在 512(正好是 max_children),但这两件事不是同一个根因。

curl -s "http://127.0.0.1/fpm-status?full" | grep -E "active|idle|listen queue|max listen"

接下来照抄运维手册做的操作——把 max_children 从 512 拉到 1024、把机器从 16 核加到 32 核——全都无效。因为 CPU 根本没跑满:

top -b -n1 | head -5
%Cpu(s):  9.3 us,  3.1 sy,  0.0 ni, 86.9 id,  0.7 wa

CPU 空闲 87%,FPM worker 全满,slowlog 是空的。这三个条件放在一起,等于直接指向一个结论:worker 不在算,在等。而 PHP 自己的 max_execution_time 根本不管"等"。

第一笔账:max_execution_time 背后是 setitimer(ITIMER_PROF)

PHP 设置执行时间上限的路径是 zend_set_timeout() → setitimer(ITIMER_PROF, &t_r, NULL),超时信号是 SIGPROF。关键在 ITIMER_PROF 这三个字母:它按进程消耗的 CPU 时间(用户态 + 内核态)递减,进程阻塞在 read()、epoll_wait()、poll() 里让出 CPU 的时间,一个 tick 都不扣。

对比一下就清楚了,两个脚本,同一份 php.ini(max_execution_time = 5):

<?php
// a.php —— 纯 IO 等待
$t = microtime(true);
sleep(30);
error_log(sprintf("wall=%.2f", microtime(true) - $t));
<?php
// b.php —— 纯 CPU 燃烧
while (true) { $x = sqrt(mt_rand()); }

b.php 在 5 秒左右被 SIGPROF 干脆利落地杀掉,a.php 老老实实跑满 30 秒。max_execution_time = 5 对 IO 阻塞型请求完全免疫。

而这恰好是 CDN 回源场景最典型的形态:回注尖峰把源站后端的 MySQL 连接池打满、Redis 排队、回源 HTTP 抖动,PHP 卡在 PDO::query() 里等锁、卡在 curl_exec() 里等上游。每一个都是"等",不是"算",max_execution_time 一次都不触发。

更狠的一刀:业务代码里但凡有一处 set_time_limit(0)(导出、批量同步、图片处理,日常代码里到处都有),这个请求就彻底没有任何 PHP 层面的时间约束了。

第二笔账:Nginx 已经断连了,PHP 还在跑(CLOSE-WAIT 那一坨)

很多人以为 fastcgi_read_timeout 60s 一到,请求就结束了。Nginx 确实断了,但 PHP-FPM 侧不会自动退出。

PHP 感知客户端断连的唯一途径是写出的时候出错:php_output_write() → SAPI 的 ub_write → fcgi_write_request() 拿到 EPIPE/ECONNRESET,才把 connection_status 置为 ABORTED;而且只有 ignore_user_abort=0 时才会 bailout。脚本不输出,就没有这个检测点。

所以链路上真实发生的是:Nginx 关连接 → 内核给 PHP 侧 socket 投递 FIN → PHP 的 socket 进入 CLOSE_WAIT → PHP 不 read,永远停在 CLOSE_WAIT → worker 被继续占着。

这条命令是定位这一步最快的手段,比翻 slowlog 还快:

ss -tanp state close-wait '( sport = :9000 or dport = :9000 )' | wc -l
ss -tanp state close-wait | grep php-fpm | head

如果这个数字跟 active processes 增长趋势一致,那就别怀疑了,全是"已被上游放弃但还在跑"的僵尸请求。

第三笔账:`fastcgi_finish_request()` 之后,worker 压根没释放

这是最容易被 status 页骗过去的一层。为了不让用户等埋点、日志、推送,代码里大量用了 fastcgi_finish_request():

<?php
echo $html;
fastcgi_finish_request();   // 响应已返回,用户不再等待
writeAccessLog($data);      // 后台逻辑,还占着这个 worker
pushToKafka($data);

问题在于:fastcgi_finish_request() 只是把响应刷出去并关闭连接,worker 的 scoreboard 状态依然是 RUNNING,要等脚本彻底结束、request shutdown 走完 fpm_request_end() 才回到 IDLE。也就是说:

  • active processes = 正在处理请求的 + 正在跑后台尾巴的,这两类混在一起,指标彻底失真;
  • CDN 回注尖峰时,后台尾巴(写日志、打点、推消息)比主逻辑更容易堵,一堵就把 max_children 吃光;
  • 因为主响应早就返回了,Nginx 侧 access.log 全是 200,误判成"业务正常"。

抓现行的方法:对几个 active 的 worker 直接看内核栈,一眼就能分辨是在算还是在等:

for p in $(pgrep -f 'php-fpm: pool www'); do
  printf "%s  %s\n" "$p" "$(cat /proc/$p/wchan 2>/dev/null)"
done | sort -k2 | uniq -c -f1 | sort -rn

# 典型输出
#   380 do_syscall_64      <- 卡在系统调用(IO 等待,占大头)
#    12 0                   <- 在用户态真跑 CPU

再看一眼具体在等什么:

strace -p <pid> -f -c -S calls -w 2>&1 | head -20
# 或者直接单次采样
cat /proc/<pid>/stack

看到 unix_stream_read_generic 或 tcp_recvmsg 挂着一大片,就是等后端;看到 nanosleep 一大片,就是代码里在 sleep / 重试退避。

第四笔账:request_slowlog_timeout 用的是墙钟,它才是能抓住 IO 的那个

这是整篇文章最关键的一组对比。FPM 里有两个"超时",计时口径完全相反:

  • max_execution_time(PHP 层):ITIMER_PROF,CPU 钟。抓不住 IO 阻塞。
  • request_slowlog_timeout(FPM master 层):master 定时扫描 scoreboard,用 fpm_clock_get()(CLOCK_MONOTONIC)减去 worker 上报到共享内存里的 request_usec,比的是墙钟。能抓住 IO 阻塞。

所以那晚 slowlog 是空的,不是因为请求不慢,而是因为 request_slowlog_timeout 压根没配(默认 0,关闭)。配上之后,同一批尖峰立刻出料,且直接给出卡在哪一行:

; php-fpm.d/www.conf
request_slowlog_timeout = 3s
slowlog = /var/log/php-fpm/slow.log
request_terminate_timeout = 65s
process_control_timeout = 10s
[01-Feb-2025 02:14:37]  [pool www] pid 28471
script_filename = /data/www/api/order.php
[0x00007f8a2c1e4a30] PDOStatement->execute() /data/www/api/order.php:88
[0x00007f8a2c1e3b10] curl_exec() /data/www/lib/Pay.php:214

栈顶直接是 PDOStatement->execute() —— 和前面 wchan 看到的 unix_stream_read_generic 完全对上了。

再强调一遍 request_terminate_timeout 的价值:它在 master 侧、用墙钟、不受脚本内 set_time_limit(0) 影响、也不受 CPU 钟口径影响,是唯一能兜住"卡死 worker"的闸门。默认值 0 = 永不超时 = 一个卡住的 worker 能挂到进程重启。

第五笔账:`Connection timed out` 和 `Connection refused` 是两种不同的 listen 队列溢出

回到开头那两条 Nginx 报错,它们指向 accept 队列(listen backlog),不是 SYN 队列,别搞混:

  • Connection refused:内核直接回 RST。对应 net.ipv4.tcp_abort_on_overflow = 1,或者内核在队列满时因为内存原因丢包。
  • Connection timed out:内核静默丢弃第三次握手 ACK(默认 tcp_abort_on_overflow = 0),客户端以为连接建好了,重传 SYN-ACK 直到超时。

但无论哪种,内核的计数器都在涨,这是唯一可信的证据:

netstat -s | grep -i -A1 listen
#     128431 times the listen queue of a socket overflowed
#     128431 SYNs to LISTEN sockets dropped

# 或者按增量看,别用 -z 看累计值
nstat -n TcpExtListenOverflows TcpExtListenDrops; sleep 10; nstat TcpExtListenOverflows TcpExtListenDrops

再看 PHP-FPM 那个 socket 实际生效的 backlog:

ss -lntp 'sport = :9000'
# State  Recv-Q  Send-Q  Local Address:Port
# LISTEN 512     511     0.0.0.0:9000          <- Send-Q 就是 min(listen.backlog, somaxconn)

PHP-FPM 的 listen.backlog 默认 511,而 net.core.somaxconn 在不少老发行版上还是 128。两个值一取 min,实际队列可能只有 128。3 万 QPS 的回注脉冲下,128 个槽位等于没有。

sysctl -w net.core.somaxconn=32768
sysctl -w net.ipv4.tcp_max_syn_backlog=32768
; php-fpm.d/www.conf
listen.backlog = 8192

这里有个经验比例:listen.backlog 应当是 pm.max_children 的 8~16 倍。因为 max_children 决定的是稳态并发,backlog 决定的是突发缓冲。回注脉冲的形态是"3 秒内涌入平时 8 倍的连接",这一层没有缓冲,前面 CPU 再空闲也白搭。

落地:三层超时梯度 + 一份能用的配置

把上面几笔账合起来,正确的结构是"由内向外依次放宽",让最短的超时先触发、让 worker 尽早主动释放:

  • 第一层,应用自身 IO 超时(最先触发,PHP 主动返回 504):PDO::ATTR_TIMEOUT = 3、CURLOPT_TIMEOUT_MS = 3000、Redis 的 read_timeout = 2。
  • 第二层,FPM master 的 request_terminate_timeout:兜底杀,因为 master 用墙钟,不受 set_time_limit(0) 影响。
  • 第三层,Nginx 的 fastcgi_read_timeout:最外层,给用户一个明确的 504。
; /etc/php-fpm.d/www.conf
listen = /dev/shm/php-fpm.sock
listen.backlog = 8192
pm = static
pm.max_children = 384
pm.max_requests = 5000
request_terminate_timeout = 20s
request_slowlog_timeout = 3s
slowlog = /var/log/php-fpm/slow.log
process_control_timeout = 10s
catch_workers_output = no
# nginx.conf  http 块
fastcgi_connect_timeout 3s;
fastcgi_send_timeout    10s;
fastcgi_read_timeout    12s;
fastcgi_buffers 16 16k;
fastcgi_busy_buffers_size 32k;

# 回源侧限速,防回注脉冲直接怼到 FPM
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn perip 50;

两个容易踩的反直觉点:

一,process_control_timeout = 10s 别留 0。 留 0 意味着 master 判定 worker 超时后立刻 SIGKILL,slowlog 和 shutdown 函数全部没机会跑,现场什么都抓不到。给它 10 秒,让它把慢日志写完再死。

二,limit_conn 在接了高防CDN 之后基本等于失效。 因为 $binary_remote_addr 拿到的是 CDN 回源节点的 IP,几台回源机分享同一个 key。必须先用 set_real_ip_from + real_ip_header 还原真实客户端 IP,否则限流全部打在一个虚拟 IP 上,要么全放要么全拦。我们源站用的是轻云互联的高防回源线路,回源节点会带 X-Forwarded-For,但源站 Nginx 不显式配置 set_real_ip_from 照样不生效——这步经常被漏掉。

最后,指标怎么改

别只看 active processes,它把 fastcgi_finish_request() 之后的后台尾巴也算进去了。真正该盯的是三个:

  • nstat TcpExtListenOverflows 的增量(不是累计值),涨了就说明 accept 队列在丢;
  • ss -tanp state close-wait | grep -c php-fpm,这个数字等价于"已被上游放弃但还在跑的请求数",它涨 = Nginx 已超时但 PHP 没退;
  • slowlog 的分钟级行数,配合 wc -l 和栈顶函数聚合,比任何 APM 都便宜直接。
# 一分钟粒度的三个数一起采集
nstat -n TcpExtListenOverflows TcpExtListenDrops
echo "close_wait=$(ss -tanp state close-wait 2>/dev/null | grep -c php-fpm)"
echo "slow=$(wc -l < /var/log/php-fpm/slow.log)"

回到最初那个凌晨:真正的修复不是加核、不是调大 max_children,而是把 request_terminate_timeout 从 0 改成 20s、把 request_slowlog_timeout 从 0 改成 3s、把 listen.backlog 从 511 提到 8192。改完当晚回注尖峰的 502 从 4.7% 掉到 0.3%,机器一台没加。