经典存储对比|LMDB 与 BoltDB:它们如何利用 mmap,以及为什么一个更偏 COW、一个更强调页式事务
前面两篇文章,我们已经分别把两个关键背景讲开了:
mmap到底是什么,它为什么既诱人又危险- BoltDB 是怎么围绕
mmap、页结构和事务组织整个数据库运行时的
如果再往前走一步,一个很自然的问题就是:
同样都是经典的 mmap 型嵌入式 KV,LMDB 和 BoltDB 到底有什么本质差别?
很多人对它们的第一印象通常差不多:
- 都是嵌入式
- 都是 B+Tree
- 都依赖
mmap - 都是单写多读
- 都很强调读路径短
这些标签都没错。
但如果你真的开始深入实现和源码,很快就会发现:
它们虽然都生活在 mmap 世界里,但工程气质其实并不一样。
LMDB 给人的感觉更像:
- 极端克制
- 更强烈地围绕 copy-on-write 版本切换来组织世界
- 把读路径尽可能压到“直接看映射页”
- 对页面生命周期、freelist 和 meta 切换控制非常硬核
而 BoltDB 给人的感觉则更像:
- 同样是 copy-on-write 思想
- 但在实现上更偏 Go 风格和页式事务组织
- 写事务里会显式 materialize
node - 通过 spill / rebalance / freelist / meta 提交形成比较清晰的页式提交链路
也就是说:
两者的共同点,远远不足以抹平它们在“如何使用 mmap”这件事上的实现差异。
所以这篇文章想专门把这件事讲透。
重点回答下面这些问题:
- LMDB 和 BoltDB 各自怎样使用
mmap - 两者为什么都强调单写多读
- 它们的 copy-on-write 分别落实在什么层次
- 为什么 LMDB 看起来更像“纯粹的 mmap + COW B+Tree”
- 为什么 BoltDB 更容易给人一种“页式事务引擎”的感觉
- 两者的代价分别是什么,适合什么场景
1. 先给结论:两者都依赖 mmap,但不是同一种“依赖法”
先把结论压缩一下。
如果只看最高层抽象,两者确实都可以被描述成:
- 用
mmap访问数据库文件 - 用 B+Tree 组织键值数据
- 允许多个只读事务并发
- 同时只允许一个写事务推进
- 通过新页版本 + 根或 meta 切换完成提交
但如果往实现层走一步,它们的气质差异会非常明显。
LMDB 更像什么
更像:
一个围绕 memory-mapped page space 直接组织 MVCC 视图的 copy-on-write B+Tree。
它的强烈气质是:
- 页就是世界本体
- 读事务尽量直接指向映射页
- 写事务通过分配新页、写新版本页、最后切换 meta 完成提交
- 不太愿意在写路径上引入太多“额外内存结构”来重塑数据世界
BoltDB 更像什么
更像:
一个建立在 mmap 文件上的页式事务引擎,读路径直接看页,写路径则会把修改组织成更明确的内存节点和提交流程。
它的强烈气质是:
- 读仍然依赖映射页直读
- 但写事务会把部分页 materialize 成
node - 再做 split、rebalance、spill
- 最后把脏页按页号写回,并更新 freelist 与 meta
两者都用 mmap,但“写路径的重建程度”和“事务组织方式”明显不同。
2. 共同底座:为什么它们都会喜欢 mmap
先说共同点,不然差异会显得太抽象。
LMDB 和 BoltDB 都偏爱 mmap,原因本质上很接近。
2.1 读路径可以非常短
一旦数据库文件被映射进地址空间:
- 找页 = 算偏移
- 访问页 = 指针解引用
- 在叶子页查 key = 内存里的二分或顺序扫描
这使得读路径非常接近:
root page -> descend B+Tree -> leaf page -> key/value
没有大量显式 read() / copy() / buffer manager glue code。
2.2 页缓存尽量交给操作系统
两者都没有像 PostgreSQL 这种重量级服务端数据库那样自带大而完整的 buffer pool 世界观。
它们更愿意相信:
- 页冷热
- 预读
- 回收
- 缺页调入
这些事情,操作系统已经能帮你做一大半。
2.3 页式数据结构和 mmap 天然合拍
只要你的数据库本来就按页组织:
- page header
- branch page
- leaf page
- freelist/meta page
那么 mmap 就会显得很自然。
因为页号到偏移的映射非常直接。
也就是说,LMDB 和 BoltDB 喜欢 mmap,不是因为追逐潮流,而是因为:
它们的数据结构本来就很适合映射成页空间。
3. 共同约束:为什么它们几乎都走向“单写多读”
只要说 mmap 型数据库,几乎总绕不开“单写多读”这个标签。
这不是偶然,而是这类设计自然会撞上的平衡点。
原因大概有三层。
3.1 读事务最希望稳定直接引用旧页
既然读路径优势来自“直接看映射页”,那读事务就非常希望:
- 已经拿到的页地址稳定
- 已经观测到的树结构不要被原地破坏
这天然鼓励:
- 读者看旧版本页
- 写者写新页
也就是 copy-on-write / MVCC 方向。
3.2 多个并发 writer 会显著拉高页分配与版本协调复杂度
如果你允许多个写事务同时推进,就要协调:
- 谁分配哪些新页
- 谁看到哪个 freelist 状态
- 哪个 meta 最后切换成功
- 冲突页面怎么处理
而对 LMDB、BoltDB 这种强调小而直接的嵌入式数据库来说,这会显著抬高复杂度。
3.3 一个 writer 足以保持语义简单和实现小
于是自然会得到一个非常经典的折中:
- 多个读事务可并行
- 一个写事务串行推进
这不是“能力不足”的偶然结果,而更像是:
为了保住 mmap 读路径的极短优势,同时控制实现复杂度,刻意选择的架构边界。
4. 从 mmap 视角看 LMDB:它更接近“直接在映射页空间上做版本化”
如果先看 LMDB,最值得先建立的印象是:
LMDB 极度信任 page space 本身。
它不是把 mmap 当成一个辅助优化,而更像把数据库整个世界直接建在映射页空间之上。
这会带来几个很鲜明的特征:
- 页面是核心物理与逻辑单位
- 读事务尽量直接引用页面
- 写事务不去原地改旧页,而是分配新页形成新版本树
- 提交本质是让新版本 root / meta 成为“当前版本”
从工程直觉看,LMDB 的整体味道非常接近:
- 读路径像内存树遍历
- 写路径像持久化页级 COW
- 恢复路径像“认最新有效 meta”
所以如果硬要用一句话形容 LMDB:
它特别像一套极端克制的、建立在 mmap 上的 COW 页面数据库。
5. 为什么说 LMDB 更偏 COW,而不是更偏“事务引擎表演感”
这里不是说 LMDB 没事务,而是说它给人的工程重心更明显地落在:
- 旧页不动
- 新页写新版本
- 最后通过 meta/root 切换版本
也就是说,它的事务感很大程度上来自:
版本切换和页级 copy-on-write。
而不是来自一条很显眼的:
- 先 materialize 节点
- 再 rebalance
- 再 spill
- 再统一写脏页
那种“事务执行流水线”的编排感。
LMDB 并不是没有这些底层动作,而是它整体给人的感觉更像:
围绕页面版本直接做事情。
这就是为什么很多人读 LMDB 时,会更强烈地感到它是“mmap + COW B+Tree”的纯粹实现。
6. 从 mmap 视角看 BoltDB:它读得很直接,写得更像一套页式事务流水线
再看 BoltDB。
BoltDB 同样高度依赖 mmap,尤其在读路径上,它也很直白:
- 数据库文件映射进来
page(id)直接算偏移- 只读事务直接看映射页
这部分它和 LMDB 在精神上非常接近。
但差异主要出现在写路径。
BoltDB 的写事务,不是简单地“对 page space 做点原子重组”就结束,而是会经过一条更显式的页式事务流水线:
- 把涉及修改的页 materialize 成内存
node - 对
node做插入、删除、rebalance - 需要时 split
- spill 把新节点重新落成页
- 更新 freelist
- 写脏页
- 最后切换 meta
所以 BoltDB 给人的整体气质就很像:
读路径是 mmap 直读,写路径则更像一套显式编排的页式事务引擎。
7. 为什么 BoltDB 看起来更强调“页式事务”
这里的“页式事务”不是说 LMDB 没有页,也不是说 LMDB 没事务。
我想强调的是:
BoltDB 把写事务的中间世界,显式暴露成了更强的内存结构与提交编排。
例如在 BoltDB 里,你很容易明显看到这些对象和阶段:
Txnodepagefreelistspillrebalance- dirty pages
- meta commit
这让它读起来有一种非常清楚的“事务引擎骨架感”。
对 Go 工程师来说,这通常也更容易上手,因为:
- 数据结构分层比较直观
- 修改路径相对好跟
- 代码组织更有明显阶段感
但它的代价就是:
写路径比“纯粹的页面版本切换直觉”更显式,也更像一个认真管理内存节点生命周期的事务系统。
8. 两者都用 COW,但“COW 的重心”并不完全一样
这点特别值得拆开说。
如果只讲一句“LMDB 和 BoltDB 都是 copy-on-write”,那是真的,但还不够。
因为两者的 COW 重心,工程上体感并不相同。
LMDB 的 COW 体感更强地贴在页面版本本身
你更容易直接想到:
- 旧页继续给旧读者看
- 写者拿新页组织新树
- meta 最终切换到新版本
BoltDB 的 COW 体感更容易通过写事务流水线体现出来
你更容易直接想到:
- 页先被解释成 node
- node 被修改和重排
- 再重新落回新页
- 最后 meta 提交
也就是说,同样是 COW:
- LMDB 更容易让人直接感到“版本化页空间”
- BoltDB 更容易让人直接感到“页式事务提交过程”
这就是为什么标题里我会说:
- 一个更偏 COW
- 一个更强调页式事务
它们不是互斥关系,而是重心差异。
9. mmap 在两者读路径里的共同好处:读事务几乎是天然快照
两者最迷人的共同点之一,是它们都把只读事务做得很有味道。
为什么?
因为只要旧版本页还活着,读事务几乎天然就能拿到一致性视图:
- 不必拷贝全树
- 不必重建复杂读缓存
- 不必对每次读取做大量逻辑合并
它只需要:
- 持有某个版本入口
- 沿那棵版本对应的页树走下去
这就是 mmap + COW 组合最漂亮的地方:
旧版本页天然就是快照材料。
对读多写少 workload,这几乎是天作之合。
10. 共同代价之一:长读事务都会拖住回收
只要旧读者还在看旧版本页,写者就不敢把这些旧页立即复用。
这意味着:
- 长读事务会拖住页回收
- freelist 的“可立即复用集合”会变小
- 文件空间膨胀风险上升
- 某些场景下 remap、增长、回收压力也会一起放大
这不是 BoltDB 独有,也不是 LMDB 独有。
它是这类 mmap + MVCC / COW 系统非常自然的代价。
也就是说,读路径越想保持:
- 稳定
- 无锁感
- 直接引用旧页
那么你就越要接受:
- 旧版本空间释放不会完全自由
这是很公平的一笔交换。
11. 共同代价之二:单写不是偶然限制,而是架构选择
很多人第一次接触这类数据库,会问:
- 为什么不能多 writer
更好的问法其实是:
- 如果要保住当前这套 mmap + COW 读路径优势,多 writer 要付出多大复杂度
答案通常是:很大。
因为一旦多个写事务并发,就要同时协调:
- 页分配
- freelist 视图
- meta/root 切换顺序
- 冲突页版本
- 提交先后与失败恢复
对 LMDB 和 BoltDB 这种“小而强”的嵌入式数据库来说,这通常不是它们想要付的账。
所以单写不是“没想到”,而是:
为了换取极致简洁的读路径和明确的恢复模型,主动划定的边界。
12. 从故障模型看,两者为什么都不敢依赖“原地改映射页就算提交”
只要你认真想一次掉电场景,这件事就很清楚。
如果你直接在映射页上原地改:
- 先改 leaf
- 再改 branch
- 再改 freelist
- 再改 meta
那么掉电时几乎一定可能看到半更新状态。
这就是为什么无论是 LMDB 还是 BoltDB,最终都不能把提交理解成:
- 程序把几个字段写进映射内存就完事了
它们都必须依赖:
- 新页版本
- 旧页保留
- 某种最终版本锚点切换
只是实现表达方式不同而已。
所以从崩溃一致性的角度看,两者站在同一边:
mmap 解决访问模型,不解决事务原子提交。
13. 从工程语言风格看,BoltDB 为什么更容易被 Go 工程师“读懂”
这不是说 LMDB 难,而是两者在代码表达上的体感确实不同。
BoltDB 的很多关键对象非常显眼:
DBTxBucketCursornodepagefreelist
加上写事务阶段感比较强:
- rebalance
- spill
- write dirty pages
- write meta
所以读起来常常有一种:
系统设计逻辑被摊开给你看。
LMDB 的感觉则更“贴底层”,更像在直接和页面、meta、free page、reader table 这些东西打交道。
这也解释了为什么很多 Go 工程师第一次读 BoltDB,会觉得它比想象中更容易形成整体图景。
14. 从适用场景看:什么时候更容易偏向 LMDB 风格,什么时候更容易偏向 BoltDB 风格
如果不去神化任何一个,它们更像是两种气质不同的答案。
更偏向 LMDB 风格的场景
你通常更看重:
- 极短读路径
- 更纯粹的 mmap + page COW 思维
- 对底层页空间和事务边界控制非常直接
- 接受它那种更硬核的系统风格
更偏向 BoltDB 风格的场景
你通常更看重:
- Go 生态内嵌入使用
- 代码结构较直观
- 页式事务流程更容易被工程团队理解和维护
- 愿意接受它在写路径上更显式的内存节点重建
从根上说,这不是“哪个绝对更好”,而是:
你更喜欢哪种复杂度分布。
15. 如果把它们和上一篇 mmap 深入解析放在一起看,会更清楚什么
上一篇我们强调过:
mmap的第一关键词是 page faultmmap的第二关键词是 page cachemmap写不等于安全持久化- 真正难点总会绕回一致性和恢复
LMDB 和 BoltDB 正好就是这几个原则的两个经典现实答案。
它们都说明:
mmap非常适合拿来做读路径- 但写路径必须靠额外协议驯服
差异则在于:
- LMDB 更像“直接在版本化页空间上玩 COW”
- BoltDB 更像“把页修改组织成一套清楚的事务流水线,再回写到页空间”
一旦这么看,两者的差异就不再只是“实现风格不同”,而是:
它们在同一类问题上,把复杂度压到了不同位置。
16. 总结:两者的根本差异,不在于有没有 mmap,而在于怎样围绕 mmap 组织版本与提交
如果把整篇文章压缩成一句话,那就是:
LMDB 和 BoltDB 的共同点在于都把数据库建立在 mmap 页空间之上,而它们真正的差异,不在于“谁用了 mmap”,而在于“怎样围绕 mmap 组织 copy-on-write、事务写路径、页回收和版本切换”。
你真正该抓住的是这几条主线:
- 两者都依赖
mmap,因为页式数据结构和读路径天然适合映射 - 两者都倾向单写多读,因为这样能保住读事务直接看旧页的优势
- LMDB 更容易给人“纯粹的 mmap + COW 页面数据库”印象
- BoltDB 更容易给人“读走映射页、写走页式事务流水线”的印象
- 两者都不能把
mmap误当成自动事务提交机制
所以如果一定要用一句最短的话概括标题里的那层差异,我会这样说:
- LMDB 更像在直接经营“版本化页空间”
- BoltDB 更像在经营“一套建立在页空间上的事务引擎”
这也是为什么,虽然它们表面标签相似,但只要真正往源码和运行时机制里走,气质差异会非常明显。
如果接下来继续写这个方向,一个很自然的下一篇就是:
再单独写一篇 fsync/msync/meta page 崩溃一致性专题,把 LMDB、BoltDB、文件系统持久化边界彻底串到一起。