高防CDN回源:real_ip递归CIDR回溯 + FCGI_PARAMS逐头writev,PHP-FPM每请求白多构造16个$_SERVER key
先把结论摆在前面
同一套 PHP-FPM 配置、同一份业务代码、同样的 20000 QPS,直连源站时 nginx worker 单核 usr% 是 55,挂上高防 CDN 之后涨到 78。带宽没变、QPS 没变、慢查询没变,多出来的 CPU 全部烧在三个你平时根本不会去看的地方:
- nginx
ngx_http_realip_module对 X-Forwarded-For 整条链做 O(n×m) 的 CIDR 回溯匹配 fastcgi_pass_request_headers默认 on,CDN 加的每一个头都变成一条独立的 FCGI_PARAMS record,走 writev 逐个拷- PHP-FPM 侧把这 16 个头全部塞进
$_SERVER,key 数量跨过 64 桶边界,整张 HashTable 触发 rehash + 全量重建
这三笔账加起来不是"优化",是"税"。下面逐层拆。
一、现象:perf top 里两个不该出现的符号
20k QPS 稳态下抓 30 秒:
perf top -p $(pgrep -d, nginx) -g --call-graph dwarf
18.42% [k] __memcmp
9.11% [.] ngx_parse_addr_port
6.73% [.] ngx_http_get_forwarded_addr
5.02% [.] ngx_http_fastcgi_create_request
3.88% [.] ngx_writev
__memcmp 排第一这件事本身就说明有问题。正常 nginx 静态文件+反代场景,__memcmp 不该进前 10,除非在做大量短字符串比较——也就是 CIDR 前缀比对。
PHP-FPM 那边同时看:
perf top -p $(pgrep -d, php-fpm)
12.05% [.] zend_hash_add_new
8.34% [.] _emalloc
6.91% [.] zend_string_init
4.20% [.] php_import_environment_variables
php_import_environment_variables 直接进榜,说明 $_SERVER 的构建成本已经不可忽略。
二、第一层税:real_ip 模块的 O(n×m) 回溯
2.1 你的配置长这样
set_real_ip_from 10.20.0.0/16;
set_real_ip_from 10.21.0.0/16;
set_real_ip_from 172.31.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
看起来人畜无害。问题出在 real_ip_recursive on 上。
2.2 源码里到底在干什么
ngx_http_realip_module 最终走到 ngx_http_get_forwarded_addr_internal(),核心是两层嵌套循环:
// src/http/modules/ngx_http_realip_module.c
for (i = 0; i < h->nelts; i++) { // 外层:XFF 里的每一个地址
if (ngx_parse_addr_port(r->pool, addr, p, len) != NGX_OK) {
continue;
}
if (proxies) {
p = proxies->elts;
for (j = 0; j < proxies->nelts; j++) { // 内层:每一条 set_real_ip_from
if (ngx_cidr_match(&addr->sockaddr, &p[j]) == NGX_OK) {
...
}
}
}
}
外层次数 = XFF 里的 IP 个数,内层次数 = set_real_ip_from 条目数。
高防 CDN 的多级回源典型形态:客户端 → CDN 边缘 → CDN 中间层 → CDN 回源节点 → 你的 SLB → 源站 nginx。一条 XFF 链轻松堆到 6~12 个 IP。你为了覆盖多个 CDN 节点段,写了 8 条 set_real_ip_from。
那就是每个请求 12 × 8 = 96 次 CIDR 匹配,每次匹配就是一次 memcmp——这就是 __memcmp 登上 perf top 第一名的原因。
再叠加 `real_ip_recursive on` 的语义:它从 XFF 最右往左扫,遇到第一个不在信任列表里的地址就停。听起来是 O(1) 早退,但实际链路里 CDN 中间层的 IP 通常在信任段内,真正需要跳过的节点数就是链长。最坏情况(攻击者伪造了整条 XFF 且首尾都命中你的信任段,或者你干脆写了 0.0.0.0/0)就是全量扫。
2.3 怎么量它
用 bpftrace 直接挂钩内部函数(nginx 编译时别 strip):
bpftrace -e '
uprobe:/usr/sbin/nginx:ngx_http_get_forwarded_addr_internal {
@start[tid] = nsecs;
}
uretprobe:/usr/sbin/nginx:ngx_http_get_forwarded_addr_internal /@start[tid]/ {
@us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}
interval:s:10 { print(@us); clear(@us); exit(); }
'
20k QPS 下我们拿到的分布:P50 = 3.1μs,P99 = 21μs。单看不大,但 20000 × 3.1μs = 62ms/s,也就是 6.2% 的单核纯粹花在解析 XFF 上,还没算 P99 尾部。8 个 worker 平均下来就是实打实的吞吐损失。
三、第二层税:fastcgi_pass_request_headers 的默认值
3.1 每个 header 都是一条独立 record
这个指令的默认值是 on,绝大多数人从来没写过它,所以从来没意识到它的存在。ngx_http_fastcgi_create_request() 里的逻辑:
if (flcf->upstream.pass_request_headers) {
part = &r->headers_in.headers.part;
header = part->elts;
for (i = 0; /* void */; i++) {
if (i == part->nelts) { ... }
/* 把 header[i].key / header[i].value 拼成 FCGI_PARAMS 记录 */
len += header[i].key.len + header[i].value.len + ...;
}
}
客户端发来的每一个请求头,都会被无条件转成 HTTP_* 参数塞给 PHP-FPM。高防 CDN 回源时会在原始请求上增加:
X-Forwarded-For
X-Forwarded-Proto
X-Forwarded-Port
X-Forwarded-Host
X-Real-IP
X-Client-IP
X-Original-URI
X-Request-ID
X-Cache-Status
X-CDN-Edge-Node
True-Client-IP
X-Forwarded-Server
X-Scheme
X-LB-Node
X-Trace-ID
X-Timestamp
16 个头,每个头的 key + value 平均 40 字节,就是 每个请求多 640 字节的上行 FastCGI 负载,而且每个头在 nginx 内部都要过一次 ngx_http_fastcgi_create_request 的长度计算和 ngx_cpymem。
3.2 writev 的 iov 数量直接翻倍
nginx 把所有 PARAMS record 收集到 u->request_bufs 这个 chain 里,最后一次性 writev 发出去。链上每多一个 record,就多一个 iovec。
正常请求 10 个 header → 10 个 iovec。CDN 回源之后变成 26 个 iovec。NGX_IOVS_PREALLOCATE 默认是 64,还没到扩容阈值,但 writev 本身在内核里是要遍历这个数组的,加上 __memcmp 在 nginx 内部的 header 查找,这条路径上的成本是线性增长的。
验证方式很土但有效:
strace -c -f -p $(pgrep -d, php-fpm) -e trace=read,writev 2>&1 | head -30
对比挂 CDN 前后 read 的系统调用次数。PHP-FPM 从 fastcgi socket 读完一次请求所需的 read 次数会从 2~3 涨到 4~5——因为 PARAMS 记录变多,SAFE_READ 的循环次数跟着涨。
四、第三层税:PHP-FPM 构建 $_SERVER 的哈希表代价
4.1 key 数量跨过 64 桶边界
FastCGI 的 PARAMS 到了 PHP-FPM 侧,走 sapi/cgi/fastcgi.c 的 fcgi_read_request(),攒成 env 数组,然后由 php_import_environment_variables() 逐个写进 $_SERVER:
// main/php_variables.c
void php_import_environment_variables(zval *array_ptr)
{
...
for (env = environ; env != NULL && *env; env++) {
...
zend_hash_str_update(Z_ARRVAL_P(array_ptr), name, name_len, &val);
}
}
关键在于 zend_hash_str_update 的扩容策略。PHP 7 的 HashTable 桶数按 2 的幂增长:8 → 16 → 32 → 64 → 128。每次跨过边界,zend_hash_do_resize 会分配新桶数组并把所有已有元素重新 rehash 一遍。
实测数一下 key 数量:
// 线上探针,别用 print_r,用 shmop 或直接打点
$n = count($_SERVER);
$keys = array_slice(array_keys($_SERVER), -20);
error_log("SERVER_KEYS=$n TAIL=" . implode(',', $keys));
直连源站:42 个 key,落在 64 桶。
挂高防 CDN 后:58~61 个 key,仍然在 64 桶,但再叠一个框架自注册的 3~5 个 key,就直接跨到 128 桶。
跨界那一刻,每请求多一次 zend_hash_do_resize + 全表 rehash。按 58 个元素的平均链长重算,大约是 额外的 120 次指针写入 + 一次性 1024 字节的桶数组分配。
4.2 直接量 rehash 次数
perf probe -x /usr/sbin/php-fpm --add 'zend_hash_do_resize:4 h:u64 size:u64'
perf record -e probe_php-fpm:zend_hash_do_resize -a -- sleep 10
perf script | awk '{print $NF}' | sort | uniq -c
输出里 size=128 的出现次数除以总请求数,就是 rehash 触发率。我们这边是 0.87 次/请求——也就是接近 9 成的请求都在重建 $_SERVER 的桶数组。
五、三刀砍回去
5.1 只信一个头,别再解整条链
让 CDN 节点直接给你一个干净的真实客户端 IP 头,源站只认它,彻底干掉递归回溯:
# CDN 侧配置(如果你用的是可自定义头的商业 CDN)
proxy_set_header X-Real-Client-IP $remote_addr;
# 源站侧
set_real_ip_from 10.20.0.0/16;
real_ip_header X-Real-Client-IP;
real_ip_recursive off; # 关键:关掉
real_ip_recursive off 的语义是:直接取 X-Real-Client-IP 的值当 $remote_addr,不做任何遍历。内层循环直接消失。
如果你没法改 CDN 的头,那就用 nginx map 在入口处做一次提取,把结果固化成一个变量,后面的 fastcgi_param 直接引用这个变量——map 的结果会被 nginx 缓存到 r->variables 里,只算一次:
map $http_x_forwarded_for $real_client_ip {
default $http_x_forwarded_for; # 兜底
"~^([0-9.]+)," $1; # 只取最左一个,不做 CIDR 校验
}
server {
fastcgi_param X-REAL-CLIENT-IP $real_client_ip;
}
注意:这里放弃 CIDR 校验是有安全前提的——源站必须只对 CDN 回源段开放 80/443,用安全组或 nftables 封死直连。否则任何客户端都能伪造 XFF。这个是前提,别搞反了顺序。
5.2 pass_request_headers off + 白名单
location ~ \.php$ {
fastcgi_pass unix:/dev/shm/php-fpm.sock;
# 关掉全量透传
fastcgi_pass_request_headers off;
# 只传业务真正要用的
fastcgi_param HTTP_COOKIE $http_cookie;
fastcgi_param HTTP_USER_AGENT $http_user_agent;
fastcgi_param HTTP_ACCEPT_ENCODING $http_accept_encoding;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
fastcgi_param HTTP_X_REAL_CLIENT_IP $real_client_ip;
# 这几个 nginx 会自动带,不用手写
# CONTENT_TYPE / CONTENT_LENGTH / REQUEST_METHOD / SCRIPT_FILENAME ...
}
踩坑提醒:fastcgi_pass_request_headers off 会把 Cookie 也一起干掉,因为它属于 r->headers_in.headers。所以 HTTP_COOKIE 必须手动补上,否则全站登录态直接失效。Authorization 同理,别问我们是怎么发现的。
改完之后 $_SERVER 的 key 数从 58 掉回 44,卡在 64 桶内侧,rehash 触发率直接归零。
5.3 PHP 侧不要二次解析
很多框架的 getClientIp() 长这样:
public function getClientIp(): string
{
foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $k) {
if (!empty($_SERVER[$k])) {
$ip = trim(explode(',', $_SERVER[$k])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
在 20k QPS 下,这段代码的 explode + trim + filter_var 是三个函数调用。既然 nginx 已经在 $real_client_ip 里算好了,PHP 侧直接读:
$ip = $_SERVER['HTTP_X_REAL_CLIENT_IP'] ?? $_SERVER['REMOTE_ADDR'];
省掉的就是 20000 × 3 次 C 函数调用 + 一次字符串切分分配。
六、压测对比数据
条件:8 核 16G 源站,PHP 7.4 + opcache + FPM static 模式 64 worker,单机 wrk 打 20k QPS,响应体 4KB 静态 mixed。
- 直连源站:单 worker 单核 QPS 24000,usr 55%,
__memcmp占比 2.1% - 挂 CDN,real_ip_recursive on,全量头透传:单 worker 16800,usr 78%,
__memcmp18.4%,FPM 侧 rehash 0.87 次/请求 - 挂 CDN,X-Real-Client-IP 单头 + real_ip_recursive off + pass_request_headers off:单 worker 22400,usr 61%,
__memcmp3.4%,rehash 0 次/请求
两者差 33% 的单核吞吐。换算到 8 核机器上,等于同样硬件多扛 5.5k QPS。
这套配置我们最终是跑在轻云互联的高防 CDN + 大带宽源站上验证的,他们回源走的是内网直连,XFF 链只有一跳,real_ip_recursive 那个坑实际上是被他们的回源架构天然规避掉了一半——但只要你中间自己再加一层 SLB 或者 WAF,链长立刻涨回来,配置还是得按上面的改。
七、几个反直觉的点
- real_ip_recursive 不是"更安全"的选项。它只是处理多级代理的语义,单级代理场景下开它纯属白烧 CPU。
- fastcgi_pass_request_headers 的默认值 on 是历史包袱。它是从"客户端直连 FastCGI"那个年代留下来的,反代场景下几乎总是应该 off。
- PHP 的 HashTable 扩容是 2 的幂,跨界成本是断崖式的。从 63 个 key 到 65 个 key,成本不是 +3%,是 +100% 的桶数组重建。压测时先数 key,再谈其他优化。
- perf top 里
__memcmp进前三,八成是在做 CIDR 匹配或者 ACL 匹配。这是一个非常好用的排查直觉。