数据库页面共用操作系统已经维护的那层缓存。

PostgreSQL 使用两层数据缓存

普通缓冲式关系文件读写中,PostgreSQL 的共享缓冲位于操作系统文件缓存之上。命中共享缓冲时,不需要再次通过下面的文件读取路径;没有命中时,也可能由操作系统直接从内存提供数据。同一文件内容可以在两层各有副本,所以这里并不承诺数据库与内核共用同一份物理内存,更不能把两层缓存理解为完全没有重复占用。 坏掉的样子 执行计划里的共享读取计数,表示块被读入 PostgreSQL 缓冲池,不证明存储设备实际执行了一次物理读取。如果把每次共享读取都算成磁盘未命中,已经温热的操作系统缓存也会被误认为冰冷,从而引出错误的内存配置判断。同样,第二次查询更快,也不能独自说明究竟是哪一层缓存发生了变化,或是否只是其他执行条件改变。 缓存驻留是临时状态。其他查询可能挤走页面,工作集超过容量时,也不能指望所有页永久留在内存里;关系扫描采用的缓冲策略还可能影响复用方式。因此应使用已知大小的工作集、检查实际访问路径,并把耗时结论与真正观察到的缓存层事实分开。没有设备层测量,就不能仅凭一个计划字段宣布磁盘速度发生了变化。 快速验证 使用 PostgreSQL 18 可丢弃实例和超级用户,确保附带的缓冲观察与预热扩展可安装、且尚未安装。本次实例使用一百二十八兆字节共享缓冲,下面的数据可以轻松放下。通过一个全新的 psql -X -v ON_ERROR_STOP=1 会话按顺序执行。关系驱逐函数用于开发测试,这里只针对隔离实验创建的对象操作: CREATE SCHEMA cache_layer_demo; CREATE EXTENSION pg_buffercache WITH SCHEMA cache_layer_demo; CREATE EXTENSION pg_prewarm WITH SCHEMA cache_layer_demo; CREATE TABLE cache_layer_demo.items (id integer PRIMARY KEY,payload text) WITH (autovacuum_enabled=false); INSERT INTO cache_layer_demo.items SELECT g,repeat(md5(g::text),10) FROM generate_series(1,12000) AS g; VACUUM (ANALYZE) cache_layer_demo.items; CREATE VIEW cache_layer_demo.residency AS SELECT count(*) AS heap_buffers FROM cache_layer_demo.pg_buffercache WHERE reldatabase=(SELECT oid FROM pg_database WHERE datname=current_database()) AND relfilenode=pg_relation_filenode('cache_layer_demo.items') AND relforknumber=0; SET max_parallel_workers_per_gather=0; SELECT pg_relation_size('cache_layer_demo.items')/current_setting('block_size')::int AS heap_blocks; SELECT heap_buffers>0 AS initially_buffered FROM cache_layer_demo.residency; 本次构建的主堆有五百四十六个块,初始缓存比较返回真。观察视图同时限制数据库、当前文件标识与主分支,避免把其他关系或辅助分支的缓冲算进来。接着,仅驱逐这个关系的共享缓冲: ...

一月 27, 2028 · 1 分钟 · XB Field Notes
两个不同机制:一个是定时截止,一个是容量触发的清理。

Redis 的过期是截止时间,逐出是容量策略

过期与逐出都可能让一次成功查找变成未命中,但它们回答的是不同问题。过期服从键的截止时间,逐出则在内存压力下移除符合条件的键。延长 TTL 不能阻止容量驱动的逐出,扩大内存也不能纠正应用写错的截止时间。必须先确定实际发生了哪种变化,才能判断应该检查写入协议、调整容量,还是重新设计缓存用途。 时间与容量是两个独立维度 带有过期设置的键在 Redis 中称为 volatile。Redis 会在访问和主动清理过程中检查过期键,但这不承诺键一定存活到截止时间。显式删除、替换以及适用的逐出策略,都可能提前改变结果。因此,不管最初设定的期限还有多久,缓存客户端都必须能处理未命中,不能把较长 TTL 当作某条数据不会丢失的保证。 maxmemory 是 Redis 进行内存管理决策时使用的统计上限,不是操作系统 RSS 的硬上限。复制与追加日志缓冲区可能包含不计入逐出判断的内存,进程开销、分配器行为和持久化任务也需要容量余量。大型命令还可能短暂超过配置值。规划时既要考虑缓存预算,也要考虑运行整个进程的环境,不能把两者设成同一个数字就认为足够。 先确定候选范围,再讨论排序 allkeys 类策略不管键有没有 TTL,都会把它纳入候选;volatile 类策略则只考虑带过期设置的键。在各自候选范围内,LRU 偏向最久未访问的键,LFU 使用访问频率信息,random 类策略随机选择。Redis 的 LRU、LFU 是近似算法,不保证全局精确的受害键顺序。volatile-ttl 偏向剩余寿命较短的条目,也不是只清除已经到期的数据。 noeviction 在无法满足内存限制时拒绝增长内存的命令,而不是为了腾空间丢键;已有数据仍可能正常读取。但它不是持久性保证,普通过期、显式删除、进程故障和持久化配置仍然分别起作用。应检查当前实例实际配置,不能根据某种部署形式或集群名称,推断所有环境都使用相同默认策略。 volatile 策略如果找不到带 TTL 的候选键,在这种场景下就表现得像 noeviction。选了 volatile-lru,不会自动把持久键变成可丢弃数据。反过来,把会话或权威记录与可重建缓存混放在 allkeys 策略之下,它们也会成为候选。键名前缀只是命名约定,不会给逐出算法建立独立的数据保护边界。 坏掉的样子 未命中突然增加,可能来自过期、逐出、主动失效或键构造变化。需要把 expired_keys、evicted_keys 的增量,与同期请求负载和写命令结果一起看。这些是累计计数,应比较有限时间区间,并考虑重启或重置;仅看到逐出数上升,还不能知道哪个应用功能失去了哪些数据。 ...

十二月 26, 2027 · 2 分钟 · XB Field Notes
更大的页面粒度,把更多地址空间塞进更小的 TLB。

巨型页用灵活性换更少的 TLB 缺失

数据库及高吞吐服务往往因此受益,但要背负需要连续内存、池子要预先算好大小的成本——这是一笔架构账,不是免费的。 为什么 4 KB 会吃亏 默认页是 4 KiB,TLB 能容纳的条目又很有限。管理百万级工作集的大型应用,很容易在访问一个页面时就 miss 掉一个 TLB 表项,于是不得不到页表里去翻。这里的成本不是页本身,而是反复的页表遍历;一旦工作集的错杀超过 TLB 回填的速度,大应用就会明显吃到痛。更大的页让每条表项覆盖更多内存,同样大小的工作集约只需要很少的表项,这正是吃内存沉重的负载最需要的缓解。 hugetlb 与 THP 的区别 想要大页有两条路。hugetlb 让你从一块预留的内存里自己分配,页的大小是确定的(常见 2 MiB 或更大),行为可预期、由你掌控;transparent(THP)则在后台自动把映射整合成大页,省心,却可能在某些负载上带来延迟尖峰、碎片与写时复制的问题。对延迟敏感的业务,hugetlb 让你自己抱住那份池和那份 trade-off,而不是把决定权丢给一个你不知道用不用得着的通用策略。选哪条路,其实是在决定「可预测性由谁掌握」——你,还是内核。 池子要提前量 大页的真正收益,建立在它被真用满、并且真的常驻内存的前提上。一页 2 MiB 若只用了很小一部分,却仍占着一个整页表项,被锁住的那块内存就等同于浪费,而池子到底定多大,才是真正的功夫。给池子只留那份最诚实、最稳定使用的内存,而不是凭感觉很轻率地划一大块,并要为「稀疏大页」附带的多占 RAM 留出余量。当这些都成立——页被用满、工作集收得小、居住集成一两个表项——大页的收益才完整兑现;要是把它喂给几乎填不满的负载,那就只是一笔平白消耗。 坏掉的样子 池子给小了,失败多在启动瞬间。数据库要的共享缓冲超过预留大页能覆盖的范围,进程会直接退出,常见报错是无法分配内存;此时系统空闲内存可能还很充足,容易误判。关键是显式大页不能自动回退到小页,缺几页就是缺几页。做法是按应用文档的需求加少量余量定池子,重启后核对总页数与已预留数,而不是只看启动参数。 启动正常、运行中分配失败,多半是碎片。大页要求物理连续的整块区间,机器跑久之后,空闲总量充足,却拼不出完整区间,新映射会被拒绝。典型信号是空闲大页数停在大于零不再下降,同时内核日志出现大页分配失败;此时重试映射没有意义,应回到启动阶段预留或用启动参数固定池子。 更隐蔽的是位置放错与自动整理。池子落在远端节点上,每次命中都要跨互联访问,省下的表项优势会被延迟吃掉,绑定前要先看分节点分布。透明大页则是另一种风险:后台整理与写时复制拆分会把平稳的尾延迟打成尖峰,从合并等待与整理线程占用能看到痕迹。延迟敏感服务建议设为按需启用,只在实测有效的地方打开。 快速验证 下面脚本只读内存信息与系统页大小,不做任何分配,也不需要特权,因此在无特权容器里同样可直接运行。若环境没有内存信息文件或不支持大页,它会打印跳过提示并继续给出页表项换算,而不是抛异常退出。 把下面代码保存为大页检查脚本并运行,即可看到本机覆盖率与固定工作集的表项对比。脚本先解析总页数、空闲数、已预留数、溢出数与单页大小,算出预留覆盖总量;再用系统调用得到的基准页大小,对比同样工作集在基准页与大页下各需多少表项,估算省下的数量。 #!/usr/bin/env python3 """Report hugepage coverage and estimated TLB entries saved (read-only).""" import os import re from pathlib import Path WORKING_SET_BYTES = 8 * 1024**3 # 8 GiB example working set def parse_meminfo(text): vals = {} for key in ("HugePages_Total", "HugePages_Free", "HugePages_Rsvd", "HugePages_Surp", "Hugepagesize"): m = re.search(r"^" + key + r":\s+(\d+)", text, re.M) if m: vals[key] = int(m.group(1)) return vals def main(): vals = {} try: text = Path("/proc/meminfo").read_text(encoding="utf-8") except OSError as exc: print(f"SKIP: /proc/meminfo unavailable ({exc}); showing page-table math only") text = "" if text: try: vals = parse_meminfo(text) if "HugePages_Total" not in vals or "Hugepagesize" not in vals: print("SKIP: no HugePages fields in /proc/meminfo (hugepages unsupported or disabled)") else: size_kb = vals["Hugepagesize"] total = vals["HugePages_Total"] free = vals.get("HugePages_Free", 0) rsvd = vals.get("HugePages_Rsvd", 0) surp = vals.get("HugePages_Surp", 0) cov_mb = total * size_kb / 1024 print(f"Hugepagesize: {size_kb} kB") print(f"HugePages total/free/rsvd/surp: {total}/{free}/{rsvd}/{surp}") print(f"Reserved coverage: {cov_mb:.1f} MiB ({cov_mb/1024:.2f} GiB)") except Exception as exc: print(f"SKIP: failed to parse /proc/meminfo ({exc})") try: base = os.sysconf("SC_PAGE_SIZE") except (AttributeError, ValueError, OSError): base = 4096 huge = vals.get("Hugepagesize", 2048) * 1024 base_entries = WORKING_SET_BYTES // base huge_entries = WORKING_SET_BYTES // huge print(f"Base page size (sysconf): {base} bytes") print(f"8 GiB needs ~{base_entries} base-page entries vs ~{huge_entries} huge-page entries") print(f"Estimated TLB entries saved: ~{base_entries - huge_entries}") if __name__ == "__main__": main() 在支持大页的系统上,预期能看到单页大小、各类计数与预留覆盖容量,以及两种粒度各自的表项数和节省量;在笔记本或极简容器里,则先看到一行跳过提示,再看到同样的表项换算,且本地基准页可能不是常见的四千字节。该脚本已在缺少内存信息文件的环境验证通过。它只衡量预留量与潜在节省,不能证明真实缺失下降、碎片或节点距离,调池子前还要用内核日志、分节点计数与延迟分位交叉确认。 ...

十二月 21, 2027 · 2 分钟 · XB Field Notes
一种假装物理内存约束不存在、让分配保持廉价的策略。

Linux 的 overcommit 是内核的一场豪赌

调整 vm.overcommit_memory,把从内核盲目乐观里让出的那部分判断交回你自己的预算,最终决定谁在 OOM 时被牺牲。 为什么 malloc 很少失败 vm.overcommit_memory 默认为 0,处在启发式模式:它几乎对每一个 mmap 和 malloc 都说好,只要系统还大概承担得起;只有当内存真正吃紧时才拒绝。因为虚拟内存的承诺本身几乎零成本,可容纳的页又是点到即给,所以绝大多数分配都会成功,普通进程以近乎免费的方式活得好好的。这份慷慨的代价是内核可以超卖远多于物理内存。它赌你不会真正写满每一页;一旦太多字节真的落到内存里,总有人要用一次 OOM kill 来买单。 那个开关 vm.overcommit_memory 让你选择由谁来拿预算。默认 0 是启发式超卖:内核只拒绝那些明显不可能兑现的请求,其余几乎照单全收,赌的是多数进程永远不会触碰自己申请的每一个字节。设为 1 表示总是允许超卖:内核几乎不再设防,适合那些映射巨大但大多保持稀疏、长期不触碰的程序,代价是所有失败都被推迟到回收路径或杀手裁决那一刻。设为 2 则是严格记账:凡是超出 CommitLimit 的申请都会在调用当场以内存不足失败,这个上限大致等于交换空间加上物理内存乘以 overcommit_ratio 的比例,当 overcommit_kbytes 非零时以后者为准。两种极端都不免费:总是超卖会把压力藏到整机停顿才爆发,严格记账则会让那些申请慷慨但实际触碰很少的程序直接启动失败,应当按你想控制的风险来选。 启发式对严格上限 当账最终要平的那天到来,真正的问题是谁来买单。启发式模式下,内核会继续超卖,并在内存压力下随机挑一个牺牲品;严格模式下,边界事先画好,失败在 malloc 这一步就浮出水面,由运维而不是调度器决定哪个进程去吞这枚 OOM kill。容器与虚拟机通常会把它调成受控值:一台代表许多 guest 一起超卖的主机,在需求同时冲高时可能把自己的底盘一起压垮。笔记本可以大方一点;而一台绝对不能悄悄丢掉某个工作进程的产线上,就应当把选择说得明明白白。 坏掉的样子 在默认启发式模式下,最让人困惑的故障是:一次巨大的 malloc 或 mmap 明明返回成功,进程却在稍后被杀掉。申请瞬间还没有触碰任何页面,看起来一切正常;等进程真正写入这些页面、内存开始吃紧,内核杀手就会挑选一个牺牲品,而它往往不是当初超额申请的那个,而是旁边体积最大的进程。日志里只剩一句内存不足杀进程,却找不到任何一次分配失败,排查很容易追错二进制文件,真正的超卖源头反而藏在身后。 ...

十二月 18, 2027 · 2 分钟 · XB Field Notes
只借助内存完成、不触发读盘的虚拟内存缺页。

缺页并不总是意味着读盘

把两类缺页一概当作慢磁盘看待会混淆差别;对内存大户调优时,‘次要还是主要’这个区分才是关键。 次缺页只用映射 缺页也分主次:缺页时如果页面其实已经常驻内存,只是进程还没有把这张页表映射,就只需补一张页表——把虚拟地址指到那块已存在的物理内存即可。这也解释了为什么「新开一页」这类动作是便宜的:它不过是在页表上多一条指针,不读磁盘、不耗内存。首次接触刚归零的区域、共享库被别的进程先行加载、或一次从 swap 换入还没有短缺时,走的都是这条路。缺页省下的成本几近于零,也几乎不构成性能问题。 主缺页要读磁盘 主缺页就完全不一样了。它意味着所需数据必须落地到磁盘(或从 swap 读出来),这一步通常几十到几百微秒、甚至毫秒级,比次缺页慢好几个量级。进程冷启动、首次读入大二进制、或又要从 swap 里找回被换出的页面,最常见的表现就是一大串主缺页。两者的差别,主要是「这数据是不是已经在本地」——主缺页多,说明缓存与前读没有把你要的那片东西预先备好。 分开统计才有意义 工具把一个数字拆成两个,不是没事找事,而是为了分清病因。ps、sar、/proc/self/stat 都会分别报 minor/major。一个健康的服务器应该是「次缺页常年不断、主缺页几乎为零」。看的是比例而非总数:主缺页密集、次缺页稀疏,多半是缓存未命中与冷启动;次缺页猛增却没什么主缺页,往往是在页面表上抖动,而不是磁盘紧张。把两者分开看,一旦主缺页冒头,第一反应是补缓存与预读,而不是去怪磁盘。 坏掉的样子 主缺页风暴看起来像延迟毛刺,本质却是缓存未命中。每次发布、容器重启或新节点上线后,所有工作进程会同时第一次触碰二进制、共享库和数据页。这时用 perf stat -e minor-faults,major-faults 观察,会看到主缺页从接近于零突然涨到每秒数千,而处理器利用率反而很低,尾延迟被拉长到数百毫秒。此时加核或加线程没有意义,正确做法是预热工作集、保持镜像常驻,或者错开重启节奏,避免所有进程同时挤兑同一批页面,否则毛刺会随实例数放大。 交换分区会让同样的信号产生共振。当常驻工作集超过物理内存,内核会把不常用的页换出到交换设备,下次访问又要从磁盘换回来。主缺页随换入一起上涨,负载攀升,每个请求都要替磁盘等待,即便代码一行没改。有人会加线程扛延迟,结果工作集更大,抖动更严重,甚至进入换入换出互相等待的恶性循环。此时应该先算内存账:让热数据真正装进内存,或者把冷数据搬出请求主路径,而不是提高并发。 第三类真实失败是误读 mmap 大文件顺序读。顺序扫描一个文件映射,第一次触碰每一页同样会缺页,主缺页很多并不代表随机读坏了,也不代表存储变慢。把所有缺页都当成磁盘寻道,就会去调预读或怪硬盘,真正该问的是这些页当初该不该映射进来。改输入输出参数之前,先用 mincore 确认常驻情况,再决定是预读、缓存还是改访问方式,避免把映射策略问题误判成存储故障。 快速验证 把下面程序保存为 fault_probe.py,运行 python3 fault_probe.py 即可。它只用标准库,不需要特权,在常见发行版和苹果系统上都能直接运行。程序用 resource.getrusage 读取进程的次缺页与主缺页计数,在全新匿名映射上做两轮逐页触碰,并用 time.perf_counter 记录每轮耗时,前后对比即可区分两类缺页,也能直观看出映射建立前后的时间差异。 import mmap import resource import time PAGE = 4096 PAGES = 20000 # 约八十兆:足够看出差值,又不会给持续集成带来负担 def counters(): usage = resource.getrusage(resource.RUSAGE_SELF) return usage.ru_minflt, usage.ru_majflt def touch(mapping): for offset in range(0, len(mapping), PAGE): mapping[offset] = 1 def main(): mapping = mmap.mmap(-1, PAGES * PAGE) base_min, base_maj = counters() start = time.perf_counter() touch(mapping) first_time = time.perf_counter() - start mid_min, mid_maj = counters() start = time.perf_counter() touch(mapping) # 此时页面已经映射好 second_time = time.perf_counter() - start end_min, end_maj = counters() mapping.close() first_min = mid_min - base_min first_maj = mid_maj - base_maj second_min = end_min - mid_min second_maj = end_maj - mid_maj print(f"first touch: minflt={first_min} majflt={first_maj} time={first_time:.4f}s") print(f"second touch: minflt={second_min} majflt={second_maj} time={second_time:.4f}s") assert first_min > 1000, f"expected thousands of minor faults, got {first_min}" assert first_maj == 0, f"anonymous first touch should not cause majors, got {first_maj}" assert second_min < first_min // 10, f"mapped pages should barely fault: {second_min} vs {first_min}" assert second_maj == 0, f"unexpected major faults on second pass: {second_maj}" print("PASS: first touch faults as minors without disk reads; second touch is almost fault-free") if __name__ == "__main__": main() 第一轮逐页写会触发大量次缺页,预期次缺页增加数千,主缺页几乎不变,而且耗时明显更长。第二轮访问已经映射好的页面,预期新增缺页接近于零,耗时也显著缩短。这个反差正是结论:匿名内存第一次触碰只产生次缺页,不需要读盘,第二次访问已经不需要建立映射。 ...

十一月 13, 2027 · 2 分钟 · XB Field Notes
碎片化空洞把 Redis 占用抬高到实际数据之上。

Redis 内存随时间堆出碎片

即使应用键已经删除,Redis 进程仍可能保留较大的常驻内存。这个现象值得解释,但它本身既不能证明内存泄漏,也不能直接证明分配器碎片。真正要回答的是哪些字节仍被分配、哪些页面仍驻留,以及这种占用是否威胁实际容量预算。只看一个名字里带“碎片”的比率,很容易选错处理对象。 把不同测量对象分开 INFO memory 中的 used_memory 表示 Redis 统计的分配量,used_memory_rss 则是操作系统观察到的进程常驻内存。它们都不等于应用负载长度的简单相加,键、对象、元数据、客户端及其他开销也要占空间。MEMORY USAGE 可以估计单个键及其值的分配量,包括相关开销,但不测量这个键独立贡献了多少进程 RSS。 mem_fragmentation_ratio 大体比较常驻内存和 Redis 统计的分配量,两者差额还可能包括代码、共享库、栈以及分配器保留的页面。删除数据后分母下降,即使 RSS 几乎不变,比率也可能迅速升高。因此必须同时检查绝对字节数;一个很小的空实例出现较高比率,不等于它正在占用危险数量的内存,更不是必须立即重启的充分理由。 used_memory_peak 有助于解释此前负载峰值留下的占用。used_memory_dataset 是另一个统计视角,不承诺空键空间对应零进程分配。各项数据也并非全都在 INFO 执行的同一瞬间采样。写入活跃时,不应要求经过四舍五入的比率,精确等于独立读取的两个数字相除,否则可能把采样差异误当成实现错误。 先核实分配器和版本 分配器专用指标有助于区分碎片、保留页面和非堆开销,但含义取决于实际构建。Redis 8.10.1 源码通过小尺寸分配桶的碎片计数计算相关指标,并对 Lua 部分作调整。不能把所有版本的指标都简单重算成 active 除以 allocated;通用文档公式与实际行为不符时,应检查对应版本实现,而不是让旧解释覆盖当前事实。 本实验有意使用 libc 编译。该构建没有 jemalloc 的详细统计,Redis 会用其他测量值补算部分分配器字段,碎片计数则保持为零。因此 allocator_frag_ratio 等于一,不证明进程真的完全没有碎片。解释专门字段或选择处理方式之前,应先记录 mem_allocator,确认数字来自真实分配器统计,还是当前构建能够提供的有限信息。 ...

六月 3, 2027 · 3 分钟 · XB Field Notes
闲置页面被临时寄放到设备上,为内存腾出空间。

交换为内存增加一层虽慢的兜底

swap 不是额外的内存。它是一次下注:内核挑出它预测近期用不到的页面,写进设备,把物理内存腾出来。赌对了,一次内存尖峰过去而进程没被杀掉;赌错了,每一次猜错都要付一次设备往返的代价,而这比读一次内存高出三到五个数量级。 这笔交易只在「当阀门」时才成立。用 swap 去覆盖一个长期配置不足的工作负载,买到的不是容量,而是一台慢得看起来像 CPU 问题的机器。所以第一个问题永远是:工作集里到底有多少是真的装不下。 回收是按预测挑受害者的 匿名页——堆、栈、以及 malloc 交出去的每一块——背后没有文件,因此它们是 swap 唯一能装的东西。文件页根本不需要 swap:内核可以把干净的 page cache 直接丢掉,以后再从磁盘读回来。vm.swappiness 的文档定义是「换出与文件系统换页之间粗略的相对 IO 成本」,取值 0 到 200,默认 60。取 0 时,内核要等到空闲页加文件页低于 zone 的高水位线才开始换出,所以 0 的含义是「万不得已才用 swap」,而不是「不用 swap」。 在内部,这个旋钮变成扫描权重:匿名链表用 swappiness,文件链表用 200 - swappiness,再结合两个链表各自的大小。超过 100 并不是配错了:文档明确说,当交换设备上的随机 IO 大约是文件系统 IO 的两倍快时,可以取 133;对于 zram 或 zswap 这类「设备其实就是内存」的内存内交换,也建议考虑超过 100 的取值。 ...

十一月 25, 2026 · 2 分钟 · XB Field Notes
内存内的一台压缩块设备,保存本来要写进磁盘的页。

zram 用压缩的方式在内存里挤出交换空间

内存吃紧时,冷页面常被送去慢速磁盘;zram 则用压缩的内存替代这一步,用小部分 CPU 换取可观的内存空间。 内存里的压缩块设备 zram 是一块驻留在内存里的块设备,它不跟磁盘对话,而是先在内存里把换出的页压缩、保存起来。内核把一页换给它时,页先被压缩、留在内存中,于是内存方面的减压方式并不是「把页挪去磁盘」,而是「让这一页变小」。因为设备本身挂在内存上,读写它的速度远比磁盘后备 swap 快;可它的容量同样受制约——既取决于当前这页有多可压缩,也取决于你愿意拿多少真实内存去换这块 swap。 压缩就是这笔交易 「压缩」就是这笔交易赚头所在,也是「一台小机型显得能装下比物理内存更多内容」的根源。一页值不值得压要看内容:全零页、文本和有结构的数据都缩得不错,而图片、压缩包这类已经被压缩过的对象几乎缩不动。站在工程视角看,zram 等于把暂时闲置的 CPU 换算成「看似更多」的内存;当初工作负责多半是可压缩的、又特别想避开真实磁盘换页的延迟时,这笔交易才划算。 适合服务器与低端设备 它最合身的地方是那些内存不多、换页压力又不过度的机器:小 VPS、容器宿主机,以及手机、路由器这类如今要把袖珍机器安排得明明白白的角色。反过来,内存本来就充裕、或者本来就要换到冷冷盘上的服务器,同一笔钱买一张真实磁盘往往能装下更多,因此未必轮到 zram。实用的判断方法是盯住压缩率和 swap 使用量:当驻留数据始终很小、又够可压缩、且 CPU 相对空闲,才是让 zram 出场的时刻。 坏掉的样子 最常见的失望是压缩比根本没有到来。照片、视频、加密缓冲和已经压过的归档几乎压不动,mm_stat 里 orig_data_size 只比 compr_data_size 大一点,交换空间却照样被占满。内存池和元数据照样吃掉真实内存,腾出来的余量几乎为零,杀手来得和没开压缩交换一样快。压缩比是数据的属性,不是设备的属性,容量只能按实测值去估。 选错算法是死在处理器上,不是死在容量上。重型算法放在小内存虚拟机或旧手机上,每次换出都要烧大量周期,回收线程转个不停,系统态占用随压力一起涨,交换还没满,长尾延迟先垮了。交互型机器先选 lzo-rle、lz4 或低档 zstd 这类快速算法,换算法前一定要在真实负载下看占用和延迟。 同时打开 zswap 和压缩交换等于把同一页压两遍,白白浪费处理器。zswap 自己先留一块压缩池,再把已经压过的页交给压缩设备,后者几乎找不到可省的空间。两条路径一起烧处理器,交换看似用了不少,有效容量却没涨多少。一条交换路径只留一层压缩:要么把压缩设备当交换,要么让 zswap 走在磁盘交换前面,不要双开。 ...

十一月 24, 2026 · 2 分钟 · XB Field Notes