跳转到内容

LMDB 2011 — 把数据库直接 mmap 进内存的嵌入式 KV 存储

是什么

LMDB(Lightning Memory-Mapped Database,原名 MDB)是 Howard Chu 2011 年给 OpenLDAP 写的嵌入式键值数据库。核心绝招一句话:把整个数据库文件 mmap 进进程地址空间,读数据库 = 读内存指针

日常类比:传统数据库像图书馆——你要书时管理员从书库取出,复印一份给你,复印件回收。LMDB 像把整个书库地板掀开搬到你客厅——书一直在原位,你直接走过去翻。

MDB_env *env;
MDB_dbi dbi;
MDB_val key = {3, "foo"}, data;
MDB_txn *txn;
mdb_env_create(&env);
mdb_env_open(env, "./mydb", 0, 0664);
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn);
mdb_get(txn, dbi, &key, &data); // data.mv_data 直接指向 mmap 区

data.mv_data 拿到的指针就是数据库文件本身那块内存,没复制、没分配,是真正的 zero-copy。

为什么重要

不理解 LMDB,下面这些事讲不清:

  • 为什么 Bitcoin Core / Monero / Trezor 这些资源紧张的项目都选 LMDB 而不是 SQLite
  • 为什么 OpenLDAP 切到 LMDB 后读延迟从毫秒级直接降到微秒级
  • 为什么 etcd v3 的 boltdb 几乎是 LMDB 的 Go 翻译版
  • 为什么”无 WAL、无后台线程、崩溃即恢复”在 2011 年是反直觉的设计

核心要点

LMDB 把简单当成第一性目标,靠三件事撑起 ACID:

  1. mmap + OS page cache 当 buffer pool:传统引擎要自己写 LRU buffer pool 管热页冷页,几千行代码。LMDB 直接信任 OS——你 mmap 一段,OS 内核已经在做 LRU,不用重写一遍。

  2. Copy-on-Write B+ 树:要改某个值?不就地改。把那条路径上从根到叶的所有页写新副本,最后原子换根指针。类比:改家谱,你不在原谱上涂改,复印一份改完,最后告诉所有人”现在看新谱”。旧版本还在,没人引用时再回收。

  3. 单写者 + 多读者 MVCC:全局只有一把写锁,同一时刻最多一个写者;读者不用任何锁,只在 reader table 登记自己的 txn ID。读者看到的是开始事务那一刻的快照(指向某棵旧 B+ 树的根),写者改它的不影响。

合起来:没有 WAL(写新页本身就是日志)、没有 compaction(旧页有 free list 自动回收)、崩溃恢复 = 读两个 meta page 选最新有效的(瞬时)。

实践案例

案例 1:OpenLDAP back-mdb 替换 BerkeleyDB

OpenLDAP 早年用 BerkeleyDB(BDB)做后端,BDB 自带 buffer pool / 锁管理 / 事务日志,几万行 C,配置参数几十个,调不好性能能差 10 倍。

Howard Chu 2011 年用 LMDB 重写 back-mdb 后端:

back-bdb 代码量: ~5 万行 C + 几十个调优旋钮
back-mdb 代码量: ~7 千行 C + 几乎不用调

读吞吐提升 5-10x(很多查询直接命中 OS page cache,不复制不解压),LDAP 服务器从此默认走 LMDB。

案例 2:Bitcoin Core 存 UTXO 集合

比特币的 UTXO(未花费输出)集合是热点数据——每笔交易都要查”这个输出还在不在”。Bitcoin Core 的 chainstate 用 LMDB 存:

// Bitcoin Core CCoinsViewDB(简化)
bool GetCoin(const COutPoint& outpoint, Coin& coin) const {
return db.Read(std::make_pair('C', outpoint), coin);
}

启动节点时不用预热缓存——mmap 一上来 OS 就按需把热页拉进物理内存。后来 Bitcoin Core 切到了 LevelDB,但早期版本和很多 fork(Monero 至今)还是 LMDB。

案例 3:在 Python 里用 LMDB 做本地缓存

import lmdb
env = lmdb.open('./cache', map_size=10 * 1024**3) # 预留 10GB
with env.begin(write=True) as txn:
txn.put(b'user:42', b'{"name": "Jason"}')
with env.begin() as txn:
val = txn.get(b'user:42') # 读直接拿 mmap 指针
print(bytes(val)) # 注意:txn 一关,指针就失效

要点:读出来的 bytes 在事务关闭后立刻失效——因为它只是指向 mmap 区的指针,事务一结束 LMDB 不保证那块还有效。要长期持有必须 bytes(val) 拷贝一份。

踩过的坑

  1. map_size 是个上限不是初始值mdb_env_set_mapsize 必须一次设够(或大)。32 位机器最大 2GB,64 位可以塞 TB 级。设小了写到一半 MDB_MAP_FULL,要关 env 重开。

  2. 长读事务会让磁盘涨:写者每次 CoW 都会产生旧页,等所有读者都不再引用才回收。如果某读者 txn 开了一小时,这一小时所有”旧页”都不能复用,db 文件能涨好几倍。

  3. 写吞吐天花板低:单写者 = 全局串行写。OLTP 高并发写场景被 RocksDB / LSM 系压得死死的。LMDB 的甜区是”读远多于写”。

  4. NOSYNC 是把双刃剑:默认每次 commit 都 fsync,慢但安全。开 MDB_NOSYNC 速度翻倍但断电会丢最后未刷盘的事务——只在”丢了能重算”的场景用。

适用 vs 不适用场景

适用

  • 嵌入式 KV / 配置存储 / 本地缓存(钱包、浏览器、IDE)
  • 读密集 + 写少(OpenLDAP 查询、UTXO 查询、ML 数据集索引)
  • 资源紧张要小代码量(硬件钱包 / IoT 设备)
  • 需要 ACID + 多进程共享同一份数据

不适用

  • 写密集高并发 OLTP(用 RocksDB / rocksdb-lsm / 真 RDBMS)
  • 数据集远大于 RAM 且热点分散(mmap 优势消失,跟普通 IO 差不多)
  • 32 位环境存大数据(2GB 上限)
  • 复杂查询 / SQL / 多表 join(这是 SQLite / Postgres 的活)

历史小故事(可跳过)

  • 2009-2010 年:Howard Chu 在 OpenLDAP 项目里改 BerkeleyDB 后端改到崩溃,BDB 越改越复杂,Oracle 收购后许可也变敏感。
  • 2011 年初:Chu 决定从零写。受 1990s Phil Howard 的 append-only 思路 + Doug Lea 的 mmap 文章启发,10 周写完 MDB 雏形。
  • 2011 年 8 月:LinuxCon 2011 的 “MDB: A Memory-Mapped Database” 演讲让圈外人首次注意到这个 7 千行的小怪物。
  • 2012-2013 年:MDB 改名 LMDB(避免和 Sleepycat MDB 混淆),back-mdb 成 OpenLDAP 默认后端。
  • 2014 年起:Mozilla rkv / Bitcoin / Monero / Postfix / Cloudflare 陆续采用;Ben Johnson 用 Go 重写出 boltdb,被 etcd v3 / Consul 等用上。

学到什么

  1. 简单是一种性能:LMDB 7 千行 C 跑赢几万行的 BDB——少一层就少一次复制 / 锁 / cache miss。
  2. 信任 OS:page cache / 文件系统已经在做的事,应用层别重写一遍。
  3. CoW + 单写者是个被低估的并发组合:实现简单,对读极友好,恰好匹配”读远多于写”的真实负载分布。
  4. 没 WAL 也能 ACID:写新页换根指针本身就是日志,meta page 双 buffer 让原子提交免费。

延伸阅读

关联

  • b-tree-1972 —— LMDB 的索引就是经典 B+ 树,只是页面 CoW
  • aries-1992 —— 传统 WAL + 三阶段恢复,LMDB 用 CoW + meta page 双 buffer 绕开了
  • rocksdb-lsm —— 写优化的代表,和 LMDB 是 KV 存储两条主路线
  • rocksdb-2017 —— RocksDB 工业实战,对比 LMDB 看清”读派 vs 写派”取舍
  • lsm-tree-1996 —— LSM 论文,理解为什么写密集场景 LSM 赢
  • sqlite-2022 —— 同样是嵌入式但走 SQL + WAL 路线,对比设计哲学
  • bigtable-2006 —— 分布式 KV,更上一层但底层思想相通

反向链接

  • aries-1992 —— ARIES 1992 — 数据库崩溃后怎么把账目对回来
  • b-tree-1972 —— B-Tree 1972 — 磁盘友好的索引结构
  • bigtable-2006 —— Bigtable 2006 — Google 把行级随机读写做到 PB 级的存储系统
  • lsm-tree-1996 —— LSM-Tree 1996 — 写优化存储引擎
  • rocksdb-2017 —— RocksDB 2017 — 把 LSM-Tree 的”空间放大”压到极低的工业经验
  • rocksdb-lsm —— LSM-tree 与 RocksDB — 把所有写都变成顺序写