原文地址
https://db.cs.cmu.edu/mmap-cidr2022/

背景

mmap是系统提供的一种特性,可以将磁盘上文件的地址映射到 程序的地址空间中,之后OS会透明的加载这些page,如果内存不够会主要驱逐page
mmap使用很方便,但会出现 正确性、性能方面的问题,而且很难发现
一些流行的DBMS一开始也使用mmap,后来他们出现了各种问题,就自己管理 文件I/O

介绍

  • 面向磁盘的DBMS 可以支持大于 物理内存的数据,这就好像查询的时候整个数据都是在内存中一样
  • 这种方式是通过 page的换入、换出实现的,将page读入到内存中,如果不够,则清理那些不再使用的page
  • 传统方式下,DBMS使用 buffer pool实现 内存<->磁盘的 page切换,用系统调用 read、write实现
  • 文件I/O机制将数据拷贝到用户态的buffer中,这样DBMS就可以完全控制它了
  • 另一种方式用mmap这个POSIX系统调用,将磁盘上的文件映射到调用者的虚拟地址空间中
  • 当DBMS访问page时,OS会采用懒惰的方式加载他们,这一切都是OS在幕后实现的
  • 理论上说,用mmap的性能应用比传统的buffer pool更好,因为免去了read、wirte系统调用
  • 也避免了将数据拷贝到用户态,DBMS可以直接访问OS的page cache
  • 1980年代早起,是有很多DBMS采用了这种方式,而且一些流行数据库也采用了,并一致沿用了这种方式
  • mmap其实有很多隐藏的问题,会导致安全性、性能问题,使用它增加了很多复杂性

mmap介绍

mmap的工作方式,假设程序调用 mmap,加载 cidr.db

  1. 调用mmap,返回一个指针,指向了文件的内存映射
  2. OS保留了程序的部分虚拟地址空间,但不加载任何文件内容
  3. 程序使用指针访问 文件内容
  4. OS尝试去检索page
  5. 由于映射的内容不在虚拟地址中,OS触发了缺页中断,从磁盘中加载文件部分内容到物理页中
  6. OS将一个条目增加到 page table,这条内容是 虚拟地址 -> 物理地址的 映射
  7. 将这个条目加载到CPU的 TLB translation lookaside buffer,为后续读取加速

OS会负责加载需要访问的page,并驱逐旧的page,同时会

  • 从 page table中删除映射条目
  • 从 每个CPU的 TLB中删除映射条目

虽然可以直接将当前CPU 核的cache刷新,但是没法通知远端CPU核
CPU也没有提供这种对于远端一致性的功能,所以只能触发:内部处理器中断
inter-processor interrupt
将结果刷新到所有远端CPU,这也叫:TLB shootdown
从测试结果看,这种操作会带来严重的性能影响

POSIX API
下面来介绍 内容映射文件I/O mmap的API

  • mmap
    • OS会将文件内容映射到调用者的虚拟地址空间中
    • MAP_SHARED会将更改最终写回到磁盘
    • MAP_PRIVATE使用copy-on-write模式,并不会持久到磁盘文件中
  • madvise
    • 告诉OS想要访问数据的模式,它提供了一种访问page的粒度,比如全部还是指定的page范围
    • 三个通用的指标:MADV_NORMAL, MADV_RANDOM, and MADV_SEQUENTIAL
    • 当出现缺页中断时,默认会使用MADV_NORMAL,此时OS将抓取将要访问的页,以及前面15个,后面16个页
    • 一共从磁盘读128K数据(即使调用者只需要一个page),根据场景不同这种预抓取会损害性能、或也有帮助
    • MADV_RANDOM只抓取一个也,对于OLTP场景比较合适
    • MADV_SEQUENTIAL对于OLAP比较时候,会顺序扫描
  • mlock
    • OS将某个page钉在内存中,不会将其驱逐出去
    • 但是根据POSIX标准,linux实现,OS允许任何时刻刷新脏页到磁盘上,即使脏页被钉住
    • 所以DBMS不能确保使用mlock,脏页就不会写磁盘,这对事物安全有严重影响
  • msync
    • 显示的将指定的内存范围刷新到磁盘上
    • 如果没有这个函数,DBMS就没有其他方式能保证更新被持久化

mmap的问题
最早使用mmap的是在1990年代,现在也有一批数据库在使用mmap

DBMS MMAP Use Details
MonetDB 2002– [12, 21]
MongoDB 2009–2019 [14, 3]
LevelDB 2011– [5]
LMDB 2011– [20]
SQLite 2013– [7]
SingleStore 2013–2015 [32]
QuestDB 2014– [34]
RavenDB 2014– [4]
InfluxDB 2015–2020 [8, 1]
WiredTiger 2020– [17]

MoneDB 存储独立的列使用mmap,SQLite也提供了mmap,而不是read、write系统调用
其他如LMDB,完全使用mmap

这里列举了 MongoDB的例子

  • 早期的时候,为了方便MongoDB也是用mmap的
  • 但是缺点是带来了复杂的拷贝模式,也没法对二级存储上的数据做压缩
  • 因为mmap 需要让 内存布局匹配磁盘上的物理布局,这就导致了空间浪费,低效的I/O
  • 2015年的时候,引入了WiredTiger,在2019年完全废弃了第一代的存储MMAPv1
  • 不过在2020年,又重新引入了mmap作为 WiredTiger 的一个可选项
  • 但这个选项会有一些使用上的限制,避免在用户态 <-> 内核态之间转换的时候出现问题

InfluxDB

  • 早期的时候也是用 mmap、
  • 当数据库大于几个G的时候,出现了I/O写入峰值,可能跟页驱逐有关
  • 当面临容器环境,或者主机没有直接挂载的磁盘时(云环境),也暴露出了很多问题,不得不替换mmap

其他

  • SingleStore使用mmap时,发现每个查询时mmap调用需要10-20毫秒,差不多是查询的一半时间
  • 这是因为共享的mmap lock,引发的竞争导致的
  • facebook fork了谷歌的levelDB,搞出了RocksDB,因为levelDB使用了mmap导致的性能问题
  • TileDB发现使用mmap在SSD时,比raed系统调用
  • Scylla,一个分布式NoSQL,评估了几种文件I/O方案后,拒绝了mmap,因为会失去系细粒度控制,比如驱逐策略,IO的操作调度
  • VictoriaMetrics这个时序库发现,mmap在缺页中断时会阻塞
  • RDF-3X,发现mmap在windows和POSIX的实现 并不兼容

mmap的问题

使用mmap会带来很多严重的问题,下面来讨论

问题1:事务安全问题

OS可能在任何时候将脏页刷新到磁盘上,不管写入的事务是否提交
同样,DBMS也无法阻止OS这样做,并且在flush的时候也收不到任何警告
为了确保不违反事务的安全性保证,DBMS需要实现复制的协议,一般来说包括

  • OS的写时拷贝
  • 用户态的写时拷贝
  • 影子页,shadow page

这里假设,DBMS存储的数据都是单个页的
OS Copy-On-Write

  • 使用mmap创建两个数据库文件拷贝,初始的时候两个都指向相同的物理page
  • 首先使用主拷贝来处理服务
  • 当有事务更新时,副拷贝会在私有空间处理,创建私有空间使用 MAP_PRIVATE 选项
  • 这使用了OS的写时复制特性,只有MongoDB的MMAPv1存储引擎实现了这种方式
  • DMBS之后在私有空间中更新这些page,OS会透明的将内容拷贝到物理页中
  • 重新映射虚拟内存地址 -> 副本内容,主副本也看不到这些变更
  • 为了确保修改被持久化,需要WAL机制
  • 当事务提交时,刷新对应的WAL到磁盘,并在后台将提交的内容变更到主拷贝上

管理这些独立的拷贝会有这么一些问题

  • (1)DBMS需要确保,在允许冲突的事务运行之前,事务提交的最新更新传播到了主拷贝中
  • 这需要增加一个机制,能跟踪正在处理的page的更新
  • (2)私有的拷贝随着不断的更新会越来越大,DBMS需要周期的收缩私有空间,调用mremap
  • 但是DBMS同样要确保,在销毁私有空间之前,正在处理的更新要传播到主拷贝中
  • 为了确保在 mremap时丢失所有的更新,DBMS需要锁住正处理的更新,直到完成了私有空间的压缩

User Space Copy-On-Write

  • 跟OS copy-on-write差不多,这个更偏手动一些,把影响的page拷贝到用户态的buffer pool中
  • SQLite、MonetDB、RaventDB等都使用了这个方式的变种
  • 为完成更新,DBMS需要对更改做拷贝,并创建对应的WAL记录
  • 通过写入WAL到持久存储,完成提交
  • 在这个点上,可以安全的拷贝修改的page到mmap的后端内存
  • 拷贝整个page太浪费了,一些DBMS仅将WAL修改的直接应用到mmap后端内存中

Shadow Paging

  • LMDB是这个方式的最著名的实现着,它是一款嵌入式内存数据库
  • 最早的实现是 System R
  • DBMS需要管理主page和影子拷贝,他们两都是用mmap来管理的
  • 为实现更新,DBMS将影响的page从主拷贝到 影子拷贝,然后应用更新
  • 提交变更牵涉到flush影子页的修改到 磁盘中,使用了 msync函数
  • 然后把指针指向影子页,当做新的主page;而原先的主page现在当做影子页
  • 这个实现不复制,但DBMS需要确保事务没有冲突,并且不会看到部分更新
  • LMDB的解决方案是,只允许单个写入者

问题2:I/O暂停

可能发生阻塞的情况

  • 传统的DBMS可以用异步I/O,比如 libaio、io_uring避免查询中的线程阻塞
  • 对于B+树要扫描页节点,DBMS可以异步处理读请求,对于不连续的页 不会引起延迟
  • 但是 mmap 不支持异步读
  • 访问任何页都可能会触发未知的I/O停住,因为DBMS访问的页不在内存中,导致了缺页中断
  • 为避免这种问题,DBMS采用了 mlock函数,将某个page钉在内存中,避免去驱逐
  • 但是OS限制了每个处理器可以钉住的page数量,因为钉住太多page会导致并发访问的问题,甚至OS本身都会有问题
  • DBMS需要小心的处理并跟踪这些page,需要对那些不再使用的page,取消钉住
  • 另一个问题是 madvise,告诉OS需要的访问模式
  • 比如MADV_SEQUENTIAL,对于顺序扫描的情况,它告诉OS对于已经读取的page将其驱逐,并预加载连续的将要访问的page
  • 这种方式比mlock简单,但是OS可能会忽略这些提示
  • 如果用错地方了,比如对于随机访问的场景,用了MADV_SEQUENTIAL,会导致性能问题

可以创建新线程来处于预抓取,这样就只会阻塞在独立的线程中,不会影响主线程了
尽管这种方式可以部分解决一些问题,但是也引入了额外的复杂性,这就跟引入 mmap的目的相违背了
毕竟,用 mmap只是考虑其简洁、便利的

问题3:错误处理

问题描述

  • DBMS需要确保数据的完整性,所以像SQL Server,会在页级别增加checksum
  • 当从磁盘读取page时,DMBS会根据page的头中的checksum对内容做校验
  • 但是使用mmap时,OS可能会在上一次访问之后,就将这个page驱逐到磁盘中了
  • 这样不得不每次访问page都要做一次checksum
  • 很多数据库都使用了内存不安全的语言,指针可能会破坏内存中的page
  • 带有防御性检查的buffer pool在写入磁盘之前会做检查,但是mmap就很难做到了
  • 优雅的处理I/O错误很重要,传统的buffer pool处理I/O错误可能是在单个模块中
  • 而使用了mmap之后,可能会需要跨模块处理,这样就需要处理信号 singnal了,代价比较高

问题4:性能问题

mmap的最大问题是影响性能,尽管DBMS的开发者可能会认为这些都可以克服
不过论文的作者们认为,如果不对OS级别做重新设计,mmap的瓶颈问题很难解决
通常认为mmap比传统的文件I/O性能更好

  • 避免了显示的read、write系统调用,OS来处理文件映射和缺页中断
  • mmap可以返回一个指针,指向内核态的page存储,因此避免了额外的拷贝到用户态这个过程

在这种优势情况下,如果使用了性能更好的硬件设备,如 PCIe 5.0 NVMe,那么优势会继续扩大
但是事实是,对于高带宽的二级存储,大于内存的DBMS场景,OS的页驱逐机制,大概就只能扩展到几个线程
我们也识别出几个主要的性能问题

  • page table的竞争
  • 单线程的页驱逐
  • TLB shootdowns

前面两个可以通过调整OS参数来缓解这些问题,但是 TLB shootdowns则很难处理
当一个CPU核将TLB变得无效时,本地flush的代价不高,但是需要触发 内部的处理器中断
这是一个同步操作,将远端TLB也变得无效,这个操作会导致 几千个时钟周期的开销
目前看想解决这个问题:

  • 修改处理器架构,采用微处理器架构
  • 大量的修改OS内核

实验分析

机器是

  • AMD EPYC 7713,64核,128线程
  • 512G内存
  • 10 * 3.8TB SSD,读 7000M/s,写3800M/s
  • OS的page cache设置为100G
  • 用 fio作为测试工具,并设置了 O_DIRECT标志
  • 对于更新场景需要额外复杂的工作,所以只测试两个场景
  • 随机读、顺序读

随机读

  • 读取2TB的数据,由于page cache只有100G,所以95%的读取都会导致缺页失败
  • 只用fio + O_DIRECT读,一共100个线程,大概每秒 900K
  • 延迟大概100微妙,接近NVMe SSD的峰值
  • 观察TLB是通过 /proc/interrupt 来观察的
  • TLB shootdowns会引发内部处理器中断,耗时几千个时钟周期;另外只有单CPU核刷新
  • CPU还必须同步page table,这也导致了大量的线程并发竞争情况
  • 在随机读的时候,前27秒使用 MADV_RANDOM 是很好的,跟 fio的差不多
  • 但是后面5秒就变成0了,此时是在页面驱逐,后面就反反复复的,大概只有fio的一半
  • 其他两个则更差

顺序读

  • 这是模拟OLAP场景,扫描2TB数据,一开始使用单个SSD,后面使用10个做RAID 0
  • fio依然是非常稳定
  • MADV_NORMAL、MADV_SEQUENTIAL 一开始还不错,接近fio
  • 但当出现页面驱逐时,效率就很低了,大概只有一半,而随机的更差
  • 对于10个SSD场景,fio会提高10倍,但是mmap没有任何提升,最后fio差不多是mmap的20倍

结论是

  • mmap的性能只在初始时跟fio差不多,而且是单SSD场景
  • 一旦出现页面驱逐,或者多个SSD场景,则有 2-20倍的差距
  • mmap在性能方面跟传统文件I/O 无法比较

总结

目前没有对现代 DBMS使用mmap的研究
为了确保事务安全,有一个新的工作特性被引入:原子的 msync调用
普通场景来说,如果在 msync时候发生了宕机,DBMS不知道哪个页被成功写磁盘了
而原子 msync跟普通msync类似,但提供了原子性保证
它有一个副作用,就是失败的原子 msync会禁止OS级别的透明页驱逐能力,这样就不会出现之前描述的安全问题
有一个数据库 Kreon,实现了自定义的 kmmap,将复杂的copy-on-write特性合并进来
确保了事务的一致性
其他一些研究项目,不仅仅是替换 buffer pool,而做了很多有趣的事情

  • 有一个项目,利用OS mmap虚拟页的机制,实现了一个低开销的方式,将冷数据迁移到二级存储上
  • RUMA 利用mmap“重写”页映射,实现各种操作,比如排序,这样就不用真正的物理拷贝数据

最近也有一些想法,利用指针切换比较开销
不过我们的想法是,轻量级的buffer pool是最好的实现方式
它跟mmap性能差不多,但不会有它的缺陷

相关文章和参考