NUMA 亲和性与内存带宽优化——双路服务器的就近取数策略
NUMA 亲和性与内存带宽优化——双路服务器的就近取数策略
一、当 CPU 离数据 200ns 远:双路服务器的隐性延迟税
在多路服务器架构中,每个 CPU 插槽都有自己本地连接的 DIMM 内存通道。访问本地内存的延迟通常在 80
100ns,而跨 CPU 访问对端内存(remote access)的延迟会增加到 180220ns。这一倍的延迟差距不是理论数字——在生产环境的 MySQL InnoDB 缓冲池命中路径上,频繁的跨 NUMA 节点访问可以直接将 TP99 延迟从 1.2ms 推高到 3.8ms。
问题在于,操作系统默认的内存分配策略(MPOL_DEFAULT)会优先在发起分配的线程所在 NUMA 节点上分配内存,但线程会被调度器迁移到其他 NUMA 节点的 CPU 核心上运行。结果就是线程在 Node 0 上分配了内存,却被调度到 Node 1 的核心上执行——每一次内存访问都在跨节点执行,延迟直接翻倍。
更隐蔽的是,dual-socket 服务器的两个 CPU 之间通过 UPI(Ultra Path Interconnect)或 Infinity Fabric 连接,这个 inter-socket 链路的带宽上限远低于本地内存带宽。在 Intel Xeon Platinum 平台上,单路内存带宽约 200GB/s,而 UPI 链路带宽约 30~40GB/s。当多个进程同时跨节点访问内存时,UPI 链路迅速饱和,形成全局性的延迟尖峰。
二、NUMA 架构的硬件真相与操作系统的错觉
graph TB
subgraph NUMA_Node_0
CPU0[CPU Cores 0-15] --- L3_0[L3 Cache 32MB]
L3_0 --- IMC0[内存控制器 0]
IMC0 --- DDR0[DDR4 DIMM 0]
IMC0 --- DDR1[DDR4 DIMM 1]
end
subgraph NUMA_Node_1
CPU1[CPU Cores 16-31] --- L3_1[L3 Cache 32MB]
L3_1 --- IMC1[内存控制器 1]
IMC1 --- DDR2[DDR4 DIMM 2]
IMC1 --- DDR3[DDR4 DIMM 3]
end
CPU0 <==>|UPI 10.4 GT/s<br/>~38GB/s 带宽| CPU1
APP0[进程 A<br/>绑定 Node 0] -.-> CPU0
APP1[进程 B<br/>绑定 Node 1] -.-> CPU1
style CPU0 fill:#c8e6c9
style CPU1 fill:#c8e6c9
style DDR0 fill:#e1f5fe
style DDR1 fill:#e1f5fe
style DDR2 fill:#e1f5fe
style DDR3 fill:#e1f5fe
2.1 为什么操作系统不知道这是性能问题
Linux 内核的调度器(CFS)在做负载均衡时会跨 NUMA 节点迁移任务。调度器的设计目标是公平性——让所有 CPU 都忙碌起来,避免某些核心空闲。但调度器不知道被迁移的线程的本地内存亲和性已经失效。
即使开启了 numa_balancing(内核 3.13+),内核的自动 NUMA 迁移机制也面临一个根本性的数据匮乏问题:内核只能观察到页表的访问位,这个数据粒度太粗。页表的 Dirty/Accessed 位在 TLBs 刷新时才会被更新,频率远低于实际的内存访问。这意味着内核在做"是否迁移内存页"的决策时,依据的是过时且不完整的信息。
2.2 使用 numastat 测量 NUMA 分布的真相
# numastat 输出示例——关注 numa_miss 和 numa_foreign 两个关键指标
# numa_miss: 本节点线程去分配其他节点内存的次数(越高说明分配策略有问题)
# numa_foreign: 其他节点线程来分配本节点内存的次数
$ numastat -p $(pgrep mysqld)
Per-node process memory usage (in MBs) for PID 31142 (mysqld):
Node 0 Node 1 Total
--------------- --------------- ---------------
Huge 0.00 0.00 0.00
Heap 512.34 1536.12 2048.46
Stack 0.45 0.38 0.83
Private 30000.12 10000.34 40000.46
---------------- --------------- --------------- ---------------
Total 30512.91 11536.84 42049.75
# 如果一个服务主要在 Node 0 上运行,但大半内存分配在 Node 1
# 说明存在严重的 NUMA 分布错配——需要通过 numactl 重新绑定
三、实战调优:从内核参数到应用层策略
3.1 使用 numactl 进行进程级绑定
# 将 MySQL 进程绑定到 NUMA Node 0,并使用交错模式分配内存
# --cpunodebind=0: 进程和它创建的所有线程只能在 Node 0 的 CPU 上运行
# --membind=0: 内存分配严格限制在 Node 0——避免跨节点访问
# 警告:如果 Node 0 内存不足,OOM Killer 会被触发
numactl --cpunodebind=0 --membind=0 \
/usr/sbin/mysqld --defaults-file=/etc/my.cnf
# 对于大型 InnoDB Buffer Pool(如 128GB+),如果单路内存不足以容纳
# 可以使用 --interleave 在各个节点上均匀分配
# 这种策略牺牲了局部性,但避免了单节点 OOM
numactl --interleave=all \
/usr/sbin/mysqld --defaults-file=/etc/my.cnf
3.2 NUMA 感知的内存池设计(Go 语言实现)
package numa
import (
"runtime"
"syscall"
"unsafe"
)
// NumaPool 是 NUMA 感知的内存分配器
// 设计目标:减少跨节点内存访问,提升缓存局部性
// 适用场景:16+ 核心的服务端程序,如 HTTP Server、RPC Framework
type NumaPool struct {
nodeID int
cpuidxs []int // 该 NUMA 节点的 CPU 核心列表
}
// NewNumaPool 创建一个绑定到指定 NUMA 节点的内存池
// nodeID 从 /sys/devices/system/node/ 目录读取
func NewNumaPool(nodeID int) (*NumaPool, error) {
cpuidxs, err := getCPUListForNode(nodeID)
if err != nil {
return nil, err
}
pool := &NumaPool{
nodeID: nodeID,
cpuidxs: cpuidxs,
}
return pool, nil
}
// ExecOnNode 在指定 NUMA 节点上执行函数
// 通过 runtime.LockOSThread 将当前 goroutine 锁定到特定 OS 线程
// 配合 syscall.SchedSetaffinity 将该线程绑定到目标 NUMA 节点的 CPU 核心
// 注意:LockOSThread 使该 goroutine 独占一个线程——不适合创建上万个 goroutine 的场景
func (np *NumaPool) ExecOnNode(fn func()) {
// 将当前 goroutine 锁定到 OS 线程
// 这确保后续的 CPU affinity 设置不会被子 goroutine 继承
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// 构建 CPU 亲和性掩码——只允许在该 NUMA 节点的核心上运行
// CPU 集合大小需要与实际的核心数匹配(通过 runtime.NumCPU() 取上限)
var cpuset syscall.CPUSet
for _, cpuidx := range np.cpuidxs {
cpuset.Set(cpuidx)
}
// 设置线程的 CPU 亲和性
// 之后该线程的所有内存分配都会优先在本地 NUMA 节点上
if err := syscall.SchedSetaffinity(0, &cpuset); err != nil {
// 在生产代码中应记录错误并决定是降级还是 panic
// 此处为了演示简洁,直接 panic——实际应与监控系统联动
panic("failed to set CPU affinity: " + err.Error())
}
fn()
}
// AllocLocal 在本地 NUMA 节点上分配固定大小的字节切片
// 警告:这个方法使用了 mmap 的直接调用,生产代码需要完善的错误处理和释放逻辑
func (np *NumaPool) AllocLocal(size int) ([]byte, error) {
// 使用 mmap 的 MAP_ANONYMOUS | MAP_PRIVATE 标志分配匿名内存
// 配合 runtime.LockOSThread 锁定线程后,内核会将物理页面分配在本地 NUMA 节点上
data, err := syscall.Mmap(
-1, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_ANONYMOUS|syscall.MAP_PRIVATE,
)
if err != nil {
return nil, err
}
return data, nil
}
// getCPUListForNode 从 sysfs 读取指定 NUMA 节点的 CPU 列表
// 读取 /sys/devices/system/node/node{N}/cpulist
// 返回格式如 "0-15,32-47" 的解析结果
func getCPUListForNode(nodeID int) ([]int, error) {
// 实现略——通过 ioutil.ReadFile 读取 cpulist 文件并解析范围
// 核心路径: /sys/devices/system/node/node{nodeID}/cpulist
// 示例输出: "0-7,16-23" → [0,1,2,3,4,5,6,7,16,17,18,19,20,21,22,23]
return nil, nil
}
四、NUMA 优化的边界:不要把问题复杂化
NUMA 优化不是包治百病的方案,它有明确的适用和不适用场景:
明确适用:
- 单进程内存占用量接近或超过单 NUMA 节点内存容量(如 InnoDB Buffer Pool 128GB+)
- 内存访问模式具有强局部性(如大数组的顺序扫描)
- 延迟敏感型服务(如广告竞价引擎,p99 延迟要求 < 5ms)
明确不适用:
- 单 NUMA 节点已足够容纳进程的所有热数据——不需要额外的优化
- 无状态的服务(请求间无数据共享),因为每次请求的数据局部性都很弱
- 容器化环境中的 Pod:除非配置了 topology manager 的
single-numa-node策略,否则容器的 NUMA 信息不完整,bind 操作可能失败
反模式:在 64 核主机上为每个 goroutine 单独绑定 NUMA 节点。每个 goroutine 的栈只有 2
8KB,NUMA 内存分配的粒度通常是 4KB 页,单个 goroutine 的热数据远不足以填满一个 cache line。正确的做法是按工作池(worker pool)级别绑定——将 816 个 worker 绑定到同一个 NUMA 节点。
五、总结
NUMA 优化是对硬件拓扑的显式利用。它的核心不是把调度器的事做一遍,而是在操作系统不知道你程序的访问模式时,直接告诉它内存应该分配在哪里、线程应该运行在哪里。
推荐的实践路径:(1) 先用 numastat -p <pid> 诊断是否存在严重的跨节点访问(numa_miss 占比 > 20% 即为需要优化);(2) 对数据库、消息队列等有状态服务使用 numactl --membind 做进程级绑定;(3) 对自研 RPC 框架部署 NUMA 感知的工作池,在编译期通过 Rust 的 #[cfg(target_os = "linux")] 条件编译——非 Linux 平台使用 no-op 的降级实现;(4) 在 K8s 环境中利用 topology manager 和 CPU Manager 的 static policy 为 Guaranteed QoS 的 Pod 提供单 NUMA 节点隔离。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)