ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP在NUMA架构下的性能优化:从系统层到代码层的完整方案

PHP在NUMA架构下的性能优化:从系统层到代码层的完整方案

如果你维护过一台双路服务器的PHP业务,大概率碰到过这类怪问题:明明CPU利用率不高,负载也就30%,可接口响应时间就是忽高忽低,QPS死活提不上去。排查了一圈数据库、缓存、代码逻辑都没毛病,最后才发现问题出在操作系统对内存和CPU的调度上。这就是NUMA架构惹的祸。

我要说的“php方案 NUMA架构感知”,就是一套从系统层到PHP-FPM再到业务代码的完整优化方案,核心目标只有一个:让PHP进程在非统一内存访问架构下尽量跑在本地内存上,减少跨节点访问带来的延迟损失。这套东西不是新概念,但很多维护PHP服务的人容易忽略,因为单路服务器上完全无感,只有换成双路或四路机器后,性能问题才会像海绵里的水一样被挤出来。

文章比较适合后端开发、运维工程师和做架构选型的人看。我会把这套方案的思路原理、实操步骤、压测验证和踩坑记录全部铺开讲,尽量让你看完后能直接在自己的服务上复现一遍。

1. 先搞清楚问题根源:NUMA到底影响了PHP什么

1.1 从CPU到内存的“远近亲疏”:NUMA是什么

NUMA的全称是Non-Uniform Memory Access,非统一内存访问架构。多路服务器上,每个物理CPU插槽都带有自己的内存控制器,也就是说每个CPU都有一段“本地内存”。CPU访问本地内存的速度快,访问其他CPU上的“远端内存”速度慢,这个速度差不是一点点,跨节点访问延迟通常比本地访问高20%到40%,在高并发场景下会被均匀放大。

用生活里的场景类比一下:你坐在自己家的书桌前拿书,一伸手就能拿到,这是在访问本地内存;但如果书放在隔壁邻居家,你就得站起来、开门、走过去、拿回来,这就是跨节点访问。系统层面的调度如果没做好,进程跑在A节点CPU上,内存却落在B节点,等于每次读写都在“跑隔壁家”。

那怎么判断服务器是不是NUMA架构?翻一下numactl --hardware的输出,如果显示多个node节点,那就是NUMA。比如我在某台双路生产机上看到的拓扑大概是这样的:

node 0 cpus: 0-15,32-47
node 1 cpus: 16-31,48-63
node distances:
node 0到0的距离是10,到1的距离是21
node 1到0的距离是21,到1的距离是10

距离21就是远端访问,每跳一次延迟就上去了。单路服务器根本没有这个距离概念,所以跑在上面的PHP应用意识不到这个问题。换到双路机器后,如果还是用默认方式启动进程,相当于随机分配书桌和书架的位置,一半时间在跑隔壁。

有些自动化脚本会开启内核的numa_balancing特性,试图自动把内存页迁移到当前运行节点。这个机制本身不坏,但它会定期扫描并迁移内存页,对PHP-FPM这种短生命周期、高频率创建请求进程的场景来说,迁移带来的开销和不确定性反而会让响应时间更加飘忽。后面实操部分我会详细讲怎么处理。

1.2 PHP-FPM为何最怕跨节点访问

很多编程语言都有常驻内存的运行时或者线程模型,进程表、共享缓存、线程池的数据结构都是预先分配好的,绑定优先级高。比如MySQL可以通过numactl --interleave把内存交错分配,nginx也可以轻松绑定CPU亲和。可PHP作为脚本语言,运行模型完全不一样。

PHP-FPM的工作方式是master进程启动后fork出一批worker进程,每个请求进来时,由worker进程执行并分配大量临时内存。这些临时变量、opcode缓存、类映射表,都散落在进程堆里。麻烦的是,PHP-FPM的worker继承master进程的内存布局:如果master在node0启动并分配了内存,但fork出来的worker被调度器丢到node1上执行,那worker访问自己的堆内存时就是跨节点的。

而且PHP-FPM的进程数量一般比较多。假设你有32个worker,默认调度器为了平衡负载,可能让16个跑在node0、16个跑在node1。每个worker的执行路径都带跨节点内存访问,QPS一旦上来,延迟立刻从平滑曲线变成锯齿状。

还有个更隐蔽的问题:opcache共享内存。worker之间通过共享内存缓存PHP脚本的opcode,这块共享内存通常是master进程启动时在某个节点上分配的。如果worker被调度到别的节点,每次读opcache都是远端访问。很多调优文章只告诉你把opcache开大,却没人提醒你:跨节点读共享缓存也是要付出延迟代价的。这就是为什么同样两路服务器,有人开完opcache性能改善明显,有人却觉得效果一般,本质可能是NUMA布局没理顺。

2. 整体方案设计:系统层到应用层的三层联动

2.1 先看硬件拓扑,再定优化路线

我见过不少人上来就到处找“PHP NUMA扩展”,期望装一个扩展就能自动解决绑定问题。实际上目前官方生态里并没有一个真正通用且生产可用的NUMA扩展,也不需要这么复杂。我的思路是三层联动:系统层管内存分配策略,进程层管PHP-FPM实例绑定,应用层管代码层面的拓扑感知与调度决策。

硬件拓扑是一切决策的前提。拿到设备后第一件事,用lscpu看CPU组和线程编号,用numactl --hardware看节点内存大小和距离矩阵,用numastat看当前进程内存分布。我一般采用下面的命令组合:

lscpu numactl --hardware numastat

这三条命令能回答三个关键问题:机器有几个NUMA节点?每个节点对应哪些CPU和内存?当前内存分配是否均匀?只有先弄清楚这三点,才能定绑核方案。

有个很容易犯的错:只绑CPU不绑内存。很多文章教你在systemd里设置CPUAffinity=0-15就完事了,其实这只是限制了进程跑在哪些核心上,内存分配仍然可能发生在其他节点。因为进程fork时可能带着父进程在别的节点上的页表,或者内核在某个瞬间从别的节点分配了新的内存页。绑了CPU后,进程依然要跨节点访问那些落在远端的页,结果就是“白跑一趟”。所以我的原则是:CPU亲和和内存绑定必须同时做,缺一个都不算NUMA感知。

2.2 三层方案的分工与取舍

我把这套方案拆成三个层面,每个层面解决不同维度的问题:

  1. 系统层:决定内存页怎么放。通过numactl的--membind选项,把指定进程的内存强制锁在某个节点上,避免默认的自动分配策略把页打散。

  2. 进程层:决定PHP-FPM worker跑在哪。通过二分法把PHP-FPM拆成多个实例,每个实例绑定一个NUMA节点,并用nginx做流量分流。这样每个节点上的PHP-FPM子进程都只跑本节点核心、只访问本节点内存。

  3. 应用层:让业务代码知道“我现在在哪个节点”。在进程启动时读取一次节点拓扑和当前CPU亲和性,缓存到进程内变量里,后续做数据分片、本地缓存选择时直接用这个信息。

取舍上面也有讲究。有人问:为什么不干脆用--interleave让内存在所有节点间交错分配?拿这个方案应对CPU绑定充分的场景也行,但我实测下来,对PHP-FPM这种高吞吐、短请求的应用,interleave不如membind稳定。因为interleave会把内存均匀铺到所有节点,但进程只跑在某个节点时,一半内存还是远端,延迟还是高。交错分配更适合那种进程数多、每个进程都跑不同节点的场景。既然PHP-FPM拆多实例后已经能做到“一个节点的进程只跑本节点”,那membind就是更彻底的选择。

这套三层方案的关键是让“进程执行的节点”和“内存所在的节点”对齐。只要对齐了,剩下的内核调度、失效策略、缓存一致性等问题都变简单了。

3. 核心实操:让PHP-FPM感知NUMA并绑定运行

3.1 第一步:用numactl确认节点拓扑

在动手改配置之前,建议先做个简单的压力测试,用数据说服自己值得花这个精力。比如用wrk打一个简单的PHP接口,观察QPS和P99延迟,再配合numastat查看php-fpm进程的内存分布情况。第一次看到node0和node1的内存分布几乎对半时,我的第一反应是:系统的负载均衡做得真勤快,可PHP不需要这种跨节点“均衡”,它需要的是本地化。

然后安装并确认numactl工具可用:

yum install numactl # CentOS/RHEL apt install numactl # Ubuntu/Debian

接着执行numactl --hardware,把输出里的节点编号和CPU范围抄下来。比如我这台测试机的输出类似:

available: 2 nodes (0-1) node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 node 0 size: 128 GB node 0 free: 112 GB node 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 node 1 size: 128 GB node 1 free: 109 GB

注意一个细节:node0的CPU列表里同时有0-15和32-47,其中32-47是0-15的超线程兄弟。绑定节点时,如果直接--cpunodebind=0,调度器可能把进程安排到超线程逻辑核上。超线程核心在争抢执行单元时会互相干扰,反而拖慢响应。所以我更推荐在明确业务负载后,用--physcpubind=0-15只绑物理核,把对应的逻辑核空出来。具体用哪种,根据你的并发模型决定。默认情况我建议先按“实际物理核数”来配,别偷懒直接全绑。

3.2 第二步:拆分PHP-FPM多实例并绑定CPU

单实例PHP-FPM没法实现真正意义上的NUMA感知,因为你把整个master绑到node0后,node1就彻底浪费了。所以正确的做法是拆成两个实例,每个实例负责一个节点,各自用numactl启动。

我在测试机上的做法是准备两份主配置:php-fpm-node0.conf和php-fpm-node1.conf。内容基本一致,但有几个关键项必须不同:

# php-fpm-node0.conf [global] pid = /var/run/php-fpm-node0.pid error_log = /var/log/php-fpm-node0.log include=/usr/local/php/etc/php-fpm.d/node0.conf # php-fpm-node1.conf [global] pid = /var/run/php-fpm-node1.pid error_log = /var/log/php-fpm-node1.log include=/usr/local/php/etc/php-fpm.d/node1.conf

对应的pool配置分别监听不同端口:

; node0.conf [www] listen = 127.0.0.1:9000 pm = static pm.max_children = 32 pm.start_servers = 32 pm.min_spare_servers = 32 pm.max_spare_servers = 32 request_terminate_timeout = 60s ; node1.conf [www] listen = 127.0.0.1:9001 pm = static pm.max_children = 32 pm.start_servers = 32 pm.min_spare_servers = 32 pm.max_spare_servers = 32 request_terminate_timeout = 60s

注意include字段不能继续用php-fpm.d/*.conf这种通配,否则两个实例会互相把对方的pool也加载进去,导致端口冲突。我一开始就是偷懒复制了原有配置,结果第二个实例启动时直接报port already in use。两份配置在逻辑上必须完全隔离。

接着在systemd里建两个unit,用numactl包住PHP-FPM的master启动命令。node0的unit大致长这样:

[Unit] Description=PHP-FPM NUMA Node0 After=network.target [Service] Type=simple ExecStart=/usr/bin/numactl --physcpubind=0-15 --membind=0 /usr/local/php/sbin/php-fpm --nodaemonize -y /usr/local/php/etc/php-fpm-node0.conf Restart=always RestartSec=3 [Install] WantedBy=multi-user.target

node1的unit改成--physcpubind=16-31 --membind=1,配置路径和启动命令里的pid文件都换成node1的。

这里有两个细节想强调:第一,numactl放在ExecStart最前面,它包着PHP-FPM master一起启动,fork出去的worker会继承master进程的affinity和memory binding,不需要在每个worker上重复设置。第二,--membind=0是硬性约束,意思是新分配的内存页只能落在node0。启动完务必检查一下,如果node0的内存快满了,说明pm.max_children开得过大或者业务本身内存占用偏高,需要调整。

我之前踩过一个坑:直接用--cpunodebind=0把node0的物理核+超线程核全绑上了,结果压测单实例吞吐时反而出现资源争抢。后来改成--physcpubind=0-15只绑物理核,P99延迟立刻降了一截。逻辑核留给系统中断、网络包处理和其他辅助进程,效果会更好。

3.3 第三步:nginx侧按节点分流

PHP-FPM拆成两个实例后,还需要一个流量入口把请求均匀分到9000和9001两个端口。我最常用的是nginx upstream轮询:

upstream php_node_pool { server 127.0.0.1:9000; server 127.0.0.1:9001; keepalive 32; } server { listen 80; server_name api.example.com; root /var/www/html; index index.php; location ~ \.php$ { fastcgi_pass php_node_pool; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_keep_conn on; } }

fastcgi_keep_conn on配上keepalive连接池,是为了避免每个请求都新建一条到PHP-FPM的socket连接。PHP-FPM的accept锁本身开销不小,能省则省。

如果你的业务有更明确的路径偏向,比如某个接口访问量明显高,也可以用map变量把指定路径固定分发到某一侧。但这会带来一个麻烦:两个节点的负载会倾斜,一个忙死一个闲死。普通业务场景我不建议在一开始就做这么细的路径感知,先用轮询跑稳定,后续再看监控决定要不要加权重。

nginx本身也可以做NUMA感知绑定,比如让它的work进程分开跑在不同节点。不过nginx是纯事件驱动、高吞吐低内存占用,对内存跨节点没那么敏感,优先级可以放低。我实际生产里通常给nginx静态绑到node0的前4个核就行,把剩下资源全让给PHP-FPM。

3.4 第四步:验证优化效果与压测

配置全部落地后,最要紧的是验证。用wrk对同一个接口做60秒压测,测试命令:

wrk -t8 -c200 -d60s http://127.0.0.1/index.php

压测前记录优化前的数据,压测后再跑一次同参数,全量对比。我在测试机上跑出的典型优化效果供你参考:

指标优化前优化后变化
QPS42005600+33%
P99延迟165ms108ms-34%
跨节点内存访问比例58%3%-95%
系统CPU time占比22%13%明显下降

观察numastat -p也可以看到更细的指标,比如node0和node1上的内存命中次数。优化前大量访问落在远端节点,优化后基本都落在本节点。这里我想提一个容易被忽略的常驻监控命令:

watch -n 5 "numastat -c"

它能持续盯住整个系统在节点间的内存访问统计,压测过程中如果看到node distances数值对应节点上的“other_node”字段还在快速增长,说明还有流量没对齐。真正调优好的状态是other_node增长几乎为0。

这个压测结果不是魔法,核心原理就是让“CPU所在节点”和“内存所在节点”重合,访问延迟从跨节点变成本地访存,CPU不用疲于跑邻居家取数据,吞吐量自然上来了。这个思路同样适用于其他常驻Web服务,不只是PHP,只是PHP因为fork模型和短生命周期特性,收益格外明显。

4. 在PHP代码里实现真正的NUMA感知逻辑

4.1 读取系统信息的手段

有人看完上面的内容会问:这算不算让PHP主动感知NUMA?严格来说,配置层面的绑定只是让PHP进程“被动适配”NUMA,代码本身并不知道节点分布。如果想让业务代码真正具备节点感知能力,比如在API进程里判断当前运行在哪个节点,然后决定是否把本地缓存先命中,就需要主动读取系统信息了。

Linux系统暴露了很多与NUMA相关的接口,比如/sys/devices/system/node/目录下列出了所有节点,每个节点下有cpulist和meminfo。PHP可以直接用文件读取方式拿到这些值,写一个简单的函数:

function getNumaTopology(): array { $topology = []; foreach (glob('/sys/devices/system/node/node*') as $path) { if (!is_dir($path) || !preg_match('/node(\d+)$/', $path, $m)) { continue; } $nodeId = (int)$m[1]; $topology[$nodeId] = [ 'cpu_list' => trim((string)file_get_contents($path . '/cpulist')), ]; foreach (file($path . '/meminfo') as $line) { if (str_starts_with($line, 'MemTotal:')) { $topology[$nodeId]['mem_total'] = (int)filter_var($line, FILTER_SANITIZE_NUMBER_INT) * 1024; } } } return $topology; }

要判断当前PHP进程运行在哪个CPU、进而映射到哪个节点,最直接的方式是读取taskset -pc输出。虽然PHP的posix扩展里没有现成的亲和性函数,但通过shell_exec调用系统命令也能拿到:

function getCurrentCpuList(): string { $raw = trim((string)shell_exec('taskset -pc ' . getmypid())); preg_match('/CPU\s*:\s*(.+)$/', $raw, $m); return trim($m[1] ?? ''); }

然后拿返回的CPU列表和拓扑里的cpu_list比对,就能推断当前进程所属节点。注意这个方案不能频繁调用,shell_exec和文件读取的I/O开销会稀释你的性能收益。正确用法是进程启动时算一次,存在静态变量或APCu缓存里,后续所有请求直接复用。PHP-FPM是常驻进程池模型,进程一旦fork出来就不会频繁重启,所以这个“启动时感知”的成本很低。

4.2 利用NUMA感知做业务调度

拿到节点信息后,能做什么实际的事情?最典型的是做本地缓存优先。比如你的业务有一个共享的配置缓存,正常情况下放在Redis里,每次请求都走一次网络。如果PHP知道当前进程落在node0,又有两个PHP-FPM实例分别跑在node0和node1,那就让每个实例各自维护一份本地内存缓存。这样同一个进程再次访问时,直接读本节点内存就行,不用跨节点去找另一份缓存放的位置。

还有一个场景是批量数据处理。PHP通过pcntl_fork拉起多个子进程并行处理大数组时,每个子进程可以按照父进程给它指定的节点编号,只处理属于本节点内存范围内的数据切片。这个操作相当于在应用层做了“数据本地化”,配合底层的membind,比盲目fork多个进程然后依赖内核调度要稳定得多。

但说句实话,PHP代码层面的NUMA感知能带来的绝对性能提升,通常远小于进程绑定层面的提升。核心原因是PHP的业务逻辑大多已经聚焦在应用层,不是每段代码都需要关心底层硬件拓扑。所以我在生产里的做法很克制:进程绑定是主线,代码感知当辅助,不让它影响业务可读性和维护成本。你的业务如果不需要“本地缓存节点选择”,完全可以跳过代码层,配置文件里的绑定已经能帮你拿回一多半收益。

5. 常见问题与排查技巧实录

5.1 压测结果不稳定或优化效果不明显

这是被问到最多的问题。不少人按教程做完,压测一看:咦,QPS没涨多少,延迟反而更抖了。这种情况大概率不是方案错了,而是有隐藏因素干扰。我经历过的一个典型案例是:压测时把irqbalance服务开着,网卡中断被自动分散到多个CPU,导致一部分CPU被软中断抢走,php-fpm worker排队时间增加。关闭irqbalance并把网卡中断绑定到固定核心后,优化效果立刻出来了。

还有个常见坑是超线程。我们前面提到--physcpubind=0-15只绑物理核,但如果你的worker数量配得比物理核数还多,多余worker必然挤在同一个物理核上用超线程,反而抢执行单元。我建议按“物理核数x1”来配worker数,比如node0有16个物理核,那pm.max_children先设16,观察CPU使用率再慢慢加。盲目照抄“worker数=CPU核心数”的教程很容易翻车。

5.2 内存绑定过死的副作用

--membind=0是一把双刃剑。它保证内存只在node0分配,但node0分配给这个PHP-FPM实例的内存池是有限的。如果pm.max_children开太大,或者业务单请求临时内存爆量,node0内存会被吃满,触发OOM甚至导致整个实例被杀。我见过有人把max_children设成64,节点内存128GB看着很大,但PHP密集分配临时数组时,瞬间能吃掉几GB,几个请求叠加就把节点内存打穿。

解法有两个:一是严格监控节点剩余内存,pm.max_children的计算不能只看平均内存占用,要按单worker峰值内存来算;二是如果业务内存模型确实比较大,可以改成--preferred=0而不是--membind=0。preferred只在有空闲时优先用node0,node0不够时可以溢出到node1,但绑定节奏就不如membind干净。我的建议是先用membind跑一段时间,如果频繁告警内存不足,再考虑降worker数或改用preferred。

5.3 多实例opcache命中率下降

拆成两个PHP-FPM实例后,opcache缓存也从一份变两份。同一个脚本在node0实例里被缓存一次,在node1实例里又缓存一次,这意味着整个系统的opcache内存占用量翻倍。如果业务脚本很多很大,总缓存占用会明显上涨。这个在我的测试机上大概让每个实例多花了近600MB内存,但换来的是缓存访问完全本地化,整体吞吐反而更高。如果你极度在意内存占用,可以适度把opcache.memory_consumption调小一点,不过我更倾向于不去动它,因为脚本级缓存带来的CPU节省远比那点内存值钱。

另外一个和opcache相关的细节:开启opcache.file_cache做二级文件缓存时,文件会被写到磁盘,这时要注意工作目录的路径不能跨节点指向远端挂载,否则文件读取又会变成网络I/O。理想情况每个节点的php-fpm脚本文件都在本机本地磁盘上,避免NFS之类的跨节点存储。

5.4 压测工具选择与数据可信度

压测工具很多人直接用ab,但它对HTTP keepalive和高并发场景模拟能力一般,容易把瓶颈压在后端而不是PHP-FPM上。我更喜欢wrk,它的线程模型能更容易地打满多核,命令也简单。不过wrk只能从一台机器发起压力,如果想测得更真实,建议用两台机器:一台跑压测,一台跑服务,避免压测工具自身抢走被压服务器的CPU资源,导致数据偏移。

压测时还要注意设置固定的并发数和持续时间。我通常的做法是-t8 -c200 -d60s,并发200已经能反映大多数线上请求的扎堆程度。一次压测最好重复3轮,取中位数而不是最好成绩,避免偶发波动影响判断。跨节点访问的统计建议用numastat -p <php-fpm主进程pid>观察,只要这个值在压测期间保持在低水平,说明绑定已经生效。

5.5 重启后配置失效

systemd unit写好后,很多人以为万事大吉,结果机器一重启,发现php-fpm进程又变回单实例、没绑核。原因多半是忘记执行systemctl enable,或者unit文件里缺少WantedBy=multi-user.target。还有个更隐蔽的问题:如果旧版本的PHP-FPM是通过init脚本启动的,systemd启动新unit时会因为端口占用冲突失败。这时候要先把旧的启动方式彻底停掉,再enable新unit,否则看起来配置生效了,实际上旧进程还占着9000端口。

为了在重启后快速验证,我习惯在unit里加一个ExecStartPost检查脚本,启动后立刻用taskset -pc打印几个关键worker的亲和性,配合系统日志确认绑定状态。这个小检查在排查服务器重启后的“又变卡”问题时特别管用。

最后再唠叨几句经验

把一套NUMA感知方案完整跑通之后,我的感受是:真正值钱的部分不光是那几条numactl命令,而是理清“内存放哪”和“进程跑哪”这两件事之间的关系。PHP-FPM这种多进程fork模型天然容易踩中跨节点访问的坑,但反过来,只要把多个实例按节点拆开绑定,收益也非常明显。

如果你打算在一台新服务器上直接落地这套方案,我建议按这个顺序来:先压测旧配置拿到基线数据,再装numactl确认拓扑,然后拆配置、绑核、重启,最后重新压测对比。不要跳步,尤其不要省略基线和压测对比环节,否则优化效果说不清楚,后续改参数也找不到参照。工具上,numactl、wrk、numastat这三个够用了,其他花哨的监控组件等业务稳定后再加也不迟。

最后分享一个小技巧:调试阶段把--membind先改成--preferred跑一遍,看系统是否稳定,再切换成正式的强硬绑定。这样能把配置错误和OOM风险提前暴露在小流量阶段,而不是等到大促压测时才翻车。这套方案后续还能延伸到更多常驻服务进程上,思路完全相通,只不过PHP-FPM的收益是最明显的,当做切入点很合适。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进