那天下午我盯着监控面板,脑子里全是问号。平均响应时间2毫秒,P99却每隔几分钟就冒一个400毫秒的尖峰,MySQL的慢查询日志里一条记录都没有,CPU、磁盘、网络全都正常。第一反应是宿主机在抢资源,第二反应是邻居的机器在偷跑带宽,两个都查不出问题。后来我把这台机器重启了一遍,尖峰消失了这才是最让我不安的地方:重启能治的病,往往藏在内存管理里。
如果你也在排查服务器上的延迟毛刺,或者正准备换一台内存更大、配置更实在的机器先把环境跑起来,可以先看看这两家的报价和技术说明:hostkvm(KVM 服务器与主机)、interserver(全球云服务器与裸金属)。前者是纯 KVM 虚拟化路线,后者同时提供云主机和独立服务器,内存规格从中小配置到独享大内存都有,选机器的时候至少有个对照。

透明大页(Transparent HugePage,缩写 THP)是 Linux 内核从 2.6.38 开始提供的内存优化机制。它的思路很简单:普通页是 4KB,一次 I/O 搬 4KB 数据,页表项就得多占一点,内核在缺页中断里忙活半天,用户态等的是磁盘和内存控制器;透明大页直接把 2MB 当一个单位分配,TLB(快表)能装下更多条目,页表项少几十倍,CPU 少走很多次查表流程。理论上这是好事,在支持大页的硬件上,性能收益从百分之几到十几个百分点都有实测报告。
问题出在「透明」两个字。它意味着内核会在背后偷偷把一堆普通 4KB 页合并成一个 2MB 大页,这个动作叫「页面规整」,英文叫 compaction。要完成合并,内核必须在物理内存里挪动数据,把散落的空闲块拼成一个完整的 2MB 连续区域。这就好比把散落在仓库各处的货挪到一起腾出一整块货架挪的过程中,占着这些页的应用是感知不到的,但它的内存访问会被拖住。几百毫秒,足够把你的 P99 从 2 毫秒推到 400 毫秒。

THP 造成延迟毛刺主要有两个来源,理解它们差别很大,因为处理方式不一样。
第一条是同步规整(direct compaction / synchronous compaction)。当应用调用内存分配,系统发现没有现成的空闲大页,就直接在分配路径上做一次内存规整,把当前正在用的页挪开、腾出连续空间。这条路径的特点是「同步」:分配调用本身被阻塞住,调用线程直接承担了挪数据的代价。如果正好赶上一个 2MB 的连续块在物理内存里到处都凑不齐,这一下就能卡掉几百毫秒。数据库的写入线程、连接池的取连接、线程池的新建线程,都可能踩中。
第二条是后台规整(khugepaged 守护线程)。内核起了个专门的后台线程,不断扫描那些已经用了一段时间、但还没被合并的匿名内存页,尝试把它们凑成大页。这个过程看起来更温和,因为它不在应用的分配路径上,但它的代价是真实的:搬数据要消耗 CPU 和内存带宽,而在这个过程中被搬动的页面所属线程如果正好在访问这些页,就会遇到页面迁移带来的短暂阻塞。很多尖峰实际上是 khugepaged 在干活,只是时间点随机,看起来像莫名其妙。
第三种情况更隐蔽:数据库自己申请内存的方式。MySQL 有一个和 InnoDB 相关的配置项专门控制是否向操作系统申请透明大页,不同版本默认行为不一样;JVM 则要用 -XX:+UseTransparentHugePages 显式开启,PG 也有对应的开关。这些应用层开关和内核层的 /sys 设置一叠加,就会出现「我明明关了它,怎么还在用大页」的错觉,因为系统里同时存在两套控制逻辑。

THP 的开关全在 /sys/kernel/mm/transparent_hugepage/ 目录下,最常看的是 enabled 和 defrag 两个文件。
enabled 文件有三个值:always、madvise、never。always 是所有匿名映射都尽量用大页;madvise 表示只有应用明确通过 madvise 系统调用提出请求时才用;never 则完全禁用。defrag 文件决定内存规整的激进程度:always 是遇到分配需求就立刻同步规整,defer 加 madvise 是延迟规整,never 则不规整,直接放弃这次大页分配。deferred 模式其实是最坑的一种:它看起来不阻塞,实际上是把规整推到 khugepaged 后台去做,延迟没消失,只是换了个地方发生,还变得更难复现。
生产环境的通行做法是:数据库和缓存类服务把 defrag 设成 never,enabled 保持 madvise,让应用自己决定要不要大页。Redis 官方文档明确建议关闭 THP,针对延迟敏感的 MySQL 实例,社区里也普遍采用同样策略。改法是写进 systemd 服务单元里的一个参数,或者写进启动脚本,别只在当前会话里临时改一下就以为完事了重启之后一切回到原样。

判断有没有踩坑,看三个地方就够了。
第一,看 /proc/meminfo 里的 AnonHugePages 字段,它统计当前系统里匿名大页占用了多少内存。跑一段压测前后对比,如果这个数字在你压测的同时疯狂上涨,说明应用确实在用大页,而且 khugepaged 正在合并。
第二,看 /proc/vmstat 里的 compact_stall 和 compact_fail 两个计数器。compact_stall 增长得快,说明内核频繁卡在同步规整上,应用就在这个时候被拖住了。这是最直接的证据。
第三,看延迟尖峰的时间点跟 compact_stall 增长的时间点能不能对上。很多时候一对上就结案了,剩下的只是决定要不要调参数、调到什么程度。
如果这台机器上跑着监控 agent,或者你想换个视角看整个进程的内存行为,用 Rayon 这类 Linux 服务器监控工具把进程的常驻内存、大页占用、IO 等待拉出来并排对比,比盯着 top 命令的单行数字直观得多。

THP 本身不是洪水猛兽,Redis 官方文档也从来没有说它有 bug,只是在大内存、多线程、延迟敏感这几个条件同时成立时,它的收益和代价之间的平衡点会突然往坏的一边偏。我这次踩坑的根本原因,是用「平均延迟 2 毫秒」安慰自己,忽略了 P99 这个指标而用户感受到的卡顿,从来都是 P99 和 P999,不是平均值。
如果你也在折腾服务器,顺手把这几件事做了:先看 P99 而不是平均值;把 THP 的两个参数记下来;扩容或者换机器的时候,别只看核数和内存大小,也看看存储是不是 NVMe、线路是不是优化线路,这些东西在大内存场景下往往比多加两个核更能决定你的延迟曲线。