1. 先搞懂NUMA从一次线上性能抖动说起前几年我接手过一套跑在双路服务器上的PHP接口服务应用逻辑本身并不复杂就是典型的PHP-FPM加MySQL加Redis的组合。可每到高峰期总会冒出一批延迟在800毫秒到1.2秒之间的慢请求分布得毫无规律。奇怪的是CPU平均负载不高内存也没有耗尽数据库慢查询日志里几乎找不到可疑SQL。排查了网络、缓存、GC配置之后最后是用numastat看到了真相问题出在NUMA架构感知缺失上——一批进程的内存被分配到了远端节点每次内存访问都在跨节点绕路延迟自然高得离谱。这套服务后来在不动业务代码的前提下做了完整的NUMA感知优化P99尾延迟直接掉了将近一半。这段经历让我意识到PHP方案在NUMA架构服务器上的表现绝不是装上就能跑那么简单。PHP的进程模型、内存分配方式、调度行为都会直接影响最终的服务稳定性。这篇文章我把整个思路和实操过程都整理出来适合正在维护大内存多路服务器的PHP开发、运维和SRE同学。1.1 所谓的内存不均匀到底指什么NUMA全称是Non-Uniform Memory Access非统一内存访问。理解它最关键的一句话是在多CPU插槽的服务器上不同的CPU核心访问不同内存条的速度是不一样的。现代CPU插槽上会集成内存控制器每个插槽直连一部分物理内存。CPU访问自己插槽直连的内存走的是内部总线速度和带宽都最好访问另一个插槽上的内存就必须通过CPU之间的互联总线转发比如UPI或者同一类跨晶圆互连技术延迟会有明显增加带宽也受到限制。拿一台典型的双路服务器举例它有两个CPU插槽每个插槽搭配128GB内存整机256GB内存。操作系统会把这台机器抽象成两个NUMA节点节点0和节点1每个节点各自持有128GB内存和一组CPU核心。当一个运行在节点0核心上的PHP-FPM进程它自己的PHP对象、临时数组、页缓存却分配在节点1的内存上那么它每一次读写这些数据CPU都要跨过互联总线去节点1取数。表面上看进程的CPU使用率不高但实际执行时间被内存延迟拖得很长这就是NUMA架构下最常见的性能损耗来源。NUMA架构感知要做的事就是让程序、运行时和整个部署方案都明白我离哪块内存更近尽量让进程在本地节点完成CPU执行和内存分配减少跨节点访问。这不是一个操作系统的自动优化而是需要部署层和应用层一起配合。1.2 PHP服务为什么逃不掉NUMA的影响可能有人会说PHP不是脚本语言吗内存管理不都交给Zend引擎了吗应用层根本感知不到物理内存地址这跟NUMA有什么关系问题恰恰出在这里。PHP把内存细节藏得很好部署层反而更容易忽视NUMA的存在。PHP-FPM的每一个Worker进程都是独立的进程由Linux内核的调度器决定它们跑在哪个CPU核心上。Worker进程申请内存时调用的是glibc的malloc默认情况下内存页倾向于分配在当前运行节点上。也就是说Worker在哪个节点上跑它的新内存大概率就落在哪个节点。但问题在于PHP-FPM的进程调度非常频繁。一个Worker先在节点0的某个核心上处理请求执行几十毫秒后请求结束进程进入空转状态。下一个请求到达时内核可能把它调度到节点1的空闲核心上。此时这个Worker的虚拟内存页还留在节点0它在节点1上执行时访问自己的堆、栈、全局变量就全变成了远端访问。一个两个请求可能无所谓但PHP-FPM的Worker生命周期很长一个Worker一天之内可能被迁移几十上百次。长此以往整个进程组的内存分布会变得碎成一片每个Worker身上都挂着大量远端内存页延迟就变成了一种随机掉落的毛刺。还有一个更隐蔽的放大器PHP请求是典型的短生命周期对象密集型负载。每一次请求都会创建成千上万个临时数组、字符串、对象分配和释放极其频繁。内核触发缺页中断时选择内存节点的行为并不总是智能的尤其是当进程在节点间不停摇摆时新页面的放置位置也会跟着不稳定。最终的结果就是无论Node.js还是Java只要是大内存多路服务器上的无状态Worker集群都会面临同样的问题但PHP因为多进程模型这个问题暴露得更随机也更难从应用日志里看出来。那么哪些PHP负载最容易受影响我实践下来总结了三种典型场景大内存页附带大量遍历操作比如几十MB的JSON聚合并分组计算长生命周期进程里常驻大量对象比如Swoole、Workerman这类常驻PHP服务PHP-FPM和其他高内存进程Redis、MySQL页缓存混布在同一台大内存机器上Socket缓冲区、页缓存、共享内存的分配都可能跨节点。2. PHP-FPM场景下的NUMA敏感性拆解再往下走就得真正拆开PHP-FPM的运行模型看看它到底在哪些环节容易踩到NUMA的坑。只有把这个想明白优化才不会变成玄学调参。2.1 进程模型多进程共享内存与节点分配PHP-FPM采用一个Master进程管理多个Worker进程的结构。Master负责监听端口、调度Worker、处理信号每个Worker是独立的PHP解释器实例有自己的独立内存空间。Worker之间并不共享用户态的PHP对象它们之间共享的主要是系统层面的资源比如文件描述符、opcache编译器缓存、已安装的扩展内存段。这里就会遇到第一个容易忽略的NUMA问题OPcache共享内存放在哪个节点。OPcache是PHP性能提升的关键组件它会把编译后的opcode缓存在共享内存中所有Worker通过mmap机制访问同一份数据。共享内存段是在Master启动时创建的创建时Master跑在哪个NUMA节点共享段就大概率落在哪个节点。如果Master和多数Worker不在同一个节点上那么所有Worker每次opcode查找命中共享页时都要跨节点去读数据。我见过一个真实案例某项目的PHP-FPM脚本里写了定时reloadMaster在节点0上创建了共享内存但配置了节点1上运行Worker整个集群的opcode访问全是远端读。这个问题用perf stat看不太直观但用numastat -p看具体进程的共享内存分布时一目了然。所以做PHP的NUMA感知优化时第一步不是急着绑核而是先搞清楚共享内存放哪儿了。共享内存是进程启动时创建的随Master走所以Master绑到哪个节点实际上决定了所有Worker的只读缓存底盘。2.2 哪些PHP工作负载最容易踩中跨节点访问并不是所有PHP请求都值得做NUMA优化。有些接口本身只做一次Redis查询总耗时不到1毫秒即使有跨节点访问损失也有限。真正受影响明显的是以下几类工作负载。第一类是CPU密集型计算。比如大量数组排序、正则表达式回溯、加解密操作、图像处理这些场景会高频访问内存中的输入数据和中间变量对内存延迟非常敏感。当CPU已经在做密集计算时再添加上几十纳秒到上百纳秒的远端内存访问整体执行时间可能放大10%到30%。第二类是内存带宽敏感型负载。典型行为是遍历超大数组、处理大文件数据块、批量拼接字符串。这类负载需要持续从内存中搬数据如果一半数据在远端节点CPU得同时占用本地和远端两条总线通道带宽立刻成为瓶颈。第三类是IO密集但依赖进程内缓冲的负载。比如打包下载、从数据库批量读取结果集再逐行加工。PHP代码里只要出现大量字符串拼接或临时数组累积内存的访问模式就变得特别重要。IO本身不是CPU瓶颈但每次IO数据落在远端节点后面的处理就会一并慢下来。我常跟团队说一句话接口单次耗时越短NUMA的影响越不明显因为它还没来得及建立局部性就结束了单次耗时越长、内存踏面越大NUMA的影响越致命。2.3 一个容易被忽略的罪魁祸首内存分配策略很多人以为进程申请内存时内核会自动选择最近的节点分配。但实际情况复杂得多。glibc的malloc默认策略是first touch意思是物理内存在哪个节点上分配取决于哪个CPU核心最先触碰这块虚拟内存。空闲虚拟内存只是在地址空间里划好了区域真正绑定物理页的时刻是第一次访问。这就导致一个常见误区如果你在PHP-FPM启动时预先加载了大块数据或者扩展在初始化阶段就申请了缓冲区这些内存的物理页其实还没被真正分配。要等到某个Worker第一次写入时物理页才会落到当时运行的那个节点上。如果后续另一个节点上的Worker大量访问这块内存页表一样会把这些页映射过去但跨节点访问的代价已经发生。系统层面还有几个相关参数会影响策略。比如/proc/sys/vm/zone_reclaim_mode控制着当某个NUMA节点内存不足时是否允许回收该节点的页缓存以满足本地分配。再比如/proc/sys/kernel/numa_balancing控制自动均衡层它允许内核根据页访问频率自动把热数据页迁移到访问进程所在的节点。听起来很美但自动迁移本身需要消耗周期且迁移期间进程状态不稳定反而会引入抖动。这也是为什么我建议如果PHP服务对延迟稳定性有要求不要完全依赖内核的自动NUMA均衡而是要通过部署层显式分配策略。要么用numactl限定进程的内存节点要么干脆用--interleaveall让分配在所有节点间交叉避免某个节点内存被打满。这两种策略的适用场景后面细说这里先记住一个结论PHP-FPM默认策略下Worker漂移得越频繁远端内存碎片越多这是许多性能毛刺的本质来源。3. 让PHP方案具备NUMA感知的实操路线理论到这里接下来是真正的落地环节。我这里给出一套我自己在项目中验证过的完整路线包括排查命令、决策思路、systemd配置和代码层面的配合。每一步都很容易复制到自己的环境里。3.1 基础排查用numactl看清当前分配状态任何优化开始前先得确认服务器到底是不是NUMA拓扑以及进程当前的内存分布长什么样。第一步查看服务器的NUMA拓扑信息numactl --hardware正常输出会列出节点数量、每个节点的CPU范围、内存大小和距离矩阵。距离矩阵里一个关键值节点0到节点1的距离通常是节点0到自身距离的1.5到2倍。如果只有单节点说明这台机器其实是UMA架构那后续绑定就没有意义了。第二步查看当前PHP-FPM主进程的运行策略numactl --show这条命令会显示当前Shell进程的cpubind和membind。如果想看某个具体进程numactl --show -p $(pidof php-fpm | awk {print $1})第三步真正判断内存落在哪儿要用numastatnumastat看整个节点内存分布是否均衡有没有某个节点内存明显偏高另一个节点打满。再单看PHP-FPM每个进程的内存分布numastat -p $(pidof php-fpm | tr ,)输出里会为每个进程列出它在节点0和节点1上的物理内存页数。如果一个Worker进程大量内存在远端节点这里会出现明显的数字倾斜。如果系统支持perf c2c还可以检测跨节点缓存行竞争perf c2c record -a -- sleep 30 perf c2c report不过perf c2c需要比较新版本的perf和内核对生产环境的侵入性也偏大我通常只在定位到方向之后再做一次确认。3.2 关键决策绑核还是定义分配策略拿到现状之后接下来要做一个关键选择是让进程固定CPU节点还是只限定内存分配策略这两者的方向和代价完全不同。如果选择绑核也就是让Worker进程的CPU亲和性固定在某个NUMA节点上这样做的好处是进程不会跨节点漂移一旦在本节点分配了内存后续访问基本都在本地完成。代价则是绑核之后进程只能使用绑定节点上的CPU资源节点空闲资源无法被利用核数利用率可能下降。如果选择只限定内存分配策略比如让进程的内存在所有节点间交错分配--interleaveall的好处是各个节点的内存压力比较均衡共享内存和堆不会集中在单个节点能避免一个节点内存打满另一个节点闲着的情况。坏处是有一半的概率访问落在远端节点对于极短请求影响不大对长CPU消耗请求则略微提高延迟。从我的经验看PHP-FPM这种高并发多进程模型最优解往往不是二选一而是组合把进程池拆成多组每组固定绑定一个NUMA节点的CPU核心同时给每组设置合适的内存分配策略如果总内存需求量不大、且希望均衡使用所有内存通道则用--interleaveall如果某个内存节点容量有限且进程内存需求大则优先--membind所在节点确保不会把本地内存打爆后跨节点交换。这里还有一点必须强调PHP-FPM的Master进程会把自身的CPU亲和性继承给所有Worker进程。也就是说如果你用numactl --cpunodebind0启动Master那么所有Worker的调度范围都限定在节点0。想跨节点利用多个节点就必须启动多份PHP-FPM实例每个实例分别绑定不同节点。这正是我在实际部署中大量使用的方式。3.3 在PHP-FPM启动脚本中落地NUMA策略以systemd管理PHP-FPM为例标准的启动方式一般是这样[Unit] DescriptionPHP-FPM Afternetwork.target [Service] Typeforking PIDFile/run/php-fpm.pid ExecStart/usr/sbin/php-fpm要让这个服务带上NUMA策略不需要改PHP代码只需要在ExecStart前面加上numactl参数[Service] ExecStart/usr/bin/numactl --cpunodebind0 --membind0 /usr/sbin/php-fpm --nodaemonize --fpm-config /etc/php-fpm.d/www0.conf注意这里的--nodaemonize是为了让进程在前台运行以便systemd管理进程生命周期。--fpm-config指向独立的配置文件因为我们要启动多个实例。如果要让Worker跑满两个NUMA节点就定义两套配置文件和两个systemd service。比如php-fpm-node0.service和php-fpm-node1.service分别对应www0.conf和www1.conf。两个配置文件里listen用不同的socket路径或端口pm.max_children按节点CPU核数的一半或实际负载合理设置。Nginx的upstream里把两个socket都加进去负载就天然分散到两个节点上了。如果你判断自己这个PHP应用对延迟不敏感只想把多节点内存用均衡也可以直接用交错策略ExecStart/usr/bin/numactl --interleaveall /usr/sbin/php-fpm --nodaemonize --fpm-config /etc/php-fpm.d/www.conf使用--interleaveall时注意它不只影响PHP的堆内存还会影响共享库的mmap映射、OPcache共享段、以及临时文件页缓存。好处是内存压力分布均匀坏处是PHP-FPM无法对访问频率高的对象控制局部性。我一般在以下场景采用这个方案PHP-FPM是容器里唯一主要进程且单台机器上的PHP实例数小于NUMA节点数。3.4 在PHP应用层做感知和规避共享内存、连接池与数据布局部署层配置结束后还有一类优化可以放到PHP代码自身层面来做尤其是那些常驻PHP方案比如Swoole、Workerman或者自己写的PHP CLI守护进程。这类进程生命周期长常驻数据多应用层如果完全不知道NUMA节点的概念等于白白放弃了感知能力。我们可以在PHP里读取当前进程可用的NUMA节点列表。Linux下/proc/self/status里的Mems_allowed_list会给出当前进程允许使用的内存节点编号。下面这个函数可以用来判断当前进程是否被限制在某个节点function getAllowedNumaNodes(): array { $status file_get_contents(/proc/self/status); if (preg_match(/^Mems_allowed_list:\s(.)$/m, $status, $match)) { $nodes []; foreach (explode(,, trim($match[1])) as $part) { if (strpos($part, -) ! false) { [$start, $end] explode(-, $part); foreach (range((int)$start, (int)$end) as $node) { $nodes[] $node; } } else { $nodes[] (int)$part; } } return $nodes; } return []; }虽然PHP应用拿不到物理内存地址但拿到节点编号之后可以做一件很有价值的事情让每台机器上的多个PHP进程把自己负责的数据分区按节点维度切分。比如在常驻内存服务里把热点缓存对象按进程ID散列尽量保证每个进程持有的热点数据都落在本地节点。这不能解决所有问题但确实能让每一个Worker的访问局部性更稳定。另外连接池和Socket缓冲区的分配也会受NUMA影响。如果你的PHP-FPM实例A绑定在节点0但它连接的是绑定在节点1上的Redis实例每一个Socket传输都要跨节点延迟会叠加。虽然不是个大数目但在高并发小请求场景下1微秒的差异也值得考虑。比较合理的做法是把PHP-FPM的节点分配和Redis、MySQL实例的物理位置对齐至少保证相近的节点。还有一个应用层常见的坑PHP的OPcache预加载功能。如果你在php.ini里开了opcache.preload预加载脚本会把类定义和函数定义提前放共享内存。这类数据的访视频率极高。如果预加载发生在Master创建之后共享内存落在Master所在节点而Worker又被绑到另一节点那这个预加载反向拖慢所有请求。建议预加载和Worker保持同一个节点绑定或者干脆用--interleaveall交错放置。3.5 进阶与集群调度器的配合当PHP服务跑在容器编排平台上的时候NUMA感知就不能只靠单机脚本了。容器编排平台的调度器决定容器落在哪台宿主机也会通过cpuset决定容器可以使用哪些CPU核心。这个场景下numactl看到的节点信息依然是宿主机级的容器里的进程如果绑定错误反而会造成更大问题。我的原则是容器内不要再用numactl --cpunodebind做硬绑定因为容器的cpuset cgroup已经限制了可用CPU集合硬绑定可能导致容器内的进程申请内存时违反cgroup限制而报错。正确的做法是让容器编排平台的拓扑感知能力去调度保证绑核和内存分配都遵循硬件拓扑。具体到技术方向就是让cpuset管理CPU亲和性同时在容器平台层面开启NUMA感知的资源拓扑对齐能力让整机调度器把多个Pod尽量分配到同一NUMA节点再配合static这种CPU管理策略让Pod在物理机上获得固定的CPU核。这样每个PHP-FPM Pod天然落在同一个节点内存访问局部性就有了基础保障。4. 实测复盘一个模拟项目的NUMA感知改造纸上得来终觉浅这一节我完整复盘一个模拟项目X的NUMA感知改造过程。项目本身是一个PHP 8.1的报表聚合服务业务逻辑是把若干业务表的数据批量读出后做分组和统计最后返回JSON。这个场景对内存带宽和延迟都敏感非常适合验证NDUMA优化效果。4.1 测试环境与压测方法测试服务器是双路物理服务器两个NUMA节点每个节点有32个逻辑核心、128GB内存整机64核256GB。操作系统是常见的企业级Linux发行版内核版本较新支持cgroup v2。环境里部署了Nginx和两套PHP-FPM配置数据库在另一台服务器上通过千兆网络连接避免数据库环境影响测试结果。压测工具用的是wrk压测命令如下wrk -t16 -c200 -d300s --latency http://127.0.0.1/report/aggregate压测的热点接口每次请求约读取2万行数据做分组统计后返回。单次请求平均耗时相对较长足以放大NUMA的差异。我们采集了QPS、平均耗时、P50、P95、P99以及各NUMA节点的内存使用分布。4.2 改造前的典型症状改造之前服务器上的PHP-FPM进程是从一个systemd服务启动的没有配置任何NUMA策略。跑30分钟压测后numastat的输出呈现出非常典型的失衡状态节点0内存使用率已经接近80%节点1只有30%左右。再看numastat -p几乎每个Worker进程都有40%以上的内存在远端节点。压测结果同样印证了问题。P50只有180毫秒但P95跳到600毫秒P99一度突破1秒。QPS在2400左右波动CPU总使用率还没到40%说明机器远没有跑满延迟却在尾部疯狂抖动。这类表现特别容易让人误判成应用性能问题实际上就是跨节点内存访问造成的。另外我还用perf stat -e cache-misses,cache-references粗略统计了全系统缓存未命中率改造前大概是每千条指令30多次未命中数值处在一个偏高的水平。4.3 改造步骤与参数选择我分三步完成了改造。第一步先给现有的单实例PHP-FPM加上--interleaveall策略观察内存均衡状况和尾部延迟。这一步没有改任何PHP业务代码只改了systemd的ExecStart。启动后立刻看到节点0和节点1的内存使用率趋于均衡P99从之前的1000毫秒以上降到780毫秒左右。但P95依然不太稳定。第二步我把PHP-FPM拆成两个实例分别绑定节点0和节点1每个实例的pm.max_children设置为48Nginx upstream里配置两个unix socket。这样每个Worker从出生到老死都固定在一个节点上不再发生跨节点漂移。这一步改完P95降到420毫秒P99稳定在550毫秒左右。第三步针对OPcache预加载做节点对齐。预加载自动执行在Master所在节点而我两个实例的Master分别绑定了节点0和节点1所以预加载本身没有问题。但我不再把整个代码库的全量类都丢进预加载而是拆分为两部分分别对应两个实例自身的配置减少Shared Memory跨节点访问频次。同时我把Nginx与两个PHP-FPM实例配置为尽量在同一组核心上运行减少进程通信的跨节点Socket传输。最终的systemd配置示意如下[Unit] DescriptionPHP-FPM node0 Afternetwork.target [Service] Typesimple ExecStart/usr/bin/numactl --cpunodebind0 --membind0 /usr/sbin/php-fpm --nodaemonize --fpm-config /etc/php-fpm.d/www0.conf Restarton-failure另一个实例把命令里的0改成1即可。4.4 结果对比与经验总结改造后的压测数据如下指标改造前interleaveall双实例绑核QPS约2400约2800约3050P50180ms160ms138msP95600ms420ms310msP991000ms780ms520ms节点0内存占比约80%约51%约50%CPU总使用率约38%约50%约60%可以看到单纯用interleaveall就能让内存分布明显均衡QPS提升约17%。加上双实例绑核之后进程不再漂移局部性大幅改善QPS再提升近9%P99更是从一开始的1000毫秒以上降到520毫秒左右。改造没有动一行业务代码完全是进程部署层面的优化。不过这里要特别说明这个结果是在特定硬件和特定负载下测出来的。如果你的PHP请求都是几十毫秒以内的轻量查询NUMA优化带来的QPS提升幅度可能不会这么大。核心理由在于轻量请求对内存带宽和缓存局部性的依赖程度低远没有CPU密集型长请求那么敏感。5. 容易翻车的地方NUMA感知需要注意的边界NUMA感知优化不是一绑了之。我在多个项目里见到过一些翻车操作这里整理成三类最常见的坑方便大家绕开。5.1 盲目绑核导致资源饥饿不少人看到绑核效果好就把所有Worker都绑定到同一个NUMA节点上。这样做最直接的问题是资源饥饿即使另一个节点有30个空闲核心这边的Worker也永远用不上。表现在指标上就是整体QPS不升反降CPU使用率分布极度不均。正确做法是根据每个节点的逻辑核心数量把Worker进程池平均拆分。比如64核双节点机器一个实例最多绑定32个核心如果想跑更多Worker就需要启动多个实例让每个节点都承担合理负载。同时还要考虑PHP-FPM的pm.max_children不只是CPU资源占用还包括内存占用。绑核之后每个节点可用内存是有限的如果max_children设置过大进程会把本地节点内存打爆然后迫不得已把页面换到远端节点反而触发更多远端访问。我的建议是把max_children和节点内存做一次交叉估算。假设每个PHP-FPM Worker平均占用150MB内存节点内有128GB内存那么理论最大可跑850个Worker但实际还得给操作系统页缓存和Redis留余量所以单节点设置200到300个上限已经很大了。实际生产建议从50左右开始压测调整。5.2 cgroup与numactl的叠加坑当PHP-FPM跑在systemd或容器里cgroup的cpuset已经限制了进程可用CPU集合。这时候再用numactl --cpunodebind会存在两套亲和性规则互相叠加的问题。cpuset限制的是可分配集合numactl绑定的是调度时的偏好二者并不总是兼容。我记得有一个项目里容器的cpuset只允许使用CPU 0到7也就是对应的节点0。但容器启动参数里又加了numactl --cpunodebind1结果进程直接被调度到节点1的CPU上超出了cpuset限制。这类冲突不会导致进程崩溃但会引发频繁的调度警告性能也明显劣化。解决思路很简单在容器场景优先使用cpuset环境变量或容器编排平台的CPU绑定能力不要去叠加numactl --cpunodebind。如果非要使用numactl请先确认cpuset允许的节点范围二者保持一致。5.3 容器化环境下的NUMA拓扑失效问题容器里看到的NUMA拓扑还是宿主机的物理拓扑但容器实际可以使用的CPU子集可能分布在多个节点上。如果你在容器内看到numactl --hardware输出了两个节点就以为可以用绑核来优化实际上容器的CPU quota和cpuset一限很多核根本不可见。这种拓扑可见但不可用的错位很容易误导人。更稳妥的方案是容器环境下不做进程级NUMA感知而是交给容器编排平台的拓扑管理能力。让调度器在放置Pod时尽量把同一个Deployment的Pod分布在不同的NUMA节点上同时每个Pod内部的进程天然绑定在Pod获得的CPU集合内。这样PHP-FPM进程看到的可调度核心和它真正使用的核心是一致的局部性自然就有了保障。当然这些都是我这几年在具体项目里反复试出来的经验未必适合所有环境。如果你的业务对延迟不敏感先跑个numastat看看节点分布再决定要不要折腾也不迟。有时候一句话的提醒比闷头调优一周更有效。希望这篇文章能帮你少走一点弯路把服务器该有的性能真正压出来。
阅读完成 · 觉得有帮助?