高防CDN的PHP高并发最佳性能实践 - 20261012

高防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 永远不能省,它是最后一道闸。

    四、几个边角料,但真踩过

    • /dev/shm 被 opcache.file_cache 塞满,socket 建不出来。listen = /dev/shm/php-fpm.sock 和 opcache.file_cache=/dev/shm/opcache 共用 tmpfs。tmpfs 默认是物理内存的一半,opcache 缓存了几万个文件后把空间吃光,php-fpm 启动时报 Unable to bind listening socket 直接挂掉。上线前先 df -h /dev/shm,并且给 file_cache 单独挂一个 tmpfs:
      mount -t tmpfs -o size=2G,mode=0700 tmpfs /var/cache/opcache
      # 然后 opcache.file_cache=/var/cache/opcache
    • pm.max_requests 不是越大越好。设成 5000 时,配合大数组业务,单个 worker RSS 能爬到 180MB。用 status 页盯住:curl -s http://127.0.0.1/fpm-status?full | awk '/^www/ {print $4, $6, $7}',看 last request memory 分布,如果 P95 超过 80MB 就把 max_requests 降到 1000~2000。
    • unix socket 的积压要看 Send-Q。ss -xlnp | grep php-fpm,Send-Q 那一列持续不为 0 就是 accept 跟不上了,先把 listen.backlog 调到 4096 再看,别一上来就加 worker——worker 加多了 CPU 上下文切换反而更慢。
    • emergency_restart 阈值设对。emergency_restart_threshold = 10 + emergency_restart_interval = 1m,避免子进程被 request_terminate_timeout 大量杀掉后 master 完全不管。

    五、改完之后的账

    • 回源放大系数 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 就永远不会成为瓶颈——真正需要扩容的情况,是你业务流量真的涨了,而不是被自己人重试打死的。