经典系统基础|mmap 深入解析:page fault、脏页回写、msync、一致性与数据库里的常见坑
上一篇文章,我们把 POSIX 文件 I/O 和 Linux 扩展放在同一张图里看了一遍,其中专门提到:
open/read/write/fsync是最经典的文件 I/O 基线mmap不是“更快的read/write”,而是另一种访问模型epoll和io_uring主要在解决事件等待和提交完成模型问题
但只要你真的开始接触数据库、KV、嵌入式存储、只读索引、共享内存文件、列式缓存或者一些高性能本地服务,很快就会发现:
mmap 这个接口,几乎总是既诱人又危险。
诱人的地方在于:
- 它让文件内容看起来像内存
- 它省掉了大量显式
read()/write()搬运代码 - 它对随机读尤其自然
- 它很适合页式数据结构、只读索引和映射型数据库
危险的地方在于:
- 你会误以为“我只是在访问内存”
- 然后忘了背后其实还是文件、页缓存、回写、持久化和一致性问题
这也是为什么很多工程师第一次用 mmap 时,往往会遇到这些典型困惑:
- 为什么明明没调用
read(),访问映射地址还是会卡 - 为什么只是改了一块内存,磁盘文件最后也变了
- 为什么
mmap写并不天然等于安全持久化 - 为什么数据库特别喜欢
mmap,又特别怕mmap - 为什么文件被截断或 remap 后,进程可能直接
SIGBUS
所以这篇文章的目标很明确:
把 mmap 作为一种文件访问模型,系统地讲透。
重点会放在五块:
- page fault
- 脏页回写
msync- 一致性与持久化边界
- 数据库和存储系统里的常见坑
1. 先定性:mmap 到底是什么
很多人对 mmap 的第一印象通常是:
- 内存映射文件
- 零拷贝
- 读文件更快
这些说法都有一点对,但都不够准确。
更准确的定义是:
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
这意味着:
- 文件并没有消失
- I/O 并没有消失
- 只是访问形式从“显式系统调用”变成了“虚拟内存驱动下的按页取数和回写”
这是整篇文章最重要的起点。
2. 为什么 mmap 容易让人产生错觉
因为从应用代码看,它真的太像内存了。
例如:
void *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
char x = ((char *)p)[123];
((char *)p)[456] = 42;
代码里没有:
read()write()- 显式缓冲区搬运
- 明确的 I/O 完成回调
于是人很容易在心理上把它归类成:
- 普通内存
但这正是最危险的地方。
因为它只是看起来像内存,本质上仍然受到下面这些机制控制:
- 虚拟内存页表
- page fault
- page cache
- 脏页回写
- 文件系统一致性语义
- 设备持久化边界
也就是说,mmap 并不是绕开了 I/O,而是把 I/O 的复杂度藏进了内存管理路径里。
3. 第一关键机制:page fault 到底在 mmap 里扮演什么角色
如果你只记住 mmap 的一个关键词,那应该是:
page fault。
因为 mmap() 本身通常并不会立刻把整个文件读进内存。
它做的更像是:
- 在进程地址空间里建立一段虚拟内存区域
- 记录这段区域和某个文件 offset 范围之间的映射关系
- 真正访问到某个页时,再由内核按需处理
于是第一次访问映射区域的某个地址时,CPU 可能发现:
- 这个虚拟页当前并没有有效物理页映射
然后触发 page fault,陷入内核。
内核再决定:
- 这是不是一个合法映射
- 如果合法,对应文件里的哪一页需要被装入
- 页缓存里是否已经有这页
- 如果没有,是否需要从磁盘读入
- 最后把页表补上,让程序继续执行
所以很多“为什么只是访问一个指针却卡住了”的问题,本质上就是:
你在 page fault。
4. page fault 不等于错误,它常常只是按需加载
很多人听到 fault 这个词,会本能觉得像异常或故障。
其实在 mmap 的正常使用中,page fault 非常常见,而且大量属于“正常缺页处理”。
比如:
- 第一次扫描一个映射文件
- 首次访问某个大索引区间
- 映射区域跨页访问时碰到尚未装入的页
这些都可能触发 minor fault 或 major fault。
粗略理解:
- minor fault:页已经在内存相关结构里,只是当前进程页表没建好或需要轻量处理
- major fault:需要真正从存储设备把页读进来,代价通常更高
这也是为什么 mmap 程序的延迟表现经常不是“每次都很平均”,而更像:
- 热页访问很快
- 冷页首次访问突然变慢
如果你不把这一层想清楚,就很容易误把 page fault 带来的尖刺延迟,当成“CPU 抖动”或者“随机系统卡顿”。
5. mmap 和 page cache 的关系:很多时候它们根本就是同一批页在说话
理解 mmap 的第二个关键点,是它和 page cache 的关系。
对大多数普通文件映射场景来说,映射文件内容背后依赖的,仍然是文件页缓存。
这意味着:
read()读一个文件时,常常会经过 page cachemmap映射这个文件时,访问到的页很多时候也是 page cache 里的页
所以从系统角度看,它们并不是两套完全独立的数据副本体系。
这有两个直接后果:
read/write路径和mmap路径对同一文件的可见性会互相影响- 页缓存状态、回写策略、内存压力同样会影响
mmap程序
也就是说,别把 mmap 理解成“完全绕开 page cache 的直接文件访问”。
大多数情况下,恰恰不是。
6. MAP_SHARED 和 MAP_PRIVATE 是理解一致性的第一道分叉
只要讲 mmap,这两个 flag 就必须讲清楚。
MAP_SHARED
含义是:
对映射区域的修改,原则上会反映到底层文件,也可能被其他共享该映射语义的观察者看到。
MAP_PRIVATE
含义是:
对映射区域的写入使用写时复制,修改只体现在当前进程私有视图中,不会直接回写到底层文件。
这两者差别极大。
如果你在数据库或存储系统里想要“修改映射页最终反映到文件”,通常讨论的是 MAP_SHARED。
而如果你只是想高效读取、偶尔做本地私有修改,那么 MAP_PRIVATE 更像一种带 COW 的视图机制。
很多一致性误判,第一步其实就是没先分清自己用的是哪一种。
7. 写映射页时到底发生了什么:脏页是怎么来的
当你对 MAP_SHARED 映射区域执行写操作时,从程序视角看只是:
((char *)p)[456] = 42;
但从内核视角,这件事通常意味着:
- 对应页必须是可写映射
- 页会被标记为 dirty
- 后续某个时刻需要被写回底层文件
这里的“dirty”是整个 mmap 世界的第三个关键词。
脏页意味着:
内存里的这一页比磁盘文件上的当前版本更新,但更新还没有完全稳定回写。
这和 buffered write() 的 dirty page 逻辑在本质上是相通的。
所以你可以把 mmap 写理解成:
- 不是“直接改磁盘”
- 而是“先改内存中的页缓存页,然后等待回写路径把它推向持久层”
这也是为什么:
mmap 写不等于 durable write。
8. 脏页什么时候写回:后台回写和显式同步都可能参与
脏页出现以后,下一个问题自然是:
它什么时候真正写回文件?
答案通常有几条路径共同参与:
- 后台回写线程按内核策略异步刷回
- 内存压力触发页回收和回写
- 进程调用
msync() - 某些情况下调用
fsync()/fdatasync() munmap()或进程退出不应该被简单理解成“自动可靠提交”
这里要特别警惕一种常见误解:
- “反正内核迟早会刷回去,所以
mmap写最终总是安全的”
这句话在“最终大概率会写回”这个层面可能不算错,但对数据库和存储系统来说完全不够。
因为它没有回答:
- 掉电前算不算安全
- 多页更新是否原子
- 写回顺序是否符合你的恢复协议
而这些才是工程上真正致命的问题。
9. msync() 到底解决什么问题
说到这里,就轮到 msync() 出场了。
最朴素地说,msync() 的作用是:
要求把映射区域内相关修改,按一定语义与底层文件同步。
常见你会看到类似:
msync(addr, len, MS_SYNC);
或者:
msync(addr, len, MS_ASYNC);
这里最关键的是,不要把它理解成一个“万能提交按钮”,而要理解成:
- 它针对的是映射区域对应页的同步请求
- 它能帮助你缩小“内存中已改、文件中未稳”的时间窗口
- 但它本身也仍然要放进具体文件系统、设备缓存和整体一致性协议里理解
也就是说,msync() 很重要,但不是魔法。
10. msync(MS_SYNC) 和 fsync() 的关系不要想当然
这又是一个特别常见的坑。
很多人会自然问:
- 那我用了
mmap,是不是msync()就等价于write + fsync
这个问题不能简单暴力地回答“完全等价”。
更稳妥的理解是:
msync()主要针对映射页同步fsync()主要针对文件描述符相关脏数据和必要元数据持久化- 两者有重叠,但关注入口和控制粒度不同
对工程实践来说,最重要的不是背它们在每个系统上的所有细枝末节,而是记住:
只要你要做可靠持久化协议,就不能靠“我感觉这样应该会写回”来设计。
而应该明确:
- 你的修改经哪条路径进入内核
- 你要求哪一层同步
- 你的文件系统和恢复协议依赖什么持久化顺序
这也是为什么成熟数据库即使用 mmap,也常常会非常克制地设计 commit 协议。
11. 一致性问题从来不是 mmap 独有,但 mmap 特别容易把它藏起来
传统 write() 路径里,你至少能直观看到:
- 这里有一次
write - 这里有一次
fsync
而在 mmap 路径里,代码看起来可能只是:
page->count = newCount;
page->checksum = newChecksum;
page->next = nextPage;
代码层面完全不像在做 I/O。
这会带来一个极大的风险:
程序员很容易忘记这其实是“对文件的多处更新”。
而只要是多处更新,就必然会遇到:
- 写回顺序
- 崩溃窗口
- 部分持久化
- 校验和版本切换
- 恢复时如何识别哪一组页算数
这就是为什么数据库谈 mmap,最后一定会绕回:
- WAL
- COW
- 双 superblock / 双 meta page
- checkpoint
因为一致性问题根本不可能被 mmap 本身替你消灭。
12. 数据库为什么既喜欢 mmap,又害怕 mmap
这是个特别值得展开的问题。
喜欢它的原因
- 随机读路径很自然
- 页式数据结构映射非常直接
- 操作系统帮你管理缓存和按需装页
- 对只读视图或读多写少 workload 很友好
比如:
- BoltDB / bbolt 的读路径
- LMDB 的整体思路
- 各类只读索引和映射表
怕它的原因
- page fault 延迟不可忽视
- 文件增长与 remap 管理很麻烦
- 崩溃一致性不能偷懒
- 写路径下多页原子更新很难
- 映射失效、文件截断、地址悬空都可能直接把进程打崩
也就是说,mmap 对数据库的吸引力主要在读路径;而它最大的工程挑战,往往集中在写路径和恢复语义。
13. 第一个数据库常见坑:以为改页等于提交
这是最经典的误区之一。
如果一个数据库直接在 MAP_SHARED 页上做原地修改,逻辑上可能会写成:
- 修改 B+Tree 节点
- 修改父节点
- 修改 freelist
- 修改 meta
从代码看只是连续几次内存写。
但掉电时你完全可能看到:
- 子页已新、父页未新
- freelist 已更新、root 未更新
- meta 已切换、部分数据页未稳
所以成熟数据库很少把“直接改映射页”当作提交语义本身。
它们通常会额外引入:
- 写前日志
- copy-on-write 页分配
- 双版本 meta page
- 明确的 checkpoint
这不是因为 mmap 不行,而是因为它只解决访问模型,不解决事务原子性。
14. 第二个数据库常见坑:长读事务 + remap + 文件增长
这个坑在映射型数据库里非常典型。
文件一旦增长,进程往往需要:
- 扩展底层文件
- 重新建立更大的映射区域
这时如果系统里还有长期持有旧映射视图的读事务,就会出现很麻烦的问题:
- 旧指针还能不能继续安全访问
- remap 时如何避免悬空引用
- 写事务如何在不踩坏读视图的前提下推进
这也是为什么像 BoltDB 这种系统,会显式维护活跃读事务集合,并非常谨慎地处理 mmap 重建。
因为:
映射地址不是逻辑 ID,指针的生命周期是要被严肃管理的。
15. 第三个数据库常见坑:文件被截断或损坏时,mmap 不是温柔报错,而可能直接 SIGBUS
这点很多没踩过坑的人很难有直觉。
传统 read() 路径如果读越界、读到坏区域,很多时候你得到的是:
- 返回值异常
errno
而在 mmap 路径下,如果底层文件被意外截断、映射范围失效、或者访问到了不再有有效 backing 的页,进程可能直接收到:
SIGBUS
这和“返回一个错误码让我优雅处理”完全不是同一类故障模型。
所以只要你用 mmap,就必须严肃对待:
- 文件生命周期
- truncate 行为
- 映射长度与文件真实长度关系
- 多进程是否会同时改这个文件
否则有些问题根本不是“读错数据”,而是“进程直接崩掉”。
16. mmap 特别适合读多写少,但写多场景为什么容易变得别扭
这是工程上很常见的分水岭。
读多写少场景
mmap 很自然,因为:
- page fault 之后热读快
- 数据结构直接指针化访问舒服
- 操作系统缓存策略往往已经够用
写多场景
mmap 会变得别扭,因为:
- 写入粒度是页,不是你以为的那个小字段
- 脏页管理和回写时机不完全由你掌控
- 多页一致性与提交边界要自己设计
- 高更新率时 page cache / dirty page / 回写压力会很现实
所以很多系统最后会形成一种折中:
- 读路径喜欢
mmap - 写路径更偏 WAL、append-only、COW、新页切换
这其实是非常自然的演化。
17. 什么时候 mmap 是非常好的选择
说了很多坑,不代表 mmap 不值得用。
相反,它在一些场景下是非常漂亮的选择。
典型包括:
- 大型只读索引
- 配置或字典型数据文件
- 页式数据库的读路径
- 共享内存风格的数据交换
- 需要按需访问大文件的随机读 workload
这些场景里的共同点通常是:
- 读路径价值很高
- 数据结构天然页化
- 你能接受操作系统主导缓存
- 写入协议另有设计,不会天真地直接原地改页就提交
如果这些条件成立,mmap 往往很好用。
18. 什么时候你应该谨慎甚至先别用 mmap
反过来,如果你处在这些场景,我会建议非常谨慎:
- 写很多、提交很频繁
- 强依赖精确可控的持久化边界
- 文件会被频繁扩容、收缩或重写
- 多进程/多组件可能同时碰同一文件
- 你希望错误以返回值形式优雅处理,而不是页访问异常
这时候,传统 pread/pwrite + fsync、append-only log 或更显式的页缓存管理方式,往往更容易把系统行为讲清楚。
不是它们一定更快,而是:
它们让 I/O、持久化和错误边界更显式。
19. 一个实用的判断框架:不要先问“mmap 快不快”,先问“你的问题更像哪一类”
工程上最容易把问题问错。
与其先问:
mmap比read/write快吗
不如先问:
- 我的 workload 是顺序读还是随机读
- 读多还是写多
- 我需不需要非常明确的提交边界
- 我能不能接受 page fault 带来的首访延迟
- 我能不能处理 remap、SIGBUS、截断、脏页回写这些故障模型
- 我的数据结构是不是天然适合页化映射
如果这些问题你回答不清,直接上 mmap 大概率只会把问题藏起来,不会把问题解决掉。
20. 总结:mmap 的本质不是“更快的文件 I/O”,而是“用虚拟内存语义来访问文件”
如果把这篇文章压缩成一句话,那就是:
mmap 最核心的价值,不是让你少写几个 read/write,而是把文件访问模型切换成了虚拟内存模型;而它最大的工程风险,也恰恰来自这种“看起来像内存、实际上还是文件 I/O 与持久化系统”的双重身份。
你真正该抓住的,是这几条主线:
mmap的第一关键词是 page fault,不是零拷贝- 映射页背后很多时候仍然是 page cache
MAP_SHARED写入会形成脏页,并不天然等于安全持久化msync()很重要,但不能被神化成自动事务提交- 数据库用
mmap时,真正难点往往在一致性、回写顺序、remap 和故障模型
这也是为什么 mmap 在数据库和存储系统里总是同时扮演两个角色:
- 一个非常优雅的读路径工具
- 一个必须被非常谨慎驯服的写路径与恢复语义挑战
一旦你把这层双重身份看清,再回头看 BoltDB、LMDB、只读索引、共享内存文件甚至某些 FUSE 场景里的映射设计,就会顺很多。
因为那些系统本质上都在回答同一个问题:
如何利用虚拟内存把访问做得更自然,同时不被持久化和一致性问题反噬。