taskset 绑核后 QPS 腰斩?8 核只用了 2 核
场景:8 核 2 NUMA 节点的交易服务,上线后 QPS 从 5000 掉到 2600,RT P99 从 20ms 抖到 300ms——CPU 总使用率才 40%,加机器也没用 路径:top -H 找热点线程 → taskset -pc 看绑定 → numactl -H 看拓扑 → numastat -p 看内存节点 → 绑核范围 + 内存绑定双修
上篇我们聊了 CPU 空闲但 Load 飙高的四种路径,这篇来看一个反过来的坑——CPU 明明够用,QPS 却腰斩。
给 8 核的机器,Java 进程却只用 2 核。不是代码问题,是 taskset 把进程"关"进了笼子。
【症状】QPS 从 5000 掉到 2600
上线后一切正常,第三天开始掉
三天前给核心交易服务升了配:从 4 核升到 8 核,内存从 64G 升到 128G。前两天 QPS 稳定在 5000,一切正常。
第三天上午,值班同事看到 CPU 有 6 个核闲着——"浪费了",为了防止上下文切换和缓存抖动,顺手执行了一条命令:
taskset -c 0-1 -p <pid>
给 Java 进程绑了 CPU 0-1。
当天下午开始,监控曲线不对劲了:
- QPS:从 5000 逐步滑落到 2600
- RT P99:从 20ms 抖到 300ms,时不时冲到 1s
- CPU 总使用率:才 40%
不是断崖式下跌,而是缓慢下滑——绑核让线程挤在 2 核上,RT 爬升 → 请求排队积压 → 客户端超时重试 → 重试又占 CPU → RT 更高,这个雪球滚了一下午,QPS 一点点被拖下来。

CPU 才用一半,QPS 却掉了一半。这不符合直觉。
团队的第一反应和所有人一样:查代码。没有慢 SQL,没有锁竞争,没有 GC 抖动。又加了一台机器——每台 QPS 还是 2600,总量只到 5200:核数翻倍也没换回该有的吞吐。绑核让进程吃不下新核,等于把同一个问题复制给了两台。
加机器和重启都试过了,没用——不是资源不够,是资源被关进了笼子。团队群里已经有人开始怀疑是代码问题了。
真相藏在 top -H 里
top 默认显示的是进程级 CPU。要定位 Java 这种多线程程序,必须先按 H 切到线程视图,再按 P 按 CPU 排序。

top - 15:42:11 up 3 days, 2 users, load average: 2.60, 2.40, 2.20
Tasks: 123 total, 1 running, 122 sleeping, 0 stopped, 0 zombie
%Cpu(s): 22.0 us, 15.0 sy, 0.0 ni, 62.0 id, 0.0 wa, 0.0 hi, 1.0 si, 0.0 st
KiB Mem : 134217728 total, 95217728 free, 9000000 used, 30000000 buff/cache
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
6789 root 20 0 25.5g 4.4g 45m S 197.0 3.4 1:03:38 java
Threads: 89 total, 1 running, 88 sleeping, 0 stopped, 0 zombie
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
6789 root 20 0 25.5g 4.4g 45m R 99.0 3.4 32:15.34 java
6790 root 20 0 25.5g 4.4g 45m R 98.0 3.4 31:02.45 java
6791 root 20 0 25.5g 4.4g 45m S 1.0 3.4 0:08.23 java
6792 root 20 0 25.5g 4.4g 45m S 1.0 3.4 0:06.45 java
6793 root 20 0 25.5g 4.4g 45m S 1.0 3.4 0:04.12 java
6794 root 20 0 25.5g 4.4g 45m S 0.0 3.4 0:02.34 java
6795 root 20 0 25.5g 4.4g 45m S 0.0 3.4 0:01.56 java
%Cpu(s) 显示机器整体才用了 37% 左右(us 22% + sy 15%),但注意最上面进程视图里的 197.0——进程自己已经打满了 2 个核。
线程视图暴露了真凶:两根线程各自占了 99% 和 98%,剩下 5 根线程几乎不干活。机器一半核空着,进程却在 2 根线程上挤到冒烟。
top -H 本身不显示线程跑在哪个核上。8 核机器只看到 2 根线程打满、其余歇着,有两种可能:要么应用并行度本来就低,要么被绑核限制只能跑 2 个核。到底哪种,taskset -pc 下一步直接给出答案。
术语:
top的进程级视图把进程内所有线程的 CPU 加总了。线程视图(top -H)才显示每个线程的真实占用。多线程程序排查第一件事就是切线程视图——否则热点会被平均值掩盖。想看线程实际跑在哪个核,用ps -eLo pid,tid,psr,pcpu | sort -k4 -rn | head(psr 列就是 CPU 编号)。
谁的锅?taskset -pc 一查便知
进程的 CPU 亲和性(affinity——允许进程在哪些核上运行)是排查的第一个问题:
taskset -pc 6789
pid 6789's current affinity list: 0-1
0-1——这个 8 核进程被绑死在了 CPU0 和 CPU1 上。
taskset -pc <pid> 是查看进程 CPU 亲和性的命令,输出 0-1 表示进程只能在核 0 和核 1 上运行。8 核的机器,6 个核在围观。
绑核的同事动机不坏——"为了防止上下文切换和缓存抖动",本意是好的,方向却是错的。而且这解释了为什么前两天没事、第三天下午开始崩:不是升配出了问题,是绑核出了问题。
【诊察】命令组合定位:绑核不是唯一问题
第一步:确认绑定范围
taskset -pc 6789 # 查看当前亲和性
taskset -pc 0-7 6789 # 先扩大范围,观察是否恢复
扩到 0-7 后,QPS 从 2600 回到 4200,但没回到 5000。RT 还是偶尔抖到 100ms+。
说明绑核范围是问题的一部分,但不是全部。
第二步:numactl -H 看 NUMA 拓扑
8 核机器往往不是"8 个平等的核",而是 2 个 NUMA 节点。numactl -H(NUMA 拓扑查询命令,-H 是 --hardware 的简写)一跑就现原形:

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3
node 0 size: 64320 MB
node 0 free: 41000 MB
node 1 cpus: 4 5 6 7
node 1 size: 65536 MB
node 1 free: 50123 MB
node distances:
node 0 1
0: 10 21
1: 21 10
这台机器是 2 个 NUMA 节点: - node0:CPU 0-3,附赠一块本地内存 - node1:CPU 4-7,附赠另一块本地内存 - node distances:同节点访问开销是 10,跨节点是 21——跨节点内存访问比本地慢一倍
术语:NUMA(Non-Uniform Memory Access,非一致性内存访问)架构下,每个 CPU 访问自己节点内的内存快、访问别的节点的内存慢。机器核越多越可能是 NUMA 架构——
numactl -H第一行available: N nodes直接告诉你是不是。
第三步:numastat -p 看内存落在哪个节点
绑核只限制了 CPU 在哪个节点跑,没限制内存分配在哪个节点。numastat -p 6789(查看进程内存的节点分布)揭开了最后一块拼图:

Per-node process memory usage (in MBs) for PID 6789 (java)
Node 0 Node 1 Total
--------------- --------------- ---------------
Huge 0.00 0.00 0.00
Heap 512.12 1890.88 2403.00
Stack 0.05 0.01 0.06
Private 1264.62 811.68 2076.30
---- --------------- --------------- ---------------
Total 1776.79 2702.57 4479.36
进程 4479MB 内存里有 2702MB 落在 node1(占 60%),其中堆(Heap)的 1890MB 基本都在 node1。 而 CPU 被绑在 node0——进程的堆访问大部分要跨节点。
术语:
numastat -p按 Huge/Heap/Stack/Private 四类列出进程内存在每个节点的分布。Node 0/Node 1 两列对应 NUMA 节点。看哪列大,就知道内存偏向哪个节点。
判断标准:进程内存在非绑核节点的比例接近或达到一半以上(本例 60% 在 node1,而 CPU 全在 node0)就是严重跨节点——正常情况内存应主要落在 CPU 运行的节点。这个数字解释了为什么刚才扩核后 QPS 只回到 4200 没到 5000:线程能跑了,但近六成堆访问仍要跨节点。
为什么内核没自动修好? 这台机器跑的是内核 3.10——自动 NUMA balancing(
numa_balancing)在 3.8 引入、3.13 起默认开启,3.10 默认关闭(cat /proc/sys/kernel/numa_balancing输出 0)。所以跨节点状态持续了三天没人来搬。现代内核(3.13+)默认开启会自动迁移,但手动绑核仍会干扰这个机制。
命令对照表
两条命令的输出互相印证:
| 命令 | 发现 | 结论 |
|---|---|---|
taskset -pc 6789 |
affinity 只有 0-1 | 进程被绑死 2 核,6 核围观 |
numactl -H |
2 节点,跨节点距离 21 | 内存与 CPU 分属两个 node |
numastat -p 6789 |
node1 占 60%(2702MB) | 大部分内存在绑核节点之外 |
三条线索指向同一个真相:绑核只绑了 CPU,没绑内存,还绑窄了。
【路径】下次遇到直接跑
四命令排查组合
看到"CPU 总使用率不高但 QPS 掉、RT 抖"时,按这个顺序排查:
# 1. 线程视图找热点(多线程程序第一步)
top -H -p <pid> # 按 H 切线程视图,按 P 按 CPU 排序
# 期望看到:少数线程各占 90%+,热点高度集中
# 想看线程实际跑在哪个核:ps -eLo pid,tid,psr,pcpu | sort -k4 -rn | head
# 2. 查 CPU 亲和性
taskset -pc <pid> # 输出 0-1 → 被绑死 2 核
grep Cpus_allowed_list /proc/<pid>/status # Cpus_allowed_list: 0-1 → 与 taskset 一致,确认绑窄
# 3. 查 NUMA 拓扑
numactl -H # available: N nodes → 是不是 NUMA 架构
lscpu | grep NUMA # 另一种方式看节点信息
# 4. 查进程内存的节点分布
numastat -p <pid> # node1 占比高而 CPU 在 node0 → 内存在对面的 node
cat /proc/<pid>/numa_maps | head -20 # 逐段看每块内存落在哪个节点
以本文的 PID 6789 为例,第 2/4 步的真实输出:
# 第 2 步:亲和性
$ taskset -pc 6789
pid 6789's current affinity list: 0-1
# 第 4 步:内存节点分布
$ numastat -p 6789
Node 0 Node 1 Total
Heap 512.12 1890.88 2403.00
Private 1264.62 811.68 2076.30
Total 1776.79 2702.57 4479.36
判断规则:
| 看到 | 说明 | 下一步 |
|---|---|---|
| 热点线程挤在少数核 | CPU 亲和性过窄 | taskset -pc 查绑定 |
| affinity 只有 2-3 个核 | 绑核范围太小 | 扩到整个 node 或解绑 |
numactl -H 显示 2+ 节点 |
是 NUMA 架构 | 检查内存是否跨节点 |
numastat 内存在非绑核节点占 ≈50% |
CPU 与内存跨节点 | 同时绑内存或改分配策略 |
经验之谈:这四条命令跑完不超过 30 秒,从"CPU 不高但 QPS 掉"到"绑核绑窄了 + 内存跨节点"——问题定位 1 分钟。跨节点严重度的判读标准见上文诊断段。
【定位】绑核是怎么把 Java 拖垮的?
三层危害,一层比一层隐蔽
你以为 taskset -c 0-1 只是"少用了几核"?不是。它对 Java 是三重打击:
第一层:线程全部挤在 2 个核上排队。
进程的几十个线程只能在 CPU0/CPU1 上运行。核就两个,线程一堆——CPU0/CPU1 打满到 99%,剩下 6 个核围观。不是 CPU 不够,是线程被塞进了笼子。
第二层:绑核的时机,决定了 JVM 会不会"自废武功"。
绑核发生在运行期(对已启动的进程执行 taskset -p),此时 JVM 已经按 8 核配置好了——GC 线程 8 个、ForkJoinPool 并行度 7,都照常工作。所以本场景里,JVM 自身的并行度没有受影响。
但如果你是在启动时绑核(taskset -c 0-1 java -jar),情况完全不同:JVM 启动时通过 sched_getaffinity() 探测可用 CPU 数,taskset 绑定后它读到的就是 2——于是 GC 线程、ForkJoinPool 并行度、并行编译等自适应参数全部按 2 核收缩,整个运行时并行能力降级。
术语:
sched_getaffinity()是 Linux 获取进程 CPU 亲和性的系统调用。JVM 的Runtime.availableProcessors()在 Linux 上就基于它——所以启动时用 taskset 绑核,会直接改变 JVM 感知的 CPU 数量。运行期绑核则不影响已完成的探测。
第三层:绑核不绑内存 → 跨节点访问惩罚。
Linux 的内存分配默认是 first-touch 策略——内存页落在第一个访问它的 CPU 所在节点。JVM 启动时没绑核,堆被各个启动线程触碰,按 first-touch 落在触碰它的 CPU 所在节点——本例大部分堆落到了 node1。
后来运行期把 CPU 收窄到 node0,但堆还躺在 node1 上——taskset 只绑 CPU 不绑内存。numastat 显示进程 60% 内存在 node1,这意味着近六成内存访问都要跨节点,延迟翻倍(距离 21 vs 10)。这正是刚才扩核后 QPS 没回到 5000 的原因。

内核在做什么?
这不是应用代码的问题——是内核调度器与内存分配策略共同造成的结果:
- 内核调度器严格遵循 CPU 亲和性掩码(
sched_setaffinity设置的 bitmask),即使其他核空闲,也绝不把线程调度到亲和性之外的核上。这是硬约束,不是"尽量"。 - 内存分配器按 first-touch 原则把页放到第一个触达它的 CPU 的节点上,与进程当前的 CPU 亲和性无关——所以运行期收窄绑核,堆仍然留在启动时分布的节点上。
- JVM 运行时在启动时根据
sched_getaffinity()的返回值决定并行度。运行期收窄绑核不影响已完成的探测,但启动时就绑核,JVM 会把整台机器当成少数核来配置。
三个机制独立运作、互不知情——组合起来就是"CPU 够用、内存够用、就是慢"。
金句:"绑核不是给进程配 CPU,是在给内核下命令——下错了,它连你剩下的核和内存都不让你碰。"
【处方】绑核的三条正确姿势
姿势一:解绑,让内核自己调度(推荐)
大部分业务场景,Linux 的调度器做得比人好。把绑核解除,内核自然把线程铺到所有核上:
# 解绑 = 把 affinity 设为全部核(0-7 按实际核数调整,进程级即时生效)
taskset -pc 0-7 <pid>
姿势二:真的要绑,CPU 和内存一起绑
如果确实需要绑核(比如隔离性能敏感的进程),必须同时指定 CPU 节点和内存节点,用 numactl 而不是 taskset:
# 启动时绑定:CPU 和内存都绑到 node0(推荐做法)
# node0 的核是 0-3,绑定就覆盖整个 node,不要只绑 2 个核
numactl --cpunodebind=0 --membind=0 java -jar app.jar
numactl --cpunodebind 绑定 CPU 节点,--membind 绑定内存节点——CPU 在哪、内存就跟到哪。注意它绑定的是整个 node0(CPU 0-3),不是某个具体核,这正是正确的"按节点绑"。
姿势三:确认修改生效
# 验证 1:亲和性已扩大
taskset -pc <pid>
# 期望:0-7(覆盖全部核)
# 验证 2:内存分布趋于本地
numastat -p <pid>
# 期望:node1 占比显著下降,内存集中在 CPU 所在节点
# 验证 3:RT 回到基线
curl -w '%{time_total}\n' -o /dev/null -s http://localhost:8080/api/trade
# 期望:回到基线附近(基线 P99 20ms,单次 22ms 属正常波动)
修复后实测输出(姿势一解绑 + 重启进程后):
# 验证 1:亲和性已扩大
$ taskset -pc 6789
pid 6789's current affinity list: 0-7
# 验证 2:内存已本地化
$ numastat -p 6789
Node 0 Node 1 Total
Huge 0.00 0.00 0.00
Heap 2182.00 221.00 2403.00
Stack 0.06 0.00 0.06
Private 1794.24 282.06 2076.30
Total 3976.30 503.06 4479.36
# node1 占比从 60% 降到 11%——跨节点访问几乎清零
# 验证 3:RT
$ curl -w '%{time_total}\n' -o /dev/null -s http://localhost:8080/api/trade
0.022
# 单请求 22ms,P99 回到基线附近
注意:
taskset只改 CPU 亲和性,不会搬动已经分配的内存页。上面 node1 占比从 60% 降到 11%,来自两件事:
- 重启进程(最干净)——新进程按 first-touch 从"第一个访问页的 CPU"所在节点分配内存,一次到位。本例采用此方式。为什么重启后内存集中在 node0 而不是像首次启动那样分散?因为重启命令由 node0 上的 shell 发起,JVM 初始化线程恰好跑在 node0 的核上,堆页就按 first-touch 落到了 node0。首次启动时负载高、线程铺开,才会分散两节点。
- numa_balancing 自动迁移——仅当内核开启了
numa_balancing(3.13+ 默认开启)时,亲和性放宽后它会在数分钟内把高频访问的页迁移回 CPU 节点。本机 3.10 默认关闭,此机制不生效。

生产环境注意事项
| 场景 | 正确做法 |
|---|---|
| 大部分业务进程 | 不要手动绑核,让内核调度 |
| 需要隔离的进程 | numactl --cpunodebind=X --membind=X 一起绑 |
| 容器环境 | 检查 cgroup cpuset(/sys/fs/cgroup/cpuset.cpus),容器内 taskset 可能读到宿主机的核 |
| 想改内存分配策略 | numactl --interleave=all 轮询分配,或调整 numa_balancing(内核 3.13+ 默认开启自动均衡) |
最关键的一条:如果内核 numa_balancing 开启(现代内核默认开启),它会自动尝试把线程和内存往同节点迁移。你手动绑了核反而可能干扰这个自动机制——手动绑核之前,先问自己:内核的自动均衡做得比你好吗?
修复后 QPS 回到 4800,RT P99 稳定在 22ms 附近(基线 20ms)。回不到 5000 属正常——接近基线即达标。整个排查 40 分钟,其中确认根因 2 分钟。
内核版本说明:
sched_getaffinity自 Linux 2.6 起存在,所有现代内核一致。numactl依赖 numactl 包(CentOS:yum install numactl,Ubuntu:apt install numactl)。自动 NUMA balancing 在内核 3.8 引入、3.13 起默认开启(用/proc/sys/kernel/numa_balancing查看当前值)。cgroup v1 的 cpuset 路径为/sys/fs/cgroup/cpuset/,v2 为/sys/fs/cgroup/cpuset.cpus,两者路径不同。
🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:Ai拆代码的曹操
下篇我们聊 NUMA 架构下跨节点内存访问延迟偏高——为什么同一台机器,换个进程跑就快了 50%。