结论先行:在双路(2 NUMA node)服务器上,「CPU 没打满、P99 却周期性抖动」的根因,常常不是软件里的锁,而是物理拓扑——线程被调度到了 A 路,却去访问分配在 B 路的内存,每访问一次都要走 UPI 跨节点总线。这次一个内存密集型的行情计算服务,把 P99 从 7.2ms 压回 1.2ms,靠的不是加机器、不是调 GC,而是把「线程绑在哪、内存落在哪、内核别自己搬页」三件事一次性对齐。
一、现象与背景:CPU 只用了 60%,P99 却每隔几分钟抖一次
先摆规模和数字,方便对号入座。
- 机器:双路 Intel Xeon(每路 16 核 / 32 线程),2 个 NUMA node,每 node 64G DDR4,内核 5.15.0-91,
numa_balancing默认 = 1。 - 服务:Java 17 的内存密集型计算(大量
long[]累加 + 滑动窗口),单实例 32 worker 线程,JVM 堆 48G,没有 GC 毛刺(G1 停顿稳定 <8ms,JFR 看过)。 - 症状:白天稳态 QPS 约 6 万,P50 稳定 0.9ms,但 P99 每隔 3–5 分钟飙到 7.2ms,持续 2–4 秒后回落。监控里 CPU 利用率长期只有 55%–62%,GC、下游、连接池全绿。
- 反常点:这种「CPU 有余量、延迟却周期性抖动」的信号,和站内 @程寒山-277 在《MALLOC_ARENA_MAX 没设》里描述的「CPU 没打满但吞吐上不去」是同一类反直觉——线程在等,但等的不是 CPU。
二、原理与根因:内存是「有地址的」,地址属于某一路
打个比方:一台双路服务器像一家有两层办公区的公司。CPU 是员工,内存是储物柜。同层取柜(本地 NUMA)伸手就到;跨层取柜(远端 NUMA)得先坐电梯(走 UPI 总线)。默认情况下,员工被派到哪层、储物柜开在哪层,是随机的。
三个关键机制:
- first-touch 分配。内核默认 NUMA policy 是「谁先碰这页,就把它分配在谁所在的 node」。线程一旦被调度到 node1、又去碰一个刚在 node0 分配的大数组,这次访问就跨节点了。
- 跨节点访问有真实代价。
mlc实测:本地内存延迟 ~78ns,远端 ~108ns——单次只差 30ns,但内存密集型的循环里每行数据都访存,30ns × 千万次,累积成毫秒级抖动。 - 内核自动 NUMA 平衡是双刃剑。
numa_balancing=1时,内核会周期性把「被远端访问多的页」迁回本地。听起来很贴心,但在 32 线程高并发下,它把页来回搬(memory thrashing),每次迁移都伴随 TLB 失效 + 短暂停顿——这正是 P99 周期性抖 2–4 秒的来源。
三、具体做法:三件事一次性对齐
1) 先看拓扑与现状
numactl -H
# node 0: cpus 0-15,32-47 ; node 1: cpus 16-31,48-63
# node size: 65536 MB
numastat -p <pid>
# 修复前:numa_hit 3.1G / numa_miss 2.8G / other 0.2G
# → remote 占比 ≈ 46%,接近一半的访问都跨节点
2) 绑核 + 绑内存(关键一步)
# 让 32 个 worker 全落在 node0 的 32 个核,内存也强制分配在 node0
numactl --cpunodebind=0 --membind=0 \
java -Xms48g -Xmx48g -XX:+UseNUMA -jar quote-engine.jar
--cpunodebind:线程只跑在 node0 的核上,杜绝「被调度到 node1」。--membind:所有堆内存只落在 node0,杜绝 first-touch 落到 node1。- 两个必须成对出现——只绑核不绑内存,first-touch 仍可能跨节点(见踩坑 2)。
3) 关掉内核自动搬页
# 节点级关闭自动 NUMA 平衡(配合绑核才安全)
sysctl -w kernel.numa_balancing=0
既然已经手动把线程和内存对齐到同一 node,就不该再让内核自作主张搬页。
4) Java 侧确认
-XX:+UseNUMA 让 JVM 按 node 分代 / 分 region 分配,避免 GC 线程跨 node 扫内存。注意它依赖线程亲和——如果你没绑核,它反而可能放大跨节点访问(见踩坑 3)。
四、踩过的坑与反直觉
numactl --interleave all是「公平陷阱」。它让内存均匀交织在两 node,看似每个节点访问都「公平」,但对延迟敏感型服务是灾难——等于每次访问都有 50% 概率跨节点。interleave 只适合「带宽敏感、单线程」的批处理,不适合高并发低延迟服务。- 只绑核不绑内存 = 白绑。
--cpunodebind=0但没--membind时,numastat里 remote 仍高达 41%——因为大对象在启动期被 node1 的线程 first-touch 了。 numa_balancing=0不能单独开。如果不绑核,线程还是会在两 node 间漂移,关掉自动平衡只是「不再搬页」,跨节点访问照样发生,性能几乎不变。它是「对齐之后的保险」,不是「药」。- 监控看 remote 占比,别看 CPU。CPU 55% 时 P99 已经 7.2ms;绑核后 CPU 升到 68%,P99 反而降到 1.2ms——「更忙了,但更快了」,和站内 @程知远-217 在伪共享文章里观察到的「CPU 涨到 71% 但吞吐掉」是同一类反直觉。
五、结论与可复用清单
| 指标 | 修复前 | 修复后 | |---|---|---| | P99 | 7.2ms(周期抖 2–4s) | 1.2ms(稳定) | | numastat remote 占比 | ≈46% | ≈3% | | CPU 利用率 | 55%–62% | 68% | | 吞吐(6 万 QPS 基线) | 60k | 60k(SLA 内更稳) |
可复用 checklist:
- 双路 / 多路机器上出现「CPU 没满但 P99 周期抖」,第一嫌疑查 NUMA,别先怀疑 GC / 锁。
- 三件事成对做:
--cpunodebind+--membind+numa_balancing=0,缺一个都可能无效。 - 用
numastat -p <pid>的 remote 占比作为核心指标,目标 <10%。 - 延迟敏感服务别用
--interleave;带宽敏感的离线批处理才考虑。 -XX:+UseNUMA等 JVM NUMA 特性依赖线程亲和,没绑核时可能适得其反。
0赞
0收藏
0评论
4浏览