Open mrdrivingduck opened 2 years ago
遵循对一个东西开喷就要讲清楚理由的原则,Andy Pavlo 把他在网课里随口说的 I hate mmap 变成了一篇完整的论文。甚至还在页眉上做了点文章: 🤣
Memory-mapped (mmap) 文件 I/O 是一个 OS 提供的特性,可以让用户空间的程序用指针像读写内存一样读写文件,即使文件无法在内存中装下。POSIX mmap 系统调用可以将一个文件映射到进程的虚拟地址空间中,并在进程读写文件时按需加载页面。由 OS 透明地管理页面的 load 和 evict,从进程的视角来看只有指针(内存)操作。
传统的 DBMS(这里特指 larger-than-memory 的 DBMS)使用 read/write 系统调用实现 buffer pool,将页面从外部存储中装入用户空间的 buffer 里。DBMS 对何时换出页面具有 100% 的控制权。
那么对于 DBMS 来说,有了 mmap 是否就可以无需实现复杂的 buffer pool 了呢?是否可以将页面的读取和换出的控制权交给 OS 呢?本文从 正确性 和 性能 的角度详细说明了 DBMS 使用 mmap 所带来的代价和要额外解决的问题,并认为解决这些问题所带来的工程量违背了使用 mmap 减少工程量的初衷。因此本文将 mmap 和 DBMS 比作 coffee 和 spicy food——它俩结合在一起挺蛋疼。
使用 mmap 访问文件的步骤:
当程序访问其它页面并需要将当前页面换出时,OS 需要从内存页表和 每个 CPU 的 TLB 中移除映射项。对 CPU 本地的 TLB 进行刷新是很快的,但 OS 必须使用代价昂贵的跨处理器通信使其它 CPU core 的 TLB 失效。这个问题被称为 TLB shootdown,将会带来性能问题。
mmap
:使用这个系统调用将可以直接用指针对 OS page cache 进行操作,OS 管理 page 的调入和换出
MAP_SHARED
标志时,对页面的写入将最终刷进文件MAP_PRIVATE
标志将会在内存中为调用者创建一份专属的 copy on write 副本,对页面的写入将不会刷入文件madvise
:进程使用这个系统调用可以向 OS 提供数据访问模式的 hint——粒度要么是文件级要么是页面级。
MADV_NORMAL
,在 Linux 上除了调入访问的那一页(dbq 我居然打成了 那一夜 ...)以外,还会 pre-fetch 之后的 16 个 page 和之前的 15 个 page(在正常情况下符合程序/数据的局部性原理)MADV_RANDOM
,则只会 fetch 访问的那一页(适合随机访问,better for OLTP)MADV_SEQUENTIAL
,则提示内核积极预读后续页面(顺序扫描,better for OLAP)mlock
:提示 OS 把页面 pin 住,不要换出——但是 Linux 依旧可以在任何时间把页面刷进磁盘,所以 DBMS 没法用这个特性来保证 事务正确性msync
:显式告诉 OS 把指定范围的页面刷进磁盘,否则 DBMS 没有任何机会保证页面的更新已经被持久化。现实中的 DBMS 对 mmap 从使用到放弃的实例 😂...喷了一通
本章详细说明了 mmap 替换 DBMS buffer pool 之后会引发的问题,以及解决这些问题所需要付出的代价。
事务是 DBMS 对文件把控最重要最复杂的环节。由于 DBMS 通过 mmap 把页面在内存与磁盘间同步的权利下放给 OS,那么 OS 可以在任何时间将脏页刷进磁盘,而 DBMS 不会收到任何通知。那么 DBMS 就没法知道一个页面有没有真正进入磁盘。这对事务的回滚、提交来说是致命的。
因此,使用 mmap 的 DBMS 必须实现复杂的协议来保证透明的页面管理不会违反事务正确性。已有的处理方式有如下几种。
使用 mmap 得到数据库文件的两个指针,初始状态下指向相同的物理页:
MAP_PRIVATE
作为私人工作空间,启用 OS 对页面的 copy-on-write在页面更新时,DBMS 使用私人工作空间指针修改页面,将引发 OS 透明地将物理页拷贝、remap 私人工作空间到新页、在新页上应用更改。此时主拷贝上没有任何更新,因此更新不会被刷入磁盘。为保证持久性,OS 必须使用 WAL,保证事务提交后 WAL 记录被刷入磁盘。然后后台线程逐步将提交后的修改页面逐步应用到主拷贝上,从而最终被刷入文件。
如果事务提交后,主拷贝中的更改还没有被刷入磁盘,那么通过 WAL 记录还是可以恢复到事务提交后的状态。
存在的问题:
手动把要更新的页面从 mmap 的内存拷贝到一个独立维护的用户空间 buffer 中,然后 DBMS 只对这个副本做 apply,并确保 WAL 刷入磁盘。WAL 输入磁盘后,才可以安全地将用户空间 buffer 中的修改页复制回 mmap 的内存中,然后随便 OS 什么时候把脏页刷回磁盘了,反正有了 WAL 日志,可以把提交的事务恢复出来。
对页面中的小改动来说,直接拷贝整个页面比较浪费。所以一些 DBMS 也支持直接对 mmap 内存应用 WAL 记录中的更改。
DBMS 维护两个 mmap 后的文件:
DBMS 实现将要更改的页面从主文件复制到 shadow copy 文件中,然后 apply changes。事务提交的操作包含用 msync
强制把更改刷进磁盘,然后把主文件指针指向 shadow copy;原先的主文件成为了 shadow copy。
问题:DBMS 需要保证事务不会冲突或没有 部分更新 问题。
传统 buffer pool 通过异步 I/O 使查询执行的线程不会被阻塞,但 mmap 不支持异步读。
由于 OS 透明地调入和换出页面,只读查询可能会被随时暂停,因为 DBMS 不知道要访问的页面是否在内存中。为解决这个问题,开发者使用 mlock
来 pin 住内存中的页面,不让 OS 把它换出,但:
一种可行的解决方法是通过 madvise
向 OS 提供 I/O pattern 的提示。但是提示终究只是提示,OS 有忽视提示的自由;并且向 OS 提供了错误的提示将会极大影响性能(比如对随机访问提供了 MADV_SEQUENTIAL
的提示)。
从磁盘中读取页面时,DBMS 一般会对页面的 check 做验证。当使用 mmap 时,DBMS 需要在每一次访问页面时都验证页面的 checksum,因为 OS 可能已经将页面换出又调入了(DBMS 不知道)。
类似地,传统 buffer pool 在将页面刷进磁盘之前也会对页面中是否有错误进行检验,而 mmap 会将 corrupted 的页面默默刷回磁盘。
优雅处理 I/O 错误也会变得更加困难。
除非 OS 级别能够重新设计,否则 mmap 对 DBMS 来说有严重瓶颈。理论上 mmap 避免了两方面的开销:
然而本文发现了 mmap 的三个致命瓶颈:
前两点或许可以在 OS 层面部分解决。
对比:使用 fio 存储跑分工具,运行几个常见的 I/O pattern
Mmap 就算使用了符合 I/O pattern 的 hint,性能也较差。性能下降始于 page cache 被填满,OS 开始淘汰页面时。页面淘汰的三个开销在于:
Mmap 在 page cache 被填满后性能下降,并且无法利用多 SSD RAID 的 I/O 带宽。
p13-crotty.pdf