1. 先把三笔账分清楚:物理内存、虚拟地址空间、swap 各自在管什么
上周帮朋友看一台跑着 Redis 加两个容器的 Ubuntu 服务器,他的原话是"内存莫名其妙就满了,重启一下又好"。我登上去敲了条free -h,available 还剩 5 个 G,可他坚持说 top 里已经飙到 90%。这就是 Ubuntu 内存分配话题里最常见的第一道坎:大多数人脑子里只有"内存"这一个概念,而系统里有三笔完全独立的账。把这三笔账分清楚,后面所有关于 ubuntu 内存分配的问题都会变得可推理,而不是靠重启碰运气。
物理内存是真实插在主板上的那几条条子;虚拟地址空间是内核发给每个进程的一张"地址地图";swap 是内核在磁盘上圈出来的一块备用空间。这三者之间没有"一一对应"的关系,一个进程虚拟地址空间占了 40G,物理内存可能只碰了 200M;反过来,一个看起来很老实的进程也可能把物理内存吃得干干净净。理解了这一点,你才不会看到VSZ特别大就喊内存泄漏。
1.1 free 命令里那个 available 才是你应该盯的数
先看一条我随手在 Ubuntu 22.04 上抓的输出:
$ free -h total used free shared buff/cache available Mem: 31Gi 8.6Gi 1.2Gi 214Mi 21Gi 21Gi Swap: 2.0Gi 0.0Gi 2.0Gi很多人第一眼看到 free 只剩 1.2Gi 就慌了,这是典型的误读。Ubuntu 会把空闲的物理内存几乎全部拿去做页缓存(buff/cache),因为空着的内存本身就是浪费。真正代表"新起一个程序还能拿到多少内存"的是最后一列available,它是内核综合了 MemFree、可回收的页缓存和可回收的 slab 之后给出的估算值。上表里 available 有 21Gi,说明这台机器其实很宽松。
顺带说一句shared这一列。在 Ubuntu 22.04 及更新版本自带的 procps 3.3.17 里,shared 显示的是 tmpfs(包括 /dev/shm)占用的内存量,而不是老版本里那个含义模糊的"共享内存"。这个改动很多人没注意到,导致在排查容器共享内存问题时看错地方。如果你的机器上 shared 是几个 G 还在涨,八成是有程序在往 /dev/shm 里写大文件,后面第 5 节会专门讲这个坑。
1.2 虚拟内存不是"内存不够时从硬盘借"
这是被讲烂了但依然被讲错的一个概念。虚拟内存(virtual memory)的核心价值是地址映射与隔离,它让每个进程都觉得自己独占了一整块连续的地址空间,同时让内核可以把物理页随意摆放、共享、回收。它顺带带来的好处才是"可以换出到 swap"。
x86-64 上的 Ubuntu,单个进程的用户态虚拟地址空间上限大约是 128TiB,这个数字大得离谱,所以你在 64 位系统上几乎不可能因为"地址空间不够"而 malloc 失败。但 32 位进程的天花板是 4GiB,默认按 3G/1G 切分给用户态和内核态,实际可用往往只有 2GiB 到 3GiB。这就是为什么同样一份大工程,在 32 位工具链上编译到一半会报"该进程已终止,因为它无法分配更多的内存",换到 64 位的 Ubuntu 上就没事——问题不在物理内存,在地址空间。这个区别我在第 5 节还会展开。
再看一个常被忽略的角色:页表本身也要占内存。进程的虚拟地址空间映射得越碎,页表项就越多。用cat /proc/meminfo | grep PageTables能看到当前页表占用,跑着大量进程或者开了透明大页的机器上,这一项轻松到几百 MB,甚至上 G。这笔开销在容器里尤其容易被低估,因为容器里 fork 出去的小进程往往特别多。
1.3 buff/cache 和 slab 的真实身份
free里的 buff/cache 是两部分的合并:Buffers 是块设备元数据缓存,通常很小;Cached 是页缓存,也就是你读过的文件内容被留在内存里,下次读就不用碰磁盘了。除此之外还有一块不在这个数字里、但同样吃物理内存的角色——slab。
slab 是内核自己用的对象分配器,dentry(目录项缓存)和 inode(索引节点缓存)是里面最大的两块,它们是可回收的;而 socket 缓冲区、task_struct 这些属于 SUnreclaim,不可回收。用sudo slabtop -s c按缓存大小排序看一眼,能立刻判断出内存是被"内核缓存吃掉了"还是被"应用真的申请了"。
提示:如果你发现
free里的 used 很高但 available 也很高,同时slabtop里 dentry 占了几百 MB,这属于完全正常的文件系统缓存行为,不需要处理。真正要盯的是 available 的绝对值,以及 Cached 里属于 shmem/tmpfs 的部分,因为它不像普通页缓存那样容易被回收掉。
2. 一次 malloc 背后发生了什么:从 VMA 到缺页中断
搞清楚三笔账之后,第二个必须建立的直觉是:malloc 返回成功,并不等于内核真的把物理页交到你手里了。这个"延迟交付"机制是理解 Ubuntu 内存分配行为的关键,也是很多"看起来内存没满却 OOM 了"这类怪现象的总根源。
2.1 申请的那一刻,内核只给你一张欠条
当你调用malloc(200 * 1024 * 1024)时,内核做的事情大致是:在进程的地址空间里找一段没被占用的虚拟地址区间,创建一个 VMA(vm_area_struct)结构记录"这段地址从哪到哪、属于哪种类型、什么权限",然后返回起始地址。这时候物理内存消耗几乎为零,只有 VMA 结构本身(几十到几百字节)和可能扩展的页表。
真正的物理内存分配发生在第一次写入时。CPU 访问一个还没有对应物理页的虚拟地址,触发缺页中断(page fault),内核这时才去伙伴系统申请一个物理页框(通常是 4KiB),填上零,建立页表映射。这个过程叫按需求页(demand paging)。所以一个进程的VmSize(虚拟大小)和VmRSS(实际驻留物理内存)经常差一个数量级,看top的 VIRT 列就下结论是不靠谱的。
2.2 brk 与 mmap:glibc 那道 128KiB 的分界线
glibc 的 malloc 并不是每次都直接找内核。对小块内存,它在进程的数据段末尾用brk/sbrk扩展一块连续的堆区域,之后所有小块分配都在这个堆里做分割和复用;只有超过阈值的分配才会走mmap单独映射一段匿名内存,释放时直接munmap还给内核。
这个阈值默认是 128KiB,有个动态调整机制:一旦某个 mmap 块被释放,阈值会临时抬高到那个块的大小,上限在 64 位上是 32MiB。这个设计是为了避免"频繁大块 mmap/munmap 导致缺页开销过大"。你可以用环境变量覆盖:
# 让超过 32KiB 的分配都走 mmap,适合排查内存碎片的场景 export MALLOC_MMAP_THRESHOLD_=32768 # 限制每个进程的 arena 数量,多线程程序省内存的经典手段 export MALLOC_ARENA_MAX=2第二行值得单独说。glibc 为了减少多线程锁竞争,会为每个线程分配独立的 arena,64 位系统上默认上限是8 × CPU 核数。在一台 64 核机器上跑一个开了几百个线程的 Java 或 Node 服务,你可能会有几十个 arena,每个都预留着各自的空闲块,RSS 就是这么涨起来的。MALLOC_ARENA_MAX=2这类设置在很多线上事故里救过场,代价是锁竞争略微上升。这是我个人认为最值得记住的一条 glibc 内存调优经验。
还有一点:free()只是把内存还给分配器,不一定会还给内核。堆顶的大块空闲内存要靠malloc_trim()或者达到M_TRIM_THRESHOLD(默认 128KiB)才会收缩。所以你在/proc/PID/status里看到 VmRSS 释放后不下降,未必是泄漏,可能只是分配器留着复用。
2.3 Overcommit 的三个档位,以及 Redis fork 失败的真实原因
这是 Ubuntu 内存分配里最有戏剧性的一环。内核有三个 overcommit 策略,由vm.overcommit_memory控制,默认值是 0:
| 取值 | 策略名 | 行为 |
|---|---|---|
| 0 | 启发式(默认) | 按"空闲内存 + 可回收页缓存 + 可回收 slab - 保留页"来判断,明显过分的申请会被拒绝 |
| 1 | 总是允许 | 从不拒绝任何申请,风险全部推给 OOM Killer |
| 2 | 从不超配 | 严格按 CommitLimit 记账,公式是 swap + 物理内存 × overcommit_ratio / 100,ratio 默认 50 |
绝大多数人第一次注意到这个参数,是因为 Redis 启动时打出的那条警告:
WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf ...为什么会这样?Redis 执行 BGSAVE 时要fork()出一个子进程,靠写时复制(COW)共享父进程的内存。但 fork 的那一刻,内核是按父进程整个虚拟地址空间来做 commit 记账的,因为在策略 0 下它没法预知未来哪些页会被写。如果父进程 VSZ 有 20G,而机器上"空闲 + 可回收"只有 10G,这次 fork 就会被直接拒绝,报fork: Cannot allocate memory。数据没丢,但持久化失败了。
设成 1 之后 fork 必然成功,代价是把判断责任交给了 OOM Killer——真到内存紧张时,内核会挑一个进程干掉。所以只在设了 1 却不做内存上限约束的机器上,是很危险的,某个进程的峰值很可能把整台机器上别的服务一起拖下水。正确的做法是设 1 的同时,用 systemd 或 cgroup 给每个服务圈定内存上限(第 4 节会给出具体配置),让超限时死的是它自己。
策略 2 一般只在跑 Oracle、SAP 这类有严格内存规划的数据库时才用,且必须自己把overcommit_ratio算准。用grep -E 'CommitLimit|Committed_AS' /proc/meminfo可以实时看到当前记账情况,Committed_AS 长期贴着 CommitLimit 就说明该扩容或者该调策略了。
3. 把内存账算清楚:Ubuntu 上真正好用的观测手段
知道原理之后,接下来就是手上要有趁手的工具。这一节我按"看得出来 → 分得清 → 定位到人"的顺序,把 Ubuntu 上几个真正好用的观测方式梳理一遍,很多都是我把top丢掉之后才开始用的。
3.1 RSS、PSS、USS:同一个进程为什么数字不一样
这是最容易把人绕晕的地方。共享库和 COW 页会被多个进程同时映射,那么这部分内存该算给谁?内核给了三种口径:
| 指标 | 含义 | 适用场景 |
|---|---|---|
| RSS | 进程映射的全部物理页,共享部分被重复计算 | 看单个进程的物理占用趋势 |
| PSS | 私有页 + 共享页 / 共享者数量 | 把所有进程的 PSS 相加,约等于真实内存占用 |
| USS | 只有私有页,完全属于本进程 | 判断"杀掉它能省下多少内存" |
所以当你用ps或top把所有进程的 RSS 加起来,得到 40G,而free显示只用了 12G,这不是系统算错了,是共享库被反复计数了。要算真实的账,用 PSS:cat /proc/*/smaps_rollup | grep -i pss | awk '{s+=$2} END {print s/1024 " MB"}'。这条命令在 Ubuntu 20.04 及以上的内核上都可用,比遍历 smaps 快得多。
3.2 从 smaps_rollup 到 ps_mem 的组合拳
需要看一个进程的内存构成时,/proc/PID/smaps_rollup是我最先打开的文件,它把整个进程的映射做了汇总,重点看三行:
$ cat /proc/1234/smaps_rollup Rss: 1048576 kB Pss: 524288 kB Private_Dirty: 400000 kB Swap: 12000 kBPrivate_Dirty是真正属于这个进程、而且被改过的页,通常对应它自己申请的内存;如果它很大并且在持续增长,基本可以认定是应用层问题。Swap那一行能看到这个进程有多少页被换出去了,长时间非零说明机器内存压力已经在影响它了。
再配上两个第三方小工具,效率会高很多。ps_mem会自动按 PSS 排序输出,适合快速找出谁在吃内存;pmap -x PID可以列出每个映射段的详细情况,特别适合定位"到底是哪个 so 或者哪块 mmap 撑大了进程"。这两个都能直接sudo apt install ps_mem装。
3.3 容器和 cgroup v2 下的内存统计
Ubuntu 21.10 之后默认启用了统一的 cgroup v2,Docker、containerd、systemd 全都在这一套体系里记账。你会在/sys/fs/cgroup/下面看到一堆文件,最常用的几个:
cat /sys/fs/cgroup/system.slice/redis.service/memory.current # 当前用量 cat /sys/fs/cgroup/system.slice/redis.service/memory.peak # 历史峰值 cat /sys/fs/cgroup/system.slice/redis.service/memory.max # 硬上限 cat /sys/fs/cgroup/system.slice/redis.service/memory.stat # 分项明细 cat /sys/fs/cgroup/system.slice/redis.service/memory.events # 是否触发过 OOMmemory.events里的oom_kill计数非常关键——它告诉你这个 cgroup 到底有没有被杀过进程。很多人查 OOM 时只看dmesg,但容器内的 cgroup 级 OOM 不一定会打印到宿主机的 dmesg 里,必须去看这个文件。memory.stat里的anon(匿名页,对应应用申请的内存)和file(页缓存)分开显示,排查"内存被页缓存撑爆"还是"应用真的吃完了"一眼就能分辨。
还有一个细节:cgroup 的内存统计包含了这个组里所有进程的页缓存。当一个容器反复读写文件时,缓存会记在容器头上,memory.current涨上去但实际可用内存还很多。这种情况在 cgroup v2 里会有自动的回写和回收,不需要你手工drop_caches。顺便说一句,echo 3 > /proc/sys/vm/drop_caches这个操作我十分不建议在生产上随手敲:它只丢干净页缓存,丢完立刻被读回来,性能反而更差,而且对匿名页完全没用——人们想解决的"used 太高"的问题,它根本解决不了。
4. 进程被杀的完整排查链路:从 dmesg 到 oom_score_adj
"好端端的服务突然没了,日志里什么都没有",这是 Ubuntu 内存分配话题下最常见的一类求助。十有八九是 OOM Killer 干的,而且很多人都不知道日志在哪。这一节我把排查链路完整走一遍,包括每一步为什么这么做。
4.1 OOM Killer 到底按什么打分挑人
内核要杀人的时候,会给每个进程算一个 badness 分数,最终落到/proc/PID/oom_score上,范围 0 到 1000,分越高越先死。这个分数在较新的内核上主要来自三块:进程的 RSS、它用掉的 swap、以及它占的页表内存。也就是说,谁实际占的物理内存多,谁就更危险,虚拟大小不参与打分,这点非常符合直觉。
在此之上还有一个人工可调的偏置/proc/PID/oom_score_adj,范围 -1000 到 1000。写成 -1000 表示"永远不选我",写成 1000 表示"优先杀我"。把这个值和刚才的 badness 按比例合成,就是最终分数。另外,持有较高特权能力的进程会被内核稍微降低一点分数,这是历史遗留的保护机制。你可以在动手之前先用一条命令看看当前谁最危险:
for p in /proc/[0-9]*; do printf "%s %s\n" "$(cat $p/oom_score 2>/dev/null)" "$(cat $p/comm 2>/dev/null)" done | sort -rn | head -104.2 给关键服务加保护的三种方式
知道了打分规则,保护手段也就清楚了。按我的经验优先级从高到低是:先用 cgroup 给上限,再用 oom_score_adj 调偏置,最后才是设成不可杀。
第一种也是最推荐的一种,是用 systemd 直接给服务圈内存。Ubuntu 上几乎所有常驻服务都是 systemd 管的,写个 drop-in 就行:
# /etc/systemd/system/redis.service.d/memory.conf [Service] MemoryAccounting=yes MemoryHigh=1500M MemoryMax=2G MemorySwapMax=512M OOMPolicy=continue这几个参数的区别值得说清楚。MemoryMax是硬顶,一旦碰到,cgroup 内部就会触发回收甚至 OOM,死的是这个组里的进程,不会波及别人。MemoryHigh是软顶,超过之后内核会开始施压回收、拖慢这个组,属于"温和提醒",我一般设成 Max 的 75% 左右。MemorySwapMax限制它能用多少 swap,设成 0 就完全不允许换出,对延迟敏感的服务很有用。OOMPolicy=continue表示组内进程被杀后 systemd 不重启整个单元,默认的 stop 行为有时候反而会让故障扩散。
第二种是调偏置。把最关键的那个进程(比如监控 agent、sshd、数据库主进程)设成 -900 左右,让它在内存竞争中活下来:
echo -900 | sudo tee /proc/$(pgrep -o mysqld)/oom_score_adj第三种是设成 -1000,也就是"不可杀"。我要提醒一句,这招要慎用:如果真的到了全局内存耗尽的地步,内核发现所有候选都是不可杀,行为会变得很难预测,最坏的结果是整台机器卡死连 SSH 都进不去。我个人的原则是除了 SSH 和监控 agent,其他都不设 -1000。
4.3 一次真实的排查记录:PyTorch 训练脚本把宿主机搞崩了
说一个我自己遇到的案例,过程挺典型。环境是 Ubuntu 22.04 加 NVMe,跑一个 PyTorch 训练任务,配置是 32G 内存加一张 24G 显存的卡。现象是训练跑十几分钟之后,训练进程直接消失,Python 那边没有任何 traceback,只有Killed两个字。
排查是这么一步步收窄的。
第一步,确认是被内核杀的。执行dmesg -T | grep -i -E 'oom|killed process',果然看到Out of memory: Killed process 21345 (python3) total-vm:28xxxxxxkB, anon-rss:24xxxxxxkB。到这里就能确认是 OOM,不是段错误也不是被容器编排杀掉的。
第二步,看是真不够还是被限制。free -h显示 available 只有 1G 不到,说明物理内存是真不够了。如果是 available 很充裕还 OOM,那就要怀疑是 ulimit 或者 cgroup 的硬顶在作怪,排查方向完全不同。
第三步,定位到具体是谁在涨。用watch -n 2 'grep -E "VmRSS|VmSwap" /proc/21345/status'盯着看,RSS 从 4G 一路涨到 24G。这时候要区分两种可能:数据本身就该占这么多,还是真的泄漏了。我又看了一眼cat /proc/21345/smaps_rollup | grep -E 'Rss|Pss|Private_Dirty',Private_Dirty 几乎等于 RSS,说明这些内存确实是这个进程私有的新分配,不是共享库。
第四步,找到那块增长的内存。用pmap -x 21345 | sort -k3 -rn | head排序,发现是一堆 256M 的匿名映射在增加,数量正好等于 DataLoader 的 worker 数乘以预取批次数。到这里就清楚了:num_workers=16、prefetch_factor=8、每批数据展平后约 200MB,光是预取缓冲就吃掉了十几 G。同时 /dev/shm 也在被 worker 之间的张量共享吃掉一部分,因为 tmpfs 是算在内存里的。
最后是三处修改:把num_workers降到 6、批大小减半、并且给训练服务加了个MemoryMax=20G的 cgroup 上限,让它在超限时自己被杀而不是拖垮整机。改完再跑,峰值稳定在 16G 左右。这个案例里最值得记住的一点是:GPU 训练任务的内存峰值往往不在模型上,而在数据加载管道里,因为它不起眼,所以最容易失控。
5. "无法分配更多内存"背后的五种完全不同的根因
编译报错、Redis 存盘失败、Java 起不来、容器里的进程莫名退出,报的错可能都是同一句Cannot allocate memory,但根因可能天差地别。这一节我按"排查优先级"把这几种情况拆开讲,你可以当成一张对照表来用。
| 现象 | 最可能的根因 | 第一个该查的东西 |
|---|---|---|
| 32 位程序链接或编译中途失败 | 用户态地址空间只有 3G | file 程序名、ulimit -v |
| fork 失败、进程数上不去 | 策略 0 下的 commit 记账拒绝 | /proc/meminfo的 Committed_AS |
| mmap 报 ENOMEM 但内存充足 | vm.max_map_count到顶 | cat /proc/sys/vm/max_map_count |
| 容器里写共享内存失败 | /dev/shm 太小 | df -h /dev/shm |
| GPU、RDMA 程序报锁页失败 | RLIMIT_MEMLOCK太小 | ulimit -l |
5.1 地址空间不够:为什么 32 位程序总是先撞墙
先说最容易被忽略的一种。Ubuntu 上跑一个 32 位程序,不管宿主机有多少内存,它的用户态可用虚拟地址最多 3GiB,操作系统、共享库、栈、堆全都要从这 3GiB 里分。一个链接了大量静态库的 32 位程序,编译到链接阶段很容易就顶到天花板,报的错正是"该进程已终止,因为它无法分配更多的内存"。同样的工程换成 64 位工具链编译,地址空间从 3GiB 变成 128TiB,问题立刻消失。
判断方法很直接:file /path/to/binary看是不是ELF 32-bit,uname -m看系统架构。如果确实必须用 32 位,可以考虑启用 Ubuntu 的 32 位大地址空间内核参数,但这条路坑很多,除非有强约束我一般不推荐。能上 64 位就上 64 位,这是最省事的解法。
顺带说ulimit -v。这是进程级虚拟地址空间的软限制,单位是 KiB。有些发行版镜像或者容器基础镜像会默认设一个值,比如 4G,这时候哪怕机器有 128G 内存,程序申请到 4G 虚拟空间就被拒。排查时一定要看ulimit -a全量输出,而不是只看-m(-m在 Linux 上其实是空操作,很多人被这一点误导过)。
5.2 max_map_count 与内存碎片
vm.max_map_count限制的是单个进程能拥有的 VMA 数量,默认 65530。你可能会想,一个进程怎么可能有六万个内存映射段?真会。JVM、MongoDB、Elasticsearch 这类程序会创建大量 mmap;内存碎片严重时,分配器也会把一个大的逻辑区域切成很多小映射段。一旦到顶,报出来的错就是mmap: Cannot allocate memory,而此时free显示的可用内存可能还有几十 G,非常具有迷惑性。
# 查看与临时调整 cat /proc/sys/vm/max_map_count sudo sysctl -w vm.max_map_count=262144 # 永久生效 echo 'vm.max_map_count=262144' | sudo tee /etc/sysctl.d/99-mmap.conf碎片是另一条独立的线索。看cat /proc/buddyinfo,如果高阶(后面几列)全是 0,说明连续的大块物理内存已经没有了,此时申请大页或者很大的连续缓冲会失败,哪怕总量充足。看cat /proc/pagetypeinfo能进一步分辨是哪种类型的页被碎片化了。THP 的取值也会影响这一块,注意不同 Ubuntu 版本出厂值不一样,可能是always也可能是madvise,先cat /sys/kernel/mm/transparent_hugepage/enabled确认现状再谈调优。跑 Redis 这类延迟敏感的库,建议设成madvise,它的启动警告里也会直接点名这件事。
5.3 tmpfs、/dev/shm 和锁页内存
/dev/shm 默认是内存的一半,但它算在内存里,写进去的数据直接消耗物理内存。Docker 给容器开的 /dev/shm 默认只有 64MB,跑 PyTorch 的 DataLoader 或者某些基于共享内存的中间件时,很容易撞到"写共享内存失败"。解法是启动时加--shm-size=2g,或者在容器里确认df -h /dev/shm的实际大小。
锁页内存是另一类。用mlock或者 CUDA 的 pinned memory 时,内存必须被锁在物理内存里不能换出,受RLIMIT_MEMLOCK限制,Ubuntu 上默认常见是 64KiB 到 64MiB 不等。这个限制在做 GPU 训练、RDMA 通信、或者用大页的数据库时会突然爆出来:
ulimit -l # 查看当前限制 ulimit -l unlimited # 临时放开(需要 root)systemd 管的服务要在单元文件里设LimitMEMLOCK=infinity,只改/etc/security/limits.conf对 systemd 服务是无效的,这个坑我踩过不止一次。
6. swap、zram 与 swappiness 的取舍
最后聊 swap。SSD 时代很多人第一反应是"SSD 寿命有限,干脆不挂 swap",这个结论不能说错,但也不够精确。真正需要判断的是:这台机器的内存压力是持续性的还是突发性的,以及它能不能接受延迟抖动。
6.1 swappiness 的字面意思和真实含义
vm.swappiness默认 60,很多人理解成"用 swap 的频率",其实它描述的是内核在回收内存时,回收匿名页相对于回收文件页的倾向权重。值越高,内核越愿意把进程的匿名内存换出去;值越低,内核越倾向于丢页缓存保住进程内存。
对一台主要跑数据库的机器,我一般设成 1 到 10。这样做不是禁止换出,而是让内核优先丢页缓存,尽量保住热数据。注意即便设成 0,在内核看来内存实在紧张时它仍然会换出,这个参数不是开关。跑着大量文件读写的文件服务器则可以保持默认甚至调高。
配套还可以看两个内核水位参数。vm.min_free_kbytes决定每个内存 zone 保留多少空闲页,太小会导致回收发生得太频繁,表现为系统卡顿;vm.watermark_scale_factor默认 10,表示水位间距是 zone 内存的 0.1%,在突发分配频繁的机器上适当调大(比如 100 到 200),可以让内核更早开始后台回收,减少卡顿。这两个参数属于"调好了没感觉、调坏了很明显"的类型,改动前务必记下原值。
6.2 zram:现在我更推荐的方案
如果你的机器内存不大、又确实需要一点交换空间来吸收突发峰值,我现在的首选是zram 而不是磁盘 swap。zram 在内存里划一块区域做压缩块设备,写进去的页会被实时压缩,典型压缩比在 2:1 到 4:1 之间。它换出的是压缩后的页,仍然在内存里,所以读写延迟比磁盘低几个数量级,同时不会磨损 SSD。
Ubuntu 上可以用zram-generator(包名随版本略有不同,先apt search zram确认一下)来配置:
sudo apt install systemd-zram-generator# /etc/systemd/zram-generator.conf [zram0] zram-size = min(ram / 2, 4096) compression-algorithm = zstd swap-priority = 100配好之后sudo systemctl daemon-reload && sudo systemctl start systemd-zram-setup@zram0.service,再用zramctl和swapon --show确认。swap-priority设成 100 是为了让系统优先用 zram,再考虑磁盘 swap。
手工配置也不复杂,适合想搞清每一步在干什么的场景:
sudo modprobe zram sudo zramctl --find --size 4G --algorithm zstd sudo mkswap /dev/zram0 sudo swapon -p 100 /dev/zram0注意 zram 占用的是物理内存,所以不要把它设得过大,否则等于自己减少了可用内存。经验值是物理内存的 25% 到 50%,上限 4G 到 8G,写入的内容得是能压缩的(它压不动视频、加密数据这类高熵内容,那些放进 zram 反而亏)。
6.3 什么时候干脆不要 swap
有三种情况我建议不要挂 swap 或者把 swappiness 压到极低:一是延迟敏感的交易类服务,任何换出都会带来不可控的抖动;二是内存规划得很清楚、内存本身就够用的机器,swap 反而会掩盖内存泄漏问题,让故障从"进程崩掉"变成"整机变慢"这种更难查的形态;三是用了 zram 但内存本来就吃紧的机器,zram 会加剧内存压力,这时候应该做的是减负载而不是加交换。
判断一台机器是不是在换页,看vmstat 1的 si/so 两列就够了,持续非零就是真在换。偶尔冒一两个非零值没什么好担心的,那可能只是内核在挪动冷页。
注意:给 systemd 服务设
MemorySwapMax=0是禁掉这个服务换出的最干净办法,比全局调 swappiness 精准得多。我现在的习惯是全局保持默认,逐个服务去设上限,这样既不影响系统整体的弹性,又能保证关键服务不被换出。
调完这一轮,回过头再看那台"内存莫名满了"的服务器,答案往往就摆在几个文件里:/proc/meminfo的 available 和 Committed_AS、cgroup 的 memory.events、还有dmesg里那几行 OOM 记录。Ubuntu 的内存分配从来不是玄学,它只是一层套一层的记账规则。把这些账看清了,你就不再需要在半夜重启机器了。