高防CDN 回源放大 3.7 倍:PHP 站被打穿的真凶是 session 锁 + fastcgi_next_upstream
先说结论:高防CDN 接入后源站 PHP-FPM 被打满,90% 的情况不是 CC 攻击流量穿透了清洗,而是「一次用户请求被放大成 3~4 次回源请求,同时每次请求的持有时长被 session 文件锁拖长」 。前者放大 QPS,后者放大并发数,两个乘起来就是雪崩。
下面是在一个日均 800 万 PV 的电商站上做的完整实操,源站是轻云互联的高防独立服务器(双路 EPYC + NVMe,回源走内网专线,节点到源站 RTT 稳定在 1.8ms),这个 RTT 很关键——超时层级能往下压,才有调优空间。
一、先算账:回源放大系数怎么来的
别急着改 pm.max_children。先量化。核心公式(Little's Law 的变形):
需要的 worker 数 = 真实 QPS × 回源放大系数 A × P95 响应时间(s)
其中 A 的定义是:源站 PHP 请求数 / CDN 边缘收到的用户请求数 。这个数字大于 1.3 就已经有病了。
先在 nginx 上加一个专用 log_format,把真实客户端 IP、上游耗时和 CDN 追加的 XFF 都打出来:
log_format cdn '$remote_addr|$realip_remote_addr|$request_uri|$upstream_response_time|$request_time|$status|$time_iso8601|$http_user_agent';
然后直接和 CDN 边缘日志对账:
# 边缘侧 10 秒窗口的请求数(示例)
awk -F'|' '{print substr($7,1,19)}' edge.log | sort | uniq -c | sort -rn | head -5
# 源站侧同一窗口的 PHP 请求数
grep '\.php' source.log | awk -F'|' '{print substr($7,1,19)}' | sort | uniq -c | sort -rn | head -5
这台机器当时的数据:边缘峰值 3120 QPS,源站 PHP 峰值 11540 QPS,A = 3.70 。
再看同一个真实 IP 在同一秒内发了几个请求,确认是不是重试:
awk -F'|' '$3 ~ /\.php/ {print $2"|"substr($7,1,19)}' source.log \
| sort | uniq -c | awk '$1 > 1 {c[$1]++} END {for (k in c) print k" 个请求/秒: "c[k]" 次"}'
结果:单秒内 2 次占比 41%,3 次占比 12%,4 次占比 3%。用户不可能手速这么快,这就是重试。
二、四个静默的重试器,逐个关掉
2.1 CDN 回源超时重试(放大 ×2~3)
高防CDN 的边缘默认策略通常是:回源超时 3s ~ 5s,失败后重试 2~3 次,间隔 100ms / 300ms / 900ms。问题在于——源站的 fastcgi_read_timeout 是 60s 。
于是 CDN 在 3 秒时判定超时,重新发一次;源站这边第一次请求的 PHP 进程还稳稳地跑着,一个都没释放。8 秒的慢 SQL 请求,被放大成 4 个并发 worker × 8 秒 = 32 个进程秒。
这是超时层级的倒挂 。正确顺序必须自上而下递减,让 PHP 自己先死:
CDN 回源超时:15s (最外层,必须最大)
nginx fastcgi_read_timeout:10s
PHP-FPM request_terminate_timeout:8s (最先触发,留 slowlog)
这样 PHP 在 8s 被 master SIGTERM 干掉,nginx 立刻拿到 502 关闭连接,CDN 在 8.1s 收到一个明确的 5xx 响应——它就不会重试了 ,因为已经拿到响应了,不是超时。
同时把 CDN 侧的重试次数从 3 次砍到 1 次(仅对 502/504 且非幂等请求关闭重试),这一步单独就把 A 从 3.70 降到 1.9。
2.2 nginx fastcgi_next_upstream 默认值是个陷阱(放大 ×1.5~2)
检查你的配置:
grep -rn "fastcgi_next_upstream" /etc/nginx/
如果没有任何输出,说明用的是默认值 error timeout。意思是:上游超时或报错时,nginx 会把同一个请求原样重发到下一个 upstream 。你配了 3 个 PHP 节点,一个慢请求就会被放大 3 倍,而且是在源站内部放大,CDN 那边完全看不见。
高防场景下必须显式关掉:
upstream php_backend {
server unix:/dev/shm/php-fpm.sock;
# 关键:单节点架构下禁止任何形式的重发
}
location ~ \.php$ {
fastcgi_pass php_backend;
fastcgi_next_upstream off;
fastcgi_connect_timeout 1s;
fastcgi_send_timeout 8s;
fastcgi_read_timeout 10s;
fastcgi_buffer_size 4k;
fastcgi_buffers 64 4k;
fastcgi_busy_buffers_size 8k;
# 回源请求体不大,交给 nginx 缓冲,避免 PHP 在读 body 时长时间占进程
fastcgi_request_buffering on;
include fastcgi_params;
}
fastcgi_connect_timeout 1s 是故意的。unix socket 本地连接超过 1 秒没建成,说明 FPM 的 accept 队列已经积压,早点失败比拖着好。
2.3 PHP session 文件锁:真正把并发数顶上去的元凶
放大系数解决后,QPS 回落了,但 P99 还是 6s 起不来。这时候看 slowlog:
; /etc/php-fpm.d/www.conf
request_slowlog_timeout = 3s
slowlog = /var/log/php-fpm/www-slow.log
request_terminate_timeout = 8s
grep -oE '[a-z_]+\(\)' /var/log/php-fpm/www-slow.log | sort | uniq -c | sort -rn | head -10
输出:
3187 session_start()
422 file_get_contents()
187 curl_exec()
64 PDO::prepare()
3187 次 session_start() 卡住 3 秒以上。原因很朴素:PHP 的 files session handler 在 session_start() 时对 /var/lib/php/session/sess_xxxx 加 flock(LOCK_EX) 排他锁,直到脚本结束或 session_write_close() 才释放。
前端页面加载会并发发起 6~8 个 XHR,全部带同一个 PHPSESSID,它们在 session 锁上排队串行执行 。一个用户 8 个请求串行,等于把这个用户的并发时长乘以 8。CDN 那边看到慢,又开始重试……闭环了。
验证方式,抓一个 live 进程看它在等什么:
for p in $(pgrep -f 'php-fpm: pool www'); do
if timeout 0.2 strace -p $p -e trace=flock 2>&1 | grep -q 'LOCK_EX'; then
echo "pid=$p 卡在 session flock"
ls -l /proc/$p/fd 2>/dev/null | grep sess_
fi
done
修复分两步。第一步,只读接口全部改成无锁读:
<?php
// 只读场景,读完立刻释放锁,不写回
session_start(['read_and_close' => true]);
// 写场景:改完马上释放,别把锁握到脚本结束
$_SESSION['cart_count'] = $n;
session_write_close();
// 后面还有 800ms 的业务逻辑?跟 session 没关系了,随便跑
第二步,换 Redis session handler,并且显式打开锁参数(phpredis 的 session handler 默认是带锁的,lock=1),但把 spin 等待调小:
session.save_handler = redis
session.save_path = "tcp://10.0.0.5:6379?persistent=1&timeout=1&read_timeout=1&database=0&prefix=SESS:&lock=1&spin_lock_wait=15000"
session.lazy_write = 0
session.gc_maxlifetime = 7200
注意两个坑:persistent=1 在 phpredis 的 session handler 下复用连接,如果 Redis 侧配了 timeout 300 而连接池空闲更久,会出现 "read error on connection" 导致 session 静默丢失——要么给 Redis 的 timeout 设 0,要么老实关掉 persistent。session.lazy_write=0 是因为有大量接口只读不写,打开 lazy_write 能省掉一次 Redis 写回,但代价是并发写同一 key 时后写的会覆盖先写的(lazy 判断基于内存中的 session 数组是否变化)。
这一步做完,P99 从 6.2s 掉到 1.4s。
2.4 opcache 编译风暴:php-fpm 重启后的第一分钟
还有一个隐藏的放大器,只在发布窗口出现:systemctl restart php-fpm 之后 opcache 共享内存被清空,接下来几十秒里所有 worker 同时编译同一批文件,CPU 直接跑满,请求排队,CDN 判超时……又一次重试风暴。
# 记录一下重启后 60 秒的 CPU 和请求耗时
systemctl restart php-fpm
vmstat 1 60 | awk '{print strftime("%H:%M:%S"), $13, $14}'
生产环境应该这么配:
; /etc/php.d/10-opcache.ini
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=1024
opcache.interned_strings_buffer=64
opcache.max_accelerated_files=130987
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.save_comments=1
opcache.huge_code_pages=1
opcache.file_cache=/dev/shm/opcache
opcache.file_cache_consistency_checks=0
opcache.file_cache_only=0
关键是 file_cache 落在 tmpfs 上。发布流程改成:先跑一次 CLI 预热脚本把编译产物写进 /dev/shm/opcache,再 reload php-fpm,FPM 启动时直接 mmap 命中二进制缓存,没有编译风暴。
<?php
// /opt/prewarm.php 用 CLI 跑,opcache.enable_cli 必须为 1
$root = '/data/www/app';
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
);
$n = 0;
foreach ($it as $f) {
if ($f->isFile() && $f->getExtension() === 'php') {
@opcache_compile_file($f->getPathname());
$n++;
}
}
fwrite(STDERR, "compiled {$n} files\n");
// 注意:CLI 与 FPM 的 opcache ini 必须逐项一致,否则 file_cache 校验失败会全量重编
opcache.huge_code_pages=1 需要提前留好大页:
echo 768 > /proc/sys/vm/nr_hugepages # 768 × 2MB = 1.5G,要覆盖 opcache 的 1G
grep HugePages /proc/meminfo
再补一句:PHP 8.2+ 的 opcache.jit=tracing 对纯 IO 型的 API 站收益实测不到 5%,RSS 反而涨 8%~12%,我们是关掉的。
三、real_ip 配错,你的限流维度是「高防节点的 IP」
这个坑不产生放大,但会让你的限流完全失效或者一刀切全站。高防CDN 回源时 $remote_addr 是 CDN 出口 IP,不配置的话所有请求看起来都来自同一个 IP。
set_real_ip_from 203.0.113.0/24; # 换成你的高防CDN回源段
set_real_ip_from 198.51.100.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
real_ip_header X-Forwarded-For;
然后限流用 $binary_remote_addr:
limit_req_zone $binary_remote_addr zone=php_per_ip:32m rate=20r/s;
limit_req_zone $binary_remote_addr zone=php_burst:32m rate=5r/s;
location ~ \.php$ {
limit_req zone=php_per_ip burst=40 nodelay;
limit_req_status 429;
...
}
但有个前提必须跟 CDN 侧确认 :XFF 是「追加」还是「重写」。如果是追加,攻击者自己带一个 X-Forwarded-For: 1.1.1.1,配合 real_ip_recursive on,nginx 会从右往左剥,剥到攻击者伪造的那个就停了,限流直接绕过。正确的做法是让 CDN 回源时覆盖 XFF,只保留真实客户端 IP。查一下:
# 抓几个回源请求的 header,看 XFF 里有几个逗号
tcpdump -i eth0 -A -s 0 'tcp port 80 and tcp[((tcp[12:1] & 0xf0) >> 2):4] = 0x47455420' -c 20 2>/dev/null | grep -i 'X-Forwarded-For'
如果出现多个 IP 串联,赶紧找 CDN 厂商开覆盖模式。
还有一个衍生点:高防CDN 的人机校验(5 秒盾)返回的是边缘 302,不消耗源站资源,但一旦攻击者拿到校验 cookie,后续请求会被全部放行到源站。所以源站自己的 limit_req 永远不能省 ,它是最后一道闸。
四、几个边角料,但真踩过
五、改完之后的账
回源放大系数 A:3.70 → 1.06
源站 PHP 峰值 QPS:11540 → 4230 (真实流量没变)
P99 响应时间:12.4s → 380ms
5xx 率:18.3% → 0.07%
pm.max_children:512 → 192 (服务器反而更闲了)
最直观的验证方式是压测时同时盯三块屏幕:CDN 边缘 QPS、源站 nginx 的 $upstream_response_time 分布、FPM status 页的 listen queue。只要放大系数 A 稳在 1.1 以内,PHP-FPM 就永远不会成为瓶颈——真正需要扩容的情况,是你业务流量真的涨了,而不是被自己人重试打死的。