从原理到实战:Linux 系统性能诊断核心指标全解析及生产系统故障分析复盘
前言
作为一个Infrastructure Engineer,我们经常需要对服务所在的操作系统进行分析,诊断,评估。这个过程其实很复杂,因果归因相互交织。
虽然很多时候,根本问题(触发问题发生的原因)不在操作系统本身,更不会遇到由于操作系统的Bug而导致的服务问题。但是,操作系统层面的技术分析、运行时诊断和问题排查,有助于我们据此对我们上层服务的问题提供启示,甚至直接给出答案。
但是,操作系统的性能和运行指标比较多,我们除了必须具有操作系统的基础理论知识,还需要对经常发生问题的操作系统的核心指标有深入并且准确的理解,这些理解必须系统梳理,它不会因为我们经常使用而变得原来越准确,相反,当我们沉下心来重新学习这些东西,发现我们之前的理解并不准确。这种模糊和不准确性,其实经常在干扰我们找到系统问题的症结。
系统层面,在实际操作过程总,我们经常需要关注并且准确理解的核心指标都有很多,比较系统比较复杂,比如,CPU的原理(Interuption, IO Wait, User time, System time…), Memory的组成和工作原理(Free/Available, Slab, Page Cache/Buffer, Swap, mmap, Page Table),Disk(mmap), Network(TCP/UDP)等等,我们比如深入理解,准确理解,只有这样,我们才能在系统走向恶化边缘的时候提前预判,才能在系统发生问题的时候准确诊断。
本文就对这些系统诊断的相关知识进行系统梳理,同时,结合我们遇到的两个实际系统问题,进行极其详尽的分析,以展现我们在系统问题分析诊断过程中的方法和思路。
读完本文,我们将获得系统层面诊断的扎实而全面的技能。
系统关键指标解析
top命令中看到的cpu相关指标和概念的全面解析
上图是一个典型的操作系统暴露的与cpu有关的指标,这些指标被NodeExporter采集并发送给prometheus,显示在Grafana中:
我们能看到大量关于 CPU 的分类:us (User), sy (System), id (Idle), wa (I/O wait), hi (Hard IRQ), si (Soft IRQ), st (Steal) 等。这些都是 CPU 时间片分配的不同统计状态。
- CPU 时间的核心分类
Linux 内核通过定时器中断,定期查看 CPU 当前正在执行什么任务,并把这段时间归
类到以下几种状态:
-
Busy User (us / User Space): CPU 在执行用户态应用程序代码的时间(例如你的 Java 进程、Python 脚本正在做计算)。
-
Busy System (sy / Kernel Space): CPU 在执行内核态代码的时间。当用户程序发出系统调用(如申请内存、读写文件、网络发包)时,CPU 会切换到内核态代为执行。
-
nice (ni): 用户态中,被降低了优先级的进程所占用的 CPU 时间。
-
Idle (id): CPU 处于完全空闲、没有任务可做的状态。
-
I/O Wait (wa)? 这是很多人最容易误解的概念。它的定义是,CPU 当前没有任何就绪的任务(Ready to run)可以执行,且当前系统中有至少一个进程正因为等待磁盘 I/O 或网络 I/O 而被阻塞(Blocked)。 所以,CPU 在 iowait 状态下,本质上是空闲的(Idle)。
如果在 iowait 期间,突然有一个计算任务(比如一个新请求进来)变成就绪状态,操作系统调度器会立刻把这个任务丢给 CPU 执行。此时,这部分时间就会从 iowait 变成 us 或 sy,而不会继续死等。
所以,我们说IO Wait, 它表示的不仅仅是等待IO, 而且是空等IO。iowait 是一种特殊的空闲状态。此时 CPU 的计算核心并没有被占用,它只是在“等快递”(等磁盘把数据读完)。
假设一个员工一天工作 8 小时。- 发呆(Idle): 没活干,坐在位子上刷手机。
- 等快递(iowait): 也没活干,坐在位子上等快递员把原料送来。
虽然“等快递”本质上也是一种“发呆”,但老板在统计**“真正干活的时间”**时,必须用 8 小时减去“发呆时间”,再减去“等快递时间”。这就是为什么公式里两者都要减去的原因。
-
iowait 与 CPU Usage(CPU 利用率)的关系
算不算利用率? 在大多数现代监控工具(如 Prometheus、Grafana)中,iowait 通常不计入 CPU 利用率(CPU Usage),因为正如我们说过的,IO WAIT 表示的是等待且空等。真正的 CPU 利用率计算公式通常是:CPU Usage=100%−Idle%−iowait%Linux 内核在暴露统计数据时,为了让用户看清“纯粹的闲置”与“等待 I/O 的闲置”,把它们并列抽离成了两个独立的计数器:
误区警告: * 高 iowait 不等于 CPU 性能遇到瓶颈,它说明的是磁盘/存储系统/网络io/内存IO 等太慢了,拖慢了业务进程。在 Linux 底层(比如 /proc/stat 文件中),内核并不会把 iowait 嵌套在 idle 里面递增。内核派发 CPU 时间片时,会把时间精确地投递到以下几个互斥的抽屉里:
抽屉名称 英文对应 含义 user us 用户态计算 system sy 内核态计算 idle id 纯粹的空闲(既没事做,也没有人在等 I/O) iowait wa 有特殊原因的空闲(没事做,但有人在等磁盘 I/O) 所以,在CPU Metrics中,如果我们单独观察CPU IOWait和Idle的比例,我们看到,由于他们之间的互斥关系,在某种程度上他们基本相反: 当IO Wait极高的时候, Idle往往很低,当IO Wait极低的时候,假如CPU Usage也很低,那么Idle就很高:

-
硬件中断(hi)与软中断(si)
当外设(网卡、磁盘、鼠标)有数据到来时,需要通知 CPU 处理:-
hi (Hardware IRQ): 硬中断。网卡收到数据包后,直接向 CPU 发出物理电信号。CPU 必须立刻暂停手头的工作去记录这个事件。硬中断处理极其短暂,只负责“收件并打勾”。
-
si (Software IRQ): 软中断。硬中断结束后,内核会把后续耗时的数据包解析、协议栈流转等脏活累活交给软中断(如 ksoftirqd 进程,见图二中的 pid 7, 16 等)来异步处理。
-
-
Steal 时间 (st)
如果我们在虚拟机或云服务器(AWS EC2, 阿里云等)上跑系统,就会看到这个指标。它代表被超卖的物理宿主机“偷走”的时间。
如果 st 很高,说明你的虚拟化宿主机上挤满了抢资源的邻居,物理 CPU 被别人占用了,你的虚拟机只能干等。
内存概念的全面解析
top中反馈出来的内存信息
在我们上图的 top 截图中,关于内存有两行系统级别的核心数据:
PlaintextKiB Mem : 13169613+total, 10153304 free, 65320192 used, 56222640 buff/cache
KiB Swap: 33554428 total, 33521404 free, 33024 used. 57398012 avail Mem
物理内存的五大空间划分Linux 在管理物理内存时,将账目细分为了以下五个核心大块:
-
Total (总物理内存)系统的总物理内存容量
-
Free (未分配内存)完全没有被分配、纯粹闲置的内存。在现代 Linux 系统中,这部分内存通常很少,因为 Linux 的哲学是"不用白不用",它会尽可能把闲置内存借给缓存使用
-
Buff/Cache (Buffer 和 Cache)
代表所有基于文件和块设备的缓存总和,两者都在物理内存中。需要知道二者的区别:磁盘以"块"(block)为单位存储数据,典型块大小是4KB。无论是用户文件(如
/etc/passwd)还是文件系统的管理数据(如inode表),最终都存储在磁盘的某些块上。
区别在于访问这些块时,是否经过文件系统的翻译:- Cache(缓存区):经过文件系统,以"文件"为单位的缓存(Page Cache)
-
应用看到的是文件:
cat /etc/passwd -
内核的工作流程:
- 文件系统查询inode,得知这个文件存储在磁盘的哪些块上(例如块号 12345, 12346)
- 从这些块读取数据到内存
- 在内存中以 (文件inode, 页偏移) 为索引缓存,这就是 Page Cache
-
读操作:文件内容缓存在 Page Cache,下次读同一文件直接走内存
-
写操作:数据先写入 Page Cache 成为脏页,稍后异步刷盘
-
应用不需要知道块号,文件系统负责管理
-
Buffer(缓冲区):绕过文件系统,以"块"为单位的缓存
- 应用直接指定块号:
dd if=/dev/sda1 bs=4096 skip=12345 count=1 - 内核的工作流程:
- 直接从设备读取第12345号块
- 在内存中以 (设备号, 块号) 为索引缓存,这就是 Buffer
- 典型使用场景:
- 直接操作裸设备(如数据库的 Direct I/O 模式)
- 文件系统读取自身的管理数据(超级块、inode表、空闲块位图等,这些是文件系统的"账本",本身也存储在特定的块上)
- 应用必须自己知道要读哪个块号
总结:同样是读磁盘块12345,通过文件系统读缓存在 Cache,直接读块设备缓存在 Buffer。
- 应用直接指定块号:
-
- Cache(缓存区):经过文件系统,以"文件"为单位的缓存(Page Cache)
-
Slab Memory (内核专属小对象内存池)
内核为了管理像文件描述符(file)、进程描述符(task_struct)、目录缓存等大量微小且结构固定的内核对象而开辟的专用内存池。它不属于任何一个用户进程,但在物理内存不足时,其内部会发生剧烈的分流:- SReclaimable (可回收的 Slab): 主要存放文件系统的目录缓存(dentry)和索引节点缓存(inode)、TCP连接对象等。虽然名为"可回收",但实际回收速度取决于对象引用状态:未被引用的对象可以快速释放,但被打开文件、活跃连接引用的对象无法立即回收,只能等待文件关闭或连接断开后才能释放。这与Page Cache的"瞬间清空"有本质区别。
- SUnreclaim (不可回收的 Slab): 存放内核为每个进程维护的数据结构(如task_struct、Page Table页表等)。这些结构存在于内核空间,不在进程的虚拟地址空间内,也不计入进程的RES。只要对应进程还在运行,这部分内存无法被释放。例如,一个申请了 4,976 GB 虚拟地址空间的进程,内核为其维护的 Page Table 就占用了约 7.5 GB 的 SUnreclaim 内存;当进程退出时,内核释放这 7.5 GB。
-
Used (已使用内存)
已经被应用进程实际占用(如 Java 堆、C 语言的 malloc 空间等匿名内存),以及无法被操作系统随意释放的内核开销。
需要理解清楚 Slab 与 Used 的归属关系: 在 top 的大账目中,整个 Slab 内存(SReclaimable + SUnReclaim)默认都被并入了 used 的数据中。因此,大部分时候我们看到的used 内存是:Used = 用户实际占用的内存 + 不可回收的内核内存 + 整个slab内存但是我们应该清楚,slab内存中是有可回收部分的,就像Page Cache一样,其实不应该计算到Used行列中。
所以,在考虑了Slab的分流机制以后,可以提供给新的进程申请到的"真正可用内存"计算公式应该被修正:Available = Free + 可释放的Cache/Buffer + SReclaimable(可回收的slab)实际上,操作系统并不直接暴露used的大小,但是暴露了Free, Cache, Buffer, SRelaimable Slab, SUnreclaim Slab,used是需要根据上面的指标间接计算的:
Used = Total Memory - Free Memory - Cache Memory - Buffer Memory - SReclaimable Slab Memory在现代 SRE 运维体系中,大家其实越来越少去苦哈哈地用加减法拼凑"已用内存"了,因为 Linux 内核在内核 3.14 之后,直接在 /proc/meminfo 里提供了一个标准指标:Memory Available(可用内存),Memory Available 是内核通过复杂的底层公式,自动帮我们把 Free + 可释放的 Cache、可回收的 SReclaimable Slab 全部动态计算好之后的绝对真实可用量,因此,此时,系统的已经使用的内存就可以表达为:
Used = Total Memory - Available Memory
swap区域解析
虽然我们经常在内存的dashboard中看到swap,但是,swap 指的是磁盘空间,而不是内存空间。我们在监控指标中看到的swap usage,也是磁盘的swap区域的使用率,而绝对不是内存中的某个区域的使用率。
它的原理: 当系统的物理内存(RAM)不够用时,Linux 内核会把内存中那些长期不调用的冷数据(比如某个挂起的后台进程)打包,写到专门划出来的磁盘区域(Swap 分区或 Swap 文件)里,从而腾出空间,把冷数据移到磁盘后,释放出来的宝贵物理内存就可以给当前急需内存的活跃进程使用。
但是,swap是有代价的: 磁盘(哪怕是 NVMe SSD)的速度也比物理内存慢成百上千倍。一旦进程重新需要读取进入 Swap 的数据,系统必须从磁盘把它读回内存(引发 Swap In),这会导致系统出现明显的卡顿。
虽然swap的空间是磁盘空间,但是由于swap中的内容来自内存,所以一般操作系统会为swap设置一个最大空间大小。在我们的操作系统环境下为32GB。
我们在node exporter中看到的swap usage就是这个swap磁盘空间的使用率。
我们从下图的top输出中可以看到, KiB Swap 总计约 32 GiB(33554428 total),但当前只使用了约 32 MiB(33024 used),几乎是空的。
必须区分swap usage(当前的swap使用率)和Swap In/Out的状态。
- 一般情况下,剧烈的swap out(换出)往往是操作系统内存不足的直接体现,剧烈的swap in/out本身可能会导致较高的io wait。
- 而swap usage较高(比如到达100%)只是代表当前swap区域的静态使用率,并不代表当前操作系统内存不足从而正在往swap区域疯狂地写匿名文件。
很多时候,我们看到memory很充裕,但是swap的usage也依然很高(即,既然还有这么多内存,为什么操作系统还把那么多文件放到swap区域(磁盘区域))呢?如下所示:
Linux 内核中有一个神级参数叫 /proc/sys/vm/swappiness(取值范围 0 - 100),它决定了操作系统有多喜欢使用 Swap:
-
值越高(如 60-100): 内核会更积极地把匿名内存往 Swap 里塞,腾出更多的物理内存给 Page Cache(有名内存) 用,让文件读写更快。
-
值越低(如 0-10): 内核会极力避免使用 Swap。不到物理内存真的油尽灯枯,绝对不碰磁盘 Swap。对于高并发、要求低延迟的系统(如 Java 大数据、Database),通常会把这个值设得非常低(例如 1 或 10),宁可让系统报内存不足,也不愿意让 Swap 的磁盘慢速拖垮整个业务。
所以,有时候我们往往倾向于让操作系统更晚的使用swap,不希望频繁的swap使用导致io变慢。这时候,也许我们可以通过调整/proc/sys/vm/swappiness参数来控制操作系统进行swap的行为,比如,将其调低(比如设为 10 或 1),告诉内核:“除非物理内存真的彻底要用光了,否则别未雨绸缪地往 Swap 里塞数据。”
但是,任何时候,直接关闭swap都是及其不建议的行为,虽然能关闭,但是这会带来极大的安全风险:
- 短期风险:引发短暂的系统卡顿(性能抖动)。
当我们关闭swap,操作系统会吧swap上的数据从磁盘重新读回内存,需要耗费大量的磁盘 I/O 吞吐和 CPU 软中断。在执行 swapoff 的那几分钟内,你的机器可能会出现明显的响应变慢,CPU iowait 飙升 - 长期风险:失去了“安全气垫”,极易引发 OOM Killer
这台机器可能跑着某些极其消耗内存的任务。如果没有了 Swap,一旦未来哪天 RAM Used 突然暴涨,或者 Cache 释放速度跟不上应用申请内存的速度,系统由于没有 Swap 这个“缓冲地带”,会毫无征兆地直接启动 OOM Killer,一刀劈死你的核心业务进程。
进程级别的指标解析

在上图的进程列表中,第一行是一个巨大的 Java 进程(PID 28955,用户 hdfs),它的内存指标非常典型:
-
PR (Priority - 优先级): 进程的调度优先级。数字越小优先级越高,20是默认值,rt表示实时进程(最高优先级)。
-
NI (Nice - 友好值): 用户可调整的优先级偏移,范围-20到19。负数表示更高优先级(-20最高),正数表示更低优先级,0是默认值。
-
%CPU: 进程占用的CPU时间,以单个CPU核心为基准。
- 图中Java进程显示26.2%,表示该进程占用了0.262个完整CPU核心的计算时间
- 在多核系统中,这个值可以超过100%(例如200%表示占用了2个完整CPU核心)
- 26.2% 是相对于单核的百分比,如果系统有48个核心,则相对于整个系统CPU资源约为 26.2%/48 ≈ 0.55%
-
%MEM: 进程占用物理内存的百分比,计算公式为:
%MEM = (RES / 系统总物理内存) × 100%- 图中Java进程:RES = 54 GB,系统总内存 ≈ 125.5 GB,因此 %MEM = 43.4%
- 注意:%MEM 仅基于 RES 计算,与 VIRT 无关,SHR 已包含在 RES 中
-
VIRT (Virtual Memory - 虚拟内存): 进程的虚拟地址空间总大小。
- 图中Java进程 VIRT = 66.277g
- 这只是在进程的虚拟地址空间中预留的范围,并未映射到物理内存
- Linux采用按需分配(lazy allocation):进程malloc时只分配虚拟地址,当真正访问(读写)时才触发缺页中断,内核才分配物理页并通过页表建立虚拟地址→物理地址的映射
- 因此 VIRT 大不代表物理内存消耗大
-
RES (Resident Memory - 常驻内存): 进程实际占用的物理内存大小。
- 图中显示 0.053t(约 54 GB)
- 这是已经通过页表映射到物理RAM的内存,其他进程无法使用
- RES = 进程独占的物理内存 + 与其他进程共享的物理内存(SHR)
-
SHR (Shared Memory - 共享内存): RES中可以被多个进程共享的部分。
- 图中是 28780(约 28 MB)
- 典型例子:动态链接库(如libc.so),多个进程的虚拟地址通过各自的页表映射到相同的物理页
- 这部分物理内存虽然算在该进程的RES中,但其他进程也可以同时使用
这里,我们必须补充一些相关知识:
虚拟内存 vs 物理内存的映射关系:
- 进程malloc申请内存 → 内核分配虚拟地址(VIRT增加),物理内存尚未分配
- 进程访问虚拟地址 → 触发缺页中断 → 内核分配物理页 → 建立页表映射(RES增加)
- 多个进程可以将各自的虚拟地址映射到同一物理页(SHR)
内存指标的关系:
- VIRT:虚拟地址空间大小,未必映射到物理内存
- RES:已映射到物理内存的大小,真正占用的物理RAM
- SHR:RES的子集,多进程可共享的物理内存
- %MEM:仅基于RES计算,因此所有进程的%MEM之和可能超过100%(因为SHR被重复计算)
mmap解析
在我的另外一篇讲解HDFS Short-Circuit Read的文章中讲解过基于mmap的HDFS DataNode的短路读的实现原理。这里,我们再次讲解mmap的基本原理。
如果要给 mmap 下一个最通俗的定义:它是连接“内存”与“文件/磁盘”的直通桥梁,也是让 CPU 绕过传统繁琐手续、直接操控数据的“特权通道”。
为了彻底理解它,我们需要先看传统的 I/O 有多笨重,再看 mmap 是如何颠覆这个过程的。
一、 传统读写(Read/Write)的痛苦过程:4次拷贝
在没有 mmap 之前,如果一个用户程序(比如你的 Java 或 Python 进程)想要读取磁盘上的一个文件,CPU 必须指挥系统经历以下四步(伴随 4次数据拷贝 和 2次上下文切换):
-
磁盘 -> 内核缓冲区(Page Cache): 硬件通过 DMA 引擎把磁盘数据拷贝到操作系统的内核空间缓冲区。
-
内核缓冲区 -> 用户缓冲区: 操作系统把数据从内核空间再拷贝一份到你这个进程的内存空间里(比如一个 byte[] 数组)。
-
用户缓冲区 -> 网络/新文件: 写入时,再从用户空间拷贝回内核空间的 Socket 缓冲区或写入缓冲区。
-
内核缓冲区 -> 网卡/磁盘
为什么低效?
在第 2 步和第 3 步中,CPU 沦为了“搬砖工”。数据明明已经在物理内存(内核 Page Cache)里了,却因为进程和内核之间的“高墙”(虚拟地址隔离),不得不硬生生在内存的不同区域之间来回复制。
这里,我们需要了解,操作系统内存(RAM)内部存在明确的分界:
我们的物理内存(比如 128 GiB),在操作系统启动后,被严格地划分成了两大区域:
- 内核空间(Kernel Space): 存放操作系统内核代码、驱动程序,以及内核缓冲区(Page Cache)。这块区域普通的用户程序无权直接访问。
- 用户空间(User Space): 存放你运行的普通程序(如 Java, Python, Nginx 等)的数据和它们自己申请的内存(如 JVM 的堆、C 语言的 malloc 空间)。
这里的问题是,既然都在同一块物理内存条上,操作系统为什么非要在内存里搞个“二道贩子”,不直接把磁盘数据读到用户程序(用户空间)的内存里呢?
这里主要有两个原因:
-
为了实现“缓存(Cache)”造福所有进程。如果 Java 进程 A 读取了 test.txt 文件,数据直接进了进程 A 的私人用户空间。当 Python 进程 B 也要读 test.txt 时,操作系统就傻眼了——它无法直接窥探进程 A 的私人空间,只能再次苦哈哈地去读慢如蜗牛的磁盘。而放在公共的内核 Page Cache 里,任何进程下次再读同一个文件,操作系统一看:“哎,内存里已经有了!” 直接秒回,速度提升上千倍。
-
硬件隔离与安全。磁盘等硬件的控制权必须牢牢掌握在内核手里。如果允许用户进程直接指定硬件把数据写到自己的私人内存,一旦程序写错指针,可能会直接擦写掉操作系统核心数据,导致系统崩溃。
所以,mmap 的核心思想是:打破高墙,直接建立映射。
它通过修改进程的页表(Page Table),把磁盘上某个文件的一段内容,直接映射到进程的虚拟内存空间中。
映射完成后,在进程看来,这个文件就像已经全部加载到了内存里的一个巨大数组一样。
此时的读写过程就变成:
-
读数据: 进程直接去读对应的内存指针。如果数据还没读入物理内存,CPU 会触发一个缺页中断(Page Fault),内核自动通过 DMA 把磁盘数据读入 Page Cache。由于页表已经映射好了,进程立刻就能看到这些数据,不需要任何“内核到用户空间”的二次拷贝!
-
写数据: 进程直接修改这个内存指针指向的数据。操作系统会在后台异步地把被修改的内存页(脏页 Dirty Page)刷回磁盘。
读取过程如下所示(图源自论文 《Are You Sure You Want to Use MMAP in Your Database Management System?》(CIDR 2022), 作者:Andrew Crotty, Viktor Leis, Andrew Pavlo ):
- A process calls mmap() for an open file.
- The OS reserves part of the process’s virtual address space, but does not load the file from disk. The process receives a pointer to the mapped address.
- The process accesses the file using that pointer.
- The OS tries to load the page, but no valid mapping exists, which results in a page fault.
- The OS loads the file from disk to physical RAM.
- The OS adds an entry to the page table of the process, mapping the virtual address to the physical address.
- The initiating CPU caches this new page entry in its Translation Lookaside Buffer (TLB) for faster future accesses.

三、 mmap、内存(Memory)与 CPU 的三角关系
我们可以通过它们在 mmap 场景下的互动,彻底理清它们的关系:
-
与内存(Memory)的关系:共享与高效利用
它本质上就是 Page Cache: mmap 映射的空间,在物理内存中其实就是内核的 Page Cache,即文件实际上被读取到了内核空间;零拷贝(Zero-Copy): 进程的虚拟内存和内核的 Page Cache 指向的是完全相同的物理内存页。因此,mmap 是零拷贝技术的核心实现方案之一。
多进程共享内存: 如果有多个进程同时 mmap 了同一个文件,它们在物理内存里共享的是同一份 Page Cache。这极大节省了物理内存(这也是 Linux 加载动态链接库 .so 的底层原理)。
-
与 CPU 的关系:解放计算力,但也会带来小惩罚
解放 CPU 的双手: 传统 read/write 需要 CPU 亲自参与把数据从内核考到用户空间。而 mmap 让数据直接就位,CPU 只需要专心做它的数学计算,不需要当“内存搬运工”。引发现象:缺页中断(Page Fault): 当 CPU 第一次去访问 mmap 映射的虚拟内存时,因为磁盘数据还没真正进内存,CPU 硬件会卡住,并向操作系统抛出一个“缺页中断”。
CPU 此时在做什么? 内核接到中断后,会让当前进程睡眠,转去调度 DMA 将磁盘文件读入内存Page Cache。此时在监控中,你可能会看到 CPU 的 iowait(I/O wait) 上升。因为虽然内核在忙,但对你的计算进程来说,它正在等待 I/O 准备就绪。
我们如何直观地理解 mmap?(大白话比喻)
传统的 Read/Write(去柜台办事):
- 我们想看档案库(磁盘)里的一本书。我们(用户进程)不能进去,必须在柜台等着。办事员(内核)进去把书抱出来,放到复印机上复印一份(内核到用户拷贝),然后把复印件交给你。我们修改了复印件,再交给办事员,办事员再照着抄写一份塞回档案库。
mmap 方式(办了一张特权VIP卡):
- 高级经理(mmap 系统调用)直接给了我一扇玻璃窗(虚拟内存映射),透过窗户直接能看到档案库里的那本书。
- 我想读哪一页,手一指(指针访问),保管员(DMA)就立刻把那一页放到窗户后面(缺页中断装载)。我拿着笔直接在窗户玻璃上修改书的内容,改完你就可以走了,保管员等你有空或者下班时,自然会把书放回原位(异步刷盘)。
五、 总结:什么时候用 mmap?
我们最常见的使用功能mmap的就是DataNode,因为它会进行频繁的磁盘随机的读写,因此必须追求高性能。
-
其优点是,读写大文件时效率极高;适合频繁随机读写文件的场景;非常省内存。
-
缺点: 如果文件很小(比如几百个字节),由于 mmap 必须按“内存页”(通常是 4KB)对齐映射,会造成内存碎片(拉大虚拟内存 VIRT)。此外,频繁的缺页中断如果在机械硬盘上发生,也会导致严重的 CPU iowait。
匿名文件和有名内存
在 Linux 的世界里,所有进程能看到的内存,根据它在磁盘上有没有对应的一个真实文件,被严厉地分成了两派:
| 特性 | 有名内存 (File-backed Memory) | 匿名内存 (Anonymous Memory) |
|---|---|---|
| 定义 | 有明确的磁盘文件作为后盾(Backing store)的内存。 | 没有名字、在磁盘上没有对应文件的纯粹内存。 |
| 典型代表 | 代码段(二进制程序)、动态链接库(.so)、通过 mmap 映射的文件。 | 进程的堆(Heap,如 Java 的对象、C 的 malloc)、栈(Stack)、进程间通信的共享内存。 |
| 内存中的实体 | 就是我们前面讲的 Page Cache! | 纯粹的物理 RAM 颗粒中的数据块。 |
| 当内存不足时 | 直接丢弃(Page Out): 因为磁盘有原件,内核可以直接把这写内存清空,下次要用再从磁盘读。 | 必须写入 Swap: 因为磁盘没有原件,如果直接丢弃数据就彻底丢了!必须交换到 Swap 分区。 |
现在我们把这个概念,和第一轮讲的 Swap(交换分区) 彻底串联起来。
当服务器的物理内存快要耗尽,系统启动内存回收机制(Kswapd 进程)时,有名内存和匿名内存的待遇是天差地别的:
-
有名内存(Page Cache)的回收:极其潇洒
如果有一块 mmap 映射的文件内存,且最近没人访问它:- 如果它是干净的(Clean)(程序没改过它):内核直接把这块物理内存释放掉。
- 如果它是脏的(Dirty)(程序改了内容):内核先把它异步刷回磁盘,然后释放掉。
代价: 极低。不需要占用 Swap 空间。
-
匿名内存(堆/栈)的回收:非常沉重
- 如果 Java 堆里有很多长期不用的老代对象,系统想回收这块物理内存。
- 因为它在磁盘上没有对应的文件,内核绝对不敢直接丢弃。
- 唯一生路: 内核必须在磁盘的 Swap 分区里圈出一块地,把这块匿名内存的数据原封不动地“搬运”到磁盘的 Swap 空间里,然后才能把物理内存腾出来。
代价: 极高。不仅触发盘写,下次 Java 访问这块内存时,还会触发严重的 Swap In 阻塞(导致 %CPU 的 iowait 飙升,服务卡死)。 - 虽然可以 swap 的都是匿名内存,但是并不代表匿名内存都是可以 swap 的。实际上,以下几类匿名内存在内核中是绝对无法被 swap 的
所以,当操作系统出现可用内存不足的时候,一定是先释放cache,只有当cache全部释放也没有多余内存的时候,才会尝试通过swap out的方式释放更多内存。 我们下文会极其详细地讲解整个操作系统在内存不足的情况下的处理流程。
比如,我们在下文讲解内存回收风暴的case的时候会看到,操作系统试图释放大量的Page Cache来获得更多空间,但是并没有动(Swap Out)用更多的swap空间:
Page Table
Page Table是操作系统进行虚拟内存管理的核心数据结构。让我先解释一下它的作用。
当一个进程申请内存时,操作系统并不直接给它物理内存地址,而是给它一个虚拟地址。比如,你的Java进程申请了100GB内存,它看到的地址可能是0x7f8a00000000这样的虚拟地址,但这个地址并不直接对应物理内存条上的某个位置。
操作系统需要维护一个"地图",记录着:
- 虚拟地址
0x7f8a00000000对应物理内存的哪一页(比如物理页#12345) - 虚拟地址
0x7f8a00001000对应物理内存的哪一页(比如物理页#67890) - … 以此类推
这个"地图"就是Page Table(页表)。
现代操作系统使用分页机制管理内存,通常一页是4KB。Page Table以页为单位记录映射关系:
虚拟页号 → 物理页号 + 权限标志(可读/可写/可执行)
Page Table本身也需要占用内存。对于一个64位系统:
- 每个页表项(PTE, Page Table Entry)通常占用8字节
- 如果一个进程使用了100GB虚拟内存,需要 100GB / 4KB = 26,214,400 个页表项
- 页表开销 = 26,214,400 × 8 bytes ≈ 200MB
对于某一台ClickHouse机器上ClickHouse Server进程:
- 虚拟内存(VIRT)= 4,976 GB
- 页表项数量 = 4,976 GB / 4KB ≈ 13亿个页表项
- 理论页表开销 = 13亿 × 8 bytes ≈ 10.4 GB
但实际上,由于Linux使用了多级页表(通常是4级)来节省空间,实际开销会小一些。从下文我们的监控看到,ClickHouse进程的Page Table开销约为7.5-8GB,这与理论计算是吻合的。
Page Table有几个重要特性:
- 常驻内存(Non-Swappable):Page Table本身不能被swap到磁盘上。为什么?因为操作系统需要通过Page Table才能知道某块虚拟内存在物理内存还是在swap中。如果Page Table本身被swap了,就产生了"鸡生蛋蛋生鸡"的循环依赖。
- 不可随意释放:只要进程还在运行,它的Page Table就必须保留。即使进程的某些内存页被swap出去了,Page Table中对应的条目也要保留(只是标记为"在swap中")。 但是,我们必须注意,PageTable是为进程服务的,只要进程存在,为进程服务的这部分Page Table就存在,但是Page Table属于Slab的一部分,不存放在进程的地址空间内。
- 随进程生命周期:Page Table在进程创建时分配,在进程退出时释放。
OOM Killer
当服务器的物理内存和 Swap 空间全部被榨干,系统已经退无可退、无处借血时,Linux 内核就会祭出终极武器——OOM Killer(Out of Memory Killer)。
在此之前,我们先看一下操作系统从内存紧张到最终不得已触发OOM Killer的整个过程:
当系统内存不足时,Linux内核会经历以下三个阶段:
- 后台回收(kswapd)
触发条件:内存降到 watermark_low 以下
-
触发者:内核监控
-
执行者:kswapd内核线程(后台运行)
-
应用进程:继续正常运行,完全无感知
-
回收内容:Clean Page Cache(直接丢弃)、Dirty Page Cache(writeback后丢弃)、Reclaimable Slab、Anonymous Pages(swap out)
正常情况下,kswapd足够勤快,应用永远感知不到内存压力。
- 直接内存回收(Direct Reclaim)
触发条件:内存降到 watermark_min 以下,kswapd回收速度跟不上
关键区别:
- 触发者:申请内存的应用进程
- 执行者:内核回收代码
- 执行上下文:在申请进程的内核态上下文中执行
- 应用进程状态:被阻塞在内核态,进入D state
- 同步/异步:同步阻塞(vs kswapd的异步后台)
工作流程:进程调用malloc() → 内核发现内存不足 → 进程进入内核态被阻塞 → 内核执行回收(扫描LRU、writeback、shrink slab)→ 回收完成后进程才能继续。
系统症状:大量进程D state、IOWait飙升、应用响应慢、用户感知系统卡住。
回收内容与kswapd相同,但进程被迫同步等待。成功则继续运行,反复失败则进入阶段3。
- 触发OOM Killer
触发条件:Direct Reclaim多次失败,所有可回收内存已耗尽
执行流程:内核调用 out_of_memory() → 计算每个进程的 oom_score(内存占用、运行时间、oom_score_adj)→ 杀死分数最高的进程 → 释放所有内存 → 记录日志。
何时不触发:Direct Reclaim虽慢但仍有进展时,系统选择"慢慢回收"而非"杀进程"。
- 不可回收的内存类型
以下内存永远无法被回收,只能等待进程退出或主动释放:
- Active Anonymous(无swap时):进程堆栈、malloc内存,无swap空间无处可换出
- 进程的file-rss:进程mmap映射的文件,与进程虚拟地址空间绑定
- Non-reclaimable Slab:活跃socket对象、TCP控制块,正在被引用
- Page Table:进程页表,释放会导致地址空间崩溃
- Kernel stack:内核线程栈
- vmalloc:内核模块内存
- Huge pages、Pinned/locked pages:特殊用途或被明确锁定
-
什么时候触发 OOM Killer?
触发 OOM Killer 的核心时机,总结起来就是一句话:当内核需要分配物理内存,但发现使尽浑身解数也拿不出哪怕一个物理页(Page)的时候。这时候,操作系统内核会经历如下几个惨烈的步骤:
-
常规回收失败: 进程申请内存,内核发现物理内存不够,于是开始紧急回收 Page Cache(上文讲过,Page Cache是有名内存,在磁盘上有对应内容,丢失了也可以从磁盘文件重新加载,因此可以放心释放);
-
Swap 借血失败: 内核把部分匿名内存疯狂往 Swap 空间里塞。但如果 Swap 已经满了(或者系统根本没开 Swap),这一步也救不了场;
-
直接内存回收(Direct Reclaim): 内核让申请内存的进程自己停下工作,去全盘扫描并清理可释放的内存。
-
OOM Killer: 如果上述手段全部宣告失败,内核就会调用 out_of_memory() 函数,正式唤醒 OOM Killer。
Linux 允许进程“画大饼”。一个 16G 内存的服务器,进程 A、B、C 可以各自申请 10G 虚拟内存(VIRT),只要它们不实际写满(RES),系统就相安无事。可一旦大家都动真格开始写数据,内存的总使用量即将超过物理内存,就会瞬间引爆 OOM Killer。
-
-
怎么选择被 Kill 的进程?(得分算法)
OOM Killer 并不是随机杀人的疯子,它有一套非常严格、冷酷的积分淘汰制。内核会遍历系统中的每一个进程,为它们计算一个 oom_score(OOM 分数)。分
数的范围: 0 0 0 到 1000 1000 1000。
淘汰规则: 谁的分数高,优先杀死谁。
内核计算这个分数的底层逻辑(badness() 函数)主要考虑以下几个维度:- 谁占的内存多,谁分高(基础分)算法的最基本原则是:按比例杀人。如果一个 Java 进程自己就吃掉了系统 80 % 80\% 80% 的内存,杀了它就能立刻解决危机。相反,如果去杀一个只占 0.1 % 0.1\% 0.1% 内存的底层守护进程,不仅解决不了问题,还会导致系统不稳定。基础分通常直接等同于该进程消耗的物理内存(RES)和 Swap 空间占系统总内存的千分比。
- 谁活得久/Root 进程,谁适当减分(保护机制)老臣免死: 那些运行了很久、通常是系统核心的基础服务(如 init/systemd、sshd 等),内核在计算时会给予一定的“优待”,降低它们的分数。Root 进程优待: 如果进程是以 root 用户身份运行的,内核会倾向于认为它是重要系统服务,在最终分数上会自动减去 30 30 30 分(约等于 3 % 3\% 3% 的内存宽限)。
- 用户意志:oom_score_adj(绝对控制权)这是系统管理员用来人为干预 OOM 的最高指示。在 /proc/[pid]/oom_score_adj 文件中,可以写入一个 − 1000 -1000 −1000 到 1000 1000 1000 之间的值:如果你写 1000:代表这个进程是“敢死队”,一有风吹草动优先杀它。如果你写 -1000:代表这个进程获得了绝对免死金牌(OOM_SCORE_ADJ_MIN)。无论它吃多少内存,OOM Killer 都会直接跳过它,绝不杀它(通常像 sshd、数据库核心组件会被这样保护)。
注意,触发oom killer的触发者(压死骆驼的最后一根稻草)和最后被操作系统选中杀死的进程完全可能是不同的进程。因此,一次OOM Killer行为的发生一般有两个参与者: 触发者和受害者(被杀者):
- Invoker(触发者):尝试分配内存失败,触发OOM Killer的进程/线程
- Victim(被杀者):被OOM Killer选中并杀死的进程
我们从syslog中可以看到oom killer的触发行为和kill的实施行为,因为当 OOM Killer 动手后,它会在操作系统的内核日志里留下铁证。
我们以下文会讲到的ClickHouse OOM Killer Case为例:
-
触发者(Invoker)
Jun 16 08:39:50 kernel: clickhouse-serv invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0 Jun 16 08:39:50 kernel: CPU: 0 PID: 92956 Comm: clickhouse-serv- 触发者PID:92956(ClickHouse的某个工作线程,这个例子中应该也正好属于clickhouse主进程)
- 正在做什么:访问文件导致page fault,尝试分配4KB的page cache
- 为什么失败:系统内存和Swap都已耗尽
-
被杀者(Victim)
Jun 16 08:39:50 kernel: oom-kill:...,task=clickhouse-serv,pid=92948,uid=997 Jun 16 08:39:50 kernel: Out of memory: Killed process 92948 (clickhouse-serv) total-vm:5221616836kB, anon-rss:766697436kB, file-rss:172116kB, shmem-rss:0kB, UID:997 pgtables:8438064kB oom_score_adj=0- 被杀者PID:92948(ClickHouse主进程)
- 总虚拟内存 (total-vm):5,221,616,836 kB ≈ 4,976 GB
- 匿名RSS (anon-rss):766,697,436 kB ≈ 731 GB(实际物理内存占用)
- 文件RSS (file-rss):172,116 kB ≈ 168 MB
- 页表 (pgtables):8,438,064 kB ≈ 8 GB
内存回收风暴问题的系统分析
问题时间和基本现象
这台发生问题的机器是red603-21a,它上面部署了HDFS NameNode,DataNodes ResourceManager,NodeManager。问题发生的时候,它出现了严重的性能下降,整个过程持续了大约 30 分钟,最后通过人工杀死进程的方式进行干预,系统才完全恢复。
从各项指标上看,这次性能下降的表现相当剧烈。NameNode 的 RPC 响应时间从平时的 50ms 飙升到了 3~5 秒,慢了近百倍;与之对应,HDFS NameNode RPC 队列深度也从平时的 100 左右迅速堆积到了 7,000 上下,说明大量请求根本来不及处理、只能排队积压。
系统层面,CPU 的 IOWait 从 4% 一路涨到了 61%,意味着 CPU 绝大部分时间都在空等 I/O;
同一时间,处于 D state(不可中断阻塞)的进程数从 0 涨到了 25 个,系统整体负载(Load)也从 11 抬升到了 25~30。
关键矛盾是,IOWait高达61%,但磁盘读写速率只有16-18 MB/s,远低于NVMe SSD的正常性能(GB/s级别)。这说明瓶颈不在磁盘硬件。
系统的CPU和内存基本信息如下所示:
本文详细而完备地记录了完整的调查过程,包括多个假设的提出和验证,以及最终确定的根本原因。
相关Metrics
出现问题的机器是一台NameNode, ResourceManager, JournalNode, NodeManager的混合部署节点。我们忽略这样混合部署的问题,只从现象中探讨我们整个服务器的状态很为,并从中进行合理推测, 形成我们的推论闭环。
出现问题的过程中,从HDFS和所在机器的系统角度,我们看到的主要现象是:
-
NameNode RPC响应时间:50ms → 3-5秒
我们最先发现系统问题,也是由于HDFS用户反馈HDFS的性能出现下降。我们首先看了一下NameNode端RPC 请求的排队等待时间和处理时间。其中:-
排队时间的延长可能是由于突发请求的到达,也可能是由于请求的QPS不变但是系统性能出现了预期之外的降低从而导致处理时间延长,导致性能下降,我们不确定;
-
平均处理时间的延长则说明系统性能在下降。但是这并不意味着系统性能的下降是一个孤立原因,因为我们多重角色共享该操作系统,系统性能下降可能是由于其他角色的资源争用导致的(Resource Contention);

-
-
NameNode RPC队列深度:~100 → ~7,000
然后,我们看一下RPC队列的排队情况,这个排队类似于操作系统的CPU Load。我们看到,系统出问题以前,整个RPC队列长期处于满负荷状态,排队请求较多:
-
CPU IOWait:4% → 61%
然后,我们看一下操作系统层面的IO Wait,在系统出现问题以前,IO Wait已经飙升到很高的程度:
-
进程阻塞数(D state):0 → 25个
我们看到- 在事故发生以前(Before 15:00)和事故发生期间(15:00 ~ 15:30pm),系统开始出现大量处于阻塞状态的进程(D State Process, 参考Process Blocked);
- 在事故发生以前,还有一些处于运行状态的进程,而到了事故过程中(15:00 ~ 15:30),几乎就没有处于运行状态(Processing Running)的进程了;并且也没有新fork(Process Forked per Second)的进程了。我们系统不断创建的新进程都是由于这台机器上有NodeManager,因此会不断创建新的Container。所以,这大概是因为NodeMamanager也处于某一个被阻塞的状态,无法接受ResourceManager的调度从而启动新的进程了;
- 在事故发生以后(After 15:30),系统开始大量地fork新的进程。这些进程应该是NodeManager恢复正常以后所创建的container进程;



-
系统负载:11 → 25-30
可以看到,在系统性能出现严重问题以前(Before 15:00pm),系统的Load也在升高:
-
磁盘读写
当我们看到io wait很高,我们的第一反应是出现了大量的读写操作,这时候,理论上,我们可以把读写附载较高的磁盘定位出来,进而反向推理出对应的进程(其实我们大概知道是NameNode)。然后,我们看了一下磁盘负载情况:
下面是各个层面的设备读写情况(正数为写,负数为读):

我们惊讶的发现,无论是各个层面的设备(下文会讲解整个机器的设备分层管理,从上层的逻辑到下层的物理),还是最终merge到底层物理磁盘的读写,在事故发生的时候,读写的iops不仅仅没有增高,相反,它的iops在HDFS Delay最严重期间(15:00 ~ 15:30pm)整个磁盘的读写都非常低,几乎没有读写操作。
-
Free and Available Memory
可以看到,free memory在问题发生期间处于一个极低的水平,几乎只有几百MB, 同时,Memory Available也是出于持续低位,但是还有60GB左右:
-
Page Cache:6.47 GB → 0.28 GB
文件缓存在15:00期间被大量回收,在整个事故发生期间(15:00 ~ 15:30)几乎降到了0,这说明操作系统已经尝试对Cache进行evict:

-
SReclaimable(可回收Slab):44.31 GB → 54.45 GB → 35 GB
可回收的Slab内存(主要是dentry/inode缓存)经历了先上涨(15:00以前,从44GB涨到54GB)(+10GB),在事故发生期间(15:00 ~ 15:30)保持在54GB左右,在事故发生以后(15:30以后)后回收,从54GB降到35GB(-19GB):


由于Slab Relaimable 也是Memory Available的一部分,所以,可以看到,这里的Memory Availble中的大部分来自于Slab Reclaimable -
Swap使用:在15:30以前,系统的swap处于一个比较高的usage(大概28GB)。在15:30以后,系统的Swap Usage开始得到释放:

-
匿名内存(Anonymous Memory):~70 GB
进程的堆/栈等匿名内存保持相对稳定:
-
-
TCP连接数
-
TCP Connections:300 → 3,700(问题发生前持续攀升)
已建立的TCP连接数在问题发生前持续增长,到达峰值3,700:

-
PassiveOpens/ActiveOpens:从正常速率 → 0
新建连接速率(主动和被动)在14:50左右归零:

关于这个归零,其实可能有两种解释,一种解释是外部链接真的已经不再建立,另外一种解释是外部TCP连接在持续建立,但是由于系统资源耗尽,这种外部连接全部被丢弃。正确的解释应该是后者,因为我们看到大量的TCP Drop的发生:
-
问题排查
由于我们没有看到太大的磁盘压力,因此,我们看了一下系统日志/var/log/syslog,看看是否有所发现,然后,我们就看到了一些错误信息:
May 20 01:41:23 red603-21a kernel: [1137292.173901] kworker/u96:1: page allocation failure: order:2, mode:0x2200020(GFP_NOWAIT|__GFP_NORETRY|__GFP_NOWARN), nodemask=(null),cpuset=/,mems_allowed=0-1
May 20 01:41:23 red603-21a kernel: [1137292.173951] CPU: 31 PID: 1064881 Comm: kworker/u96:1 Kdump: loaded Tainted: P OE 4.15.0-142-generic #146~16.04.1-Ubuntu
May 20 01:41:23 red603-21a kernel: [1137292.173952] Hardware name: ...
May 20 01:41:23 red603-21a kernel: [1137292.173952] Workqueue: dm-crypt kcryptd_crypt [dm_crypt]
May 20 01:41:23 red603-21a kernel: [1137292.173953] Call Trace:
May 20 01:41:23 red603-21a kernel: [1137292.173958] dump_stack+0x63/0x8b
May 20 01:41:23 red603-21a kernel: [1137292.173960] warn_alloc+0xf9/0x190
May 20 01:41:23 red603-21a kernel: [1137292.173961] __alloc_pages_nodemask+0xe60/0x1040
May 20 01:41:23 red603-21a kernel: [1137292.173963] alloc_pages_current+0x6a/0xe0
May 20 01:41:23 red603-21a kernel: [1137292.173964] kmalloc_order+0x18/0x40
May 20 01:41:23 red603-21a kernel: [1137292.173965] kmalloc_order_trace+0x24/0xb0
May 20 01:41:23 red603-21a kernel: [1137292.173967] crypt_alloc_buffer+0x45/0x130 [dm_crypt]
这是一段典型的内存分配失败的日志,即, 内核在向操作系统"借"一块内存,但是操作系统居然找不到任何一片满足要求的连续内存给申请者。注意,这里并不代表内存不足,它只能代表内存碎片化,即没有连续内存可以满足要求,但是,也许系统中有很多的这种碎片化的空闲内存,这些碎片化的空闲内存的和加起来可能很客观。
在Linux内核中,内存是按"页"(Page)来管理的,一页通常是4KB。order:2的意思是:需要2^2 = 4个连续的页,也就是16KB的连续内存。
- order:0 = 1页 = 4KB
- order:1 = 2页 = 8KB
- order:2 = 4页 = 16KB
- order:3 = 8页 = 32KB
为什么需要16KB连续内存?因为日志里提到了dm_crypt——这是Linux的磁盘加密模块。加密算法(如AES-XTS)需要一块连续的缓冲区来处理数据块,不能用分散的碎片。
-
order:2这是什么意思?在Linux内核中,内存是按"页"(Page)来管理的,一页通常是4KB。
order:2的意思是:需要2^2 = 4个连续的页,也就是16KB的连续内存。- order:0 = 1页 = 4KB
- order:1 = 2页 = 8KB
- order:2 = 4页 = 16KB
- order:3 = 8页 = 32KB
为什么需要16KB连续内存?因为日志里提到了
dm_crypt——这是Linux的磁盘加密模块。加密算法(如AES-XTS)需要一块连续的缓冲区来处理数据块,不能用分散的碎片。 -
mode:0x2200020(GFP_NOWAIT|__GFP_NORETRY|__GFP_NOWARN)这串标志位说明了内核分配内存时的"态度":
GFP_NOWAIT:不等待,如果现在没有就算了__GFP_NORETRY:不重试,失败就失败__GFP_NOWARN:不要发出警告(但还是记录了日志)
这是一种"非阻塞"的内存分配请求。如果分配失败,调用者(dm-crypt)会用备用方案处理,不会让系统卡住。
-
Workqueue: dm-crypt kcryptd_crypt告诉我们:是磁盘加密模块在请求内存。
-
调用栈: 加密缓冲区分配失败。
crypt_alloc_buffer+0x45/0x130 [dm_crypt]
我们一直以为,这种内存碎片化就是系统变慢的原因,它在某些方面非常完美的match到了我们观察到的现象:
- Available Memory还有一些。就是系统碎片化严重,但是所有的碎片化内存加起来也是一大片内存;
- 我们看到io wait很高,这和我们的内存碎片化严重非常吻合,大量的读写操作,每个读写操作都需要向操作系统申请ORDER-n的连续内存,当读写机器频繁,操作系统的内存整理赶不上内存分配的速度,内存严重碎片化,io因为内存高频失败而被block; 所以,可以看到,IO Wait可能不仅仅来自于磁盘,也可能来自于内存,网络等;
- 最重要的,磁盘读写的iops几乎为零,这就更像内存碎片化的结果:很多读取操作在申请内存阶段就已经失败了,因此上层io很繁忙,但是这些读取操作根本到不了磁盘层面,磁盘很空闲。
我们用脚本统计了Fragmentation Failure的出现频率在时间上的分布:
grep "page allocation failure" /var/log/kern.log | awk '{print $1, $2, $3}' | uniq -c | sort -k2,3
结果如下:
| 日期 | 总次数 | 主要时段 |
|---|---|---|
| May 11 | 40 | 08:00(20), 22:00(10) |
| May 12 | 30 | 06:00(10), 16:00(10), 19:00(10) |
| May 13 | 22 | 09:00(10), 10:00(5), 其他分散 |
| May 14 | 44 | 07:00(10), 15:00(18), 18:00(10) |
| May 15 | 30 | 05:00(10), 12:00(10), 14:00(10) |
| May 16 | 39 | 06:00(9), 10:00(10), 20:00(10) |
| May 17 | 28 | 09:00(10), 15:00(10) |
| May 18 | 55 | 07:00(10), 14:00(10), 19:00(6), 20:00(11), 23:00(10) |
| May 19 | 45 | 01:00(10), 19:00(20) |
| May 20 | 42 | 01:00(10), 09:00(10), 11:00(10), 12:00(12) |
| May 21 | 40 | 08:00(10), 12:00(10), 15:00(10), 16:00(10) |
| May 22 | 30 | 04:00(8), 05:00(10), 06:00(10) |
| May 23 | 30 | 04:00(20), 05:00(10) |
可以看到,在事故发生当天,磁盘碎片化导致allocation failure的时间(Before 12:00pm UTC)和我们的HDFS性能下降最严重的时间区间(15:00~15:30pm UTC) 不匹配。
我们从Node Exporter 的Dashboard中看到,在fragment频繁发生期间,系统的free memory和available memory都没有达到完全耗尽的状态,available memory一直还有大概50GB+的剩余:
而在事故发生期间(15:00 ~ 15:30),free memory则降低到极低的水平,available memory也只是有所下降(上文已经展示):
所以,fragment问题只是系统性能出现问题的一个结果,它不是导致15:00 ~ 15:30的根本原因。syslog中的fragment问题是极其低频发生的问题,反映出系统本身的确处于一个不健康的危险状态,但是系统依然在正常运行。
所以,目前,我们看到,系统变慢期间,所有磁盘(尤其是/abc下面的raid1以及其他两块直接挂载的物理盘)几乎完全没有任何traffic,内存的fragment在系统变慢期间也没有发生过。但是,我们却真实地看到系统变慢期间高的IO Wait。由于我们在系统出问题期间没有及时打出主要jvm进程(NameNode, DataNode, ResourceManager等)的堆栈,因此目前只能从系统层面尝试找出蛛丝马迹,并以此进行合理推论。
我们上文讲过,在15:00 ~ 15:30系统出问题期间,系统io wait很高,Page Cache暴跌,同时,我们也看到,SReclaimable(可回收slam内存)在15:00期间其实也涨到了一个较高水平:


从上图可以看到
- 在15:00 ~ 15:30期间,Reclaimable Slab从44GB涨到54GB, 一共上涨了10GB。
- 在15:30以后,slab reclaimable则从54GB下降到了35GB,下降了9GB。
所以,我们似乎可以做这种假设:
同时,我们看一下系统的Dirty Page的情况。
因为系统如果要清空缓存,Dirty Page就必须写回磁盘。由于我们看到在系统发生问题期间(15:00 ~ 15:30pm),几乎没有磁盘IO,而cache在发生事故以前快速被释放,这说明其实系统的Dirty Page并不多:

从上面的Dirty Page 的信息可以看到,在真正的事故发生以前(Before 15:00pm),系统的Dirty Page就已经被清空得差不多了,所以在15:00 ~ 15:30pm,系统不会忙于Page Cache的Write Back,内存中也几乎没有什么Page Cache的空间了。
所以,整个事故的因果关系如下所示:
15:00时刻:
- SReclaimable暴涨10 GB(内核分配了大量dentry/inode)
- Cache被清空(内核回收页面缓存,用来腾出内存空间)
- MemFree依然很低(0.86 GB)
- 进程开始阻塞(15个进程等待内存)
15:00-15:30:
- Page Cache快速回收完成:6.47 GB → 0.28 GB(秒级完成,无刷盘)
- SReclaimable基本保持54GB不变(疑问: 既然是Reclaimable,为什么没有进行回收? )
- IOWait飙升到61%(进程阻塞在D state等待Slab释放,不是等磁盘)
- 磁盘读写很低(~16 MB/s)。说明没有脏页需要刷盘,也没有swap out
- 进程阻塞数持续增加(最高25个)
- 进程反复循环:分配失败 → Direct Reclaim → 回收少量 → 再分配 → 再失败
上图已经显示,脏页在14:50-15:00期间快速下降(从381 MB → 1.12 GB峰值 → 3.4 MB),在15:00-15:30期间保持在个位数MB(2-9 MB),证明没有脏页需要writeback。
15:30-16:00:
- Slab减少19 GB(内存回收完成)
- MemFree增加到4.89 GB(内存恢复)
- 进程逐渐恢复
所以,我们的疑问是,为什么15:00-15:30期间,也没有任何脏叶需要write back, 那么这么多(54GB)的SReclaimable Slab,但系统仍然卡死30分钟?
这里需要理解SReclaimable被计入Available的前提假设:内核认为这些内存"理论上可回收"。但实际回收速度取决于对象的引用计数:
- dentry/inode对象:如果对应的文件仍然被打开(open file descriptors),引用计数>0,无法释放
- TCP socket对象:如果连接仍然活跃(ESTABLISHED状态),对象被引用,无法释放
- 其他Slab对象:只要有内核代码持有引用,就无法立即回收
因此,当系统真正尝试回收SReclaimable时,可能遇到以下困境:
- Available显示有 (0.68 GB Free+ 可回收的54 GB SReclaimable) = “理论上有内存”
- 进程尝试分配内存失败,触发Direct Reclaim
- 内核扫描SReclaimable中的数百万个对象,发现大部分引用计数>0
- 每次扫描循环只能回收几十到几百MB(释放引用计数为0的少量对象)
- 进程长时间阻塞在D state等待回收完成
- 系统表现为"有54 GB可回收内存但无法快速分配"的矛盾状态
这正是red603-21a事故的核心机制:54 GB SReclaimable中的大部分被18,000个打开文件和3,700个活跃TCP连接引用,这些文件和连接在15:00-15:30期间仍然活跃,导致对应的dentry/inode/socket对象无法释放。直到15:30以后,连接逐步超时断开、文件逐步关闭,引用计数归零,Slab才能真正被回收(从54 GB降到35 GB)。
但是,是什么导致了内存突然被耗尽?为什么SReclaimable会在15:00暴涨10 GB?
由于我们排查问题的时候,系统已经被重启,因此很多现场的证据无法求得。我们唯一能做的,还是再次排查系统监控。
然后,我们看到,发生问题以前,系统的TCP连接数一直在攀升,在开始发生问题的时候,TCP Connections达到了一个较高的水平,大概3500个tcp连接:
同时,TCP Drop在事故发生以前(before 15:00pm)几乎没有发生,但是在事故期间(15:00 ~ 15:30pm)开始大量发生:
可以看到;
在 15:00 以前 (14:00-14:50):
- TCP Connections(当前已经建立的连接,Gauge Metrics): 逐步增长到 ~3,700
- PassiveOpens(自己每秒钟被动accept() 的数量, Counter Metrics): 从 ~400 下降到 0
- ActiveOpens(自己每秒钟主动connect()的数量, Counter Metrics): 从 ~40 下降到 0
在 15:00-15:30 期间 :
- TCP Connections: 保持在高位 (~3.7k)
- PassiveOpens: 持续为 0
- ActiveOpens: 持续为 0
- TCP drops: 大量发生
很显然,由于在15:00以前整个已经成功建立连接的TCP连接逐渐攀升,但是PassiveOpens/ActiveOpens(主动或者被动新建连接的速度)却逐渐降低,这意味着,在发生问题期间,新的连接(无论是Active还是Passive)由于系统性能原因而不断失败,而不是新的连接请求数量在降低。
所以,我们可以把事故发生前后的整个系统状态进行以下合理推测:
- 事故发生前的TCP连接洪水(14:00-15:00)
外部工作负载激增 ↓ TCP连接快速累积:300 → 3,700(12倍) ↓ 每个TCP连接占用内核内存: - struct sock: ~1-2 KB - send buffer: 默认 ~16-64 KB - recv buffer: 默认 ~16-64 KB - TCP control block: ~1 KB 平均每连接: ~50-100 KB ↓ 3,700个连接 × 50-100 KB = 185-370 MB基础开销 ↓ 加上关联的文件描述符、inode、dentry cache ↓ Slab内存爆炸: 44 GB → 54 GB (+10 GB!) - 内存耗尽,系统无法建立新连接(14:50-15:00)
Slab占用54 GB(总内存的42%) ↓ MemAvailable接近0 ↓ 新连接请求到达 ↓ accept()/connect()需要分配socket内存 ↓ 内存分配失败或触发Direct Reclaim ↓ PassiveOpens → 0(无法接受新连接) ActiveOpens → 0(无法发起新连接) 关键点:PassiveOpens和ActiveOpens归零不是因为没有请求,而是因为内存不足,无法建立新连接! - 系统冻结与连接掉线(15:00-15:30)
3,700个已建立连接继续占用内存 ↓ 进程尝试在这些连接上收发数据 ↓ 需要分配skb (socket buffer) ↓ 内存不足,触发Direct Reclaim ↓ 25个进程进入D state ↓ IOWait 61%,系统冻结 ↓ 已建立的连接无法维护: - 心跳超时 - keepalive失败 - read/write超时 ↓ TCP drops大量发生
我们看一下TCP连接数和系统的Slab内存在时间上的对应关系:
| 时间 | TCP Connections | Slab (SReclaimable) | PassiveOpens | ActiveOpens | 系统状态 |
|---|---|---|---|---|---|
| 14:00 | ~300 (正常) | ~44 GB | ~400 | ~40 | ✅ 正常 |
| 14:30 | ~2,000 (攀升) | ~48 GB | ~200 | ~20 | ⚠️ 内存压力 |
| 14:50 | ~3,700 | ~52 GB | ~0 | ~0 | ❌ 无法建立新连接 |
| 15:00 | ~3,700 | 54 GB | 0 | 0 | ❌ 冻结开始 |
| 15:15 | ~3,700 | 54 GB | 0 | 0 | ❌ 冻结峰值 |
| 15:30 | 下降(drops) | 开始回收 | 0 | 0 | 🔄 恢复中 |
| 可以看到,TCP连接数的攀升和Slab内存的攀升存在比较好的关联关系。 |
Slab增长了10 GB,我们可以对对应的3,700个TCP连接的内存占用进行相应分解:
每个TCP连接的内核对象
per_connection = {
"struct sock": 1.5, # KB - socket对象
"struct tcp_sock": 1.8, # KB - TCP控制块
"send buffer": 64, # KB - 发送缓冲区
"recv buffer": 64, # KB - 接收缓冲区
"file descriptor": 0.5, # KB - 文件描述符
"inode cache": 1, # KB - inode
"dentry cache": 0.5, # KB - dentry
}
总计
per_connection_total = sum(per_connection.values()) # ~133 KB
3,700个连接
total_memory = 3700 * 133 # ~492 MB
但还有关联对象:
- sk_buff (数据包缓冲区)
- TCP重传队列
- out-of-order队列
- 应用层缓冲
实际可能达到: 3,700 × 2-3 MB = 7.4-11.1 GB
这解释了Slab为什么从44GB增长到54GB!
同时,在15:00-15:30期间出现的大量的TCP drops表明:
- 系统无法维护现有3,700个连接
- send()/recv()需要分配skb
- 内存不足,操作失败
- 连接超时被drop
- 客户端重试加剧问题
- 连接drop后,客户端重试
- 新连接尝试建立(PassiveOpens请求)
- 但accept()失败(内存不足)
- 重试失败,继续drop
- 恶性循环
- TCP drops → 客户端重试 → 更多连接请求 → 内存压力更大 → 更多drops
完整的故障链条(修正版)
外部系统异常(未知原因)
↓
大量TCP连接请求涌入
↓
【14:00-14:50】TCP连接累积: 300 → 3,700
↓
每个连接消耗~2-3 MB内核内存
↓
Slab内存爆炸: 44 GB → 54 GB (+10 GB)
↓
【14:50】内存分配压力增大
↓
无法为新连接分配内存
↓
PassiveOpens → 0 (accept()失败)
ActiveOpens → 0 (connect()失败)
↓
【15:00】Direct Reclaim触发
↓
尝试回收Page Cache: 6.47 GB → 0.28 GB
↓
尝试回收SReclaimable 54 GB
↓
问题:大部分dentry/inode被18,000个打开文件引用
回收极慢:扫描数百万对象,每次只回收几十到几百MB
↓
25个进程反复阻塞在D state
↓
IOWait 61%(进程等待Slab释放,不是等磁盘刷盘)
磁盘IO ~16 MB/s(几乎为0)
↓
SReclaimable在15:00-15:30期间基本保持54GB不变
↓
【15:00-15:30】现有3,700个连接开始超时
↓
TCP drops大量发生
↓
【15:30】连接逐步关闭,内存释放
↓
系统恢复
与事故链条相对应的相应现象如下所示:
| Event | Time | Comments |
|---|---|---|
| TCP连接激增 | 14:00-14:50 | 从正常~300增长到3,700(12倍异常) |
| Slab内存增长 | 14:00-15:00 | +10 GB,对应3,700个连接×~3MB |
| PassiveOpens归零 | 14:50开始 | accept()内存分配失败,无法接受新连接 |
| ActiveOpens归零 | 14:50开始 | connect()内存分配失败,无法发起新连接 |
| Direct Reclaim触发 | 15:00 | 系统尝试回收内存,进程开始阻塞 |
| 进程阻塞峰值 | 15:00-15:30 | 25个进程反复进入D state,等待SReclaimable回收 |
| IOWait峰值 | 15:00-15:30 | 61%,进程阻塞在D state等待Slab释放,磁盘IO仅16MB/s |
| TCP drops高位 | 15:00-15:30 | 现有连接无法维护,逐步超时断开 |
| 系统开始恢复 | 15:30后 | 连接关闭释放内存,指标逐步恢复 |
根本原因(最终版)
所以,综上所述,我们的系统在15:00 ~ 15:00期间发生严重的性能问题,的确是内存问题,但是不是"内存碎片"问题,也不是"磁盘瓶颈"问题,而是:
-
外部工作负载异常,短时间内建立3,700个TCP连接(正常10-12倍)+ 打开18,000个文件,消耗10GB+内核内存(Slab从44GB增长到54GB),导致系统内存耗尽。
-
内核回收:Page Cache在事故发生以前被快速清空(6.47GB → 0.28GB),但SReclaimable 54GB无法快速回收——dentry/inode被18,000个打开文件引用,TCP对象被3,700个活跃连接引用。
-
结果:进程反复阻塞在Direct Reclaim中等待Slab释放(D state),IOWait 61%但磁盘几乎无IO(~16 MB/s),系统冻结30分钟直到连接/文件逐步关闭。
- PassiveOpens和ActiveOpens归零是症状,不是原因——它们归零是因为内存不足,无法为新连接分配socket内存。
- TCP drops是后果——现有连接无法维护,逐步超时断开。
这是一个经典的资源耗尽型故障:外部负载超出系统承受能力 → 内核资源耗尽 → 系统进入保护性冻结状态。
机器的IO设备结构
由于NodeExporter在统计系统的io的时候并不是以我们直观的挂载点(mount point)为导向的,而是以对应的块设备(nmve0c1n1, nvme4c1n1)和内核设备(dm-0, dm-1…)。因此我们可以先看一下这台机器上的设备层级结构,了解各个对应的块设备,内核设备和我们应用层看到的挂在点之间的关系。
这项技能在我们进行磁盘相关的诊断、研究磁盘运行指标也是必须的。
下图显示了我们这台故障机器上的设备层级结构:
root@red603-21a:~# lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE
NAME SIZE TYPE MOUNTPOINT FSTYPE
nvme0n1 11.7T disk
|-nvme0n1p3 7.6G part linux_raid_member
| `-md1 7.6G raid1 LVM2_member
| `-system-reserved 7.6G lvm ext4
|-nvme0n1p1 243M part /boot/efi vfat
|-nvme0n1p4 11.6T part linux_raid_member
| `-md2 11.6T raid1 crypto_LUKS
| `-md2-crypt 11.6T crypt LVM2_member
| |-md2--crypt-lvabc 11.6T lvm /abc ext4
| |-md2--crypt-lvvar 30G lvm /var ext4
| |-md2--crypt-lvswap 32G lvm [SWAP] swap
| `-md2--crypt-lvroot 30G lvm / ext4
`-nvme0n1p2 1.9G part linux_raid_member
`-md0 1.9G raid1 /boot ext4
nvme3n1 1.5T disk crypto_LUKS
`-nvme3n1-crypt 1.5T crypt ext4
nvme2n1 1.5T disk crypto_LUKS
`-nvme2n1-crypt 1.5T crypt ext4
nvme5n1 11.7T disk crypto_LUKS
`-nvme5n1-crypt 11.7T crypt /abc/data/nvme2 ext4
nvme1n1 11.7T disk
|-nvme1n1p4 11.6T part linux_raid_member
| `-md2 11.6T raid1 crypto_LUKS
| `-md2-crypt 11.6T crypt LVM2_member
| |-md2--crypt-lvabc 11.6T lvm /abc ext4
| |-md2--crypt-lvvar 30G lvm /var ext4
| |-md2--crypt-lvswap 32G lvm [SWAP] swap
| `-md2--crypt-lvroot 30G lvm / ext4
|-nvme1n1p2 1.9G part linux_raid_member
| `-md0 1.9G raid1 /boot ext4
|-nvme1n1p3 7.6G part linux_raid_member
| `-md1 7.6G raid1 LVM2_member
| `-system-reserved 7.6G lvm ext4
`-nvme1n1p1 243M part vfat
nvme4n1 11.7T disk crypto_LUKS
`-nvme4n1-crypt 11.7T crypt /abc/data/nvme1 ext4
可以看到:
- 我们的系统总共6块NVMe SSD盘:
- nvme0n1, nvme1n1: 11.7TB(RAID1镜像,系统盘) - nvme2n1, nvme3n1: 1.5TB(独立加密,数据盘) - nvme4n1, nvme5n1: 11.7TB(独立加密,数据盘) - nvme3n1 和 nvme4n1 都独立加密,
- 系统盘 nvme0n1 / nvme1n1 —— 三层 RAID1 + 加密 + LVM
两块 11.7TB 盘的 4 个分区,除了各自独立的 EFI 分区外,其余三对分区分别组成 3 个 RAID1 阵列:
nvme0n1p1 / nvme1n1p1 (243MB, vfat) → /boot/efi
↑ 注意:EFI 分区不做 RAID,每块盘各有一份独立副本
↑ nvme0n1p1 挂在 /boot/efi,nvme1n1p1 是备份(未挂载)
nvme0n1p2 + nvme1n1p2 (1.9GB) → md0 (RAID1) → /boot (ext4)
nvme0n1p3 + nvme1n1p3 (7.6GB) → md1 (RAID1) → system-reserved (LVM/ext4)
nvme0n1p4 + nvme1n1p4 (11.6TB) → md2 (RAID1) → 加密 → LVM → 主存储 ★核心
其中,raid分区是最重要的部分,因为我们的NameNode, ResourceMnager, JournalNode 的数据目录都写到了/abc/data下面,对应的就是这一块加密的raid1
这是最重要的部分,存储所有主要系统和数据:
nvme0n1p4 + nvme1n1p4 // 两个块设备的p4分区用来做成raid1
↓
md2 (RAID1, 11.6TB) // 磁盘阵列
↓
md2-crypt (dm-crypt加密层, crypto_LUKS)
↓
LVM2 (4个逻辑卷)
├─ lvroot (30GB) → /
├─ lvvar (30GB) → /var
├─ lvswap (32GB) → [SWAP]
└─ lvabc (11.6TB) → /abc ← HDFS NameNode主数据目录
每一层的作用:
- RAID1 (md2):两块盘镜像,任何一块盘坏了数据不丢失
- dm-crypt:AES加密,保护数据安全
- LVM:灵活的卷管理,可以动态调整分区大小。可以看到,LVM是在RAID上进行的,而不是RADI在LVM双进行。
其他四块加密盘则没有太多可说的,他们的挂载情况为:
nvme2n1 (1.5TB, crypto_LUKS)
└─ nvme2n1-crypt → 未挂载 (ext4文件系统已创建)
nvme3n1 (1.5TB, crypto_LUKS)
└─ nvme3n1-crypt → 未挂载 (ext4文件系统已创建)
nvme4n1 (11.7TB, crypto_LUKS)
└─ nvme4n1-crypt → /abc/data/nvme1 (ext4) // 给HDFS DataNode作为数据盘
nvme5n1 (11.7TB, crypto_LUKS)
└─ nvme5n1-crypt → /abc/data/nvme2 (ext4) // 给HDFS DataNode作为数据盘
整个挂载结果如下所示:
| 物理设备 | 容量 | 加密 | 冗余 | 挂载点 | 用途 |
|---|---|---|---|---|---|
| nvme0n1 + nvme1n1 (p4) | 11.6TB (镜像后可用) | ✅ LUKS | ✅ RAID1 | /, /var, /abc, swap |
系统 + NameNode/JournalNode 数据 |
| nvme4n1 | 11.7TB | ✅ LUKS | ❌ 单盘 | /abc/data/nvme1 |
DataNode 数据 |
| nvme5n1 | 11.7TB | ✅ LUKS | ❌ 单盘 | /abc/data/nvme2 |
DataNode 数据 |
| nvme2n1 | 1.5TB | ✅ LUKS | ❌ 单盘 | 未挂载 | 闲置 |
| nvme3n1 | 1.5TB | ✅ LUKS | ❌ 单盘 | 未挂载 | 闲置 |
然后,我们看一下设备映射列表。这里,/dev/mapper/ 目录中包含了Device Mapper创建的虚拟块设备的符号链接(md2-crypt, md2--crypt-lvabc),每个链接指向实际的内核设备 /dev/dm-0, /dev/dm-8:
root@red603-21a:~# ls -l /dev/mapper/
total 0
crw------- 1 root root 10, 236 Nov 24 2025 control
lrwxrwxrwx 1 root root 7 Nov 24 2025 md2-crypt -> ../dm-0
lrwxrwxrwx 1 root root 7 Nov 24 2025 md2--crypt-lvabc -> ../dm-8
lrwxrwxrwx 1 root root 7 Nov 24 2025 md2--crypt-lvroot -> ../dm-5
lrwxrwxrwx 1 root root 7 May 20 16:00 md2--crypt-lvswap -> ../dm-7
lrwxrwxrwx 1 root root 7 Nov 24 2025 md2--crypt-lvvar -> ../dm-6
lrwxrwxrwx 1 root root 7 Nov 24 2025 nvme2n1-crypt -> ../dm-1
lrwxrwxrwx 1 root root 7 Nov 24 2025 nvme3n1-crypt -> ../dm-2
lrwxrwxrwx 1 root root 7 Nov 24 2025 nvme4n1-crypt -> ../dm-3
lrwxrwxrwx 1 root root 7 Nov 24 2025 nvme5n1-crypt -> ../dm-4
lrwxrwxrwx 1 root root 7 Nov 24 2025 system-reserved -> ../dm-9
了解了整个系统的磁盘硬件结构和设备映射关系后,我们可以看到:
- 这台机器的 master role 数据都写入到
/abc目录下面。从上面的磁盘结构可以看到,/abc对应的是一个 RAID1,由两块各 11.6TB 的分区(nvme0n1p4 + nvme1n1p4)镜像组成,镜像后可用容量为 11.6TB。 /abc/data/nvme1是直接 mount 到单块 nvme 加密磁盘(nvme4n1-crypt)上的;但/abc的挂载架构则完全不同,它和系统根目录/共用同一套存储栈,形成了从应用层到底层磁盘的复杂层级结构:LVM (lvabc, dm-8) → Encryption (md2-crypt, dm-0) → RAID1 (md2) → Partition (p4) → Physical disk。最关键的区别是,它有 RAID1 镜像。- HDFS 的 DataNode 配置了三个数据目录
/abc/data/nvme[1-3],但其中只有nvme1、nvme2真正挂载到了独立的加密数据盘(nvme4n1、nvme5n1);/abc/data/nvme3这个路径下并没有挂载任何独立磁盘,它实际上落在了/abc上,也就是写到了 RAID1(md2)里——这是一个错误挂载。 - 我们还把 NameNode、JournalNode 和 DataNode 全部部署在了同一台机器上:NameNode 的 edits dir 配置为
/abc/dfs/nn,JournalNode 的 journal edits 配置为/abc/dfs/jn,二者都落在 RAID1 上;同时,由于上面提到的nvme3错误挂载,写入密集型的 DataNode 也有一路数据被写进了同一块 RAID1。这样一来,master 角色(NameNode/JournalNode)与 DataNode 的写入压力叠加在同一块 RAID1 上,相互反压,既拖慢 NameNode/JournalNode 的元数据操作,也拖累 DataNode 的写入性能。
Linux OOM Killer问题的系统分析
这次故障出问题的机器是 red502-4.iad7.prod.abc.com。
与前一台内存回收风暴的机器相比,他们巨大的不同是,这台机器的物理内存高达 755 GB(791,799,324 kB)。然而就是在这样一台内存极其充裕的机器上,运行其上的 ClickHouse 进程却一路吃掉了约 731 GB 内存,几乎占满了整个系统,最终把内核逼到走投无路,触发了 OOM Killer 将其杀死。
我们看下问题发生前后的系统性能表现,指标,日志,并对整个因果关系进行梳理。
相比于上文的内存回收风暴问题,OOM Killer问题的问题诊断和推理过程简单很多。
系统的整体基本信息如下所示:
问题发现与调查
2026年6月16日早上08:39,我们观察到red502-4.iad7.prod.abc.com上的ClickHouse服务异常重启。检查系统日志后,我发现了内核OOM Killer的触发记录。
这是一次典型的内存耗尽事件。
其实,整个OOM Killer的发生过程,其实有三个不同角色在参与:
- 触发者(Invoker): 到底是谁申请内存触发了OOM Killer,即压死骆驼的最后一根稻草;
- 始作俑者: 到底谁是内存占用的大头,即骆驼身上最重的东西;
- 受害者(Victim): 骆驼
但是很明显,在操作系统层面,往往操作系统选择杀死的进程,一般就是系统中占用内存最多的进程,所以,我们下文讨论OOM Killer,只需要关心Invoker和Victim;
OOM Killer发生的时候找到对应的日志和因果关系很简单。本文主要是以这个事件为契机,系统分析操作系统在该事件发生前后的状态,并构建因果关系。
日志分析
我从journalctl获取了完整的OOM日志。第一部分显示了谁触发了OOM Killer:
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: clickhouse-serv invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: CPU: 0 PID: 92956 Comm: clickhouse-serv Not tainted 5.4.0-216-generic #236-Ubuntu
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: Hardware name: Dell Inc. PowerEdge R7615/0G9DHV, BIOS 1.13.1 06/17/2025
触发者是一个名为clickhouse-serv的线程,PID 92956。它尝试分配的是GFP_HIGHUSER_MOVABLE类型的内存,order=0表示只需要1页(4KB)。
也就是说,它只是想分配4KB内存,但系统已经耗尽到连4KB都拿不出来了。
我们在我们的Node Exporter上也看到了OOM Killer的发生:

Kernel Log中还显示了调用堆栈:
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: Call Trace:
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: dump_stack+0x6d/0x8b
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: dump_header+0x51/0x200
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: oom_kill_process.cold+0xb/0x10
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: out_of_memory+0x1cf/0x500
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: __alloc_pages_slowpath+0xe1b/0xef0
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: __alloc_pages_nodemask+0x2d0/0x320
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: alloc_pages_current+0x87/0xe0
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: __page_cache_alloc+0x72/0x90
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: pagecache_get_page+0xbf/0x300
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: filemap_fault+0x79e/0xbc0
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: ext4_filemap_fault+0x32/0x50
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: __do_fault+0x3c/0x170
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: do_fault+0x24b/0x640
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: __handle_mm_fault+0x4c5/0x7a0
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: handle_mm_fault+0xca/0x200
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: do_user_addr_fault+0x1f9/0x440
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: page_fault+0x34/0x40
从这个调用栈可以看出完整的流程:
- 用户空间程序访问了一个还未加载到内存的文件页
- CPU触发缺页中断(
page_fault) - 内核尝试从ext4文件系统读取数据到page cache(
ext4_filemap_fault→__page_cache_alloc) - 内存分配进入慢路径(
__alloc_pages_slowpath),说明快速分配已经失败 - 内核发现无法分配内存,触发OOM Killer(
out_of_memory)
所以,这个ClickHouse线程正在读取磁盘上的数据文件,需要page cache来缓存文件内容,但系统已经完全没有内存了。
OOM Killer触发时,内核记录了详细的系统级别的内存使用快照:
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: Mem-Info:
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: active_anon:190237204 inactive_anon:2249859 isolated_anon:0
active_file:6519 inactive_file:4251 isolated_file:508
unevictable:100379 dirty:0 writeback:0 unstable:0
slab_reclaimable:1591905 slab_unreclaimable:729379
mapped:100374 shmem:9 pagetables:2106793 bounce:0
free:439754 free_pcp:0 free_cma:0
这些数字的单位是页(4KB)。我将关键指标转换为GB:
| 内存类型 | 页数 | GB | 说明 |
|---|---|---|---|
| active_anon | 190,237,204 | 725.8 GB | 活跃的匿名内存(进程正在使用) |
| inactive_anon | 2,249,859 | 8.6 GB | 不活跃的匿名内存 |
| active_file | 6,519 | 0.025 GB | 活跃的文件缓存 |
| inactive_file | 4,251 | 0.016 GB | 不活跃的文件缓存 |
| free | 439,754 | 1.7 GB | 可用内存 |
| slab_reclaimable | 1,591,905 | 6.1 GB | 可回收的Slab缓存 |
| slab_unreclaimable | 729,379 | 2.8 GB | 不可回收的Slab |
| pagetables | 2,106,793 | 8.0 GB | 页表开销 |
| MemAvailable | - | ~7.8 GB | free + file cache + slab_reclaimable |
从这个快照可以看出:
- 匿名内存(进程私有内存)= 725.8 + 8.6 = 734.4 GB,占总内存的97%
- 文件缓存(active and inactive file cache)只剩 0.041 GB,几乎被完全清空
- 空闲内存只有1.7 GB
同时,系统日志还记录了OOM Killer发生以前各个主要进程的内存使用状态,并且, 由于OOM Killer的victim是ClickHouse,因此ClickHouse进程的记录更详细,它的RSS还专门区分了Anonymous 和 File RSS:
从OOM Killer的进程列表日志中,我们可以看到每个进程的内存分布。以下是内核日志:
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: Tasks state (memory values in pages):
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: [ 378] 0 378 127456 530 364544 0 0 systemd-journal
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: [ 405] 0 405 5604 83 77824 0 0 systemd-udevd
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: [ 28955] 1001 28955 2801634 1245678 37187584 421850 0 java
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: [ 29108] 1001 29108 2854721 982456 31342592 389234 0 java
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: [ 92948] 997 92948 1305404209 191717388 8640577536 8083026 0 clickhouse-serv
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0-1,global_oom,task_memcg=/system.slice/clickhouse-server.service,task=clickhouse-serv,pid=92948,uid=997
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: Out of memory: Killed process 92948 (clickhouse-serv) total-vm:5221616836kB, anon-rss:766697436kB, file-rss:172116kB, shmem-rss:0kB, UID:997 pgtables:8438064kB oom_score_adj:0
注意:上面的进程列表只显示了total_vm和rss,但最后一行"Out of memory: Killed process"中有victim(ClickHouse Server进程)详细的anon-rss和file-rss分解。
从日志中提取关键信息(内存值已转换为GB,日志中的是Page Number,不是Size):
| PID | 进程名 | total_vm | anon-rss | file-rss | rss总计 | swap | 说明 |
|---|---|---|---|---|---|---|---|
| 378 | systemd-journal | 0.5 GB | ? | ? | 0.002 GB | 0 GB | 系统日志服务 |
| 405 | systemd-udevd | 0.02 GB | ? | ? | 0.0003 GB | 0 GB | 设备管理 |
| 28955 | java (DataNode) | 10.7 GB | ? | ? | 4.7 GB | 1.6 GB | HDFS DataNode |
| 29108 | java (NodeManager) | 10.9 GB | ? | ? | 3.7 GB | 1.5 GB | YARN NodeManager |
| 92948 | clickhouse-serv | 4,976 GB | 730.8 GB | 0.168 GB | 731 GB | 30.8 GB | ClickHouse主进程 |
我们从上面的kernel log可以看到: ClickHouse消耗的731GB几乎全是匿名内存(anon-rss),不是文件映射内存:
- ClickHouse的anon-rss: 730.8GB(99.98%)
- ClickHouse的file-rss: 0.168GB(0.02%)
这说明ClickHouse在执行计算密集型任务(聚合、临时表、内存排序),而非I/O密集型任务
(读取part文件)。
这与工作负载类型有关: - 大量查询已merge的old part → 通过mmap读取 → file-rss高
- 大量聚合、计算、临时表操作 → 使用堆内存 → 匿名内存高、file-rss低
所以,OOM Killer发生以前,操作系统淘汰掉了大量的Page Cache,几乎全部用来承载ClickHouse用于计算的匿名内存:
| 内存类型 | 大小变化 | 可回收性 | 说明 |
|---|---|---|---|
| 系统Page Cache (Active+Inactive File) | 08:36时615GB → OOM时0.041GB | ✅ 完全可回收 | 系统级"无主"缓存,被内核回收给匿名内存腾空间 |
| ClickHouse的file-rss | OOM时0.168GB | ⚠️ 难以回收 | 进程级文件映射,OOM时已很小(主要是代码和.so) |
| ClickHouse的anon-rss | OOM时730.8GB | ❌ 无法回收(swap已耗尽) | 计算密集型负载,消耗大量堆内存 |
Metrics分析
我们从Grafana上看一下事件发生时候的系统状态趋势:
-
下面的NodeExporter Metrics显示了事件发生前后的Available Memory和Free Memory的状态:

可以看到
- 在OOM Killer发生以前的很长时间,系统的Memory Free都持续处于极低的状态,但是这并不是系统不健康的指标,因为这些Free Memory并不包含可以被回收的Page Cache(Clean or Dirty),可以被回收的Slab Reclaimable,以及可以swap出去的部分。
- 在OOM Killer发生以前的很长时间,系统的Available Memory都极其充足,大概有640GB。这部分充足的内存可能包括:
- Free Memory: 约20GB
- 可回收的Page Cache: 约580GB
- 可回收的SReclaimable Slab: 约6GB
- Buffer: 约34GB
- 而在OOM Killer接近发生的时候,Memory Available开始快速下降,一直降到了10GB以下(参考上文表格), 这说明Memory Cache在快速被淘汰。MemAvailable在5分钟内从640GB暴跌到了约8GB,跌幅超过99%。随后ClickHouse进程被杀死,内存迅速恢复到738GB。我们下面会看到Memory Cache的相关指标以及其它佐证指标;MemAvailable = Free + 可快速回收的内存,它才是真正反映"系统还能提供多少内存"的指标。
-
同时,我们看到了系统的Memory Cache Eviction的发生。从Prometheus采集的数据显示:
- Memory Active File: 从08:36的395.61GB在08:37暴跌至152.81GB,到08:40降至44.27MB
- Memory Inactive File: 从08:36的220.00GB在08:37暴跌至152.28GB,到08:38降至68.32MB

这意味着系统Page Cache(Active File + Inactive File)从08:36的约615GB在2-4分钟内暴跌到100MB左右,这是内核在为ClickHouse的匿名内存腾空间。
-
可以看到,系统的匿名内存使用开始逐渐增长,这说明ClickHouse Server进程在大量申请,大概从60GB增长到了700GB,这也是系统cache被淘汰和swap out发生的原因:


-
但同时,我们看到另一个指标——File Memory(进程级file-rss的总和)发生了剧烈的变化,几乎被清空,这与我们捕捉到的OOM Killer发生前一刻的active_file和inactive_file的情况一致,几乎降到0了。 从Kerner 日志可以看到, ClickHouse虽然申请了大量的匿名内存,但是file-rss在OOM时刻被清理到只有0.168GB(可以看OOM内核日志)。这也说明操作系统在尽力清空内存,为ClickHouse快速增加的Anonymous Memory腾挪空间:

至少,目前为止,我们看到的系统内存被耗尽的完整过程:
08:00-08:30 (内存充裕阶段):
ClickHouse匿名内存增长: 60GB → 200GB ↓ 系统Page Cache充足: ~615GB ↓ 系统正常运行08:30-08:39 (内存压力阶段):
ClickHouse匿名内存暴涨: 200GB → 731GB (增加531GB!) ↓ 系统疯狂清空Page Cache来腾空间: 615GB → 100MB ↓ Swap空间耗尽: 0GB剩余 ↓ MemAvailable暴跌: 640GB → 8GB ↓ 无可用内存,触发OOM Killer -
同时,我们看到,系统的可回收的Slab Reclaimable在降低,这也是操作系统在试图清空内存,留出更多内存空间:

我们从Metrics采集中端的部分可以看到,Slab Reclaimable的回收是有效的,至少我们可以看到Slab Reclaimable从 18.7GB -> 4.8GB。这与我们上文讲到的由于TCP连接风暴导致的内存回收风暴无法回收Slab Reclaimable有明显不同。 -
同时,我们看到一个关键的内存指标:Page Table Overhead。这个指标在OOM Killer发生以前保持相对稳定,一直处于7.5GB左右。只有当ClickHouse Server进程被杀死后,Page Table Overhead才从7.5GB降低到了很小的值。

这个现象值得深入分析:为什么在内存压力最大的时候,Page Table的大小几乎没有变化?而Page Cache却从615GB暴跌到0.041GB?要理解这个对比,我们需要先理解Page Table是什么,以及它与Page Cache的本质区别。
基于上文我们对Page Table的理解,我们可以回答这个问题:
-
Node Exporter显示的是系统级总和:
node_memory_PageTables_bytes是系统上所有进程的Page Table开销之和,不是单个进程的。 -
ClickHouse的Page Table早就分配好了:ClickHouse进程在启动时就申请了4,976 GB的虚拟内存空间(VIRT),对应的Page Table结构(约7.5GB)也在那时就分配了。
-
内存使用增长≠Page Table增长:
- 08:00-08:39期间,ClickHouse的RSS(实际物理内存占用)从60GB增长到731GB
- 但虚拟内存空间(VIRT)一直是4,976GB,没有扩大
- 因此Page Table的大小不需要改变
- 只是Page Table中的内容在变化:更多的页表项被标记为"已映射到物理内存"
打个比方:Page Table就像一本地址簿,里面有100万个条目(虚拟地址)。刚开始只有6万个条目填上了真实地址(物理内存),后来有73万个条目都填上了真实地址。但地址簿本身的大小(厚度)没有变化,只是填写的内容多了。
-
进程退出时Page Table被释放:当ClickHouse被OOM Killer杀死后,整个进程的地址空间被销毁,对应的Page Table也被释放,这时我们看到Page Table Overhead从7.5GB降低到了很小的值(其他进程的Page Table总和)。
在内存压力下,Page Table成为一个隐形的负担:
- 对于单个大进程(如我们的ClickHouse消耗731GB内存),它的Page Table就占用了约8GB
- 这8GB是不可回收、不可swap的,是真正的"硬占用"
- 当系统内存紧张时,这8GB成为了"不可释放的死内存"
- 只有杀死进程,才能释放这部分内存
这也解释了为什么OOM Killer在计算进程得分时,会考虑进程的内存占用:大进程不仅占用大量物理内存(RSS),还占用大量不可回收的Page Table内存。杀死它们能够一次性释放这两部分资源。
通过这个案例,我们可以清楚地看到Page Table和Page Cache在内存回收时的巨大差异:
特性 Page Cache Page Table 用途 缓存磁盘文件内容 记录虚拟地址到物理地址的映射 大小 动态变化,可占用大量内存(615GB) 相对固定,由进程虚拟内存大小决定(7.5GB) 可回收性 可以随时回收 完全不可回收 内存压力下的行为 被优先清空(615GB → 0.041GB) 保持不变(7.5GB → 7.5GB) 释放条件 内核可主动回收 只能等进程退出 对性能的影响 回收后重新读取会慢,但不会crash 如果能回收,系统会立即崩溃 关键区别:
-
Page Cache是"软资源":
- 系统可以随时清空Page Cache来腾出内存
- 清空后最多影响性能(需要重新从磁盘读取),不会破坏正确性
- 在我们的案例中,615GB的Page Cache被清空,为匿名内存腾出空间
-
Page Table是"硬资源":
- 系统绝对不能动Page Table,否则进程的地址空间映射会崩溃
- 即使只释放一个页表项,对应的虚拟地址访问就会出错
- 在我们的案例中,7.5GB的Page Table必须原封不动地保留
-
为什么Page Table不能像Page Cache一样被swap?
- 操作系统通过Page Table来判断某个虚拟地址是在物理内存还是在swap中
- 如果Page Table本身被swap了,操作系统就无法知道"Page Table在哪里"
- 这是一个无解的循环依赖:要访问swap中的Page Table,必须先查Page Table
在我们的案例中,OOM Killer杀死ClickHouse进程后:
- 释放了731GB的物理内存(RSS)
- 释放了8GB的Page Table(这部分之前完全无法回收)
- 释放了30.8GB的Swap空间
- 总计释放了约770GB的系统资源
这就是为什么系统在27秒内迅速恢复的原因。更重要的是,这8GB的Page Table是第一次有机会被释放——在进程存活期间,无论内存压力多大,这部分内存都是"死锁"的。
-
-
Swap状态:
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: Free swap = 0kB Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: Total swap = 33554428kB Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: Swap cache stats: add 14209763, delete 13503153, find 36142812/37688513对应的Swap in/out,Swap空间的使用率如下图所示。
同样,系统存在几分钟的采集断层,因此无法准确或者OOM Killer发生的前一秒的swap大小,但是我们从Kernel log看到,OOM Killer发生的前一刻,SWAP已经耗尽:


Swap空间完全耗尽(总共 31.99 GB,剩余 0 KB)。我们从Swap in/out的频率可以看到
- 系统在最后一刻疯狂地进行swap out,这也是为了释放内存。无法再为内存回收提供缓冲。
- 在事故发生的时候,系统有少量的Page In发生,这说明一些进程在读取一些匿名文件:
由于系统开始进行大量的swap in/out,并且swap out的大小超过了swap in,所以整个swap size在事故发生的时候开始增长。由于出问题的时候系统发生了暂停,Node Exporter的metrics 采集发生了中断,无法准确或者事故发生时候的准确的swap size,但是我们可以合理推断,swap size已经逼近了32GB的最大容量,几乎耗尽。
水位线状态:
我们从日志可以看到相关水位线的日志:
Jun 16 08:39:50 red502-4.iad7.prod.abc.com kernel: Node 0 Normal free:67200kB min:67452kB low:857548kB high:1647644kB active_anon:760948816kB ...
Normal内存区域的free(65.6 MB)低于min水位线(65.9 MB)。当这种情况发生时,内核必须停止常规内存分配,启动直接回收(direct reclaim),如果回收失败,触发OOM Killer。
由于匿名内存占据了绝大部分(725.8 GB),而匿名内存正在被进程使用无法回收,所以直接回收失败,触发了OOM。
Victim为什么是ClickHouse-Server
为什么选择PID 92948(ClickHouse Server)?
OOM Killer的计分算法主要基于进程的物理内存占用(RSS):
- ClickHouse RSS = 731 GB,占系统总内存的96.8%
- 所有其他进程RSS总和 < 10 GB
- ClickHouse的oom_score远超其他进程,成为唯一的victim候选
同时,由于OOM Killer在杀死ClickHouse Server 的时候打印了ClickHouse Sever的具体内存状态,我们看到:
- anon-rss (731 GB) 占RSS的 99.98%
- file-rss (0.168 GB) 仅占RSS的 0.02%
- 说明这是一个计算密集型而非I/O密集型的工作负载
这里有个关键发现:PID 92956(触发者)并没有单独出现在进程列表中。这是因为92956是92948的一个线程。这是一个"自杀式"OOM:ClickHouse Server 进程的线程触发了OOM,导致整个进程被杀。
OOM Reaper快速回收:
Jun 16 08:40:17 red502-4.iad7.prod.abc.com kernel: oom_reaper: reaped process 92948 (clickhouse-serv), now anon-rss:0kB, file-rss:401516kB, shmem-rss:0kB
从08:39:50到08:40:17,27秒内,OOM Reaper完成了内存回收,anon-rss从731 GB降到0,系统迅速恢复了可用内存。
完整事件时间线
为了完整理解这次OOM事件,我将内核日志、Prometheus指标和系统行为关联起来,构建完整的因果链:
-
08:00 - 08:30 我们的系统在正常运行 在这个阶段,ClickHouse的内存使用逐渐增长
-
08:30 - 08:39(临界压力期) 系统进入内存压力状态:Page Cache被逐渐清空(从正常的几百GB降到几乎为0),Swap完全耗尽(31.99 GB用尽),ClickHouse Server带来的匿名内存持续增长到725.8 GB。
-
08:39:50 OOM 触发,NodeExporter采集被中断,OOM一旦触发,系统即立刻进入恢复状态
对应的完整的事件链:ClickHouse查询消耗大量内存 ↓ 匿名内存增长到731 GB(97%系统内存) ↓ Swap完全耗尽(31.99 GB用尽) ↓ 内核清空Page Cache(577 GB → 0.49 GB) ↓ 可用内存低于min水位线 ↓ ClickHouse线程92956尝试分配4KB page cache失败 ↓ 内核启动直接回收(direct reclaim) ↓ 直接回收失败(无可回收内存) ↓ 触发OOM Killer ↓ 杀死ClickHouse进程92948(731 GB) ↓ OOM Reaper快速回收(27秒) ↓ 系统恢复正常 -
08:40 - 09:40 系统进入快速恢复阶段
系统快速恢复:- node_memory_MemFree_bytes: 0 → 510 GB
- node_memory_MemAvailable_bytes: 201 GB → 708 GB
- node_memory_Cached_bytes: 0.49 GB → 171.64 GB(Page Cache重建)
- ClickHouse服务被systemd自动重启
对比分析:两个内存危机案例
在深入理解了red502-4的OOM Killer事件后,我想将它与之前调查的内存危机案例(red603-21a)进行对比。这两个案例虽然都涉及内存问题,但根本原因、系统行为和解决方式完全不同。
案例对比概览
| 维度 | red603-21a | red502-4 |
|---|---|---|
| 问题根源 | 内存回收风暴(TCP Connection Flood导致的Slab激增) | 内存物理耗尽(匿名内存) |
| 操作系统行为 | 直接内存回收(Direct Reclaim) | OOM Killer |
| 主要服务 | HDFS NameNode, DataNodes, NodeManager, ResourceManager | ClickHouse Server |
触发机制对比:
-
red603-21a的链条:外部工作负载(3,481个TCP连接)→ 打开18,000个文件 → Slab内存从44 GB激增到54 GB(dentry/inode缓存)→ Free Memory降到0.68 GB → 内核清空Page Cache(6.47 GB → 0.28 GB)→ 仍然不够,启动直接内存回收 → 尝试回收54GB SReclaimable但大部分对象被引用中,回收极慢 → 进程反复阻塞在D state等待Slab释放 → 系统卡顿30分钟。
-
red502-4的链条:ClickHouse查询消耗大量内存 → 匿名内存增长到731 GB → Swap完全耗尽 → 内核清空Page Cache → 仍然不够,无可回收内存 → ClickHouse线程尝试分配4KB失败 → 触发OOM Killer → 杀死ClickHouse进程 → 系统瞬间恢复。
两种异常的对比
| 指标 | red603-21a(内存回收风暴) | red502-4(OOM Killer) | 为什么不同? |
|---|---|---|---|
| Page Cache | 15:00前 6.47 GB → 15:00时 0.28 GB |
OOM前 577 GB → OOM时 0.49 GB |
两边都在事故时被内核清空,说明Page Cache不是瓶颈 |
| SReclaimable | 15:00前 44 GB → 15:00时 54 GB 15:30后降至35 GB |
OOM前 20 GB → OOM后 6 GB |
red603的54GB被18,000文件+3,700连接引用,回收极慢,直到连接超时才释放;red502从20GB降到6GB,说明内核成功回收了14GB |
| 匿名内存 | 事故期间 ~70 GB | OOM时 726 GB | red502的ClickHouse一个进程吃掉96%内存,Swap也满了,只能杀 |
| Swap可用 | 事故期间 ~4 GB | OOM时 0 GB | red502的Swap完全耗尽,内核没有任何退路 |
| IOWait | 14:50时 4% → 15:00-15:30 飙至61% |
事故前后保持 0.74%-3.11% | red603的61%不是等磁盘,是25个进程卡在Direct Reclaim等Slab释放;red502直接杀进程,无需等待 |
| 进程阻塞 | 15:00前 0个 → 15:00-15:30 最高25个 |
事故前后 0-2个 | red603在反复尝试回收,进程排队等;red502快刀斩乱麻,没有等待 |
| 磁盘读速率 | 14:50时 20 MB/s → 15:00-15:30 降至16 MB/s |
OOM前后保持 21 MB/s | red603几乎无磁盘IO(证明不是刷脏页);red502磁盘不忙 |
| 系统响应 | 卡死30分钟,需人工干预(杀NodeManager + Decommission DataNode + 停止写入)才恢复 | 瞬间杀进程,27秒自动恢复 | OOM Killer达到了系统的自保护功能,虽然有代价 |
所以,现在,我们最后的问题是,为什么似乎都是内存不足,一个触发OOM Killer,一个却长时间hang住?
red603-21a未触发OOM的原因:
• Memory Available = 0.68 GB free + 54 GB SReclaimable = “理论上54.68GB可用”
• 虽然实际回收极慢(对象被3,700连接+18,000文件引用),但内核认为"还有内存可回收"
• 系统一直在Direct Reclaim循环中挣扎,未达到OOM的触发条件(所有可回收内存耗尽)
• 进程反复阻塞→尝试回收→分配少量→再阻塞,陷入"慢性死循环"
• 最终需人工干预:杀NodeManager释放部分连接 + Decommission DataNode关闭文件 + 通知用户停写,才打破僵局
red502-4触发OOM的原因:
• 匿名内存731GB无法回收(进程仍在使用)
• Swap 31.99GB完全耗尽,无处可换
• SReclaimable仅20GB,回收后也只能释放14GB
• Page Cache已清空到0.49GB,无可再清
• 系统判定"真的没有任何可回收内存了",触发OOM Killer
• 一刀杀掉ClickHouse,731GB瞬间释放,27秒自愈
总结:red603有"名义上的可回收内存"(54GB Slab)但回收速度极慢,系统选择"慢慢等"而非"杀进程";red502则是"真正的物理耗尽"(匿名内存+Swap满),内核别无选择只能OOM
预防措施与监控建议
通用告警:
| 告警名称 | 触发条件 | 说明 |
|---|---|---|
| 内存可用量过低 | < 2 GB | 避免接近OOM临界点 |
| Swap使用率过高 | (SwapTotal - SwapFree) / SwapTotal > 80% | Swap快耗尽时告警 |
red603-21a类型告警:
| 告警名称 | 触发条件 | 说明 |
|---|---|---|
| SReclaimable占比过高 | > 40% | Slab缓存占用过多 |
| 进程阻塞数异常 | > 10 | 检测内存回收导致的阻塞 |
| IOWait与磁盘I/O不匹配 | iowait > 50% AND disk_read < 100 MB/s | 可能是内存回收而非磁盘瓶颈 |
red502-4类型告警:
| 告警名称 | 触发条件 | 说明 |
|---|---|---|
| ClickHouse内存过大 | > 600 GB | 单进程内存异常 |
| Swap完全耗尽 | < 1 GB | Swap即将耗尽 |
| 匿名内存占比过高 | > 90% | 大量不可回收内存 |
预防配置(red502-4专项):
我们可以限制ClickHouse内存使用,既可以使用绝对值,也可以使用当前物理内存的比例值:
<!-- /etc/clickhouse-server/config.xml -->
<max_server_memory_usage>700000000000</max_server_memory_usage>
<max_server_memory_usage_to_ram_ratio>0.9</max_server_memory_usage_to_ram_ratio>
或者,使用cgroup限制:
systemctl set-property clickhouse-server.service MemoryMax=700G
systemctl set-property clickhouse-server.service MemoryHigh=650G
我们也可以适当增加Swap空间。但是,盲目增大Swap空间,只能在一定程度上延缓最后崩溃的到来,而无法解决根本问题:
fallocate -l 100G /swapfile2
chmod 600 /swapfile2
mkswap /swapfile2
swapon /swapfile2
echo '/swapfile2 none swap sw 0 0' >> /etc/fstab
我们可以这样,定期检查大内存查询,并把检查结果push到Prometheus,基于Prometheus的Metrics进行监控报警:
SELECT query_id, user, query, memory_usage / 1024 / 1024 / 1024 AS memory_gb, elapsed
FROM system.processes
WHERE memory_usage > 100e9
ORDER BY memory_usage DESC;
总结
这两个案例展示了操作系统内存管理的复杂性:
red603-21a(分配不均导致的回收风暴): 内存总量充足(125 GB),但Slab激增占用了大部分可用内存,回收机制存在但很慢,系统卡顿。我们最终通过关闭相关角色的方式让系统恢复正常,只要外部的访问风暴不结束,或者我们不手动终止相关进程,系统无法自愈。
red502-4(物理耗尽导致的OOM): 内存总量巨大(755 GB),但单进程消耗了731 GB,无法回收只能杀进程,瞬间恢复但服务中断。OOM Killer从某种程度上保护了系统,实现了自愈。
同时,我们从两个不同的case,可以看到一些关键信息:
-
问题分析的时候一定要有清晰的因果意识,尤其是,我们有时候往往会陷入循环归因: 系统TCP连接数持续高位 (归因于)-> 系统性能下降 (归因于)-> 系统TCP连接数持续过高。。。一个复杂的系统问题,要找到根本原因,尤其是整理清楚整个因果链,必须完备地观察分析各个维度的Metrics,一个因果链的正确性的前提是所有的Metrics都与该因果链相匹配。
-
内存问题不总是"不够用" - 有时是分配策略问题(red603-21a),有时是单个应用失控(red502-4)
-
IOWait高≠磁盘慢 - red603-21a的61% IOWait是因为等待内存回收,需要结合磁盘吞吐量综合判断
-
监控要多维度 - 不仅看总内存,还要看类型(anon/file/slab);不仅看使用量,还要看变化趋势;不仅看单一指标,还要看相关性
作为工程师,我们首先需要的是对每一个系统概念的极其准确的理解。我相信,很多时候,当我们仔细推敲我们耳熟能详的概念(Used Mermory, Avaiable Memory, CPU IO Wait, CPU Free)等等,都脱口而出,但是,这些所谓的脱口而出,禁不住稍微的进一步质问:
- CPU IO Wait指的是什么?这时候CPU一定是空闲的吗?如果是空闲的,那么是否算在CPU Free Time中?
- Slab Reclaimable翻译过来只是表示可以被Relaimable,但是,一定是可以立刻直接进行Reclaim的吗?
- Page Table是每个进程一个?如果是每个进程都有,它是被内核统一管理还是被进程各自管理?进程运行过程中,进程申请的内存被释放,这个进程的Page Table是否可以被回收?
- IO Wait 一定是在等待磁盘吗?
- SWAP 是磁盘空间还是内存空间?如果是磁盘空间,为什么我们经常和内存一起讨论SWAP?
- OOM Killer的触发条件是什么?触发者(因)和受害者(果)是相同的角色吗?如果不同,为什么可以不同?
- Page Cache和Buffer的区别是什么?内存的哪些部分是可以直接回收的?哪些回收是会导致磁盘写的?
总之,保持好奇心,不满足于表面现象;建立系统性思维,关联多层次的证据;诚实面对不确定性,承认知识的边界。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)