下午三点,监控面板上的内存曲线挺好看:整机几百 G,已用一半出头,free 那一栏还剩几十 G。同一时间业务侧的报警先响了——请求开始排队,慢日志里冒出一批平时不该出现的条目,应用线程卡在等内存分配上。运维的第一反应通常是"内存明明还有啊",再点开 swap 一看,用了几个 G,于是更糊涂了:是不是 swap 把机器拖慢了?
这两件事摆在一起,恰好说明一个常被忽略的事实:内存"还剩多少"和内存"压力大不大"根本不是一回事。回收动作不是等内存用完了才启动的,而是按水位提前触发。你看到 free 还剩几十 G,但某个内存区域的水位可能已经被压到临界线以下,后台回收线程已经在跑,只是追不上申请速度,于是回收工作落到了正在申请内存的那个进程自己头上——那一次分配就阻塞在那里,反应到业务上就是"突然卡一下",卡完之后又恢复正常,曲线上留下一根突刺而不是一段平缓的抬升。
所以这篇的顺序是反着来的:不先谈 swap 怎么配,先谈这台机器上哪一类内存是动不了的,再看回收器在压力下能牺牲谁,最后才轮到 swap 的形态与参数。顺序搞反了,参数调得再勤也只是在碰运气。说实话,我见过太多人一上来就改倾向性参数,改完曲线确实变了,但变的到底是哪个环节,没人说得清。
内核看内存不是按"用了多少 G"来看的,它按页的类型分开记账。最粗的一条分界线是:这一页有没有后备存储。
文件页有后备存储——它本来就是磁盘上某个文件在内存里的一份拷贝。干净的(没被改过的)文件页,回收时直接丢弃,下次要用再从磁盘读回来;脏的必须先回写落盘才能丢。目录项缓存和 inode 缓存也算在这条路上,它们同样能缩,只是缩得太狠会让路径查找和文件属性查询变慢。
匿名页没有后备存储——进程的堆、栈、匿名映射、大部分共享内存,都是运行时才有内容的东西。这类页想回收,必须先把内容写到某个地方存起来,下次访问时再读回来,这个"某个地方"就是 swap。没有 swap,匿名页就没有换出目标,回收器对它们无能为力。
还有一类压根不在回收名单上:内核 slab、页表、内核栈、被锁定的内存、部分大页。这些内存被占用之后,内核不能凭自己的意愿把它们拿走。说白了,"不可回收"这三个字在这台机器上的占比,决定了你回收空间的上限在哪里。如果一台机器上匿名页占了大头、又没配 swap,那它真正能动的就是文件页那一块,压力一来全堆在那儿。
这里有个概念值得单独拎出来:工作集。它指的是一段时间内真正被反复访问的那部分页,而不是进程申请了多少。申请了很多、只用其中一小部分是很常见的,这种情况下把冷的那部分换出去反而是好事,因为腾出来的空间可以用在页缓存上。反过来,如果工作集本身就超过可用内存,换入换出会持续活跃,机器会进入一种"没崩但一直在搬页"的状态,那时候参数已经不重要了。
这也是为什么同样是"内存用了六成",数据库和缓存的表现天差地别。数据库自己维护了一大块缓冲区,再叠上连接带来的进程栈与堆;缓存类服务的匿名页占比通常更高,且这部分内容大多没有后备存储。占比不同,压力下能腾出来的空间就不同,卡的方式也不同——一个表现为读放大,一个表现为换入风暴。
内核给每个内存区域设了三档水位:min、low、high。它不是全局一个数字,是按区域分别算的。这个细节很关键,因为有些区域本身就小,压力可能只在其中一个上出现,从全局看却还很宽裕,于是监控上"内存还剩几十 G"和"分配卡住了"同时成立。
分配内存走快路径时,内核会检查目标区域的空闲页数。低于 low,后台回收线程 kswapd 被唤醒,开始异步回收,一直把空闲页提到 high 以上才继续睡。这一阶段回收是在专门的线程里做的,申请内存的进程通常感知不到,业务曲线上什么也看不出来。
问题出在下一阶段:如果申请速度超过后台回收速度,空闲页继续往下掉,掉到 min 附近甚至以下,新来的分配请求就走慢路径——在它自己的进程上下文里做直接回收。要扫多少页、扫到的页是干净的还是要写回的、要不要顺带做内存规整、要不要等 IO 完成,全部由这次分配自己承担。于是那个正在处理请求的线程,突然从"干活"切换成"替内核扫页并把页写出去",拿到页才能往下走。
这就解释了为什么卡顿是"抖"的而不是"慢"的:直接回收的耗时不固定,取决于当时系统里那一堆变量。它可能短到没人注意,也可能长到让业务超时。业务侧看到的就是延迟尾巴突然翘起来,平均分位数却可能毫无变化——只盯平均延迟的人,往往会漏掉这类问题。
除了回收本身,还有一个容易算错账的环节:内存规整。当分配需要的连续页不足时,内核会搬移页面去拼出连续空间,这部分开销同样落在申请的进程头上。于是业务侧看到的"卡一下",有时并不是回收慢,而是规整慢。两者应对方式不同:一个往回收倾向和水位上找,一个要往大页的使用方式上找。
再往下,如果直接回收也腾不出空间,内核还有更激进的手段,包括触发 OOM 去杀进程。走到那一步已经不是调优问题,而是容量或者隔离问题,改参数救不了。
顺带提一句:透明大页与内存碎片会让分配在"空闲页总数够、但连续页不够"的情况下失败或触发规整;NUMA 机器上分区域的水位则会让不同节点的行为不一致。这两块各有各的判断方式,本文各带一句,不展开。
把前面两段合起来看,swap 的位置就很清楚了:它是匿名页的换出目标,是给回收器多开的一个选项,不是"内存不够了才用"的补丁。
有 swap 时,回收器面对压力可以在两条路之间取舍——丢文件页,或者换出匿名页。没有 swap 时,第二条路被封死,压力下能动的只剩下文件页。后果是页缓存被清得比平时干净:本来命中缓存的读,现在要真的落到磁盘上,IO 次数变多,依赖缓存的服务延迟跟着抖。换句话说,关掉 swap 并没有让"回收"这件事消失,只是把代价从换出匿名页转移到了清页缓存和多出来的 IO 上,而且这种转移往往更隐蔽——你看不到 swap 在用,只看到磁盘突然忙了。
所以"swap 用了几个 G"这句话本身不构成故障判断。它只说明回收器在当时选择了换出匿名页,可能因为那段时间匿名页确实比文件页冷。真正该问的是:被换出去的页会不会很快被访问回来(那是灾难),还是它们本来就是长期不活跃的部分(那是赚了)。把"swap 有用量"直接等同于"内存不够",是这类问题里最常见的一个误判。
换出这件事落到什么地方,就是三种形态的区别。别被名字绕进去,它们解决的是同一个问题,只是承接的位置不同。
磁盘 swap(分区或文件)是最传统的形态,换出直接写块设备。代价取决于介质和当时的 IO 负载;机械盘上尤其难受,固态盘上会好一些,但和内存相比仍然是量级上的差距。好处是容量大、实现简单、重启后内容可以保留(这也是休眠功能的基础)。要注意一点:如果 swap 和业务数据共用同一块介质,换出的写会跟业务 IO 抢队列,等于把内存压力转嫁成 IO 压力,本来只是回收慢一点,最后变成存储也跟着抖。
zswap 是换出路径上的一层压缩缓存。页在换出时先被压缩,放进内存里的一个压缩池;只有当池满了、某些页压不动、或者需要腾空间时,才把最老的那部分写回后端的块设备。也就是说,zswap 本身并不替代 swap 设备,它需要一个真实的 swap 后端兜底。它的意义在于让相当一部分换出不真的落到磁盘上,从而把换入换出的代价压下来;代价是额外的压缩解压 CPU 开销,以及被池子占掉的一部分内存。可调的东西包括压缩池占内存的比例、压缩算法、池的分配器,以及是否允许写回。
zram 是另一条思路:它是内存里的一块压缩块设备,本身就当 swap 用。换出的页压缩后存在内存里,不落盘。优点是换入换出都不到磁盘,代价是这块内存是从可用内存里划走的,压缩率取决于内容,同时消耗 CPU。对内存本身就紧张、又没有合适磁盘可放 swap 的机器(一些容器宿主机、瘦客户端、嵌入式场景)比较合适。
形态定了之后还有两件小事容易被忽略。一是 swap 文件的可用性:并非所有文件系统都支持拿普通文件当 swap 用,某些环境只能划独立分区;用文件时还要注意它不能被截断、不能建立在有空洞的文件上。二是多块 swap 的优先级:可以给不同区域设不同优先级,让内核优先用快的那块,但同一优先级的多块区域会被并行使用,这会让"到底是哪块在承压"变得难以判断,排查时反而更麻烦。
怎么取舍,说白了就看三件事:手上有什么介质、这台机器的 CPU 是否宽裕、你愿意为换出这件事付出多少内存。有快盘而 CPU 紧张,就用磁盘 swap,把压缩层关掉或调到很小;CPU 宽裕、内存压力大但不想把抖动传导到存储上,压缩方案更合理。说实话,在没搞清楚匿名页占比之前就盲目上内存内压缩设备,这类配置我一般不推荐——它把一部分内存永久划走,如果这台机器的压力其实来自文件页,那你是在拿真金白银的内存换一个用不上的缓冲区。
swappiness 是最常被误读的一个。它不是"什么时候开始用 swap"的阈值,而是回收时的倾向:值越高,回收匿名页的相对意愿越强;值越低,越倾向于保留匿名页、优先动文件页。把它设成很低并不代表"不用 swap",只代表回收器更愿意先动文件页——如果文件页已经没什么可动的,该换出照样换出。
vfs_cache_pressure 管的是目录项与 inode 缓存的回收倾向。默认值下内核会在回收页缓存与回收这些元数据缓存之间取一个平衡;调高会更积极地回收元数据,调低则更倾向于保留它们。对于路径查找密集、inode 数量巨大的业务(海量小文件、很深的目录树),这个值的方向值得单独想一下,而不是沿用默认。
min_free_kbytes 决定水位基线,进而影响三档水位的绝对数值。它留的是给内核自己和一些不允许失败的关键分配用的兜底空间。设得太小,系统在压力下更容易掉进直接回收;设得过大,等于白占一部分内存。更常用的调法是用 watermark_scale_factor 调整水位之间的间距:间距拉大,后台回收线程会更早、更卖力地工作,代价是回收更频繁、页缓存被清得更勤;间距缩小,后台更保守,直接回收的概率上升。这两个值是一组,别只动一个,也别指望它们能替代容量规划。
脏页回写相关的参数(dirty_ratio、dirty_background_ratio 一类)虽然属于 IO 路径,但在回收脏文件页时会和回收耦合:脏页必须先写回才能丢弃,回写阈值设得太宽松,直接回收时就可能撞上一次大批量的写回,把一次本该很短的分配拖长。它们不该单独调,要和存储的写能力一起看。
至于保留与隔离,如果某类业务必须有下限保障,与其靠全局参数,不如用 cgroup 的分层保护。全局参数只能影响"回收时更偏向谁",做不到"这块内存谁也别动"——这是两个层次的能力,别指望一个覆盖另一个。
容器里看到的行为和裸机不一样,因为中间多了一层记账与限制。cgroup v2 提供了 memory.min、memory.low、memory.high、memory.max 四档,作用各不相同:min 是硬保护,尽量不被回收;low 是尽力保护,同层竞争时才体现出来;high 是节流线,超过之后这个组里的分配会被主动减慢并触发组内回收,属于提前施压;max 是硬上限,超过就在这个组里触发 OOM,杀的是组内的进程,而不是整机。
这里有个容易踩的点:容器里"没有 swap"和"宿主机没有 swap"是两件事。容器视角看到的 swap 限制是组层面的限制;宿主机上仍然可能全局开启了 swap,全局回收也照常发生。反过来,容器里设了 max,组内的回收压力只在组内消化,宿主机的后台回收线程未必会被唤醒——所以容器里 OOM 了,宿主机监控上可能还显示内存很宽裕,两边的数据看着矛盾,其实都对。
判断容器内压力要看组内自己的指标,而不是宿主机的 free:memory.stat 里的各类计数、memory.events 里被记录的事件次数(触及 low、触及 high、触及 max、触发 OOM 的次数)。这些计数能直接告诉你这个组是"被节流过"还是"被杀过",比看一个瞬时数值有用得多。宿主机层面则看全局的扫描计数与直接回收计数,判断整机是不是也在回收。
组层面还能对压缩缓存单独设限,比如限制某个组能用多少压缩池、是否允许它写回,这让"谁享受压缩、谁直接落盘"变成可以分开控制的事。混部时通常只给对延迟敏感的那部分开,其余一律走常规路径,免得压缩池被某一个组吃满,让本该受益的服务反而用不上。
混部场景(数据库与缓存跑在同一台宿主机上)尤其需要这层隔离:给数据库组一个明确的 low 或 min 保障,把缓存组放在只能用到剩余空间的位置。否则缓存组疯狂吃内存时,数据库组的文件页会被一起清掉,缓存命中率掉下来,最后两个服务一起变慢——这种邻居拖累在没有保护的裸机上很常见,而且很难从单个服务的监控里看出原因。
还有一个更隐蔽的坑:在容器里执行 free,看到的往往是宿主机的全局数字,而不是容器自己被限制到的那一份。于是容器里已经在节流、甚至已经触发过组内 OOM,你看到的仍然是"还有几十 G"。这类场景下,第一手证据必须取自容器自己的计数,而不是那条看起来很宽裕的曲线。
遇到"内存还剩很多却开始卡",按下面这个顺序看,每一步都能排除掉一类可能,不用一上来就调参数。
看可用量,而不是空闲量。free 那一列包含的是完全没被用上的部分,真正能大致代表"还能分配多少"的是 available。如果 available 已经很低而 free 很高,通常说明大量内存被页缓存占着且其中相当一部分不活跃——这本身不是坏事,要看的是压力有没有真的发生。
长驻的 Java 类服务值得单独说一句:它的堆属于匿名页,而回收垃圾时要周期性遍历其中相当一部分。一旦这部分被换出去,遍历就会不断把它们换回来,回收与换入互相叠加,表现出来的停顿会比堆一直留在内存里时更长。对这类服务,通常宁可让页缓存被清掉,也不愿意让堆被换出,这也是很多人在数据库与 Java 服务上把回收倾向调低的真实原因。
拆开看内存构成。/proc/meminfo 里把文件页、匿名页、共享内存、slab、不可回收的部分分开记了账。重点看三件事:匿名页占多大比例、slab 是不是异常膨胀(内核对象泄漏的常见表现)、不可回收部分有多少。这三件事决定了"能腾出多少空间"的上限,也决定了后面该往哪个方向调。
确认回收是不是真的在发生。/proc/vmstat 里的扫描计数(扫了多少页、实际回收了多少页)与直接回收次数的计数是硬证据:如果它们持续增长,说明回收确实在频繁发生,前面的现象就不是错觉,也不是应用自己的问题。
区分换入换出的方向。swap 的换入换出速率和 major fault 的计数能告诉你到底是谁在被搬进搬出。换出持续发生但换入很少,说明换出去的页确实冷,属于健康状态;换入换出都很活跃,说明工作集已经超出了内存能容纳的范围,这时候加参数救不了,得动容量或者动业务。
最后才看水位和参数。/proc/zoneinfo 里能看到每个区域的三档水位和空闲页数,顺着能判断"为什么全局还有空间却触发了回收"。到这一步,才决定是调倾向性、调水位间距,还是换 swap 的形态。五步走完,通常不需要猜;最怕的是跳过前四步直接改倾向性参数,改完不知道动了什么,下次出问题还得重来一遍。
下面这张表是前面几段的压缩版,排查时可以顺着看:先看第二列判断这台机器能腾出多少,再看第三列判断压力下谁先被牺牲,最后才看第四列决定调什么。
| 内存类别 | 能不能被回收 | 回收时走哪条路 | 受哪个参数影响 | 回收它带来的副作用 |
|---|---|---|---|---|
| 文件页(页缓存,干净) | 能,成本最低 | 直接从链表丢弃,下次访问再从磁盘读入 | 受回收倾向参数与水位间距影响 | 命中率下降,原本命中内存的读变成磁盘读 |
| 文件页(脏页) | 能,但必须先回写 | 先回写到后备存储再丢弃 | 受脏页回写阈值约束,回收时要等 IO | 产生写 IO,与业务写争队列,耗时取决于存储当时负载 |
| 匿名页(堆、栈、匿名映射) | 只有存在换出目标时才能 | 换出到 swap(磁盘、zswap 压缩池或 zram),访问时再换入 | 受回收倾向、swap 形态与压缩相关参数影响 | 换入产生 major fault,被频繁访问的页换出会造成延迟抖动 |
| 共享内存(tmpfs 一类) | 视实现而定,通常按匿名页处理 | 走 swap,或由使用者自己释放 | 同匿名页,另受组内记账与限制影响 | 常被误算进缓存里,容易在排查时被看漏 |
| 目录项与 inode 缓存 | 能 | 缩容后需要重新查找与加载 | 受 vfs_cache_pressure 影响 | 路径查找与属性查询变慢,小文件多的业务感受明显 |
| 内核 slab、页表、内核栈 | 大部分不能强制回收 | 不在常规回收名单上,由内核自行缩容 | 受内核内部策略影响,无统一开关 | 膨胀时表现为内存被吃掉却查不到归属,需单独排查 |
| 被锁定的页、部分大页 | 不能 | 不参与回收,只能由调用方主动解锁 | 由调用方行为决定 | 计入不可回收部分,抬高压力下限,压缩可腾挪空间 |
swap 开了是不是就等于内存不够?不是。swap 被使用只说明回收器在当时的取舍里选择了换出匿名页,这是回收路径的正常工作方式。判断内存够不够,看的是工作集能不能留在内存里、换入换出是否持续活跃,而不是 swap 有没有被用过一点。这两件事被混为一谈,是很多误操作的源头。
swap 用了一部分,要不要马上处理?看方向,不看数值。换出在增长、换入几乎不动,说明换出去的页确实冷,属于正常状态;换入和换出都很活跃,说明工作集已经超出内存容量,这才是需要处理的。单纯因为"swap 用了几个 G"就去关掉它,往往只是把代价转移到页缓存和 IO 上,问题没有消失,只是换了个地方出现。
数据库机器上要不要关 swap?说白了这个问题问反了。数据库关心的不是 swap 开不开,而是自己的关键内存会不会被换出去。常见做法是保留 swap 作为兜底,避免没有退路时直接走到 OOM,同时把回收倾向调低,让内核优先动文件页而不是数据库的长驻内存;有条件再用 cgroup 的 min 或 low 给数据库组一个下限。一刀切关掉 swap,等于在极端情况下少了一层缓冲。
zram 和 zswap 是不是一回事?不是。zswap 是换出路径上的压缩缓存,页压缩后放进内存池,必要时仍会写回后端的 swap 设备,它需要一个真实的 swap 后端;zram 本身就是一块内存内的压缩块设备,直接当 swap 用,不落盘。两者都能减少真正落到磁盘上的换出,但占用的内存、消耗的 CPU、是否需要后端设备都不一样。
swappiness 调到很低是不是更好?不一定。它调的是倾向,不是开关。调低只是让内核更偏向回收文件页;如果这台机器上文件页本来就没多少可收的,该换出还是会换出,反而可能因为文件页被清得太狠导致 IO 变多。更合理的做法是先看清匿名页与文件页的占比,再决定把天平压向哪一边。
容器里设了内存限制之后,行为有什么不同?多了一层记账和限制。容器视角看到的是自己组内的限额,超过 high 会被节流并触发组内回收,超过 max 会在组内触发 OOM,杀的是组内进程而不是整机。所以容器里 OOM 时宿主机监控可能还很宽裕;反过来宿主机全局紧张时,容器内未必能感知到。两层都要看,而且要看各自的事件计数,而不是只看一个百分比。
内存还剩几十 G 却开始卡,第一步该看什么?先看 available 而不是 free,再拆开看内存构成(匿名页、slab、不可回收部分各占多少),接着确认回收相关计数是不是真的在涨,最后才看每个区域的水位。前三步负责定位"是哪一类内存把压力逼出来的",最后一步才知道该动哪个参数——跳步往往导致改了个不该改的值。
把全文压成一句话:先分清哪一类内存是不可回收的,再决定压力下先牺牲谁,最后才选 swap 的形态和参数。这个顺序反过来做,就是大部分人调了半天倾向性参数却没效果的原因——倾向性只在"两边都还有东西可收"的时候才有意义,如果一边根本收不动,天平压过去也没有用。
具体一点:匿名页占比高、又没有 swap 的机器,压力会全部压在文件页上,表现为页缓存被清光、IO 变多、延迟抖动,这类机器给回收器开一个换出目标,往往比反复调倾向性有用得多。文件页占比高、IO 已经很忙的机器,换出匿名页反而是更划算的选择,这时候让压缩层承接一部分换出,能把抖动从存储路径上摘掉不少。容器宿主机则先把隔离做起来,分清"谁该被保护、谁该被牺牲",再谈全局参数,否则宿主机调得再好,容器里该 OOM 还是会 OOM。
内存这块想自己说了算,物理机比云主机好办——一万网络深耕 IDC 19 年(成立于 2007 年),swap 落在哪块盘上能提前定。
最后提醒一句:本文不展开内存容量与带宽的关系、NUMA 本地性、透明大页这三块,它们各有各的判断方式,混在一起看容易把结论带偏。调参之前先把这三者排除掉,剩下的才是水位与 swap 该管的事。
本文内容整理自 Linux 内核内存管理的公开机制说明,包括内存页的分类与回收路径、按区域划分的水位与后台回收、直接回收的分工、swap 相关机制的公开接口文档,以及 cgroup v2 内存控制器的接口说明;文中提到的参数名称均取自内核公开暴露的接口。文中不涉及任何自有业务的测试数据,也未引用任何基准测试结果,所描述的是机制层面的因果关系,实际表现会随内核版本、工作负载与存储介质而不同。涉及服务器、存储与部署方案的信息,以一万网络官网 https://www.idc10000.net/ 的相关服务器产品页为准,具体以官网实时报价与合同为准。参数与配置调整请结合自有业务验证,行业常见区间仅供参考,不宜直接照搬。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品