结论
三类根因不是并列的,而是串联叠加:khugepaged 合并 / 邻居 IO softirq 抢占 CPU → CFS 把 Java 线程迁走 → 跨 NUMA 访存延迟放大。所以"三种 perf 签名重叠"是必然的——它们共享同一条下游路径(sched latency)。区分的关键不是看下游症状,而是看谁在毛刺窗口内抢了 CPU。
优先级排序:先治 khugepaged(收益最大、风险最小)→ 再治 IO 优先级 → 最后绑核。你直觉的方向是对的。
1. 可区分的观测指标组合
核心思路:perf sched record -g 里看 waker(谁唤醒了被延迟的线程),而不是只看"被唤醒的线程是谁"。
| 根因 | 直接指纹(看增量速率,不是绝对值) | 辅助确认 |
|---|---|---|
| khugepaged | /proc/vmstat 的 pages_collapsed Δ + compact_stall Δ(毛刺窗口内突增) | perf sched 里 waker 是 khugepaged kthread(pid 固定,kthread) |
| 邻居 IO | 该 Pod cgroup v2 的 io.stat write bytes/iops 速率 + /proc/softirqs 的 BLOCK 行 delta | iostat -x 1 的 await 尖峰(仅辅助,设备级无法归因到具体 Pod) |
| CPU 迁移 | perf stat -e context-migrations Δ + 进程级 numastat 的 numa_miss/numa_hit 比率 | perf stat -e LLC-load-misses,cache-misses 在窗口内翻 2× 以上 |
一条命令同时采三类(毛刺窗口前 10s 开始,采 30s,0.5s 间隔):
#!/bin/bash
# 在毛刺高发时段跑,后台持续采样
PID=<下单服务pid>
CGROUP=/sys/fs/cgroup/<pod-cgroup> # cgroup v2 路径
while true; do
ts=$(date +%s.%N)
echo "=== $ts ==="
# THP 指纹
grep -E 'pages_collapsed|compact_stall|compact_fail' /proc/vmstat
# IO 指纹(该 Pod 的 cgroup)
cat "$CGROUP/io.stat" 2>/dev/null
# softirq 指纹
awk '/^BLOCK/{print $0}' /proc/softirqs
# 迁移指纹
numastat -p $PID 2>/dev/null | grep -E 'numa_(hit|miss)'
sleep 0.5
done
同时跑 perf sched record -g -p $PID -- sleep 30,事后 perf sched latency 对齐时间线。如果 waker 列里反复出现 khugepaged(kthread),就是 THP;如果是 ksoftirqd/N 或 blk_* kworker,就是 IO softirq;如果 waker 是 CFS migration(kworker 在另一 CPU 上),就是调度迁移。
关键判据:pages_collapsed Δ 和 sched latency 的时间相关性。 如果每次毛刺前 100~500ms 内 pages_collapsed 都有增量,就是 khugepaged 主导。你的数据(毛刺前后各涨 200~400 页)已经高度指向这一点。
2. 优先级排序与具体操作
第一步:治 khugepaged(风险最小、收益最大)
你已经做了 madvise,还差两步:
# 1a. 禁止缺页路径上的同步 defrag(关键!)
echo 0 > /sys/kernel/mm/transparent_hugepage/khugepaged/defrag
# 1b. 降低扫描频率(默认 10ms 太激进,调到 100ms)
echo 100000 > /sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millis
对 G1 的副作用分析: defrag=0 的含义是"khugepaged 扫描时如果发现候选大页区域但内存不连续,不做同步 compaction,直接跳过"。对 G1 的影响:
- G1 堆在启动时一次性分配 4G 连续虚拟地址空间,运行期不再新分配大区域(只做 region 内的对象分配和 evacuation)。所以
defrag=0只影响"运行期新分配",对 G1 几乎零影响。 - Concurrent Mark 线程是用户态 Java 线程,不触发 THP 分配路径。
defrag=0对它零副作用。 - 反而因为 khugepaged 少干活(跳过 compaction),CPU 竞争减少,Concurrent Mark 线程的调度延迟会下降。
所以放心关 defrag,对 G1 是纯收益。
第二步:IO 优先级
你只限了 wbps(bytes),但 Flink checkpoint 的写模式是大量 4~64KB 小文件(snapshot 分片),iops 密集、bps 不高。只限 bps 挡不住 iops 突发。
# cgroup v2:Flink Pod 同时限 bps 和 iops
echo "801:1 wbps=104857600 wiops=5000" > /sys/fs/cgroup/<flink-pod>/io.max
# 比例优先级(1~10000,类似旧 blkio.weight 的 1~1000)
echo "801:1 weight=1000" > /sys/fs/cgroup/<order-pod>/io.weight
echo "801:1 weight=100" > /sys/fs/cgroup/<flink-pod>/io.weight
但说实话: 如果节点磁盘 %util 已经 92%,io.weight 只是"谁多拿一点",总量还是不够。更彻底的方案是把 Flink Pod 调度到其他节点(nodeAffinity 或 taint),物理隔离比软隔离可靠得多。
第三步:绑核(最后手段)
# 需要节点 cpuManagerPolicy: static
# Pod 的 requests = limits 触发独占核
resources:
requests:
cpu: "4"
limits:
cpu: "4"
共享节点上会独占 4 核、浪费另一半 CPU,只在 1+2 都做了还有毛刺时才上。
3. THP 对 G1 的长期影响(madvise vs never vs always)
G1 默认不使用 -XX:+UseLargePages(那是 ZGC/Shenandoah 的推荐配置),堆内存由 OS 透明管理:
always:khugepaged 激进合并,后台 CPU 开销 + 偶发 compaction stall。对 4G 堆,2MB 页的 TLB 收益远小于 16G+ 堆,性价比低。madvise:只有显式MADV_HUGEPAGE的区域用 THP。JVM 默认不设这个 flag(除非加-XX:+UseTransparentHugePages),所以堆实际用 4KB 页。最安全,推荐。never:和madvise对 G1 效果相同(因为 G1 不 opt-in THP)。
你的 48h 数据(毛刺从 ~4 次/天降到 ~1 次/天)已经验证了方向。剩余的那 1 次/天大概率是 IO 或迁移导致的,不是 THP。
如果后续换 ZGC,再考虑 always(ZGC 的 region-based 设计对大页收益明显)。
4. 操作顺序总结
khugepaged/defrag=0+scan_sleep_millis=100000→ 观察 48h- Flink Pod
io.max加wiops硬限 +io.weight比例调整 → 观察 48h - 如果还有毛刺,把 Flink 调度到其他节点(物理隔离)
- 最后才是绑核
每一步观察 48h,用第 1 节的多指标采样脚本量化"毛刺次数 + 每次持续时长"的变化,不要凭感觉判断。