Linux内核内存规整(Memory Compaction)引发的微服务P99毛刺排查剖析

凌晨两点半,机房的白噪音在此刻显得格外清晰,刚切断了视频会议,端起已经冷掉的浓茶。
过去这四个小时,我一直在追踪一个幽灵般的生产问题。核心链路上的一个高频Redis集群,每隔几小时就会出现一次毫无规律的P99延迟毛刺。正常的响应时间在2ms以内,但毛刺发生时,延迟会瞬间飙升到300ms甚至500ms。业务侧告警如暴雨般涌入,但当你去查监控大盘时,网络带宽没跑满,IOPS在水位线下,CPU使用率平均不到40%,DBA查了Redis的Slowlog,干干净净。
这种现象,新手通常会归咎于“网络抖动”然后草草结案。但在我的经验里,一切无法复现的偶发性系统停顿(Stall),大概率都藏在内核的内存管理子系统或者调度器里。

现场捕获:跳出指标监控的盲区

Prometheus这种秒级采集频率的监控,对于毫秒级的毛刺是无能为力的。毛刺发生的时间窗口极短,平均值会将这些尖刺完全抹平。
我直接挂载了eBPF工具集。既然Redis的请求被拖慢了,那必然是Redis的工作线程在某个地方被挂起(Block)了。排查这类问题,offcputime 是最直接的手术刀,它能精准剖析线程不在CPU上运行的时长和内核调用栈。
我写了一个简单的BCC脚本挂在宿主机上,专门抓取Redis进程离开CPU超过50ms的堆栈:

/usr/share/bcc/tools/offcputime -p $(pgrep -f redis-server) -K -m 50

蹲守了将近一个小时,屏幕上终于吐出了一段让我豁然开朗的内核栈:

    __schedule
    schedule
    schedule_timeout
    wait_iff_congested
    shrink_inactive_list
    shrink_lruvec
    shrink_node
    do_try_to_free_pages
    try_to_free_pages
    __alloc_pages_slowpath
    __alloc_pages_nodemask
    alloc_pages_vma
    do_huge_pmd_wp_page
    __handle_mm_fault
    handle_mm_fault
    __do_page_fault
    do_page_fault
    page_fault
    -                redis-server (pid 18233)
        312.450 ms

堆栈清晰地表明,Redis单线程在处理请求时,触发了缺页中断(page_fault),进而进入了 __alloc_pages_slowpath(内存分配慢速路径),最终被阻塞在了 try_to_free_pages(直接内存回收)上,整整卡顿了312毫秒。

深度剖析:THP与直接内存回收的绞杀

为什么一个内存充足(Free + Cache 还有几十GB)的宿主机,会触发直接内存回收(Direct Reclaim)?
这就不得不提 Linux 的 Transparent Huge Pages (THP, 透明大页) 机制。为了减少 TLB Miss,内核默认尝试为匿名内存分配 2MB 的大页,而不是标准的 4KB 小页。
在这个场景中,Redis 在进行 BGSAVE 或者大规模写入时,触发了写时复制(COW)或新的内存分配,内核的 do_huge_pmd_wp_page 尝试分配一个 2MB 的连续物理内存页。
但由于这台 Kubernetes Node 已经连续运行了半年,容器频繁的创建销毁,加上大量的文件读写,导致物理内存极度碎片化。系统里虽然有几十GB的空闲内存,但找不到连续的 2MB 物理页框
此时,内核的内存分配策略由 /sys/kernel/mm/transparent_hugepage/defrag 决定。如果是 always(某些老发行版的默认值),内核为了凑齐这 2MB,会毫不犹豫地挂起当前的用户态线程,同步执行直接内存规整(Direct Compaction)直接内存回收(Direct Reclaim)
内核就像一个在杂乱无章的仓库里整理出完整货架的搬运工,需要不断地迁移现有的活动页面(Page Migration),驱逐文件缓存(Page Cache)。而在这个漫长的过程中,Redis 的主线程只能绝望地等待(wait_iff_congested),导致这期间到达的所有网络请求全部超时。

破局与根治:重塑内存分配规则

查明了机制,解决起来不过是几行配置的事。但作为运维架构,不能头痛医头。
第一步:线上止血,禁用THP与规整
对于 Redis、数据库这类极其注重长尾延迟的中间件,THP 带来的 TLB 性能提升,远不足以抵消它引发内存规整造成的灾难性毛刺。
直接在宿主机层面关闭 THP,并将碎片整理策略改为 madvise,仅允许明确声明需要大页的进程使用:

# 立即生效
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo madvise > /sys/kernel/mm/transparent_hugepage/defrag
# 持久化到 GRUB_CMDLINE_LINUX
# transparent_hugepage=never

第二步:调整水位线,防御突发内存分配
即便关了 THP,常规的小页分配在极端情况下依然可能触发 Direct Reclaim。内核中 kswapd(异步回收线程)的唤醒阈值由 vm.min_free_kbytes 控制。如果这个值太小,突发的大量内存申请会瞬间击穿水位线,导致 kswapd 来不及回收,应用线程被迫下场同步回收。
我检查了这台 256GB 内存的宿主机,min_free_kbytes 竟然是默认的 90MB。这极其不合理。我将其调整至系统总内存的 1%~2%(约 3GB),给 kswapd 留出足够的异步作业缓冲带。

sysctl -w vm.min_free_kbytes=3145728
# 同时优化 watermark_scale_factor,让 kswapd 尽早介入
sysctl -w vm.watermark_scale_factor=50

第三步:K8s QoS 与 Cgroup 限制
在 K8s 环境下,Pod 级别的 Memory Limit 只能限制 Cgroup 内的内存使用,但内核的页面碎片化是全局(Node-level)的。这意味着,即使你的 Redis Pod 内存远未达到 Limit,同宿主机的其他日志采集组件(如 Filebeat/Fluentd)刷 Page Cache 造成的碎片,依然会通过底层系统调用拖死你的 Redis。
因此,对于核心 DB 类 Pod,必须使用 Guaranteed 的 QoS 类别,绑定独占的 NUMA 节点,同时在 kubelet 配置中开启 --topology-manager-policy=single-numa-node,从硬件拓扑层面隔离内存访问延迟。
问题解决,图表上的P99线条重新变成了一条平滑的直线。
排查系统底层的偶发性问题,从来就没有什么玄学。它靠的不是盲目的重启和扩容,而是对 Kernel 源码机制的敬畏,以及能否在枯燥的十六进制调用栈中,敏锐地抓住那个正在阻塞业务的自旋锁。
合上电脑,天快亮了。