写在开头
本文由 AI 翻译自 Debunking zswap and zram myths
并由本人精修润色
先说结论:
如果不确定,请优先使用 zswap
除非你有非常具体的原因,否则才使用 zram
在架构(architecture)方面:
- zswap 位于你的磁盘交换空间 (disk swap) 之前,在内存 (RAM) 中压缩页面 (pages),并自动将冷数据 (cold data) 分层 (tiers) 到磁盘。它直接与内核的内存管理 (memory management) 集成,并能优雅地分散压力。
- zram 是一个带有硬容量限制的压缩内存块设备 (block device)。当你在其上放置 swap 并且它满了时,没有自动的驱逐 (eviction) 机制,内核几乎没有任何手段来处理这种情况。系统要么遭遇内存耗尽 (OOMs),要么回退到低优先级的 swap,导致 LRU 反转 (LRU inversion,见下文)。它实际上只在内存极其受限的嵌入式系统 (embedded systems)、无盘设置 (diskless setups) 或对保持私有数据远离持久存储有特定安全要求的情况下才有意义。上游 (upstream) 对 zram 上的 swap 的支持也越来越少。
我的建议是:
- 尽可能 不要将 zram 与磁盘 swap 一起运行。 在这种设置中,zram 会用冷旧页面填满快速内存,同时将你的活动工作集 (active working set) 推到缓慢的磁盘上,这比根本没有压缩 swap 还要糟糕。
- 如果你必须使用 zram,请将其与用户空间 OOM 管理器 (userspace OOM manager)(如 systemd-oomd 或 earlyoom)结合使用。如果没有它,内核的 OOM killer 在采取行动前可能会让系统挂起几分钟,因为它通常只在耗尽了许多轮毫无效果的激进回收 (aggressive reclaim) 周期后才会触发(详情见下文)。
- 对于服务器而言,zram 还有一层难以忽视的架构硬伤一个主要问题是其内存使用与系统的其余部分完全隔离,因此不会被计入任何控制组 (cgroup),从而破坏了隔离语义 (isolation semantics)。
最近,我收到了一位读者关于 Linux 上压缩 swap 技术的提问:
“我读了你关于 Linux 内存管理(swap)的文章,终于听到专家说的话了 :) 而不是互联网专家常说的‘你有 32GB 内存 - 关掉它吧’。
如果能有一篇关于 zswap 或 zram 的后续文章就好了…… :) 它们好用吗(在拥有 32GB 或更多 RAM 的桌面上),哪一个更好以及为什么…… 我(从描述中)了解它们是如何工作的,但很难在现实生活中测量和比较。再次说明,很多‘互联网专家’只会说‘用 zram - 因为你不会磨损你的 SSD’……”
好吧,首先,既然我在写这篇文章,显然奉承是对我有效的 ;-)
确实,互联网上关于何时使用 zswap 与 zram 以及它们之间的权衡有很多困惑和错误信息。我在内核内存管理 (kernel memory management) 和 swap 代码方面工作了近十年,我见证了这些技术的发展以及围绕它们产生的一些常见误解。
对大多数人来说,简短的答案是:使用 zswap。如果没有深入了解 zram 可能对你的工作负载 (workloads) 造成的风险,请不要使用 zram。但要理解为什么(以及理解何时 zram 实际上可能是正确的选择),需要深入了解这两种技术在内核本身中的工作原理。
架构差异 (Architectural differences)
大多数人认为 zswap 和 zram 只是同一事物的两种不同变体:压缩 swap (compressed swap)。从表面上看,这是正确的——两者都压缩原本会进入磁盘的页面——但它们在内核应如何处理内存压力方面有截然不同的设计理念,在你的情况下选择错误的那个实际上会比没有 swap 更糟糕。
它们之间最显著的差异在于它们在内核存储层次结构 (storage hierarchy) 中的位置,从而也决定了它们能够向内核其他部分传递什么信号:
- zram 作为一个压缩块设备 (compressed block device) 运行,本质上是内存中的虚拟磁盘。当进程需要 swap 时,内核像对待任何其他块设备一样对待 zram 上的 swap,通过块层 (block layer) 发送 I/O 请求。重要的是,一旦 zram 满了,它就只是另一个达到容量上限的存储设备。没有自动机制将数据移到其他地方,这意味着最先被换出的冷页面被锁定在快速内存(RAM)中,无法被驱逐 (evict)。你可以想象,这通常是非常糟糕的。
- zswap 则与内存管理子系统 (memory management subsystem) 结合得更紧密。它作为一个压缩层 (compression layer) 位于你的磁盘 swap 之前。当进程需要 swap 时,zswap 会在页面到达磁盘之前拦截它,决定是否压缩它,如果压缩,则将其存储在内存池 (memory pool) 中。当该池满时,zswap 使用自己的启发式算法 (heuristics) 将最近最少使用 (least recently used, LRU) 的页面驱逐到你的后备 swap 设备 (backing swap device),旨在将你的热数据保留在压缩的 RAM 缓存中。
换句话说,zram 提供了一个硬容量限制,而 zswap 提供了更快的(即压缩 RAM)和更慢的(即你的磁盘)swap 之间的自动分层(automatic tiering),并随着内存压力的增加而优雅地降级。对于大多数人来说,优雅的降级是你想要的,不过让我们看看这些在实践中是如何发挥作用的。
zram 的块设备架构 (zram’s block device architecture)
zram 创建一个用作独立 swap 的压缩块设备。在 zram 上设置 swap 的典型过程是:
- 通过
modprobe zram加载模块来创建 zram 设备 - 设置
/sys/block/zram0/comp_algorithm(使用的压缩算法)和/sys/block/zram0/disksize(虚拟设备容量) - 在
/dev/zram0上执行mkswap - 在
/dev/zram0上执行swapon
你可能会注意到,这看起来几乎与在分区上设置 swap 时所做的完全一样,这绝非巧合:内核通过块层将其视为只是另一个存储设备(参见 zram_submit_bio())。
这使得 zram 非常适合嵌入式系统:它是完全自包含的,不依赖于磁盘存储,而这些系统一开始可能就没有磁盘存储。当你在嵌入式控制器或带有 SD 卡的树莓派上时,zram 可以为你提供一定量的内存卸载,而没有任何外部依赖。到目前为止一切都很合理。
然而,当你确实有可用的磁盘存储(如 SSD)时,zram 的块设备架构会产生一些明显的限制。内核本质上天真地认为 zram 与慢速磁盘上的典型块设备没什么不同,因此将通常的面向磁盘的默认设置应用于它。举一个例子,有一个名为 vm.page-cluster 的内核可调参数,它决定了当缺页中断 (faulting in) 发生在一个单独的 swap 页面时,我们想要预读 (readahead) 多少个页面。它默认值为 3——意味着内核一次读取 2^3 = 8 个页面——以摊销在连续读取时成本较低的磁盘工作。这在硬盘驱动器上更为重要,但即使在现代 NAND 上,随机读取和顺序读取之间仍然存在有意义的性能差异。
当我们开始致力于在 Quest(因为它运行的是 Android,使用 zram)上使用 zram 时,这种预读行为是我们遇到的第一个问题。对于磁盘,预读假设成立:磁盘上彼此靠近的页面在时间上也往往会一起被需要,所以摊销成本是好事。对于 zram,压缩页面没有局部性 (locality),因此假设反转了:现在你每次只需要 1 个页面时,却用 7 个你不需要的页面污染了交换缓存 (swap cache),这极大地影响了性能。
重要的是,这既不是 Quest 特有的,也不是 vm.page-cluster 特有的——这更多是内核将 zram 像对待任何其他块设备一样对待的结果。vm.page-cluster 至少是可调的,但内核中还内置了其他甚至没有作为 sysctls 暴露出来的假设。在许多情况下,内核会与你作对,这需要花费大量的精力和知识才能把这件事做好。
zswap 的内存管理集成 (zswap’s memory management integration)
相比之下,zswap 根本不创建块设备——它直接集成到内核的内存管理子系统 (memory management subsystem) 中。这种区别比听起来更重要:因为 zswap 融入了回收路径 (reclaim path) 本身,内核实际上知道 zswap 池中的哪些页面是热的,哪些是冷的。而 zram 只是作为另一个块设备,没有这种可见性。
这种意识是自动分层 (automatic tiering) 的基础,这也是为什么在出现内存压力时,zswap 的降级表现明显优于 zram 的主要原因。
关于如何启用 zswap,可以通过 cat /sys/module/zswap/parameters/enabled 检查它是否已开启。大多数主流发行版默认启用它。如果没有:echo 1 > /sys/module/zswap/parameters/enabled
来启用它
要使其在重启后持久化,请将 zswap.enabled=1 添加到你的内核命令行。
在分配器 (allocator) 选择上,优先选择 zsmalloc 而不是较旧的 z3fold 或 zbud 分配器。zsmalloc 通过对相似对象进行分组来实现高得多的压缩比,而较旧的分配器使用固定大小的对象,往往会浪费空间。z3fold 和 zbud(以及 zpool 接口本身)已从上游内核中移除,因此在当前内核上你不需要设置此项。在仍保留它们的发行版内核上,你可能需要显式选择 zsmalloc:echo zsmalloc > /sys/module/zswap/parameters/zpool
那么 zswap 的分层是如何实际运作的呢?当内核需要换出 (swap out) 一个页面时,它会调用 swap_writeout(),这给了 zswap 拦截它的优先权:
int swap_writeout(struct folio *folio, struct swap_iocb **swap_plug)
{
/* ... */
if (zswap_store(folio)) { /* zswap has called bagsy on the page */
count_mthp_stat(folio_order(folio), MTHP_STAT_ZSWPOUT);
goto out_unlock;
}
if (!mem_cgroup_zswap_writeback_enabled(folio_memcg(folio))) {
folio_mark_dirty(folio);
return AOP_WRITEPAGE_ACTIVATE;
}
__swap_writepage(folio, swap_plug);
return 0;
out_unlock:
folio_unlock(folio);
return ret;
}
swao_writeout() 来自 Linux 6.19
如果 zswap_store() 返回 true,则表明该页面已存储在压缩 RAM 中,且从未接触过磁盘。只有当 zswap 拒绝(或未启用)时,内核才会回退到写入后备 swap 设备。
下面展示 zswap_store() 的内部工作原理:
bool zswap_store(struct folio *folio)
{
/* ... */
/* Check if we've hit pool size limits */
if (zswap_check_limits())
goto put_objcg;
/* Get the current compression pool */
pool = zswap_pool_current_get();
if (!pool)
goto put_objcg;
/* Try to compress and store each page in the folio */
for (index = 0; index < nr_pages; ++index) {
struct page *page = folio_page(folio, index);
if (!zswap_store_page(page, objcg, pool))
goto put_pool;
}
ret = true; /* Success! */
put_pool:
zswap_pool_put(pool);
put_objcg:
obj_cgroup_put(objcg);
/* If we failed because pool was full, queue work to shrink it */
if (!ret && zswap_pool_reached_full)
queue_work(shrink_wq, &zswap_shrink_work);
check_old:
return ret;
}
zswap_store() 来自 Linux 6.19
你可能会注意到 queue_work(shrink_wq, &zswap_shrink_work) 这部分逻辑。它的作用是,既然我们刚发现 zswap 满了,就排队一个工作线程,自动将一些冷页面驱逐到磁盘。它最终会调用 shrink_worker()来处理这个回收 (reclamation) 过程。
这种与内存管理子系统(memory management subsystem)其余部分的深度整合,以及其他 mm 代码能够感知该存储机制特性的事实,正是 zswap 与 zram 截然不同的核心所在。zswap 作为 SSD swap 之前的透明压缩层,而不是像 zram 那样作为单独的存储层。当池满时,它会自动触发收缩器 (shrinker) 将冷页面驱逐到磁盘。
LRU 反转 (LRU inversion)
“等等,Chris,我在我的 zram swap 设备上设置了优先级 (priority)。那不就和 zswap 这种‘分层’架构一样了吗?”
不幸的是,这种逻辑是人们在 zram 问题上“搬起石头砸自己的脚”的最常见方式之一。故事大致是这样的:
- 一个页面准备被换出,此时调用
swap_writeout()。 - 没有 zswap,只有 zram,因此内核只需查看 swap 设备列表即可找到优先级最高的设备。
- zram 配置为最高 swap 优先级,因此只要它有空间就会被选中。
这一切在纸面上听起来都很好,对吧?当然,而这正是它不断让人掉入陷阱的原因。那么陷阱是什么呢?
问题在于内核如何在多个设备之间分配 swap 空间。内核中有一个名为 swap_alloc_slow() 的函数,它专门负责寻找合适的设备与簇(cluster)来进行写入操作:
/* Rotate the device and switch to a new cluster */
static void swap_alloc_slow(swp_entry_t *entry, int order)
{
unsigned long offset;
struct swap_info_struct *si, *next;
spin_lock(&swap_avail_lock);
start_over:
plist_for_each_entry_safe(si, next, &swap_avail_head, avail_list) {
/* Rotate the device and switch to a new cluster */
plist_requeue(&si->avail_list, &swap_avail_head);
spin_unlock(&swap_avail_lock);
if (get_swap_device_info(si)) {
offset = cluster_alloc_swap_entry(si, order, SWAP_HAS_CACHE);
put_swap_device(si);
if (offset) {
*entry = swp_entry(si->type, offset);
return;
}
if (order)
return;
}
spin_lock(&swap_avail_lock);
/* ... continue to next device if this one is full ... */
}
}
这段代码的基本逻辑其实就是:高优先级(higher priority)的设备会被优先使用。这听起来合情合理,对吧?确实如此,这也是为什么用户总是习惯这样去配置的原因。然而,这些用户却在不知不觉中埋下了一个陷阱,而且随着系统运行时间(uptime)的不断增加,触发这个陷阱的几率会越来越高。
陷阱是这样的:由于 zram 设备上的 swap 具有最高优先级,内核在进行所有的内存分配时都会优先选择 zram。当 zram 满时,它将切换到基于磁盘的 swap 进行所有未来的分配。
这意味着在没有干预的情况下,你宝贵的 zram 会被无论什么 碰巧最先被换出的页面填满。 这通常与你现在真正需要的页面完全呈负相关。
在典型的桌面会话中,这些页面通常是冷数据、初始化时的数据,这些数据被尽早驱逐,以便为你刚刚打开的浏览器(例如)腾出空间。然后这些冷页面永久占据着快速的 zram。与此同时,随着你的会话继续和内存压力持续存在,较新的、潜在的“更热”的页面(如你正在积极切换的最近浏览器标签页)被迫溢出到低优先级设备:缓慢的机械硬盘或 SSD。
这就是所谓的 LRU 反转 (LRU inversion):你最快的存储层被最冷的数据堵塞,无法将其驱逐,这实际上迫使你的工作集落到了最慢的存储上。在这种情况下,zram 不仅未能提供帮助,反而让情况变得比根本没有压缩 swap 更糟。更糟糕的是,系统运行时间越长,情况就越糟:热的页面被丢到磁盘,冷页面在 zram 中固化,zram 持有的数据和你实际需要的数据之间的差距越来越大,那很好了。
译者注:原文此处有一个用于演示 zram 和 zswap 工作区别的小动画 感兴趣的读者可以前往体验
除了放置位置的问题,操作两端还需要付出真正的开销 (overhead)。进入 zram 的每一个页面都会消耗 CPU 周期进行压缩。每次访问仍在 zram 中的页面都需要轻微的缺页中断 (minor fault) 并解压缩回主内存才能被使用。你为完全不积极使用的数据付出了压缩和解压缩的开销,而你实际上则在慢速磁盘 I/O 中艰难前行。
zram 回写 (writeback) 及其局限性
如果我是在几年前写这篇文章,讨论到这里可能就结束了。:-) 但从内核 4.14 开始,zram 支持了回写 (writeback),这是试图解决这个问题的一次尝试。
在配置了回写后,zram 可以将空闲 (idle) 或难以压缩的页面写回磁盘。这意味着现代的 zram 设置在理论上可以实现分层 (tiering),但是:
- 它需要手动配置,而不是在内核回收路径 (reclaim path) 中自动完成。
- 它需要你深思熟虑,并建立一个合理的方法来处理回写和启发式策略 (heuristics)。
也许这听起来并不难。请允许我试着说服你改变想法。
要实现类似于 zswap 的行为,即将不可压缩或空闲页面移动到磁盘,你必须创建自己的解决方案。一个问题是,你不能简单地将 zram 回写指向一个 swap 文件或现有的 swap 分区。相反,回写接口需要一个专用的、未格式化的块设备。使用 zram-generator,配置看起来像这样:
[zram0]
zram-size = ram / 2
writeback-device = /dev/sda4
另一个问题是,你必须对磁盘进行重新分区以创建一个专门用于 zram 后备的分区,或者管理回环设备 (loopback devices)(这会增加开销)。你也很难与系统的休眠 swap (hibernation swap) 或其他数据共享这个空间。
更大的障碍实际上在于如何进行刷新 (flushes)。即使连接了后备设备,zram 也不会自动将页面写出,而且内核也没有内部启发式机制来决定何时将数据从 zram 移动到后备设备,因为 zram 没有充分集成到 mm(内存管理)子系统中来有效地实现这一点。
相反,你必须创建一个 systemd 定时器或 cron 作业来手动触发这种刷新,即使这样也并不容易。
例如,要刷新 zram 无法压缩的页面,只需简单执行:
echo huge > /sys/block/zram0/writeback
……但要想刷出(flush)空闲或冷数据,情况要复杂得多。因为 zram 无法感知其上承载的是交换数据还是其他任何种类的数据,所以 zram 本身并不会以支持简单驱逐(eviction)的方式去追踪数据的最近最少使用时长(LRU age)。你必须首先告诉内核将页面标记为空闲,然后告诉 zram 将它们写出。
echo 3600 > /sys/block/zram0/idle # 1小时
echo idle > /sys/block/zram0/writeback
正如 Sam 在审阅本文时所说,这是“内存管理的宜家 (IKEA)”——在你组装 dombås 衣柜时感觉还不错,但当它在生产环境中反咬你一口时,你会觉得自己像个笨蛋
(dombås 在瑞典语中是笨蛋的谐音) —— 译者注。
除了复杂性之外,与 zswap 原生的 LRU 分层相比,zram 在架构上也处于相当不利的地位。
例如,在 zram 中,这种最近最少使用时长检查是一次性事件。当你运行脚本或定时器时,它会获取当前状态的快照。如果一个页面在 5 分钟后变冷,它将留在 RAM 中,直到你再次运行脚本。这与回收过程或收缩器 (shrinkers) 没有任何联系,如果内存压力突然上升,运行你的脚本可能为时已晚。
将其与 zswap 进行比较,在 zswap 中,LRU 列表的评估是正常内存回收生命周期的一部分。一旦内存压力上升,内核就会在回收过程中查看活动页面列表,并立即驱逐最旧的页面。用具体的话来说,使用 zram 时,在你的脚本运行间隔发生的内存使用尖峰,意味着你的应用程序将在内存压力最大的时候使用磁盘 swap。相比之下,使用 zswap,内核会随着压力的发生自然地做出响应。
还有一个粒度 (granularity) 问题。使用 zram,你必须蒙一个基于时间(比如 24 小时)执行驱逐的“魔法数字”。如果你蒙得太高,就会浪费 RAM。但如果你蒙得太低,就会刷新你可能真正想要的数据。毕竟,系统只按你说的做,如果没有长期的广泛分析 (profiling),很难知道告诉它什么才是有效的。
相比之下,在 zswap 中,没有魔法数字,它会根据压力动态平衡 LRU。如果你有充足的 RAM 且没有压力,它会无限期地保留数据。如果你极度缺乏 RAM,它会积极地驱逐最旧的数据,不管它是 24 小时还是 24 分钟前的数据。它具有内省能力,并能够与系统的其余部分进行协商,因此能够做出更好的决策。
最终,zram 回写是一种权宜之计 (workaround),而不是解决方案。并不是说它在某种学术意义上无法发挥作用——它当然可以。问题在于,所有糟糕的边缘情况都正好在最错误的时刻出现:在尖峰中期,当内存压力最高,且你精心猜测的阈值最有可能出错的时候。我强烈建议你不要以这种方式管理内存。
zswap 的自动分层(及其性能悬崖)
如上所述,zswap 紧密的 mm(内存管理)集成免费为你提供了所有这些功能,这让人怀疑是不是“天上掉馅饼”。对于大多数工作负载来说,它确实有点像免费的午餐,但有一些值得注意的陷阱。
zswap 的分层机制通过两个不同的收缩器 (shrinker) 机制工作,这两个机制很容易混淆,因此有必要提前了解它们。
第一个——zswap_shrinker_count()(及其同伴 zswap_shrinker_scan())——作为动态收缩器 (dynamic shrinker) 的一部分存在。它由内存回收器(如 kswapd、直接回收器以及像 Senpai 这样的主动回收器)独立触发,而不是由池限制触发。它的工作是根据内存访问模式、可压缩性和内存压力动态调整 zswap 池的大小,目标是理想情况下你永远不会触及静态池限制。在 Meta 生产环境的实践中,触及静态池限制的情况很少见,因为这个动态收缩器在此之前就能控制住局面。在内存受限的系统(如笔记本电脑)上,你可能会更频繁地看到它。
第二个收缩器 shrink_worker(),是基于限制的后备机制,仅当实际触及池限制时才会触发。这就是性能悬崖 (performance cliff) 所在的地方,下文将对此进行详细说明。
棘手的部分是决定要驱逐多少页面。驱逐太少,池会继续填满;驱逐太多,你会在页面刚刚写入磁盘后又立即将其颠簸 (thrash) 回来。以下是 zswap_shrinker_count() 处理它的方式:
static unsigned long zswap_shrinker_count(
struct shrinker *shrinker,
struct shrink_control *sc)
{
/* zswap shrinker_count basically answers the question of
* how many pages we should evict from zswap to the
* backing swap device. */
struct lruvec *lruvec =
mem_cgroup_lruvec(sc->memcg, NODE_DATA(sc->nid));
/* This is how often we had to fetch data from slow disk
* recently. We track this to avoid thrashing. */
atomic_long_t *nr_disk_swapins =
&lruvec->zswap_lruvec_state.nr_disk_swapins;
/* ... */
/* Subtract from the lru size the number of pages that
* were recently swapped in from disk. The idea is that
* had we protected this many more pages in the zswap
* LRU from eviction, those disk swapins would not have
* happened. */
nr_disk_swapins_cur = atomic_long_read(nr_disk_swapins);
do {
if (nr_freeable >= nr_disk_swapins_cur)
nr_remain = 0;
else
nr_remain = nr_disk_swapins_cur - nr_freeable;
} while (!atomic_long_try_cmpxchg(
nr_disk_swapins, &nr_disk_swapins_cur, nr_remain));
nr_freeable -= nr_disk_swapins_cur - nr_remain;
if (!nr_freeable)
return 0;
/* Scale eviction by compression ratio. If compression is
* good (stored is small), we evict fewer pages to avoid
* wasting I/O for small gains. */
return mult_frac(nr_freeable, nr_backing, nr_stored);
}
zswap_shrinker_count() 来自 Linux 6.19
也就是说,zswap 不是依赖静态阈值或定期轮询,而是基于回收路径的实时反馈进行驱逐,跟踪实际的磁盘换入 (swap-in) 率和压缩比。冷页面在压力累积的那一刻流向 SSD。当内存真正稀缺时,压缩池中容纳的是你活跃的工作集,而不是你几个小时前就不再碰的数据,并且最关键的缺页中断 (page faults) 留在快速的压缩 RAM 中,而不是走向磁盘。
不过,该来的总会来的,这里也有个坑。当 zswap 达到其 max_pool_percent 限制时,zswap_check_limits() 会导致 zswap_store() 拒绝页面并返回 false。这会唤醒 shrink_worker() 将冷页面驱逐到磁盘,但该工作是异步发生的——当前页面未存储在 zswap 中,必须马上找个地方呆着。
接下来发生的事情取决于 cgroup 的回写模式:
- 如果该 cgroup 启用了回写(默认情况),页面会落入
__swap_writepage()并直接进入磁盘,完全绕过 zswap 缓存。 - 如果该 cgroup 禁用了回写,页面会循环回到活动列表 (active list),这避免了磁盘 I/O,但意味着回收器必须稍后重试。
这意味着在启用回写且处于重度内存压力下,zswap 会在没有警告的情况下开始将页面直接发送到磁盘。这创造了一个性能悬崖,系统 swap 性能会突然从你期望的 RAM 访问量级下降到你预期的磁盘访问量级。这并不比 zram 的行为更糟,但这确实是需要牢记的一点。
性能特征与权衡 (Performance characteristics and trade-offs)
这两种技术都用 CPU 周期换取更少的 I/O——在正常运行下,它们的开销概况大体相当。真正重要的差异在于故障模式 (failure modes),而且这些差异是巨大的。
处理不可压缩数据
当数据很容易被压缩时,压缩 swap 显然是有用的。不太明显的是,当数据不易压缩时会发生什么,在这方面,这两种技术做出了相反的押注。
zswap 可以在压缩期间检测到不可压缩页面并拒绝它们,将它们直接发送到磁盘。这既节省了 RAM(通过不存储压缩率差的数据),又节省了 CPU 周期(通过不反复尝试压缩不可压缩的数据)。你可以在 /sys/kernel/debug/zswap/ 的 reject_compress_poor 计数器中看到 zswap 这样做的频率。
相比之下,zram 默认会压缩所有内容,不管压缩比如何。zram 在其 huge_pages 统计中跟踪这些压缩不良的页面,但会乐于存储甚至只压缩到 3.9KB 的 4KB 页面,从而浪费内存和 CPU。
这意味着 zswap 在面对具有大量不可压缩数据的工作负载时,在最坏情况下通常表现得更好,尽管在典型的混合工作负载的实践中,这种差异通常可以忽略不计。
最近的内核版本中,zswap 拒绝不可压缩页面时的行为也发生了演变。默认情况下,如前文 swap_writeout() 所示,被拒绝的页面会落入 __swap_writepage() 并转到磁盘。但是对于不希望出现任何 swap I/O 的工作负载,内核现在支持按照每个 cgroup 单独禁用回写模式 (per-cgroup writeback disabled mode)(内核 6.8+)。在 cgroup 禁用该模式后,任何被 zswap 拒绝的页面——无论是由于不可压缩、池限制还是任何其他原因——都会循环回到活动列表,而不是转到磁盘。这防止了一种形式的 LRU 反转:热的不可压缩页面先于更冷但可压缩的页面写入磁盘。要启用它:
echo 0 > /sys/fs/cgroup/<cgroup>/memory.zswap.writeback
然而,禁用回写模式也有一个缺点:如果内存压力很高,且一个 cgroup 正在生成大量不可压缩数据,回收器最终可能会陷入病态的循环——反复尝试压缩相同的不可压缩页面,失败,将它们循环回活动列表,然后再试一次。如果没有磁盘作为后备,就无法取得进展,这会在生产环境中引发严重问题。我们目前正在研究一种方法,将不可压缩页面按原样保留在 zswap 池中而不是循环它们,将其组织在每个 cgroup 的 LRU 中,以便收缩器在它们变冷后将它们驱逐到磁盘。
SSD 磨损考虑 (SSD wear considerations)
一些人建议优先选择 zram 而不是 zswap 的另一个原因是,他们认为这能减少 SSD 的磨损——也就是说,他们认为使用它可以减少磁盘 I/O。
但这是愚蠢的。RAM 是有限的。如果你用某种数据填满你的 RAM,最终,当你所有的 RAM 都被使用时,数据必须去往某个地方。
在服务器和桌面环境的多数情况下,内存由两种类型的页面主导。一种是匿名页面 (anonymous pages),如程序堆和栈数据。另一种是文件页面 (file pages),即磁盘缓存 (disk cache)。如果你在没有物理后备设备的情况下使用 zram,你实际上将所有匿名数据锁定在了 RAM 中。当内存压力来袭时,内核别无选择,只能积极地驱逐文件缓存以腾出空间。
如果这些被驱逐的文件页面是“脏的” (dirty)(即它们包含修改过的数据),内核被迫将它们写入 SSD 以释放空间并继续运行。即使它们是“干净的”(即它们未被修改),它们也会被丢弃,迫使 SSD 在下次需要时再次读取它们。因为拒绝了从 zswap 或 swap 分区将冷的、未使用的匿名数据换出到物理磁盘,你扼杀了页面缓存 (page cache)。这迫使系统不断刷新并重新读取活跃文件。
仅使用 zram 确实消除了磁盘 swap I/O,但它实际上只是 将压力转移到了页面缓存上 。在内存压力下,更多的文件缓存可能会被丢弃(导致重新读取)或写回(如果是脏的)。使用磁盘后备的 swap(或其前面的 zswap),系统通常可以驱逐冷的匿名页面,这可能会减少缓存波动 (cache churn),从而 减少 I/O。这意味着如果管理不当,zram 实际上会 增加 总的磁盘 I/O。
真正的目标是将活跃的工作集保留在 RAM 中——而妥善使用的磁盘 swap 能够为你提供帮助,它为冷匿名页面提供了一个去处,而不是强迫冷数据和热数据争夺同一个内存池。
我们有一些具体的数字可以在实践中展示这一点。Instagram 基于 Django 运行,且在很大程度上受限于内存瓶颈(memory bound)。我们在 Instagram 上进行了一项测试:将其现有的配置(完全禁用交换空间,即 swap)调整为采用磁盘交换(disk swap)和 zswap 分层(zswap tiering)的新架构。Django worker 在其生命周期内会积累大量冷堆状态 (cold heap state),比如复制了内存的 fork 进程、不断增长的请求缓存、Python 对象开销等等,懂的都懂。结果有两个方面:
- 我们实现了大约 5:1 的压缩比。这对于内存限制的工作负载来说是一个巨大的好处,也使我们能够考虑进一步堆叠工作负载。
- 与完全没有 swap 相比,启用 zswap 将磁盘写入减少了高达 25%(!)。
你可以想象,作为这项测试的结果,Instagram 已经使用 zswap 好几年了。
现在,你们中的一些人看着这个可能会想,增加 swap 怎么可能会减少磁盘 I/O。增加更多基于磁盘的内存卸载反而会减少 I/O,这是何意味呢?
好吧,你可能认为你永远不会用光所有的 RAM。但在 Linux 上,我们实际上不会让 RAM 空着。内核遵循的理念是“未使用的 RAM 就是浪费的 RAM”,因此会自动用页面缓存和其他有用的东西(如文件、库和磁盘数据的副本)填满任何空闲空间,以加快未来的访问速度。
作为其中的一部分,内核守护进程 kswapd 会在可用空间低于特定水位线时主动唤醒以回收内存,并努力平衡内存使用。在理想情况下,我们希望始终有可用的页面可以立即分配而无需阻塞等待回收,因此它将压力管理作为一种正常的运行状态,以确保始终存在用于即时分配的缓冲区。
这些被回收的内存必须找个地方呆着,若只有 zram,它只有一个去处:文件缓存。而有了基于磁盘的 swap(或前端的 zswap),内核就有了选择——它可以根据新近度 (recency) 和访问模式回收较冷的那一个,不管是匿名页面还是文件缓存。此时发生的 I/O 是深思熟虑的,而不是迫不得已的。
当然,Instagram 的工作负载对 zswap 来说尤其有利,所以对于具体数字只需姑妄听之。但尽管如此,其方向几乎适用于所有用例:工作负载通常确实会随着时间的推移积累冷的匿名页面,而且这些页面往往可以很好地被压缩。
不仅如此,zswap 充当了写入减少过滤器 (write-reduction filter),从而显著降低了 SSD 的磨损。它吸收了 RAM 中高频的换出/换入瞬态波动,在数据接触磁盘之前对其进行压缩。只有经过缓存洗礼后真正冷的数据才会被写入。在我上面提到的 Instagram 案例中,磁盘写入减少 25% 的原因正是因为内存缓存吸收了原本会到达 SSD 的频繁写入波动。
现代 SSD 通常也能处理数百T(terabytes) 的写入。消费级 SSD 通常提供 150-600 TB TBW(Terabytes Written)。在 2026 年,除非你使用的是非常廉价的 eMMC 存储,否则整个关于磨损的讨论在很大程度上已经失去意义,而且即使在 eMMC 上,zram 也未必是你最好的选择。
话虽如此,如果 swap I/O 确实是特定工作负载的硬性限制要求,zswap 的 per-cgroup 禁用回写模式(见上文不可压缩数据部分)使你能够完全阻止特定 cgroup 的磁盘 swap I/O,而无需放弃 zswap 与内存管理子系统其余部分的集成。你甚至可以进行混合搭配:对延迟敏感的服务可以使用禁用了回写的 zswap,而其他服务则使用完整的“zswap-然后-磁盘”分层。这比一刀切的 zram 方法要灵活得多。
内存压力下的性能 (Performance under memory pressure)
zswap 和 zram 在正常运行下具有相似的开销。它们真正有区别的地方在于它们在内存压力下的故障模式 (failure modes),理解这些故障模式是理解该使用它们之间哪一个的关键。
对于 zswap,压力是被持续且主动地处理的。随着池被填满,动态收缩器 (zswap_shrinker_count) 会唤醒并提前将冷页面驱逐到磁盘,跟踪磁盘换入率和压缩比以避免颠簸。在实践中,这意味着几乎很少触及池限制。在 Meta 的生产服务器上,它几乎从未触发——动态收缩器在那之前很久就控制了局面。当触及限制时,会出现一个性能悬崖,页面开始绕过缓存直接流向磁盘。这并不好,但这是一个逐渐的降级:系统是变慢,而不是突然崩溃。
对于 zram,没有同等的机制。没有任何东西在监控设备的填充并采取行动。当它达到容量时,它直接停止接受页面。如果有一个较低优先级的磁盘 swap 设备,内核就会溢出到那里,并带来上面描述的所有 LRU 反转问题。如果没有其他设备,系统要么在内核被迫尝试回收任何可用资源时挂起 (hangs),要么 OOM killer 被触发。在任何一种情况下,系统都不会优雅地降级。
情况实际上可能更糟——在某些情况下,OOM killer 甚至可能根本不会触发。2026 年 3 月,Cloudflare 的 Matt Fleming 报告称生产机器出现了 20 到 30 分钟的停机 (brownouts),而 OOM killer 一次也没有触发。原因正是 zram 块设备架构的直接后果。should_reclaim_retry() 通过检查空闲的 swap 槽位来估计可回收的内存。对于有磁盘后备的 swap,空闲槽位意味着页面可以落在那里而不消耗更多的 RAM。对于 zram,设备是精简配置 (thin-provisioned) 的:它报告其配置的全部容量为可用容量,即使本应用于支持这些槽位的物理 RAM 已经耗尽。一个使用率为 10% 的 377 GiB 设备会报告大约 340 GiB 的空闲槽位——但写入它们所需的 RAM 系统中已经没有了。should_reclaim_retry() 不断返回 true,而内核则无限期地在直接回收 (direct reclaim) 中空转。即使 OOM killer 最终触发,它也绝不是许多人所期望的那种干净的退出机制。
你可能会想:如果系统正在大量向磁盘进行 swap,响应能力早就被毁了。我宁愿让系统 OOM 杀掉一个进程,也不愿让它慢慢颠簸折磨用户。但这里有一个经常被忽视的危险细微差别——内核 OOM killer 离瞬间触发差得远呢。
正如我在 SREcon 演讲中提到的那样,依赖内核内置的 OOM killer 来挽救响应能力往往是一场注定失败的战斗。内核在任何直接意义上其实并不知道自己何时耗尽了内存:“内存耗尽 (out of memory)”不仅意味着内存满了,还意味着没有任何东西可以回收了——而确定这一点的唯一方法是尝试完整的回收周期并让它失败。
在调用 OOM killer 之前,内核会进入一个激进回收 (aggressive reclaim) 的循环:
- 扫描 LRU 列表寻找可丢弃的干净页面。
- 尝试将脏页面刷新到磁盘。
- 循环遍历各种内存类型以试图释放任何东西。
- 与驱动程序协商进行内存收缩。
我们经常看到,对于简单的生产工作负载,这个过程可能会花费几秒钟甚至几分钟。在此期间,你的应用程序被挂起,系统似乎死机了。到 OOM killer 真正触发时,用户可能已经经历了严重的不响应,并且系统可能被死锁到了用户几乎无能为力的地步。
内核 OOM killer 也非常不精确。它使用一个启发式的“得分 (score)”来决定杀死谁——如果“得分”听起来像个推脱之词,那是因为它确实如此。这是内核在承认它也不知道谁才是正确的牺牲品,并希望你能通过 oom_score_adj 来填补这个空白。实际的结果是它通常只是杀掉最大的进程,而不是真正泄漏内存的那个。想象一个 Chrome 占用 80% RAM 而某个后台守护进程开始泄漏的系统:OOM killer 将目标锁定为 Chrome,杀了它能使系统稳定,而那个守护进程从未被识别。下次它泄漏时,Chrome 会再次死亡。而那个守护进程,就它而言,该漏还得漏。
Fedora 上的 zram
为什么像 Fedora 这样的发行版默认在配备快速 SSD 的桌面上使用纯 zram 设置?为什么不直接使用 zswap?
答案是 zswap 实际上从未成为选项。Fedora 一段时间以来的目标是完全消除磁盘 swap,既然 zswap 在架构上是磁盘 swap 前面的缓存,它根本就不符合候选条件。
他们消除磁盘 swap 的原因并不完全在于内存管理,还在很大程度上受到其他系统属性的驱动。例如,当 swap 将页面驱逐到磁盘时,私钥、密码、会话令牌 (session tokens) 和浏览器状态最终会留在一个持久化的分区上。zram 完全避开了这一点:它位于 RAM 中,重启就会擦除它,所以不存在任何数据到达磁盘的风险。Swap 加密也能在这里提供帮助,但它增加了配置的复杂性,并且仍然需要信任密钥管理的流程,最终 Fedora 的目标是消除这个攻击面 (surface area),而不是在上面叠加缓解措施。
Fedora 将 zram 与 systemd-oomd 结合使用,后者监控 PSI 以根据策略提前主动杀死进程。他们通过只有这一个 swap 设备(即 zram)也避开了 LRU 反转,由于根本没有磁盘 swap,自然也就无从反转。
考虑到他们所处的限制条件,以及他们通过 systemd-oomd 实施的缓解措施,这种设置在某种程度上是有意义的。在他们的配置下,处于沉重内存压力下的桌面用户可能已经感觉到了卡顿,一个用户空间 OOM 守护进程干净利落地终止掉有问题的进程,通常好过于等待系统在磁盘 swap 颠簸中挣扎好几分钟。
但是,这很重要,这只有在用户空间 OOM 守护进程正在运行和配置,且没有任何磁盘 swap 设备的情况下才有效。没有 systemd-oomd,你得到的只有硬限制而不是干净利落的杀死进程,系统将同样严重或更严重地挂起。
所以,可以说纯粹从内存管理的角度来看,这几乎肯定不是最佳的设置,zswap 更紧密的 mm(内存管理)集成和 LRU 分层提供了 zram 无法匹敌的真正优势。但内存效率并不是 Fedora 优化的唯一目标,他们的一些限制与内存管理毫无关系。在这些限制范围内,他们的决定是连贯的:最佳性总是相对于你试图实现的目标而言的(这一点也适用于你,亲爱的读者——你比我更清楚你试图做什么)。
话虽如此,如果一旦 zswap 获得了即将推出的无盘模式 (disk-free mode),Fedora 也开始向 zswap 倾斜,我不会感到惊讶,特别是考虑到内核开发者越来越倾向于不再支持 zram(见下文)。
如果我使用 zram,我应该如何确定设备大小?
在简单的场景中,使用 zram,你必须预先猜测设备的大小。猜小了,浪费潜力。猜大了,有触发 OOM killer或导致不必要的轻微缺页中断 (minor faults) 的风险。
那么 Fedora 是如何决定大小的呢?嗯,他们将其大小设置为高达你的 RAM 的 100%。大功告成。
……好吧,也许这需要更多的解释。:-)
Fedora 在这里采取了一种激进的方法。他们将 zram 设备的大小设置为等于 100% 的物理 RAM,上限为 8GB。你可能会想,这怎么可能讲得通——一个人怎么能拥有一个大小等同于全部 RAM 的 swap 设备呢?当然,要从 zram 中读取一个页面,我必须将其解压缩到主 RAM 中,对吧?如果 zram 满了,它甚至能把解压缩的页面放在哪里?这是什么疯狂的想法?!
Fedora 用一点受过教育的赌博 (educated gambling) 解决了这个问题。首先,zram 是精简配置的 (thin provisioned)。在实际发生页面错误 (faulted) 之前,是不占用内存的。因此,一个设置为 100% 但里面什么都没有的 zram 设备不占用任何空间,除了用于元数据 (bookkeeping) 的少量空间。
除此之外,他们还打赌你的数据压缩良好——比方说,3:1。这样的话,一个达到 100% 大小的 zram 设备物理上将只占据三分之一的 RAM,留下 66% 空闲给操作系统和解压缩缓冲区。
然后,他们使用 systemd-oomd 监视内存压力。如果它看到 zram 在物理上填满了 RAM,它就会根据策略在触发死锁(即由于没有足够空间解压缩而卡死)的边界之前杀掉某些东西。
如何决定使用哪个 (How to decide which to use)
如果有疑问,我强烈建议你使用具有磁盘后备的 zswap (zswap with disk-backed swap)。 它比 zram 更紧密地与内存管理子系统集成,在回收和驱逐方面有更好的启发式算法,处理不可压缩数据的能力更强,并且在压力下能够优雅地降级。它还能透明地处理休眠 (hibernation)。即使你有大量的 RAM,在很多情况下,基于磁盘的 swap 仍然具有显着的优势。
zram 有意义的情况则更为微妙。在嵌入式系统中,zram 非常简单且自包含。当根本没有磁盘时,它是明显的选择,在这些环境中,硬限制的可预测性通常是一个特性而不是缺陷。另一种情况是你出于设计考量故意采用完全无盘,就像 Fedora 的情况一样。
Android 是无盘方法的最佳范例:数十亿台设备运行着没有磁盘 swap 的 zram,并配有用户空间的 kill 守护进程 (lmkd)。这种组合完全避免了 LRU 反转,因为没有可倒置的磁盘 swap 层。但 Android 的 zram 之所以有效,是因为它针对手机硬件和手机工作负载进行了广泛的调优——正如上文所述,即便是开箱即用的诸如预读默认值这样基础的东西,也会与你作对,而这些还只是可见的选项。这些假设无法迁移,Android 的调优也是一样。
服务器是另一个 zram 极难被接受的地方。除了 zram 的不降级方式之外,zram 的内存使用对内核基本上是不透明的,并且不计入任何 cgroup。内核无法了解 zram 代表给定 cgroup 消耗了多少内存,这可能会破坏服务之间的资源隔离和压力信号。对于许多运行容器化或隔离工作负载的组织而言,仅这一缺口就成为了采用 zram 的硬性障碍。
即便是嵌入式和无盘场景的应用范围也在缩小。我们在这个领域工作的许多人对未来的走向有着相似的看法。作为块层核心贡献者之一的 Christoph,话说得很直接:
“不行。停止仅仅因为你在滥用块驱动程序作为压缩 swap 而向块层添加 hack。请大家把精力集中到可插拔的 zswap 后端 (pluggable zswap backends) 和无后备存储的 zswap (backing-store-less zswap) 上,而不是让 zram 这个烂摊子变得更糟。”
—— Christoph Hellwig, NVMe 维护者和块开发人员
内存管理维护者之一的 Johannes 也同意:
“压缩是内存的消费者。很大的那种。在 swap 机制下它处于回收路径。所以现在你必须通过夹在中间的块层来解决错综复杂的 MM (内存管理) 问题。[…] 我们应该努力使 zswap 成为唯一的压缩 swap 实现。它将极大地简化从事 MM 和 swap 子系统的内核开发人员的工作。这也会让用户的情况变得更好。”
—— Johannes Weiner, MM 维护者
Christoph 提到的“可插拔的 zswap 后端和无后备存储的 zswap”,指的是正在进行的积极工作,允许 zswap 在没有任何磁盘 swap 设备的情况下运行——这将终结 zram 在无盘设置下的剩余用例。Nhat Pham 目前正在以虚拟交换空间 (virtual swap spaces) 的名义领导这项工作。前行的方向非常明确。
在实践中,在我们大规模部署 zswap 的所有服务中,它持续地减少了 OOM 发生率,降低了磁盘写入压力,而且这一切都在没有任何人工干预的情况下完成。zram 是内存管理子系统中完全属于手动维护的部分——你承担着正确管理它的责任,或者承受后果——而 zswap 由内核自身管理,包含着所有附带的实时反馈、回收集成和自动分层。对于绝大多数的 Linux 系统,你真的希望内核用 zswap 来做这件工作。
非常感谢 Nhat, Javier, Sam, Johannes 和 Andreas 对这篇文章的反馈。