Q先生的世界

面朝大海,春暖花开

经典系统基础|mmap 深入解析:page fault、脏页回写、msync、一致性与数据库里的常见坑

上一篇文章,我们把 POSIX 文件 I/O 和 Linux 扩展放在同一张图里看了一遍,其中专门提到:

  1. open/read/write/fsync 是最经典的文件 I/O 基线
  2. mmap 不是“更快的 read/write”,而是另一种访问模型
  3. epollio_uring 主要在解决事件等待和提交完成模型问题

但只要你真的开始接触数据库、KV、嵌入式存储、只读索引、共享内存文件、列式缓存或者一些高性能本地服务,很快就会发现:

mmap 这个接口,几乎总是既诱人又危险。

诱人的地方在于:

  1. 它让文件内容看起来像内存
  2. 它省掉了大量显式 read() / write() 搬运代码
  3. 它对随机读尤其自然
  4. 它很适合页式数据结构、只读索引和映射型数据库

危险的地方在于:

  1. 你会误以为“我只是在访问内存”
  2. 然后忘了背后其实还是文件、页缓存、回写、持久化和一致性问题

这也是为什么很多工程师第一次用 mmap 时,往往会遇到这些典型困惑:

  1. 为什么明明没调用 read(),访问映射地址还是会卡
  2. 为什么只是改了一块内存,磁盘文件最后也变了
  3. 为什么 mmap 写并不天然等于安全持久化
  4. 为什么数据库特别喜欢 mmap,又特别怕 mmap
  5. 为什么文件被截断或 remap 后,进程可能直接 SIGBUS

所以这篇文章的目标很明确:

mmap 作为一种文件访问模型,系统地讲透。

重点会放在五块:

  1. page fault
  2. 脏页回写
  3. msync
  4. 一致性与持久化边界
  5. 数据库和存储系统里的常见坑

1. 先定性:mmap 到底是什么

很多人对 mmap 的第一印象通常是:

  1. 内存映射文件
  2. 零拷贝
  3. 读文件更快

这些说法都有一点对,但都不够准确。

更准确的定义是:

mmap 把一个文件或匿名内存区域映射到进程虚拟地址空间里,让程序通过普通内存 load/store 的方式访问它。

也就是说,程序视角发生了一个非常关键的变化。

传统文件 I/O 是这样:

fd -> read/write syscall -> kernel copies / page cache -> user buffer

mmap 的视角更像:

fd -> mmap -> virtual memory area -> page fault on demand -> access memory directly

这意味着:

  1. 文件并没有消失
  2. I/O 并没有消失
  3. 只是访问形式从“显式系统调用”变成了“虚拟内存驱动下的按页取数和回写”

这是整篇文章最重要的起点。


2. 为什么 mmap 容易让人产生错觉

因为从应用代码看,它真的太像内存了。

例如:

void *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
char x = ((char *)p)[123];
((char *)p)[456] = 42;

代码里没有:

  1. read()
  2. write()
  3. 显式缓冲区搬运
  4. 明确的 I/O 完成回调

于是人很容易在心理上把它归类成:

  1. 普通内存

但这正是最危险的地方。

因为它只是看起来像内存,本质上仍然受到下面这些机制控制:

  1. 虚拟内存页表
  2. page fault
  3. page cache
  4. 脏页回写
  5. 文件系统一致性语义
  6. 设备持久化边界

也就是说,mmap 并不是绕开了 I/O,而是把 I/O 的复杂度藏进了内存管理路径里。


3. 第一关键机制:page fault 到底在 mmap 里扮演什么角色

如果你只记住 mmap 的一个关键词,那应该是:

page fault。

因为 mmap() 本身通常并不会立刻把整个文件读进内存。

它做的更像是:

  1. 在进程地址空间里建立一段虚拟内存区域
  2. 记录这段区域和某个文件 offset 范围之间的映射关系
  3. 真正访问到某个页时,再由内核按需处理

于是第一次访问映射区域的某个地址时,CPU 可能发现:

  1. 这个虚拟页当前并没有有效物理页映射

然后触发 page fault,陷入内核。

内核再决定:

  1. 这是不是一个合法映射
  2. 如果合法,对应文件里的哪一页需要被装入
  3. 页缓存里是否已经有这页
  4. 如果没有,是否需要从磁盘读入
  5. 最后把页表补上,让程序继续执行

所以很多“为什么只是访问一个指针却卡住了”的问题,本质上就是:

你在 page fault。


4. page fault 不等于错误,它常常只是按需加载

很多人听到 fault 这个词,会本能觉得像异常或故障。

其实在 mmap 的正常使用中,page fault 非常常见,而且大量属于“正常缺页处理”。

比如:

  1. 第一次扫描一个映射文件
  2. 首次访问某个大索引区间
  3. 映射区域跨页访问时碰到尚未装入的页

这些都可能触发 minor fault 或 major fault。

粗略理解:

  1. minor fault:页已经在内存相关结构里,只是当前进程页表没建好或需要轻量处理
  2. major fault:需要真正从存储设备把页读进来,代价通常更高

这也是为什么 mmap 程序的延迟表现经常不是“每次都很平均”,而更像:

  1. 热页访问很快
  2. 冷页首次访问突然变慢

如果你不把这一层想清楚,就很容易误把 page fault 带来的尖刺延迟,当成“CPU 抖动”或者“随机系统卡顿”。


5. mmap 和 page cache 的关系:很多时候它们根本就是同一批页在说话

理解 mmap 的第二个关键点,是它和 page cache 的关系。

对大多数普通文件映射场景来说,映射文件内容背后依赖的,仍然是文件页缓存。

这意味着:

  1. read() 读一个文件时,常常会经过 page cache
  2. mmap 映射这个文件时,访问到的页很多时候也是 page cache 里的页

所以从系统角度看,它们并不是两套完全独立的数据副本体系。

这有两个直接后果:

  1. read/write 路径和 mmap 路径对同一文件的可见性会互相影响
  2. 页缓存状态、回写策略、内存压力同样会影响 mmap 程序

也就是说,别把 mmap 理解成“完全绕开 page cache 的直接文件访问”。

大多数情况下,恰恰不是。


6. MAP_SHAREDMAP_PRIVATE 是理解一致性的第一道分叉

只要讲 mmap,这两个 flag 就必须讲清楚。

MAP_SHARED

含义是:

对映射区域的修改,原则上会反映到底层文件,也可能被其他共享该映射语义的观察者看到。

MAP_PRIVATE

含义是:

对映射区域的写入使用写时复制,修改只体现在当前进程私有视图中,不会直接回写到底层文件。

这两者差别极大。

如果你在数据库或存储系统里想要“修改映射页最终反映到文件”,通常讨论的是 MAP_SHARED

而如果你只是想高效读取、偶尔做本地私有修改,那么 MAP_PRIVATE 更像一种带 COW 的视图机制。

很多一致性误判,第一步其实就是没先分清自己用的是哪一种。


7. 写映射页时到底发生了什么:脏页是怎么来的

当你对 MAP_SHARED 映射区域执行写操作时,从程序视角看只是:

((char *)p)[456] = 42;

但从内核视角,这件事通常意味着:

  1. 对应页必须是可写映射
  2. 页会被标记为 dirty
  3. 后续某个时刻需要被写回底层文件

这里的“dirty”是整个 mmap 世界的第三个关键词。

脏页意味着:

内存里的这一页比磁盘文件上的当前版本更新,但更新还没有完全稳定回写。

这和 buffered write() 的 dirty page 逻辑在本质上是相通的。

所以你可以把 mmap 写理解成:

  1. 不是“直接改磁盘”
  2. 而是“先改内存中的页缓存页,然后等待回写路径把它推向持久层”

这也是为什么:

mmap 写不等于 durable write。


8. 脏页什么时候写回:后台回写和显式同步都可能参与

脏页出现以后,下一个问题自然是:

它什么时候真正写回文件?

答案通常有几条路径共同参与:

  1. 后台回写线程按内核策略异步刷回
  2. 内存压力触发页回收和回写
  3. 进程调用 msync()
  4. 某些情况下调用 fsync() / fdatasync()
  5. munmap() 或进程退出不应该被简单理解成“自动可靠提交”

这里要特别警惕一种常见误解:

  1. “反正内核迟早会刷回去,所以 mmap 写最终总是安全的”

这句话在“最终大概率会写回”这个层面可能不算错,但对数据库和存储系统来说完全不够。

因为它没有回答:

  1. 掉电前算不算安全
  2. 多页更新是否原子
  3. 写回顺序是否符合你的恢复协议

而这些才是工程上真正致命的问题。


9. msync() 到底解决什么问题

说到这里,就轮到 msync() 出场了。

最朴素地说,msync() 的作用是:

要求把映射区域内相关修改,按一定语义与底层文件同步。

常见你会看到类似:

msync(addr, len, MS_SYNC);

或者:

msync(addr, len, MS_ASYNC);

这里最关键的是,不要把它理解成一个“万能提交按钮”,而要理解成:

  1. 它针对的是映射区域对应页的同步请求
  2. 它能帮助你缩小“内存中已改、文件中未稳”的时间窗口
  3. 但它本身也仍然要放进具体文件系统、设备缓存和整体一致性协议里理解

也就是说,msync() 很重要,但不是魔法。


10. msync(MS_SYNC)fsync() 的关系不要想当然

这又是一个特别常见的坑。

很多人会自然问:

  1. 那我用了 mmap,是不是 msync() 就等价于 write + fsync

这个问题不能简单暴力地回答“完全等价”。

更稳妥的理解是:

  1. msync() 主要针对映射页同步
  2. fsync() 主要针对文件描述符相关脏数据和必要元数据持久化
  3. 两者有重叠,但关注入口和控制粒度不同

对工程实践来说,最重要的不是背它们在每个系统上的所有细枝末节,而是记住:

只要你要做可靠持久化协议,就不能靠“我感觉这样应该会写回”来设计。

而应该明确:

  1. 你的修改经哪条路径进入内核
  2. 你要求哪一层同步
  3. 你的文件系统和恢复协议依赖什么持久化顺序

这也是为什么成熟数据库即使用 mmap,也常常会非常克制地设计 commit 协议。


11. 一致性问题从来不是 mmap 独有,但 mmap 特别容易把它藏起来

传统 write() 路径里,你至少能直观看到:

  1. 这里有一次 write
  2. 这里有一次 fsync

而在 mmap 路径里,代码看起来可能只是:

page->count = newCount;
page->checksum = newChecksum;
page->next = nextPage;

代码层面完全不像在做 I/O。

这会带来一个极大的风险:

程序员很容易忘记这其实是“对文件的多处更新”。

而只要是多处更新,就必然会遇到:

  1. 写回顺序
  2. 崩溃窗口
  3. 部分持久化
  4. 校验和版本切换
  5. 恢复时如何识别哪一组页算数

这就是为什么数据库谈 mmap,最后一定会绕回:

  1. WAL
  2. COW
  3. 双 superblock / 双 meta page
  4. checkpoint

因为一致性问题根本不可能被 mmap 本身替你消灭。


12. 数据库为什么既喜欢 mmap,又害怕 mmap

这是个特别值得展开的问题。

喜欢它的原因

  1. 随机读路径很自然
  2. 页式数据结构映射非常直接
  3. 操作系统帮你管理缓存和按需装页
  4. 对只读视图或读多写少 workload 很友好

比如:

  1. BoltDB / bbolt 的读路径
  2. LMDB 的整体思路
  3. 各类只读索引和映射表

怕它的原因

  1. page fault 延迟不可忽视
  2. 文件增长与 remap 管理很麻烦
  3. 崩溃一致性不能偷懒
  4. 写路径下多页原子更新很难
  5. 映射失效、文件截断、地址悬空都可能直接把进程打崩

也就是说,mmap 对数据库的吸引力主要在读路径;而它最大的工程挑战,往往集中在写路径和恢复语义。


13. 第一个数据库常见坑:以为改页等于提交

这是最经典的误区之一。

如果一个数据库直接在 MAP_SHARED 页上做原地修改,逻辑上可能会写成:

  1. 修改 B+Tree 节点
  2. 修改父节点
  3. 修改 freelist
  4. 修改 meta

从代码看只是连续几次内存写。

但掉电时你完全可能看到:

  1. 子页已新、父页未新
  2. freelist 已更新、root 未更新
  3. meta 已切换、部分数据页未稳

所以成熟数据库很少把“直接改映射页”当作提交语义本身。

它们通常会额外引入:

  1. 写前日志
  2. copy-on-write 页分配
  3. 双版本 meta page
  4. 明确的 checkpoint

这不是因为 mmap 不行,而是因为它只解决访问模型,不解决事务原子性。


14. 第二个数据库常见坑:长读事务 + remap + 文件增长

这个坑在映射型数据库里非常典型。

文件一旦增长,进程往往需要:

  1. 扩展底层文件
  2. 重新建立更大的映射区域

这时如果系统里还有长期持有旧映射视图的读事务,就会出现很麻烦的问题:

  1. 旧指针还能不能继续安全访问
  2. remap 时如何避免悬空引用
  3. 写事务如何在不踩坏读视图的前提下推进

这也是为什么像 BoltDB 这种系统,会显式维护活跃读事务集合,并非常谨慎地处理 mmap 重建。

因为:

映射地址不是逻辑 ID,指针的生命周期是要被严肃管理的。


15. 第三个数据库常见坑:文件被截断或损坏时,mmap 不是温柔报错,而可能直接 SIGBUS

这点很多没踩过坑的人很难有直觉。

传统 read() 路径如果读越界、读到坏区域,很多时候你得到的是:

  1. 返回值异常
  2. errno

而在 mmap 路径下,如果底层文件被意外截断、映射范围失效、或者访问到了不再有有效 backing 的页,进程可能直接收到:

  1. SIGBUS

这和“返回一个错误码让我优雅处理”完全不是同一类故障模型。

所以只要你用 mmap,就必须严肃对待:

  1. 文件生命周期
  2. truncate 行为
  3. 映射长度与文件真实长度关系
  4. 多进程是否会同时改这个文件

否则有些问题根本不是“读错数据”,而是“进程直接崩掉”。


16. mmap 特别适合读多写少,但写多场景为什么容易变得别扭

这是工程上很常见的分水岭。

读多写少场景

mmap 很自然,因为:

  1. page fault 之后热读快
  2. 数据结构直接指针化访问舒服
  3. 操作系统缓存策略往往已经够用

写多场景

mmap 会变得别扭,因为:

  1. 写入粒度是页,不是你以为的那个小字段
  2. 脏页管理和回写时机不完全由你掌控
  3. 多页一致性与提交边界要自己设计
  4. 高更新率时 page cache / dirty page / 回写压力会很现实

所以很多系统最后会形成一种折中:

  1. 读路径喜欢 mmap
  2. 写路径更偏 WAL、append-only、COW、新页切换

这其实是非常自然的演化。


17. 什么时候 mmap 是非常好的选择

说了很多坑,不代表 mmap 不值得用。

相反,它在一些场景下是非常漂亮的选择。

典型包括:

  1. 大型只读索引
  2. 配置或字典型数据文件
  3. 页式数据库的读路径
  4. 共享内存风格的数据交换
  5. 需要按需访问大文件的随机读 workload

这些场景里的共同点通常是:

  1. 读路径价值很高
  2. 数据结构天然页化
  3. 你能接受操作系统主导缓存
  4. 写入协议另有设计,不会天真地直接原地改页就提交

如果这些条件成立,mmap 往往很好用。


18. 什么时候你应该谨慎甚至先别用 mmap

反过来,如果你处在这些场景,我会建议非常谨慎:

  1. 写很多、提交很频繁
  2. 强依赖精确可控的持久化边界
  3. 文件会被频繁扩容、收缩或重写
  4. 多进程/多组件可能同时碰同一文件
  5. 你希望错误以返回值形式优雅处理,而不是页访问异常

这时候,传统 pread/pwrite + fsync、append-only log 或更显式的页缓存管理方式,往往更容易把系统行为讲清楚。

不是它们一定更快,而是:

它们让 I/O、持久化和错误边界更显式。


19. 一个实用的判断框架:不要先问“mmap 快不快”,先问“你的问题更像哪一类”

工程上最容易把问题问错。

与其先问:

  1. mmapread/write 快吗

不如先问:

  1. 我的 workload 是顺序读还是随机读
  2. 读多还是写多
  3. 我需不需要非常明确的提交边界
  4. 我能不能接受 page fault 带来的首访延迟
  5. 我能不能处理 remap、SIGBUS、截断、脏页回写这些故障模型
  6. 我的数据结构是不是天然适合页化映射

如果这些问题你回答不清,直接上 mmap 大概率只会把问题藏起来,不会把问题解决掉。


20. 总结:mmap 的本质不是“更快的文件 I/O”,而是“用虚拟内存语义来访问文件”

如果把这篇文章压缩成一句话,那就是:

mmap 最核心的价值,不是让你少写几个 read/write,而是把文件访问模型切换成了虚拟内存模型;而它最大的工程风险,也恰恰来自这种“看起来像内存、实际上还是文件 I/O 与持久化系统”的双重身份。

你真正该抓住的,是这几条主线:

  1. mmap 的第一关键词是 page fault,不是零拷贝
  2. 映射页背后很多时候仍然是 page cache
  3. MAP_SHARED 写入会形成脏页,并不天然等于安全持久化
  4. msync() 很重要,但不能被神化成自动事务提交
  5. 数据库用 mmap 时,真正难点往往在一致性、回写顺序、remap 和故障模型

这也是为什么 mmap 在数据库和存储系统里总是同时扮演两个角色:

  1. 一个非常优雅的读路径工具
  2. 一个必须被非常谨慎驯服的写路径与恢复语义挑战

一旦你把这层双重身份看清,再回头看 BoltDB、LMDB、只读索引、共享内存文件甚至某些 FUSE 场景里的映射设计,就会顺很多。

因为那些系统本质上都在回答同一个问题:

如何利用虚拟内存把访问做得更自然,同时不被持久化和一致性问题反噬。