# ZRAM 原理 ```{contents} ``` ZRAM 是 Linux 内核中的一个模块,它在 RAM 中创建压缩的块设备。简单来说,ZRAM 就是**用 CPU 算力换取内存容量**——把本应占用物理内存的数据压缩存放,需要时再解压读取。 ```{toctree} :maxdepth: 2 ``` ## 概念 传统的块设备(如硬盘、SSD)以扇区为单位存取数据。ZRAM 则在内存中虚拟出一个块设备,数据写入时**先压缩再存入内存**,读取时**先解压再返回**。 ``` +-------------------+ | 应用程序 | +--------+----------+ | 读写 +--------v----------+ | ZRAM 块设备 | /dev/zram0 | (压缩层) | +--------+----------+ | 压缩/解压 +--------v----------+ | 物理内存 | | (压缩后的数据) | +-------------------+ ``` ## 工作原理 ### 压缩 ZRAM 把物理内存分成固定大小的"页"(通常是 4KB),每个页独立压缩: 并发的优势: ZRAM 的每个压缩操作都是针对单个内存页(4KB)进行的,且不同页之间没有任何关联。这意味着多个 CPU 核心可以同时压缩不同页,性能可以随 CPU 核心数线性扩展。这也是 ZRAM 在现代多核服务器上表现优异的原因之一。 ```{mermaid} flowchart LR A[写请求] --> B[接收内存页 4KB] B --> C[选择压缩算法] C --> D[压缩该页] D --> E{压缩后大小?} E -- 小于原页 --> F[存入压缩内存池] E -- 压缩比差 --> G[直接存原始数据] ``` > 图示: 一个内存页被送到 ZRAM 后,先压缩。如果压缩率不错就存压缩后的数据,否则放弃压缩(并非所有数据都能被有效压缩,比如已经压缩过的图片)。 压缩后的数据不是原地存放的,内核会维护一个映射表,记录: 1. 哪个逻辑扇区对应哪个物理内存位置 2. 压缩前的原始大小、压缩后的实际大小 3. 当前是否还能写入 **注意事项:** 存压缩数据的内存不是预分配的,而是动态分配的。因此 ZRAM 设备创建时声明的大小(如 8GB)是"解压后的数据最多 8GB"的意思,实际占用的物理内存远小于这个值。 ### 压缩后的数据如何保存 ZRAM 背后依赖一个专门的内存分配器——**zsmalloc**,它负责管理所有压缩后大小不一的数据块。 #### 问题:碎片化 如果直接用内核的伙伴系统(buddy allocator)来存压缩后的数据,会产生严重的**内部碎片**。比如一个 4KB 页被压缩到 500 字节,如果分配一个完整的 4KB 物理页来存放,就浪费了 3.5KB。极端情况下压缩效果反而让内存占用更多。 zsmalloc 的思路是:**把多个压缩后的对象打包到同一个物理页里**。 #### zsmalloc 的结构 zsmalloc 将物理页组织为 **zspage**(一组连续的物理页),一个 zspage 内可以存放多个压缩对象: ``` +----------------------------------+ | zspage (2个物理页) | | +----------------------------+ | | | obj1 (512B) | obj2 (300B) | | | +----------------------------+ | | | obj3 (1KB) | obj4... | | | +----------------------------+ | +----------------------------------+ ``` ```{mermaid} flowchart TB subgraph "大小类 0 (32-48B)" ZP1[zspage
存 ~80 个对象] end subgraph "大小类 5 (256-384B)" ZP2[zspage
存 ~20 个对象] end subgraph "大小类 10 (2KB-3KB)" ZP3[zspage
存 ~4 个对象] end ALLOC[zsmalloc 分配器] --> ZP1 ALLOC --> ZP2 ALLOC --> ZP3 ``` > 图示: zsmalloc 按压缩后的大小把对象分到不同的"大小类"。同一类的对象大小相近,所以打包到一个 zspage 时浪费的空隙最少。 #### 大小类(size class) zsmalloc 预先定义了若干"大小类",每个类覆盖一个尺寸区间。当压缩后的数据需要存储时: 1. 算出压缩后数据的实际大小(比如 700 字节) 2. 找到能容纳它的最小大小类(比如 768-1024 这个类) 3. 从该类的某个 zspage 中分配一个槽位 4. 如果该类没有空闲槽位,申请新的物理页创建 zspage 这种按大小分类的策略类似于 slab 分配器,极大地减少了内部碎片。 #### 映射表:从扇区到压缩对象 ZRAM 在内核中维护一张映射表,每个逻辑扇区对应一条记录: | 字段 | 含义 | |------|------| | handle (句柄) | 指向 zsmalloc 中压缩对象的指针 | | stored_size | 压缩后的实际大小 | | flags | 状态标记(全零页、写入中 等) | ```{note} handle 并不是一个普通的内存指针。zsmalloc 用一个 32 位或 64 位的整数编码了对象所在的 zspage 和在页内的偏移量。这样 ZRAM 不需要关心对象具体被放在哪个物理页——只需要持有 handle,交给 zsmalloc 去定位即可。 ``` #### 写入流程(完整版) 把之前的流程图展开到存储层面: 1. 应用写入一个 4KB 页 2. 压缩算法将其压缩为 N 字节 3. 检查 N 是否小于阈值(通常设为原页大小,即压缩必须有收益) 4. 若压缩无收益,直接存原始 4KB 数据(此时 stored_size = 4096) 5. 若压缩有收益,调用 zsmalloc 分配 N 字节的空间,把压缩后的数据拷贝进去 6. 将返回的 handle 和 stored_size 写入映射表 #### 释放与覆盖 当某个扇区被覆盖写入或不再需要时: 1. 查映射表拿到旧的 handle 2. 调用 zsmalloc 释放该对象(对应槽位标记为空闲) 3. 更新映射表指向新的 handle 释放的对象**不会立即归还给内核**——zsmalloc 保留这些空槽位,等下次分配时复用。只有当整个 zspage 上的所有对象都被释放后,该 zspage 的物理页才会归还给伙伴系统。 ```{mermaid} flowchart LR subgraph "写入前" ZP_OLD[zspage
▇ objA ▇ ▇ objC ▇] end subgraph "释放 objA 后" ZP_FREE[zspage
□ 空洞 □ ▇ objC ▇] end subgraph "写入 objD" ZP_NEW[zspage
▇ objD ▇ ▇ objC ▇] end ZP_OLD --> ZP_FREE --> ZP_NEW ``` > 图示: objA 被释放后留下空洞,新写入的 objD 如果大小合适,就直接填入那个空洞。 #### 避免空洞:zspage 的分组管理 上面的机制有一个隐患:运行时间长了,可能出现大量 zspage 每个都只剩一两个对象——页面稀疏却占着整页物理内存。zsmalloc 通过**按填充率分组 + 对象迁移**来解决。 **三个队列** 每个大小类内部,zspage 按填充率分到三个链表: ```{mermaid} flowchart LR subgraph ALMOST_EMPTY["almost_empty (几乎空了)"] AE[填充率 ≤ 25%] end subgraph ALMOST_FULL["almost_full (几乎满了)"] AF[填充率 ≥ 75%] end subgraph FULL["full (满了)"] F[填充率 = 100%] end ALLOC[新分配请求] --> AE ALLOC --> AF RELEASE[释放对象后] --> ALMOST_EMPTY RELEASE --> ALMOST_FULL RELEASE --> FULL ``` > 图示: 分配新对象优先从 almost_full 取,释放对象后 zspage 可能从 full 掉到 almost_full 或 almost_empty。 | 分组 | 填充率 | 定位 | 作用 | |------|--------|------|------| | almost_empty | ≤ 25% | `pages_almost_empty` | 搬迁候选——尽量把剩下的对象搬走,释放整页 | | almost_full | ≥ 75% | `pages_almost_full` | 写入首选——还有空位,优先填充 | | full | 100% | `pages_full` | 已满,不接受新写入,直到有对象被释放 | 普通填充率(25%~75%)的 zspage 不在任何特殊链表中,处于中间态。 **对象迁移(compaction)** 当 zsmalloc 发现某个大小类的 almost_empty 页太多时,触发压缩整理: 1. 扫描 almost_empty 链表,挑出一个"源 zspage" 2. 遍历该页中剩余的少量对象,在**同大小类**的 almost_full 页中找到空槽位 3. 把对象 memcpy 过去,更新 ZRAM 映射表中的 handle 4. 源 zspage 上所有对象都搬走后,整页归还给伙伴系统 ```{mermaid} flowchart TB subgraph BEFORE["迁移前"] AE_PAGE["almost_empty 页
□ obj_X □ □ □ □"] AF_PAGE["almost_full 页
▇ ▇ ▇ ▇ ▇ □"] end subgraph AFTER["迁移后"] AE_FREE["空页 → 归还内核"] AF_DONE["full 页
▇ ▇ ▇ ▇ ▇ ▇"] end BEFORE -->|"memcpy obj_X"| AFTER ``` > 图示: obj_X 从稀疏页搬迁到有空位的 almost_full 页,稀疏页释放。 **为什么这件事重要** 如果没有这套机制,随着时间推移,会出现"每个页面都只剩 1 个对象"的尴尬局面——zspage 总数很高,但有效数据很少,内存被空洞白白浪费。有了迁移机制,zsmalloc 可以持续把碎片合并,让空闲页面真正归还给系统。 这也解释了为什么 `zramctl` 看到的 `mem_used_total` 会在系统空闲时缓慢下降——后台的 compaction 在回收稀疏页面。 #### 总结 ZRAM 的存储层 = **zsmalloc(防碎片打包器)+ 映射表(扇区→句柄的索引)**。压缩后的数据不是零散存放的,而是按大小分类、紧密打包在物理页中,这使得 ZRAM 的实际内存开销接近压缩后数据的总和,浪费极少。 ### 读取与解压 读数据时: 1. 先查映射表,找到压缩后的数据在内存中的位置 2. 把数据解压到临时的页缓存中 3. 返回给调用方 如果发现要读的页根本不存在(全零页),ZRAM 会直接返回全零的数据,这个优化大幅减少了"已分配但从未使用"的内存浪费。 ### 压缩算法的选择 ZRAM 支持多种压缩算法,选择标准通常是**压缩率 vs CPU 开销**: | 算法 | 压缩率 | CPU 开销 | 适合场景 | |------|--------|----------|----------| | lzo / lzo-rle | 低 | 极低 | 低延迟要求的场景 | | lz4 | 中低 | 低 | 通用场景,平衡之选 | | zstd | 高 | 中 | 内存紧张、追求高压缩比 | **实际配置**可以通过 `/sys/block/zram0/comp_algorithm` 查看和切换。 ## 典型用途 ### 作为 swap 设备 ZRAM 最常见的用法是把 `/dev/zram0` 格式化成 swap 设备。这比传统的磁盘 swap **快几个数量级**: 传统的 swap 是当物理内存不够用时,把不常用的页写入到磁盘上。而 ZRAM swap 则是在物理内存中划出一块区域存放压缩后的数据,完全不涉及硬盘 I/O。 **代价**是: 你的 CPU 需要花时间压缩/解压数据。但在现代的 CPU 上,这个开销通常远小于硬盘寻道的时间。 现代 Linux 发行版(如 Fedora、Ubuntu)默认就启用了 ZRAM swap。 ### 作为临时文件系统 可以把 ZRAM 设备格式化为 ext4 或任何文件系统,当作一个超快的压缩 RAM 盘: ```bash # 创建 zram 设备 echo 1 > /sys/class/zram-control/hot_add # 返回设备号, 比如 zram0 echo 4G > /sys/block/zram0/disksize mkfs.ext4 /dev/zram0 mount /dev/zram0 /mnt/zram ``` 适合存放编译临时文件、日志等不需要持久化但需要快速读写的场景。 ### 对比 zswap 两者都涉及内存压缩,但思路不同: | 特性 | ZRAM | zswap | |------|------|-------| | 位置 | 纯内存中 | 内存 → 磁盘之间 | | 是否涉及磁盘 I/O | 否 | 是(交换到磁盘上的存储池) | | 配置复杂度 | 低 | 中 | | 适用场景 | 没有磁盘 swap 或内存紧张 | 有磁盘 swap 且想减少 I/O | 简单来说: **ZRAM 在内存不够时压缩一部分数据,zswap 在往磁盘写 swap 时先压缩一次**。很多系统会同时使用两者。 ## 配置 ### 内核参数 创建 ZRAM 设备不需要手动编译内核,现代发行版的内核都内置了 `zram` 模块。 检查是否已加载: ```bash lsmod | grep zram ``` 如果没有加载: ```bash modprobe zram ``` ### 手动创建与启用 ```bash # 1. 创建一个 zram 设备 echo 1 > /sys/class/zram-control/hot_add # 返回数字 N,则设备为 /dev/zramN # 2. 设置大小(解压后的最大大小) echo 8G > /sys/block/zram0/disksize # 3. 选择压缩算法 echo zstd > /sys/block/zram0/comp_algorithm # 4. 格式化为 swap mkswap /dev/zram0 swapon /dev/zram0 -p 100 # -p 设置优先级, 数字越大越优先使用 ``` ### 监控 ```bash # 查看压缩统计 cat /sys/block/zram0/mm_stat # 输出格式: # orig_data_size compr_data_size mem_used_total mem_limit mem_used_max # same_pages_pages(被识别为全零的页数) pages_compacted(压缩过的页数) huge_pages(大页数) # 更易读的统计 zramctl ``` ### 卸载 ZRAM 设备 ```bash # 如果用作 swap swapoff /dev/zram0 # 释放设备 echo 1 > /sys/block/zram0/reset ``` ### systemd 方式 `systemd` 提供了 `systemd-zram-setup@.service`,可以自动管理 ZRAM 设备: ```bash # 创建一个 zram 设备 echo '[Swap]' > /etc/systemd/zram-generator.conf echo 'zram-size = 4096' >> /etc/systemd/zram-generator.conf echo 'compression-algorithm = zstd' >> /etc/systemd/zram-generator.conf systemctl enable --now systemd-zram-setup@zram0.service ``` ### 如何选择合适的压缩算法? 检查你的 CPU 支持哪些: ```bash cat /sys/block/zram0/comp_algorithm # 输出类似: lzo [lzo-rle] lz4 lz4hc 842 zstd # 方括号内的是当前使用的算法 ``` 大多数 Fedora 版本默认使用 `lzo-rle` 算法,因为它的 CPU 开销极低,适合做频换的换入换出操作。 **建议:** 1. 新内核上使用 `zstd`(高压缩比+不错的性能) 2. 老系统上使用 `lz4`(快速+靠谱) 3. 如果是临时挂载的文件系统,也不需要特别高的压缩比,用 `lzo-rle` ## 技术细节 ### 为什么 4KB Linux 内核管理内存的基本单位就是 4KB(不考虑大页)。ZRAM 的压缩单元与内核的内存页一一对应,这样: 1. 换出时:直接把一个 4KB 页送给 ZRAM 压缩 2. 换入时:需要一个页大小的空间,ZRAM 就解压出一页来 不需要中间转换,减少了复杂度。 ### ZRAM 创建的磁盘大小 ZRAM 初始化时,**并不是立即占用 `disksize` 大小的内存**。它只是声明"最多存这么多解压后的数据"。实际内存占用 = 压缩后的数据 + 元数据(映射表),通常在 `disksize` 的 1/3 到 1/2。 用 `zramctl` 可以看到当前的实际内存占用。 ### 全零页识别 ZRAM 在写入每一页前会检查该页是否全是零。如果是全零页,不分配任何内存——映射表中标记为"全零"即可。很多应用分配了内存页但从未真正使用,这个优化能节省可观的物理内存。 ```{note} `/sys/block/zram0/mm_stat` 中的 `same_pages_present` 字段就是被识别为全零的页数。如果你的系统运行很多 Java 或 Node.js 应用(这些程序倾向于分配大块内存但未必全都使用),这个数字可能非常大。 ``` ### 内存不足时怎么办 极端情况下,ZRAM 本身也会被内核的 OOM killer 回收。但如果要写入的新数据无法压缩到可用内存空间,可能会发生死锁。好的做法是保守设置 `disksize`,确保它不超过物理内存的 200%。 ### 现代桌面 Linux 的默认配置 大多数现代 Linux 发行版(Fedora 33+、Ubuntu 20.04+)默认启用了 ZRAM,通常配置为: - 大小: 物理内存的 50%-100% - 算法: lzo-rle(老发行版)或 lz4(新发行版) - 优先级: 高于所有磁盘 swap(-p 100) 可以通过 `cat /proc/swaps` 查看当前活跃的 swap 设备及其优先级。 ## 参考资料 - [Linux ZRAM 文档](https://docs.kernel.org/admin-guide/blockdev/zram.html) - [systemd ZRAM 生成器](https://github.com/systemd/zram-generator) - [Arch Wiki: ZRAM](https://wiki.archlinux.org/title/Zram) - [Fedora ZRAM 默认启用说明](https://fedoraproject.org/wiki/Changes/ZRAM) ## 线上资源 ```{toctree} :maxdepth: 2 ```