Q先生的世界

面朝大海,春暖花开

经典存储对比|LMDB 与 BoltDB:它们如何利用 mmap,以及为什么一个更偏 COW、一个更强调页式事务

前面两篇文章,我们已经分别把两个关键背景讲开了:

  1. mmap 到底是什么,它为什么既诱人又危险
  2. BoltDB 是怎么围绕 mmap、页结构和事务组织整个数据库运行时的

如果再往前走一步,一个很自然的问题就是:

同样都是经典的 mmap 型嵌入式 KV,LMDB 和 BoltDB 到底有什么本质差别?

很多人对它们的第一印象通常差不多:

  1. 都是嵌入式
  2. 都是 B+Tree
  3. 都依赖 mmap
  4. 都是单写多读
  5. 都很强调读路径短

这些标签都没错。

但如果你真的开始深入实现和源码,很快就会发现:

它们虽然都生活在 mmap 世界里,但工程气质其实并不一样。

LMDB 给人的感觉更像:

  1. 极端克制
  2. 更强烈地围绕 copy-on-write 版本切换来组织世界
  3. 把读路径尽可能压到“直接看映射页”
  4. 对页面生命周期、freelist 和 meta 切换控制非常硬核

而 BoltDB 给人的感觉则更像:

  1. 同样是 copy-on-write 思想
  2. 但在实现上更偏 Go 风格和页式事务组织
  3. 写事务里会显式 materialize node
  4. 通过 spill / rebalance / freelist / meta 提交形成比较清晰的页式提交链路

也就是说:

两者的共同点,远远不足以抹平它们在“如何使用 mmap”这件事上的实现差异。

所以这篇文章想专门把这件事讲透。

重点回答下面这些问题:

  1. LMDB 和 BoltDB 各自怎样使用 mmap
  2. 两者为什么都强调单写多读
  3. 它们的 copy-on-write 分别落实在什么层次
  4. 为什么 LMDB 看起来更像“纯粹的 mmap + COW B+Tree”
  5. 为什么 BoltDB 更容易给人一种“页式事务引擎”的感觉
  6. 两者的代价分别是什么,适合什么场景

1. 先给结论:两者都依赖 mmap,但不是同一种“依赖法”

先把结论压缩一下。

如果只看最高层抽象,两者确实都可以被描述成:

  1. mmap 访问数据库文件
  2. 用 B+Tree 组织键值数据
  3. 允许多个只读事务并发
  4. 同时只允许一个写事务推进
  5. 通过新页版本 + 根或 meta 切换完成提交

但如果往实现层走一步,它们的气质差异会非常明显。

LMDB 更像什么

更像:

一个围绕 memory-mapped page space 直接组织 MVCC 视图的 copy-on-write B+Tree。

它的强烈气质是:

  1. 页就是世界本体
  2. 读事务尽量直接指向映射页
  3. 写事务通过分配新页、写新版本页、最后切换 meta 完成提交
  4. 不太愿意在写路径上引入太多“额外内存结构”来重塑数据世界

BoltDB 更像什么

更像:

一个建立在 mmap 文件上的页式事务引擎,读路径直接看页,写路径则会把修改组织成更明确的内存节点和提交流程。

它的强烈气质是:

  1. 读仍然依赖映射页直读
  2. 但写事务会把部分页 materialize 成 node
  3. 再做 split、rebalance、spill
  4. 最后把脏页按页号写回,并更新 freelist 与 meta

两者都用 mmap,但“写路径的重建程度”和“事务组织方式”明显不同。


2. 共同底座:为什么它们都会喜欢 mmap

先说共同点,不然差异会显得太抽象。

LMDB 和 BoltDB 都偏爱 mmap,原因本质上很接近。

2.1 读路径可以非常短

一旦数据库文件被映射进地址空间:

  1. 找页 = 算偏移
  2. 访问页 = 指针解引用
  3. 在叶子页查 key = 内存里的二分或顺序扫描

这使得读路径非常接近:

root page -> descend B+Tree -> leaf page -> key/value

没有大量显式 read() / copy() / buffer manager glue code。

2.2 页缓存尽量交给操作系统

两者都没有像 PostgreSQL 这种重量级服务端数据库那样自带大而完整的 buffer pool 世界观。

它们更愿意相信:

  1. 页冷热
  2. 预读
  3. 回收
  4. 缺页调入

这些事情,操作系统已经能帮你做一大半。

2.3 页式数据结构和 mmap 天然合拍

只要你的数据库本来就按页组织:

  1. page header
  2. branch page
  3. leaf page
  4. freelist/meta page

那么 mmap 就会显得很自然。

因为页号到偏移的映射非常直接。

也就是说,LMDB 和 BoltDB 喜欢 mmap,不是因为追逐潮流,而是因为:

它们的数据结构本来就很适合映射成页空间。


3. 共同约束:为什么它们几乎都走向“单写多读”

只要说 mmap 型数据库,几乎总绕不开“单写多读”这个标签。

这不是偶然,而是这类设计自然会撞上的平衡点。

原因大概有三层。

3.1 读事务最希望稳定直接引用旧页

既然读路径优势来自“直接看映射页”,那读事务就非常希望:

  1. 已经拿到的页地址稳定
  2. 已经观测到的树结构不要被原地破坏

这天然鼓励:

  1. 读者看旧版本页
  2. 写者写新页

也就是 copy-on-write / MVCC 方向。

3.2 多个并发 writer 会显著拉高页分配与版本协调复杂度

如果你允许多个写事务同时推进,就要协调:

  1. 谁分配哪些新页
  2. 谁看到哪个 freelist 状态
  3. 哪个 meta 最后切换成功
  4. 冲突页面怎么处理

而对 LMDB、BoltDB 这种强调小而直接的嵌入式数据库来说,这会显著抬高复杂度。

3.3 一个 writer 足以保持语义简单和实现小

于是自然会得到一个非常经典的折中:

  1. 多个读事务可并行
  2. 一个写事务串行推进

这不是“能力不足”的偶然结果,而更像是:

为了保住 mmap 读路径的极短优势,同时控制实现复杂度,刻意选择的架构边界。


4. 从 mmap 视角看 LMDB:它更接近“直接在映射页空间上做版本化”

如果先看 LMDB,最值得先建立的印象是:

LMDB 极度信任 page space 本身。

它不是把 mmap 当成一个辅助优化,而更像把数据库整个世界直接建在映射页空间之上。

这会带来几个很鲜明的特征:

  1. 页面是核心物理与逻辑单位
  2. 读事务尽量直接引用页面
  3. 写事务不去原地改旧页,而是分配新页形成新版本树
  4. 提交本质是让新版本 root / meta 成为“当前版本”

从工程直觉看,LMDB 的整体味道非常接近:

  1. 读路径像内存树遍历
  2. 写路径像持久化页级 COW
  3. 恢复路径像“认最新有效 meta”

所以如果硬要用一句话形容 LMDB:

它特别像一套极端克制的、建立在 mmap 上的 COW 页面数据库。


5. 为什么说 LMDB 更偏 COW,而不是更偏“事务引擎表演感”

这里不是说 LMDB 没事务,而是说它给人的工程重心更明显地落在:

  1. 旧页不动
  2. 新页写新版本
  3. 最后通过 meta/root 切换版本

也就是说,它的事务感很大程度上来自:

版本切换和页级 copy-on-write。

而不是来自一条很显眼的:

  1. 先 materialize 节点
  2. 再 rebalance
  3. 再 spill
  4. 再统一写脏页

那种“事务执行流水线”的编排感。

LMDB 并不是没有这些底层动作,而是它整体给人的感觉更像:

围绕页面版本直接做事情。

这就是为什么很多人读 LMDB 时,会更强烈地感到它是“mmap + COW B+Tree”的纯粹实现。


6. 从 mmap 视角看 BoltDB:它读得很直接,写得更像一套页式事务流水线

再看 BoltDB。

BoltDB 同样高度依赖 mmap,尤其在读路径上,它也很直白:

  1. 数据库文件映射进来
  2. page(id) 直接算偏移
  3. 只读事务直接看映射页

这部分它和 LMDB 在精神上非常接近。

但差异主要出现在写路径。

BoltDB 的写事务,不是简单地“对 page space 做点原子重组”就结束,而是会经过一条更显式的页式事务流水线:

  1. 把涉及修改的页 materialize 成内存 node
  2. node 做插入、删除、rebalance
  3. 需要时 split
  4. spill 把新节点重新落成页
  5. 更新 freelist
  6. 写脏页
  7. 最后切换 meta

所以 BoltDB 给人的整体气质就很像:

读路径是 mmap 直读,写路径则更像一套显式编排的页式事务引擎。


7. 为什么 BoltDB 看起来更强调“页式事务”

这里的“页式事务”不是说 LMDB 没有页,也不是说 LMDB 没事务。

我想强调的是:

BoltDB 把写事务的中间世界,显式暴露成了更强的内存结构与提交编排。

例如在 BoltDB 里,你很容易明显看到这些对象和阶段:

  1. Tx
  2. node
  3. page
  4. freelist
  5. spill
  6. rebalance
  7. dirty pages
  8. meta commit

这让它读起来有一种非常清楚的“事务引擎骨架感”。

对 Go 工程师来说,这通常也更容易上手,因为:

  1. 数据结构分层比较直观
  2. 修改路径相对好跟
  3. 代码组织更有明显阶段感

但它的代价就是:

写路径比“纯粹的页面版本切换直觉”更显式,也更像一个认真管理内存节点生命周期的事务系统。


8. 两者都用 COW,但“COW 的重心”并不完全一样

这点特别值得拆开说。

如果只讲一句“LMDB 和 BoltDB 都是 copy-on-write”,那是真的,但还不够。

因为两者的 COW 重心,工程上体感并不相同。

LMDB 的 COW 体感更强地贴在页面版本本身

你更容易直接想到:

  1. 旧页继续给旧读者看
  2. 写者拿新页组织新树
  3. meta 最终切换到新版本

BoltDB 的 COW 体感更容易通过写事务流水线体现出来

你更容易直接想到:

  1. 页先被解释成 node
  2. node 被修改和重排
  3. 再重新落回新页
  4. 最后 meta 提交

也就是说,同样是 COW:

  1. LMDB 更容易让人直接感到“版本化页空间”
  2. BoltDB 更容易让人直接感到“页式事务提交过程”

这就是为什么标题里我会说:

  1. 一个更偏 COW
  2. 一个更强调页式事务

它们不是互斥关系,而是重心差异。


9. mmap 在两者读路径里的共同好处:读事务几乎是天然快照

两者最迷人的共同点之一,是它们都把只读事务做得很有味道。

为什么?

因为只要旧版本页还活着,读事务几乎天然就能拿到一致性视图:

  1. 不必拷贝全树
  2. 不必重建复杂读缓存
  3. 不必对每次读取做大量逻辑合并

它只需要:

  1. 持有某个版本入口
  2. 沿那棵版本对应的页树走下去

这就是 mmap + COW 组合最漂亮的地方:

旧版本页天然就是快照材料。

对读多写少 workload,这几乎是天作之合。


10. 共同代价之一:长读事务都会拖住回收

只要旧读者还在看旧版本页,写者就不敢把这些旧页立即复用。

这意味着:

  1. 长读事务会拖住页回收
  2. freelist 的“可立即复用集合”会变小
  3. 文件空间膨胀风险上升
  4. 某些场景下 remap、增长、回收压力也会一起放大

这不是 BoltDB 独有,也不是 LMDB 独有。

它是这类 mmap + MVCC / COW 系统非常自然的代价。

也就是说,读路径越想保持:

  1. 稳定
  2. 无锁感
  3. 直接引用旧页

那么你就越要接受:

  1. 旧版本空间释放不会完全自由

这是很公平的一笔交换。


11. 共同代价之二:单写不是偶然限制,而是架构选择

很多人第一次接触这类数据库,会问:

  1. 为什么不能多 writer

更好的问法其实是:

  1. 如果要保住当前这套 mmap + COW 读路径优势,多 writer 要付出多大复杂度

答案通常是:很大。

因为一旦多个写事务并发,就要同时协调:

  1. 页分配
  2. freelist 视图
  3. meta/root 切换顺序
  4. 冲突页版本
  5. 提交先后与失败恢复

对 LMDB 和 BoltDB 这种“小而强”的嵌入式数据库来说,这通常不是它们想要付的账。

所以单写不是“没想到”,而是:

为了换取极致简洁的读路径和明确的恢复模型,主动划定的边界。


12. 从故障模型看,两者为什么都不敢依赖“原地改映射页就算提交”

只要你认真想一次掉电场景,这件事就很清楚。

如果你直接在映射页上原地改:

  1. 先改 leaf
  2. 再改 branch
  3. 再改 freelist
  4. 再改 meta

那么掉电时几乎一定可能看到半更新状态。

这就是为什么无论是 LMDB 还是 BoltDB,最终都不能把提交理解成:

  1. 程序把几个字段写进映射内存就完事了

它们都必须依赖:

  1. 新页版本
  2. 旧页保留
  3. 某种最终版本锚点切换

只是实现表达方式不同而已。

所以从崩溃一致性的角度看,两者站在同一边:

mmap 解决访问模型,不解决事务原子提交。


13. 从工程语言风格看,BoltDB 为什么更容易被 Go 工程师“读懂”

这不是说 LMDB 难,而是两者在代码表达上的体感确实不同。

BoltDB 的很多关键对象非常显眼:

  1. DB
  2. Tx
  3. Bucket
  4. Cursor
  5. node
  6. page
  7. freelist

加上写事务阶段感比较强:

  1. rebalance
  2. spill
  3. write dirty pages
  4. write meta

所以读起来常常有一种:

系统设计逻辑被摊开给你看。

LMDB 的感觉则更“贴底层”,更像在直接和页面、meta、free page、reader table 这些东西打交道。

这也解释了为什么很多 Go 工程师第一次读 BoltDB,会觉得它比想象中更容易形成整体图景。


14. 从适用场景看:什么时候更容易偏向 LMDB 风格,什么时候更容易偏向 BoltDB 风格

如果不去神化任何一个,它们更像是两种气质不同的答案。

更偏向 LMDB 风格的场景

你通常更看重:

  1. 极短读路径
  2. 更纯粹的 mmap + page COW 思维
  3. 对底层页空间和事务边界控制非常直接
  4. 接受它那种更硬核的系统风格

更偏向 BoltDB 风格的场景

你通常更看重:

  1. Go 生态内嵌入使用
  2. 代码结构较直观
  3. 页式事务流程更容易被工程团队理解和维护
  4. 愿意接受它在写路径上更显式的内存节点重建

从根上说,这不是“哪个绝对更好”,而是:

你更喜欢哪种复杂度分布。


15. 如果把它们和上一篇 mmap 深入解析放在一起看,会更清楚什么

上一篇我们强调过:

  1. mmap 的第一关键词是 page fault
  2. mmap 的第二关键词是 page cache
  3. mmap 写不等于安全持久化
  4. 真正难点总会绕回一致性和恢复

LMDB 和 BoltDB 正好就是这几个原则的两个经典现实答案。

它们都说明:

  1. mmap 非常适合拿来做读路径
  2. 但写路径必须靠额外协议驯服

差异则在于:

  1. LMDB 更像“直接在版本化页空间上玩 COW”
  2. BoltDB 更像“把页修改组织成一套清楚的事务流水线,再回写到页空间”

一旦这么看,两者的差异就不再只是“实现风格不同”,而是:

它们在同一类问题上,把复杂度压到了不同位置。


16. 总结:两者的根本差异,不在于有没有 mmap,而在于怎样围绕 mmap 组织版本与提交

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

LMDB 和 BoltDB 的共同点在于都把数据库建立在 mmap 页空间之上,而它们真正的差异,不在于“谁用了 mmap”,而在于“怎样围绕 mmap 组织 copy-on-write、事务写路径、页回收和版本切换”。

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

  1. 两者都依赖 mmap,因为页式数据结构和读路径天然适合映射
  2. 两者都倾向单写多读,因为这样能保住读事务直接看旧页的优势
  3. LMDB 更容易给人“纯粹的 mmap + COW 页面数据库”印象
  4. BoltDB 更容易给人“读走映射页、写走页式事务流水线”的印象
  5. 两者都不能把 mmap 误当成自动事务提交机制

所以如果一定要用一句最短的话概括标题里的那层差异,我会这样说:

  1. LMDB 更像在直接经营“版本化页空间”
  2. BoltDB 更像在经营“一套建立在页空间上的事务引擎”

这也是为什么,虽然它们表面标签相似,但只要真正往源码和运行时机制里走,气质差异会非常明显。

如果接下来继续写这个方向,一个很自然的下一篇就是:

再单独写一篇 fsync/msync/meta page 崩溃一致性专题,把 LMDB、BoltDB、文件系统持久化边界彻底串到一起。