☰
NUMA 亲和性与内存带宽优化——双路服务器的就近取数策略
2026/10/22 11:45:15 网站建设 项目流程

NUMA 亲和性与内存带宽优化——双路服务器的就近取数策略

一、当 CPU 离数据 200ns 远:双路服务器的隐性延迟税

在多路服务器架构中,每个 CPU 插槽都有自己本地连接的 DIMM 内存通道。访问本地内存的延迟通常在 80100ns,而跨 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 的栈只有 28KB,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 节点隔离。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询