同样的 Java 进程,RT 差一倍?NUMA 内存落错节点

场景:同一台 2-NUMA 节点机器跑两个完全一样的 Java 实例,负载均衡 1:1 分发,A 的 RT P99 25ms,B 却 50ms 抖到 300ms——重启、换端口都没用 路径:numactl -H 看拓扑 → numastat -p A/B 对比内存落点 → /proc//numa_maps 逐段确认 → first-touch 原理定位 → 重启 / CPU+内存双绑修复

上篇我们聊了 taskset 绑核把进程"关"进笼子,这篇来看一个更隐蔽的坑——没人绑核,CPU 也不忙,两个代码一模一样的进程,一个快一个慢

同一个 jar、同一份配置、同一台 2-socket 服务器,A 的 RT P99 稳定 25ms,B 却 50ms 抖到 300ms。CPU 不忙、IO 不忙、没有慢 SQL,也没有人绑过核。

但 numastat -p 一跑,A 和 B 的内存落点南辕北辙——问题不在代码,在内存落在了哪个节点


【症状】两个一样的实例,RT 差一倍

完全相同的部署,表现截然不同

给支付网关部署了双实例做负载均衡:A 和 B,同一个 jar、同一份配置、同一批启动参数,跑在同一台 2-socket 服务器上。负载均衡按 1:1 把流量分给两个实例。

第一天就发现不对劲:

  • 实例 A:RT P99 稳定在 25ms,偶尔 30ms
  • 实例 B:RT P99 直接 50ms,高峰期抖到 300ms,时不时有请求超时重试

实例 A/B RT 监控对比——A 稳定 25ms,B 从 50ms 抖到 300ms

流量是一半一半分的,代码完全一样,为什么 B 就慢一倍?

重启、换端口、清缓存,全都没用

团队按老办法轮了一遍:

  • 重启 B:重启后前几分钟恢复正常,跑起来又慢回去
  • 换端口/换负载策略:B 还是慢
  • 清页面缓存sync && echo 3 > /proc/sys/vm/drop_caches):无效
  • 加机器:新加的实例 C 正常,和 A 一样快

最后只剩一招:把 B 整个迁到另一台服务器,立刻恢复正常

问题跟"这台机器 + 这个进程"绑定,跟代码无关。这时候有人嘀咕了一句:"是不是内存访问慢?"——还真猜对了方向。

术语:NUMA(Non-Uniform Memory Access,非一致性内存访问)——多 socket 服务器上,每个 CPU 访问"自己节点"的内存快,访问别的节点的内存慢。核数越多越可能是 NUMA 架构,用 numactl -H 第一行确认。


【诊察】命令组合定位:内存落点对比

第一步:排除 CPU 和 IO

先用 top 看资源分布(us——用户态 CPU,sy——内核态 CPU,wa——等待 IO 的 CPU):

top - 14:02:33 up 21 days,  3 users,  load average: 1.20, 1.10, 1.05
%Cpu(s): 18.0 us,  6.0 sy,  0.0 ni, 75.0 id,  0.5 wa,  0.0 hi,  0.5 si,  0.0 st

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 1024 root      20   0 8.6g 4.0g  45m S 35.0   3.1  12:03:21 java
 2048 root      20   0 8.6g 4.0g  45m S 35.0   3.1  12:03:20 java

两个进程 CPU 占用都在 35% 左右(%CPU 是单核百分比,表示各占约三分之一个核),机器整体才用了 24%(%Cpu(s) 的 us 18% + sy 6% 是全核平均口径),wa 只有 0.5%——CPU 不忙、IO 不忙。两个实例 CPU 占用还几乎一样,排除 CPU/IO 瓶颈。

术语wa(wait IO)——CPU 等待磁盘 IO 完成的时间占比。wa > 30% 说明 IO 已明显影响 CPU 利用率,wa > 80% 接近磁盘饱和;本案例 wa 0.5% 说明磁盘不是瓶颈。

第二步:numactl -H 确认 NUMA 拓扑

numactl -H(NUMA 拓扑查询命令,-H 是 --hardware 的简写)看这台机器的内存布局:

numactl -H 输出——2 个 NUMA 节点,node0 有 CPU0-7 和内存,node1 有 CPU8-15 和内存,跨节点距离 21

2 个 NUMA 节点,各带一块本地内存。node distances:同节点访问开销 10,跨节点 21——跨节点内存访问比本地慢一倍

第三步:numastat -p 对比两个进程的内存落点

关键一步——用 numastat -p(查看进程内存在每个节点的分布)分别看 A 和 B,两张表一对比,真相呼之欲出:

numastat -p A/B 对比——A 的 97% 内存在 node0,B 的 97% 在 node1,落点南辕北辙

  • A 的内存:3986MB 里有 3885MB(97%)落在 node0
  • B 的内存:3986MB 里有 3886MB(97%)落在 node1

术语numastat -p 按 Huge/Heap/Stack/Private 四类列出进程内存在每个节点的分布。哪列数字大,内存就偏向哪个节点。注意 Java 进程的堆(-Xmx 分配的大内存)走 mmap 匿名映射,计入 Private 行——所以判断 Java 内存落点看 Private 和 Total 两行即可,Heap 行(brk 段)通常很小。判断标准:内存在一个节点的占比超过 50%,就说明"内存有归属"了——关键看它和 CPU 所在节点是否一致。

B 的匿名堆(Private 3700MB)几乎全在 node1,A 的堆也几乎全在 node0。调度器把 B 的线程均衡分配到 node0/node1 的核上——跑在 node0 上的那半线程,每次堆访问都要跨节点,延迟翻倍。

第四步:/proc//numa_maps 逐段确认

numastat -p 给了总量,/proc/<pid>/numa_maps(内核暴露的进程内存到物理页的映射)能逐段看每块内存落在哪个节点——Java 的匿名堆对应 anon 段:

$ grep anon /proc/2048/numa_maps | head -5

7f3c00000000 default anon=947200 dirty=947200 N0=22528 N1=924672 kernelpagesize_kB=4

Java 主堆是单一连续匿名段,所以只有这一行。

N1=924672 表示匿名段约 3612MB(924672 页 × 4KB)落在 node1,N0=22528 约 88MB 在 node0——B 的整个堆躺在 node1anon/dirty 是匿名页与脏页总数(单位是页),kernelpagesize_kB=4 说明页大小 4KB。注意 numa_maps 的 N<n> 编号直接对应节点号:N0 就是 node0、N1 就是 node1,本机只有 2 个节点,所以只会出现 N0/N1。

命令对照表

三条命令的输出互相印证,指向同一个结论:

命令 发现 结论
top us 18%、wa 0.5%、两进程 CPU 相当 排除 CPU/IO 瓶颈
numactl -H 2 节点,跨节点距离 21 内存与 CPU 存在跨节点访问代价
numastat -p A vs B A 内存 97% 在 node0,B 内存 97% 在 node1 B 的内存和 CPU 不在一个节点
/proc/2048/numa_maps 匿名堆段 3612MB 落在 node1 B 的堆访问全部跨节点

四组数据指向同一个真相:B 的堆被分配在了 node1,而调度器会把它一半的线程放到 node0 的核上——跑在 node0 上的那半线程,每次堆访问都要跨节点,延迟翻倍,慢一倍。


【路径】下次遇到直接跑

三命令排查组合

看到"两个一样进程,一个慢一倍 / CPU 内存都不忙"时,按这个顺序排查:

# 1. 确认 NUMA 拓扑(机器有没有节点之分)
numactl -H
# 期望:available: 2 nodes (0-1),node distances 对角线 10、跨节点 21

# 2. 对比多实例的内存落点(快慢各看一个)
numastat -p <快进程PID>
numastat -p <慢进程PID>
# 期望:慢进程的 Heap/Private 大列与快进程相反 → 内存落点不同

# 3. 逐段确认匿名堆的物理落点
grep anon /proc/<慢进程PID>/numa_maps
# 期望:N1=924672 大数字 → 匿名段落在某一个 node(N<n> 对应 node<n>)

NUMA 跨节点排查命令组合——拓扑确认 + 快慢对比 + 匿名段落点

判断规则:

看到 说明 下一步
numactl -H 只有 1 个 node 非 NUMA,此路不通 换方向查
node distances 10/21 NUMA 架构,跨节点有 2 倍延迟代价 对比多实例内存落点
慢进程内存在某 node 占比 >90% 内存"住"在了别处 查线程跑在哪个 node
numa_maps 匿名段 N1 大数字 堆跨节点,确认根因 修复(见处方)

经验之谈:三条命令跑完不到 1 分钟。核心心法不是看单个命令,而是快慢各看一次,对比落点——单看 B 看不出问题,A 和 B 一对比就现形。


【定位】first-touch:内存落点是谁决定的?

内存不是"共享"的,是"先到先得"的

NUMA 机器上,内核分配物理内存时遵循 first-touch 策略——每个内存页落在第一个访问它的 CPU 所在节点

新进程启动、JVM 分配堆时,最先触达这些页的线程跑在哪个 node 的核上,堆页就落在哪个 node。这解释了全部现象:

  • A 启动时:初始化线程恰好跑在 node0 的核上 → 堆按 first-touch 落在 node0
  • B 启动时:初始化线程恰好跑在 node1 的核上 → 堆按 first-touch 落在 node1
  • 之后:负载均衡把流量均分,B 的线程被内核调度到 node0/node1 各一半的核上 → 跑在 node0 上的线程访问堆(在 node1)→ 每次跨节点

术语:first-touch(首次触达分配)——物理页在第一次被访问时,分配在访问它的 CPU 所在节点的本地内存上。这是内核的默认分配策略,不是"均衡分配"。同一个进程,第一次碰它的人决定了它的"出生地"。

这意味着 first-touch 是个"看运气"的机制——谁先碰页,内存就住谁家。你的进程会不会中招,取决于启动那一刻初始化线程恰好跑在哪个核上:负载均衡、前一个进程残留的 CPU 亲和、甚至部署脚本的执行顺序,都会影响这个"恰好"。所以别赌运气,直接看本文处方的双绑。

回看案例,B 的"重启后短暂正常又慢回去"正是这个随机性的最好证据:重启时 first-touch 让堆短暂落在 node0,但负载均衡压上来后,调度器把一半线程调度到 node1 的核,配合内存回收和新的堆分配,落点又漂回 node1。

跨节点访问为什么慢一倍?

node distances 里的 21 vs 10 不是抽象数字,对应真实的硬件路径:

  • 本地访问(distance 10):CPU → 自己 socket 上的内存控制器(IMC)→ 本地 DRAM,一条直路
  • 跨节点访问(distance 21):CPU → 自己 socket 的 IMC → 跨 socket 互联总线(QPI/UPI) → 对面 socket 的 IMC → 对面 DRAM,多走一段物理链路

NUMA 跨节点访问路径——本地访问直连 IMC,跨节点要过 QPI 总线,延迟翻倍,LLC 不共享

跨节点访问慢在三个地方:总线往返时延(QPI/UPI 链路本身有延迟)、带宽争抢(两个 socket 同时访问对方内存时互相挤占)、缓存不共享(每个 CPU 的 LLC 末级缓存只缓存本地内存,跨节点数据每次都要去对面读,缓存命中率骤降)。

对 Java 这种堆访问极其频繁的程序,堆在对面节点等于每次 new、每次引用读写都付过路费——P99 翻倍一点都不奇怪。

金句:"NUMA 的世界里,内存不是公共图书馆,是每个 CPU 自己的书房——跨节点访问,就是大半夜穿过走廊去邻居家借书,借一次,慢一次。"

内核为什么没自动修好?

现代内核(3.13+)默认开启自动 NUMA balancing(numa_balancing),会周期性扫描、把高频访问的页迁移回访问它的 CPU 所在节点。它没修好 B,有两个常见原因:

  1. 内核版本过旧:自动 NUMA balancing 在 3.8 引入(默认关闭的实验版)、3.13 起默认开启。低版本没有或默认关闭,cat /proc/sys/kernel/numa_balancing 输出 0
  2. 迁移成本高/不触发:自动迁移不是无条件的——迁移需要留出内存和 CPU 开销,且只迁移"值得"的热页;如果节点内存紧张或页访问模式分散,内核会认为迁移不划算

术语numa_balancing(自动内存均衡)——内核后台任务定期扫描页的访问频率,把高访问页从远端迁移到访问它的 CPU 所在节点。默认开启(3.13+),可通过 /proc/sys/kernel/numa_balancing 查看(1 开启 / 0 关闭)。

这是系统层(内存分配策略)的问题,不是应用层的问题——代码、配置、JVM 参数全都一样,差的是内存页的物理落点。

金句:"同样的代码,不同的命运——进程快慢不只看代码,还看内存落在了哪个节点。"


【处方】让内存跟 CPU 住到同一个节点

方案一:重启,重新 first-touch(不保证,但最便宜)

重启进程让 first-touch 重新决定落点。但落点取决于重启时初始化线程跑在哪个核——不保证一定落到 CPU 当前节点:

# 先确认当前负载低的 node 作为目标,再把进程拉起
numactl --cpunodebind=0 java -jar app.jar &   # 见方案二,用双绑最稳

注意:上面这个命令只是示例——单绑 CPU 不解决内存落点问题(first-touch 仍可能把堆落在别处)。要真正修复,直接跳到方案二用 --cpunodebind + --membind 双绑。

单纯 kill 后重启可能又落在原节点,所以实践中重启要配合方案二的双绑才有把握。

方案二:CPU 和内存一起绑(推荐)

numactl 同时指定 CPU 节点和内存节点,让线程和堆从一开始就住在同一个 node:

# 启动时绑定:CPU 和内存都绑到 node0
numactl --cpunodebind=0 --membind=0 java -jar app.jar

# 验证:进程级确认绑定生效(numactl --show 只能看当前 shell,验证目标进程要用下面两种)
taskset -pc <新PID>                        # 期望:0-7(node0 的全部核)
cat /proc/<新PID>/status | grep -i mems_allowed_list   # 期望:mems_allowed_list: 0

--cpunodebind=0 绑 CPU 节点,--membind=0 绑内存节点——CPU 在哪、内存跟到哪。这是对付 NUMA 跨节点最干净的办法。注意它绑的是整个 node(CPU 0-7),不是单个核。

方案三:开启自动均衡(让内核自己搬)

如果不想绑核、想让内核自动迁移,确认 numa_balancing 已开启:

# 查看(1 开启 / 0 关闭)
$ cat /proc/sys/kernel/numa_balancing
1

# 临时开启
echo 1 > /proc/sys/kernel/numa_balancing

# 持久化(重启后仍生效)
echo 'kernel.numa_balancing=1' >> /etc/sysctl.conf && sysctl -p

输出 1 表示自动内存均衡已开启——本案例内核 3.13+ 默认就是开启的,B 仍然慢,说明迁移要么未触发、要么迁移成本高。而且它生效慢(数分钟到数十分钟)、只迁移"热页"、迁移本身还要消耗 CPU 和内存——适合不能重启进程的场景应急,生产上别指望它兜底,直接用方案二更可靠。

修复验证

修复后(方案二:--cpunodebind=0 --membind=0 重启 B,PID 以重启后的实际值为准,此处沿用 2048 作示例),再跑一遍命令组合——node1 占比从 97% 降到 3%,堆几乎全部回到 node0;实测 RT 从 50ms 回到 24ms,和实例 A 的 25ms 基本对齐:

修复命令与验证输出——numactl 双绑 + numastat 确认内存本地化 + curl 实测 RT

生产环境注意事项

场景 正确做法
多实例部署在同一台 NUMA 机器 各实例 --cpunodebind=X --membind=X 绑定不同 node,天然分散
单实例 + 不想绑核 开启 numa_balancing(3.13+ 默认开),让内核自动均衡
低版本内核(<3.13) 没有自动均衡,必须手动双绑或考虑升级内核
容器环境 检查容器 cgroup 内的 cpuset.cpus(v1: /sys/fs/cgroup/cpuset/<容器>/cpuset.cpus,v2: /sys/fs/cgroup/<容器>/cpuset.cpus);容器内 numactl --show 反映的是 cpuset 限制下的有效 node,而非宿主机全部 node
判断是否为 NUMA lscpu | grep NUMA——有输出即 NUMA 架构

修复后 B 的 RT P99 回到 24ms,与 A 的 25ms 基本对齐,负载均衡恢复正常分流。

这次排查留给你的,是三句话:

  1. NUMA 机器上,内存不是"共享"的,是有"归属"的——first-touch 决定每块页住哪个节点
  2. 进程快慢不只看代码,还看内存落点——同样的代码,落点不同,命运不同
  3. 唯一确定性的解法是双绑——--cpunodebind=X --membind=X,让 CPU 和内存从一开始就住在一起,别赌 first-touch 的运气

整个排查 30 分钟,确认根因 5 分钟——快慢各看一次 numastat -p,对比落点,问题 30 秒现形。

内核版本说明numactl 依赖 numactl 包(CentOS: yum install numactl,Ubuntu: apt install numactl)。自动 NUMA balancing 在 3.8 引入(默认关闭的实验版)、3.13 起默认开启;kernel.numa_balancing sysctl 自 3.8 存在,3.13 后默认值为 1,通过 /proc/sys/kernel/numa_balancing 查看(1 开 / 0 关)。/proc/<pid>/numa_maps 自 Linux 2.6.14 存在,所有现代内核一致。容器内 cgroup 的 cpuset.cpus 路径:v1 为 /sys/fs/cgroup/cpuset/<容器>/cpuset.cpus,v2 为 /sys/fs/cgroup/<容器>/cpuset.cpus


🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:Ai拆代码的曹操

下篇我们聊 CPU 节流(throttling)导致容器内 Java 性能不稳定——CPU 配额 2 核,实际只用到 1.5 核,接口却频繁超时。