先从一个真实场景说起。去年我为客户调优一套双路服务器上的数据库服务,CPU 利用率明明只有 30%,但读写延迟却高得离谱,业务方急得不行。查了半小时,在 numastat 里一眼看到了问题:数据库进程的内存访问,一多半都落在了远端内存节点上。这个现象,就是 CPU NUMA 架构下典型的“内存串门”问题。NUMA(Non-Uniform Memory Access,非统一内存访问)其实不是什么高深技术,它描述的是多路 CPU 服务器里“不同 CPU 访问不同内存,速度不一样”的客观事实。这篇内容会讲清楚 NUMA 到底怎么来的、怎么用命令看清你的机器拓扑、怎么定位和解决实际性能问题,适合运维、后端开发和搞高性能计算的同学参考,尤其是那些“机器配置很高但性能就是不行”的疑难杂症。
1. NUMA 到底怎么来的:为什么要让内存“不均衡”
1.1 从单路到多路:内存墙问题逼出来的设计
早期的 x86 服务器只有一颗 CPU,所有核心通过前端总线或统一内存控制器访问同一块物理内存。这种架构叫 UMA(Uniform Memory Access,统一内存访问),特点是“无论哪个核心,访问内存的延迟和带宽都是一样的”,设计简单,软件写起来也省心。但有个致命问题:单颗 CPU 的计算能力有限,内存控制器的带宽上限就那么高,想往上扩展,只能把多颗 CPU 塞进同一台机器。
问题随之而来——如果多颗 CPU 仍然共享同一个内存控制器,那么每颗 CPU 发出的内存请求都要排队等这个控制器响应。请求少还好,一旦核多、并发高,内存控制器立刻成为瓶颈,整体性能不升反降。这就像一个大办公楼只有一个食堂,人少的时候大家排队也能忍,人一多,队伍能从一楼排到三楼,谁都不舒服。
于是硬件工程师换了个思路:与其让大家挤同一个食堂,不如每层楼(每路 CPU)都配一个小食堂(本地内存控制器和内存插槽)。这样每颗 CPU 访问自己脚下的内存速度最快,这就是 NUMA 的雏形。当然,跨层楼的人偶尔也得去别的食堂吃饭,这时候就得走楼梯、坐电梯,速度自然慢一些。
所以 NUMA 并不是设计上的倒退,它是在“追求多路扩展性”和“保证内存访问效率”之间做的工程妥协。现代服务器几乎全是这个形态:多路 Intel Xeon、AMD EPYC、以及市场上主流的国产 x86 服务器平台,底层都遵循 NUMA 的访问模型。
1.2 本地访问与远端访问:核心概念就俩
要理解 NUMA,抓住两个词就够:本地(Local) 和 远端(Remote)。
每颗物理 CPU 及其配备的内存控制器、本地内存插槽,合起来叫作一个 NUMA 节点(Node)。CPU 访问自己节点内的内存,叫本地访问;访问其他节点的内存,叫远端访问。远端访问的代价更高,因为数据要跨越节点间的互联总线,常见的有 Intel 的 UPI/QPI、AMD 的 Infinity Fabric。
代价高到什么程度?不同平台数据不太一样,但经验值大概是:远端内存访问延迟比本地高出 40% 到 100%,内存带宽损失 30% 到 50%。如果机器是 4 路或者 8 路,节点之间的“跳数”还会增加,A 节点的 CPU 访问 D 节点的内存,可能要在中间节点转两次,延迟会更难看。
这带来一个核心结论:NUMA 管理的本质,是让数据尽量靠近 CPU。如果进程随机漂移、内存随机分配,程序就会频繁“跨楼吃饭”,性能自然拉胯。这也是为什么很多“高配低能”的服务器,问题都出在 NUMA 失衡上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先看清你的机器:NUMA 拓扑观测与解读
2.1 五分钟摸清 CPU 和内存分布
动手调优之前,必须先知道机器长什么样。我推荐一套组合命令,装好之后基本无死角:
bash复制# 查看 CPU 与 NUMA 节点的映射关系
lscpu -e
lscpu -p
# 查看 NUMA 节点数量和内存分布
numactl --hardware
# 实时统计节点间内存访问命中情况
numastat
numastat -c
# 图形化查看拓扑(如果有图形界面或远程桌面)
lstopo
以一台双路服务器为例,实际输出大概长这样(示例数据,非某台具体机器):
text复制$ lscpu -e
CPU NODE SOCKET CORE L1d:L1i:L2:L3 ONLINE
0 0 0 0 0:0:0:0 yes
1 0 0 0 0:0:0:0 yes
...
16 1 1 16 16:16:16:16 yes
text复制$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30
node 0 size: 18446744073709551615 MB # 这里通常显示实际内存大小
node 0 free: 18446744073709551615 MB
node 1 cpus: 1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31
node 1 size: 18446744073709551615 MB
node 1 free: 18446744073709551615 MB
node distances:
node 0 1
0: 10 21
1: 21 10
注意看 lscpu -e 里的 CPU ID 排列:在开启超线程的双路机器上,CPU ID 是按“先逻辑核交叉、再物理核编号”的方式排列的,所以你可能会看到核心 0 和核心 16 在同一个物理核上,但分别在两个 NUMA 节点。这就是超线程的交叉列队,不熟悉的话特别容易搞混。
2.2 怎么读懂这些输出:距离矩阵才是关键
numactl --hardware 里最有价值的不是节点数量,而是 node distances 那段小表格。数字越小表示访问越近,10 是本地节点到自身的距离,21 通常代表跨一个节点的远端距离。如果看到 21、30 这种数字,说明这是多路平台,远端访问一跳或两跳。
实操建议:拿到一台新机器的第一件事,就是用 numactl --hardware 打印拓扑,然后画一张简易表贴在笔记里。比如“node0 有 CPU 0-15、内存 192GB,node1 有 CPU 16-31、内存 192GB”。后续排查性能问题的时候,这张表能帮你快速判断“进程跑在哪个节点、内存从哪来”。
补充一个排查技巧:如果机器是虚拟机,numactl --hardware 看到的可能是“虚拟化层模拟出来的拓扑”,或者被 CPU 亲和性设置掩盖了真实布局。所以在物理机上看一次,在虚拟机里再看一次,两边对不上是正常的,别拿虚拟机的拓扑去硬套物理机。
3. 你踩过的坑,很多都是 NUMA 惹的祸
3.1 典型症状:CPU 利用率不高,性能却上不去
这类故障信息在社区里非常多见,典型描述是“CPU 占用率只有 20%,但业务延迟暴涨”“加了核反而更慢”“每跑一个应用,整台机器就卡”。抛开业务逻辑问题,很大概率是 NUMA 失衡。
拿一个很常见的场景举例:有人在 Python 里用 RapidOCR 做文字识别,发现纯 CPU 推理非常吃 CPU,核数加到 64 还是顶不住。先说结论:推理本身是计算密集任务,CPU 占用高是正常的。但如果 CPU 占用率已经很高,吞吐量却远低于理论峰值,就要怀疑内存访问模式了。RapidOCR 这类推理框架在每次识别时会加载模型权重、创建中间张量,如果进程频繁被调度到不同 CPU 核上,而内存页仍留在旧节点,就会出现大量远端内存访问和 TLB miss,CPU 每执行一条指令都在等数据,结果就是“看起来在忙,实际没干多少活”。
另一个高频场景是媒体播放。有人用 MPC-BE 播放高码率视频,CPU 占用率奇高,其实不一定是解码算法低效——先把硬解是否开启排除掉,如果确定是软解,再看看线程亲和性。很多播放器默认线程是自由调度的,视频流数据一旦跨节点搬运,CPU 缓存命中率下降,解码耗时飙升,占用率自然难看。
再比如数据库场景:MySQL 或 PostgreSQL 跑在双路服务器上,buffer pool 命中率看着正常,但 QPS 波动剧烈,慢查询时多时少。此时用 numastat 一看,发现“Local Node”命中率低、“Other Node”命中率高,问题就很清楚了:要么进程被调度到了和内存不同的节点,要么内存分配策略没有优先使用本地内存。
这里想强调一个区分原则:要把“计算密集所以 CPU 高”和“NUMA 失衡导致 CPU 空转”分开。判断标准很简单——跑同一个任务,把进程绑到与内存同一节点后,CPU 占用率明显下降且吞吐量不变或提升,就说明之前是 NUMA 的问题;如果绑定了 CPU 还是高,那纯粹是计算量太大,不要甩锅给架构。
3.2 NUMA 与虚拟化:宿主机失衡,虚拟机遭殃
虚拟化环境下 NUMA 问题更隐蔽。宿主机上跑着多个虚拟机,虚拟机的 vCPU 和内存由 Hypervisor 调度分配。如果 Hypervisor 不感知 NUMA,虚拟机的内存可能被分散到多个节点,而 vCPU 集中在一个节点,这个虚拟机在内部访问自己的内存时,就会反复“跨楼”。
症状上,虚拟机里会出现“CPU 利用率低但响应慢”“负载一高就卡死”“物理主机 CPU 占用不高但虚拟机性能极差”等现象。严重时,某些虚拟化平台还会报出“虚拟 CPU 进入关闭状态”这类错误——这通常和 CPU 调度超时有关,而 NUMA 失衡会加剧调度等待。
解决思路也很明确:在虚拟化层把“大虚拟机”的内存和 vCPU 绑定到同一 NUMA 节点。VMware 在部分版本里默认开启了 NUMA 感知调度,但是如果虚拟机配置了超过单个节点容量的内存或 vCPU,也会出现自动拆分。KVM 下可以用 vCPU pinning 和内存绑定的方式手动对齐。这些具体的操作方法,放到下面实战部分展开。核心原则是:宿主机先理顺,虚拟机才能稳。
4. 实操上手:NUMA 性能调优三板斧
4.1 进程绑定:numactl 与 taskset 的正确姿势
NUMA 调优最直接、最见效的手段就是进程绑定。绑定的核心目的有两个:一是让进程的线程尽量跑在固定 CPU 核上,二是让进程的内存尽量分配在固定节点。
numactl 是 Linux 下最常用的工具,几个关键参数要记牢:
bash复制# 绑定 CPU 节点和内存节点到 node0
numactl --cpunodebind=0 --membind=0 ./your_app
# 只绑定 CPU 节点,内存仍可在多节点间分配
numactl --cpunodebind=0 ./your_app
# 优先从 node0 分配内存,不够再从其他节点分配
numactl --preferred=0 ./your_app
而 taskset 是另一个常见的工具,但它有个重要局限:taskset 只设置 CPU 亲和性,不控制内存分配策略。也就是说,你把进程绑到了 node0 的 CPU 上,但内存分配器可能还是会从 node1 分配内存,结果就是 CPU 在 node0、内存页在 node1,仍然跨节点。
所以,对 NUMA 场景,我更推荐优先用 numactl 而不是 taskset。当然,实际生产环境里两者经常配合使用:先用 numactl --membind 控制内存,再用 taskset -c 或者 numactl --cpunodebind 控制 CPU。
做个简单实验你就能直观感受差异。准备一个内存带宽测试工具(比如 mbw 或自写一个循环读写 1GB 数组的 C 程序),分别测试三种情况:
- 进程和内存都在 node0:
numactl --cpunodebind=0 --membind=0 ./test - 进程在 node0,内存自由分配:
numactl --cpunodebind=0 ./test - 进程自由调度,内存自由分配:直接
./test
我实测的效果是:第一种情况的内存带宽最高且稳定;第二种容易出现抖动;第三种在高并发多进程场景下,带宽直接掉一截。原因不复杂——内存分配器在默认策略下会把内存摊到多个节点,进程在哪个节点跑,决定了它要“跑多远”去内存取数据。
4.2 内核层面的自动 NUMA 平衡:好事,也可能坏事
Linux 内核从 3.8 开始引入了自动 NUMA 平衡(NUMA Balancing)机制,核心思路是由内核自动迁移线程和内存页,尽量让线程访问内存的“距离”变短。听起来很智能,但实际效果要看场景。
自动 NUMA 平衡的相关参数:
bash复制# 查看当前状态
sysctl kernel.numa_balancing
# 0 为关闭,1 为开启
sysctl kernel.numa_balancing=1
什么场景适合开启?如果机器上跑的是大量短生命周期进程,比如临时任务、批处理脚本,进程不断启停、线程不断切换,手动绑定根本不现实,这时候让内核自动平衡反而能减少人工干预。什么场景不适合?遇到数据库、JVM 这类大内存长期存活的应用,如果内核频繁迁移内存页,反而会带来额外的页迁移开销。很多数据库厂商和资深 DBA 都建议在数据库场景关闭自动 NUMA 平衡,改用手动绑定——因为大内存应用的内存页一旦被迁移,代价非常大。
怎么判断该不该关?我的习惯是:先用 numastat 观察 mmap_fault、numa_hit、numa_miss 这几个指标。如果 numa_miss 持续增长、且 numa_foreign 很高,说明跨节点访问频繁;此时试着关闭自动平衡,改用 numactl --membind 手动绑定,对比关键性能指标(QPS、P99 延迟、CPU 占用率),用数据说话。
4.3 系统级配置:从 BIOS 到操作系统的完整链路
除了进程级绑定,还有一台机器的全局配置项值得关注,最典型的是 BIOS 里的 Node Interleaving(节点交错)选项。
Node Interleaving 的思路是:把内存请求轮流分发到所有节点,让整个服务器的内存带宽“均匀化”。代价是牺牲了本地访问的低延迟——你访问哪块内存都差不多,不会出现“某些内存特别快,某些特别慢”的情况。这个选项适合什么场景?对内存访问没有明显局部性、吃的是总带宽的批处理任务,比如大数据离线计算、日志分析,可以考虑开启。但如果是延迟敏感的数据库、在线服务,通常建议关闭,让系统尽量使用本地内存。
操作系统层面的全局策略同样重要。比如用 cgroup 的 cpuset 控制器,可以精确控制一组进程的 CPU 亲和性;还有内核的调度器 NUMA 感知功能,在开启的状态下,调度器会优先把线程调度到内存所在节点,减少迁移。
还有一个容易踩的坑:修改 BIOS 或固件里关于 CPU、内存的配置之后,一定要重启并进系统确认参数生效。有人改完 CPU 相关配置后重启发现“设置又变回去了”,这多半是 BIOS 电池失效、或者修改没有保存到 NVRAM,别把这类基础问题误判成系统调优失败。
5. 典型场景下的 NUMA 策略差异
5.1 数据库与高并发服务:绑定还是禁用,别一刀切
数据库是 NUMA 争议最大的领域,网上说法两极分化:有人说“必须关 NUMA”,有人说“必须绑 NUMA”。我跑了这么多年,结论是:先观察,再决定,别用口号代替数据。
MySQL 的经典问题是:InnoDB buffer pool 默认在进程启动时一次性申请大块内存,如果进程启动时被调度到了 node0,而后续线程被调度到 node1,就会出现“主线程在 node0、干活线程在 node1”的错位。以前大家习惯直接禁用 NUMA 平衡,配合 innodb_numa_interleave 参数让 InnoDB 均匀分配内存。MySQL 8.0 版本已经内置了内存互分配支持,默认策略也更合理。
PostgreSQL 类似,shared_buffer 也是启动时就分配。JVM 更是典型:JVM 堆默认使用 mmap 分配,线程和堆内存如果不在同一节点,GC 表现会非常差。我在双路服务器上调过一个 Java 微服务,Full GC 频繁到每秒一次,后来用:
bash复制numactl --cpunodebind=0 --membind=0 ./start.sh
把整个 JVM 进程按在 node0 上,Full GC 频率直接降了一个数量级,P99 延迟从 200ms 降到 40ms 左右。可能有人会问:只用一个节点,另一个节点的资源不浪费了吗?如果应用实例不多,完全可以在每个节点上各起一个实例,各绑各的节点,互不干扰,整体吞吐还能翻倍。
5.2 虚拟化与容器:NUMA 感知调度是硬需求
虚拟化层面,KVM 的 vCPU pinning 可以这样操作:
bash复制# 先看虚拟机的 vCPU 映射关系
virsh vcpupin <vm-name> --list
# 把 vCPU 0 绑定到物理 CPU 0-3
virsh vcpupin <vm-name> 0 0-3
内存方面的控制更关键。KVM/QEMU 启动虚拟机时,可以设置 CPU 亲和性和内存绑定策略,比如:
bash复制taskset -c 0-15 qemu-system-x86_64 -m 64G -mem-path ...
-mem-path 配合大页使用时,可以指定内存分配的 NUMA 策略,尽量让虚拟机的内存在宿主机的同一节点内。
容器场景比虚拟机简单但也更麻烦。Kubernetes 的 CPU Manager 支持 CPU 绑核策略,Topology Manager 也提供了 NUMA 对齐能力,但这两个特性对节点内核版本和容器运行时版本有要求,很多传统集群根本没有启用。结果就是,容器调度时完全不感知 NUMA,Pod 的内存可能跨节点分散,性能表现完全取决于运气。
我的经验是:如果你的集群里跑着对延迟敏感的服务,一定要先确认 Kubernetes 的 Topology Manager 是否开启。如果没条件开,就用节点亲和性(nodeAffinity)或者直接在同一个物理节点上只调度特定 Pod,靠人工手动对齐 NUMA。
5.3 异构计算与新一代架构:NUMA 的边界正在扩大
NUMA 这个概念原本只描述 CPU 和内存,但现在的异构硬件让“距离”变得更加复杂。AMD EPYC 在单路里也内置了多 CCX/CDIE 结构,Intel 的 SNC(Sub-NUMA Clustering)技术则把单颗 CPU 的物理核拆成多个 NUMA 域,软件调度策略在这些平台上的差异非常明显。
GPU 也一样。GPU 显存离哪个 CPU 近、通过哪条 PCIe/NVLink 链路访问,决定了你的数据传输延迟。在 CPU+GPU 协作训练或推理的场景里,如果 CPU 端线程和 GPU 显存不在同一条链路上,就会出现“GPU 等数据、CPU 忙搬运”的空转。
还有一个不可忽视的方向:内存形态正在从 DDR4/DDR5 走向 HBM、CXL 内存。CXL 内存可以动态挂载到某个处理器附近,这让“某个地址到底离谁更近”的问题更具动态性。未来的 NUMA 管理不会是简单的“两个节点互不干涉”,而是一张动态的拓扑图。国产 ARM 服务器、以及 RISC-V 生态里正在推进的多核可扩展设计,也在呈现类似 NUMA 的访问特性,用 lscpu、numactl 这一类工具可以同样观测和调优。
6. 常见问题排查速查表与个人经验总结
6.1 一张表看清常见问题与排查路径
| 症状 | 可能原因 | 排查工具 | 处理方法 |
|---|---|---|---|
| CPU 利用率低但延迟高 | NUMA 失衡,远端访问过多 | numastat -c、lscpu -e |
用 numactl --cpunodebind --membind 绑定 |
| CPU 占用率持续 100% | 计算密集或缺扩展性 | top、htop、pidstat |
先区分业务计算量和跨节点搬运,再做绑定 |
| 数据库 QPS 波动剧烈 | buffer pool 与线程节点错位 | numastat、perf |
关闭内核自动平衡,手动绑定进程到固定节点 |
| 虚拟机内部响应慢 | 宿主机内存跨节点分配 | numastat -c(宿主机) |
调整虚拟机内存亲和、vCPU pinning |
| Java Full GC 频繁 | JVM 堆与线程节点不一致 | jstat、numastat |
启动时用 numactl 绑定 JVM 进程 |
| 播放视频/软解压 CPU 异常高 | 线程不固定、数据跨节点 | perf sched、numastat |
绑定线程亲和,优先本地内存;排查硬解 |
| 内存带宽跑不满 | 内存交错策略与任务不匹配 | mbw、numactl --hardware |
按工作负载调整 BIOS Node Interleaving |
提醒一句:上面这些场景里,头号排查顺序永远是“先用 top 和 perf 看热点,再用 numastat 看节点命中和远程访问”,不要跳步骤。很多时候 CPU 占用率高只是纯粹的计算量大,比如杀毒组件扫描、系统更新、索引重建,这些和 NUMA 没关系,得按常规性能排查流程走。
6.2 我踩过的一些坑:真实教训与避坑建议
我第一次给服务器调 NUMA 时犯过一个典型错误:看完几篇网帖,二话不说在 BIOS 里关了 Node Interleaving,然后以为大功告成。结果业务上线后延迟反而比之前更高。后来冷静跑了一遍 numastat 才发现,这台机器跑的是内存带宽密集的批处理任务,关闭 interleaving 后,内存访问的局部性特征并不明显,反而是均匀分布更多;重新开启 Node Interleaving,带宽利用率和吞吐量立刻上去了。
还有一次,在双路机器上手动给数据库进程绑了 node0,绑完就忘了评估另一颗 CPU 的压力。结果 node0 的 CPU 长时间跑满,node1 却在“看戏”,整体吞吐还不如不绑。后来把数据库实例拆成两个,分别在 node0 和 node1 上各跑一个,用 HA 代理分发流量,两台“半台机器”加在一起反而比一台绑定的性能强很多。
这些教训总结成三条原则,我一直沿用:
第一,观察永远先于操作。每台机器的硬件拓扑、工作负载都不相同,网帖建议只能当参考,不能当圣旨。先跑一遍 numactl --hardware、抓 10 分钟 numastat,看看跨节点访问占比到底是多少,再决定要不要动。
第二,绑定不是越强越好。手动绑定会限制调度器灵活性,适合长期稳定运行的负载,不适合突发性强、进程数量波动的场景。高并发短任务的场景,要么用内核自动平衡,要么对整个容器/虚拟机做粗粒度绑定。
第三,改动一次只验证一个变量。比如先只绑定 CPU,跑基线;再绑定内存,对比结果;最后调整内核参数。不要把多个调优动作一起上,否则出了问题根本定位不了是谁的锅。
最后再分享一个小技巧:做 NUMA 调优首先要建立“基线”。上线前花一小时压测记录数据,包含吞吐、延迟、CPU 占用率、numastat 的本地命中率,后面任何一次调优,都拿新数据和基线对比。数字不会骗人,这比任何“我猜”“网上说”都靠谱。
