容器化MySQL的隐秘杀手:Cgroup内存阈值与Page Cache回写风暴

凌晨三点,机房所在园区的路灯已经熄了大半。我刚关掉终端里的几个排查窗口,顺手把工单状态改成了“已解决”。
这是一个潜伏了半个多月的幽灵故障。业务端反馈,迁移到K8S环境后的MySQL集群,偶尔会出现极端的P99延迟毛刺。平时毫秒级的响应,一天里总有那么几次会突然飙升到1秒甚至2秒以上。此时QPS并没有明显波动,慢查询日志里也只是一些普通的更新语句。
很多时候,容器化数据库的性能问题,答案往往不在数据库本身,而藏在操作系统内核与容器隔离机制的夹缝里。

现场还原与初步排查

接到问题后,我首先看了一眼Prometheus监控。在延迟飙升的那个时间点,CPU使用率平稳,但iowait有一个短暂的尖峰。到底层宿主机看,NVMe SSD的iostat显示await并不高,说明磁盘硬件本身没有遇到瓶颈。
MySQL的配置是老规矩:

innodb_flush_log_at_trx_commit = 1
sync_binlog = 1
innodb_flush_method = O_DIRECT

数据文件走O_DIRECT绕过了内核Page Cache,直接落盘;redo log和binlog虽然用了双1,但也只是每次事务提交时fsync。按理说,这种配置下的IO行为应该是平滑且可预测的。
为了抓取现场,我写了一个简单的bpftrace脚本,挂载在宿主机上,专门盯防MySQL进程在内核态的延迟。既然怀疑是IO阻塞,那就直接看进程因为什么内核函数被挂起。

# 抓取 mysqld 进程阻塞超过 50ms 的内核调用栈
bpftrace -e '
tracepoint:sched:sched_switch
/args->prev_comm == "mysqld"/
{
    @start[args->prev_pid] = nsecs;
}
tracepoint:sched:sched_switch
/args->next_comm == "mysqld" && @start[args->next_pid]/
{
    $dur = nsecs - @start[args->next_pid];
    if ($dur > 50000000) {
        @[kstack] = count();
    }
    delete(@start[args->next_pid]);
}
'

蹲守了几个小时,毛刺再次出现时,终端打印出了一段非常关键的内核栈:

@[
    io_schedule+65
    bit_wait_io+17
    __wait_on_bit+91
    wait_on_page_bit+132
    shrink_page_list+2233
    shrink_inactive_list+548
    shrink_node_memcg+1002
    shrink_node+192
    do_try_to_free_pages+253
    try_to_free_mem_cgroup_pages+268
    try_charge+567
    mem_cgroup_try_charge+123
    __add_to_page_cache_locked+165
    add_to_page_cache_lru+76
    generic_file_buffered_read+522
    ...
]: 12

看到shrink_node_memcgtry_charge,案件的轮廓已经清晰了。这是典型的Cgroup内存达到上限,触发了同步的直接内存回收(Direct Reclaim)。

深入底层:Cgroup v1 与 Page Cache 的错位

在传统的物理机或虚拟机上,Linux内核通过vm.dirty_ratiovm.dirty_background_ratio来控制Page Cache的脏页回写。当系统整体脏页达到后台阈值时,kflushd线程开始异步刷盘;达到硬限制时,触发写操作的进程才会被阻塞,执行同步刷盘(balance_dirty_pages_ratelimited)。
但Cgroup v1在设计上有一个致命缺陷:它没有隔离脏页回写的阈值。
系统级别的dirty_ratio是对整个宿主机物理内存生效的。假设宿主机有256GB内存,Pod的Limit限制为16GB。
对于宿主机来说,脏页远没有达到触发后台异步回写的阈值。
但对于这个16GB的Pod来说,它内部的进程(比如MySQL写binlog,因为binlog走的是Buffered IO,会产生Page Cache)不断产生脏页,这些脏页被记在Pod的memory.usage_in_bytes账上。
当Pod的内存使用量逼近16GB的Limit时,由于宿主机的脏页阈值没到,后台并没有去主动清理属于这个Pod的脏页。直到Pod内存触顶,内核别无选择,只能在MySQL进程申请内存(或者写入新缓存页)的上下文中,当场触发Direct Reclaim
如果回收的是干净的页还好,但如果全是脏页,MySQL进程就必须同步等待这些脏页刷入磁盘。这就是为什么拥有强悍NVMe硬盘的系统,依然会因为内核的调度逻辑产生长达数秒的延迟毛刺。

破局与最佳实践

定位到根因后,解决路径就明确了。这不是调优几下MySQL参数就能解决的,需要从系统资源分配与内核机制入手。
方案一:为Buffer IO预留充足的水位线(当前采用的快速止血方案)
之前这个Pod的资源配置是 Request=Limit=16GB,而 innodb_buffer_pool_size 设为了 12GB(占了75%)。加上MySQL内部的其他内存开销,留给OS作为Page Cache的空间极小。
我修改了MySQL的配置,将BP降到10GB,强行在Pod Limit内留出更大的空隙,降低直接回收触发的频率;同时结合K8s环境,调整了宿主机的系统参数,把基于百分比的脏页控制改为绝对字节数:

# sysctl.conf
vm.dirty_background_bytes = 134217728  # 128MB
vm.dirty_bytes = 536870912             # 512MB

强迫宿主机内核更勤快地在后台刷脏页,防止单个Pod攒下过多脏页。
方案二:升级 Cgroup v2(根本解法)
Cgroup v1中 memory hierarchy 和 blkio hierarchy 是分离的,这导致了Page Cache隔离的一系列烂摊子。而Cgroup v2采用了统一层级(Unified Hierarchy),内核终于能够正确感知到特定Cgroup下的脏页比例,并且实现了基于Cgroup的异步脏页回写(cgroup writeback)。
如果基础设施允许,将宿主机OS升级到支持Cgroup v2的版本(Kernel 4.20+ 开始完善,5.x时代成熟),并在K8S节点开启Cgroup v2支持,这种“隐秘的毛刺”将彻底从底层被消除。

结语

容器技术给应用的部署和编排带来了极大的便利,但也用一层虚拟的边界遮蔽了操作系统的本来面目。在这个边界之上,一切看起来都符合逻辑;但在边界之下,内核的机制和容器的配额常常在暗中角力。
排查这类问题没有捷径可走。只看应用的日志,或者只看监控面板上的平均水位,永远无法触及真相。唯一的办法就是沉下心来,带着工具潜入内核的调用栈,去看CPU指令在那些微秒和纳秒级的瞬间,到底在等待什么。
时间不早了,喝完杯底凉掉的咖啡,今天就到这里。有些复杂的架构问题,明天天亮再继续。