LMDB/B+ tree 与读事务快照

复制修改路径,保留读事务正在访问的旧页

LMDB 修改旧快照中的树页时分配新页并调整当前事务的父子引用;读事务继续沿自己的根访问旧版本,旧页复用受最老读事务限制。

700e10f91并发与一致性核验 2026-10-12
相关概念

工程问题#

读事务可能持续扫描一棵树,而写事务同时更新其中的记录。直接覆盖共享页会改变读者正在观察的数据;长期用读写锁保护整棵树又会让写入等待慢读者。

实际实现#

读事务登记事务编号并取得对应元数据中的数据库根。修改已存在的记录路径时,_mdb_cursor_put 调用 mdb_cursor_touch,自根向叶使路径上的页可写;mdb_page_touch 对旧快照页分配新页、修改当前事务中的父节点或根页号,再复制页内容。旧页号加入待回收集合,空闲页分配时根据最老活跃读事务判断哪些旧页可以复用。

处理流程#

  1. 读事务选择稳定的事务编号,记录到 reader slot,并从对应元数据取得数据库信息。
  2. 写入通过游标找到目标;修改已有树路径前,先从根向叶调用 mdb_page_touch。
  3. 遇到旧快照页时分配新页,将旧页号加入当前写事务的回收记录,并将父节点引用或当前根改为新页号。
  4. 复制旧页内容,在新页上进行后续更新;本事务中已经可写的页可直接复用,不是每次修改都再复制一次。
  5. 后续分配器只复用早于最老活跃读事务界限的回收记录,避免把仍可见的旧页交给新写入。

源码片段#

固定版本 · 原文摘录
libraries/liblmdb/mdb.c700e10f91a65fae69520301926fb9819f16d292f · L3353–L3364
查看原文件
3353			do /* LY: Retry on a race, ITS#7970. */3354				r->mr_txnid = ti->mti_txnid;3355			while(r->mr_txnid != ti->mti_txnid);3356			if (!r->mr_txnid && (env->me_flags & MDB_RDONLY)) {3357				meta = mdb_env_pick_meta(env);3358				r->mr_txnid = meta->mm_txnid;3359			} else {3360				meta = env->me_metas[r->mr_txnid & 1];3361			}3362			txn->mt_txnid = r->mr_txnid;3363			txn->mt_u.reader = r;3364		}
mdb_txn_renew0:读事务选择快照

读者重复读取共享事务号以处理竞争,再选对应元数据页并保存 reader slot。随后函数把 meta->mm_dbs 复制到事务对象,因此事务拥有自己的根信息。

libraries/liblmdb/mdb.c700e10f91a65fae69520301926fb9819f16d292f · L8695–L8700
查看原文件
8695	} else {8696		/* make sure all cursor pages are writable */8697		rc2 = mdb_cursor_touch(mc);8698		if (rc2)8699			return rc2;8700	}
_mdb_cursor_put:修改前准备树路径

已有树的更新分支明确先让游标路径可写,失败直接返回。新数据库首次创建根页的路径在上文,不能把这里的复制当作所有插入的必经操作。

libraries/liblmdb/mdb.c700e10f91a65fae69520301926fb9819f16d292f · L8539–L8546
查看原文件
8539	mc->mc_top = 0;8540	if (mc->mc_snum) {8541		do {8542			rc = mdb_page_touch(mc);8543		} while (!rc && ++(mc->mc_top) < mc->mc_snum);8544		mc->mc_top = mc->mc_snum-1;8545	}8546	return rc;
mdb_cursor_touch:自根向叶处理路径

从路径下标 0 开始逐页 touch,先确保父页属于可修改版本,再处理子页并更新引用。这里沿已有游标栈前进,不是在复制整棵树。

libraries/liblmdb/mdb.c700e10f91a65fae69520301926fb9819f16d292f · L3031–L3048
查看原文件
3031	if (!IS_MUTABLE(txn, mp)) {3032		/* Page from an older snapshot */3033		if ((rc = mdb_midl_need(&txn->mt_free_pgs, 1)) ||3034			(rc = mdb_page_alloc(mc, 1, &np)))3035			goto fail;3036		pgno = np->mp_pgno;3037		DPRINTF(("touched db %d page %"Yu" -> %"Yu, DDBI(mc),3038			mp->mp_pgno, pgno));3039		mdb_cassert(mc, mp->mp_pgno != pgno);3040		mdb_midl_xappend(txn->mt_free_pgs, mp->mp_pgno);3041		/* Update the parent page, if any, to point to the new page */3042		if (mc->mc_top) {3043			MDB_page *parent = mc->mc_pg[mc->mc_top-1];3044			MDB_node *node = NODEPTR(parent, mc->mc_ki[mc->mc_top-1]);3045			SETPGNO(node, pgno);3046		} else {3047			mc->mc_db->md_root = pgno;3048		}
mdb_page_touch:为旧快照页建立新引用

只有旧快照页进入该分支。分配新页,登记旧页号,并更新当前事务路径中的父节点页号;触及根时更新事务的 md_root。旧页此时并没有直接放回可立即分配的空闲链表。

libraries/liblmdb/mdb.c700e10f91a65fae69520301926fb9819f16d292f · L3074–L3082
查看原文件
3074	np_flags = np->mp_flags;	/* P_ADM_FLAGS */3075	mdb_page_copy(np, mp, txn->mt_env->me_psize);3076	np->mp_flags |= np_flags;3077	np->mp_pgno = pgno;3078	SET_PGTXNID(txn, np);30793080done:3081	/* Adjust cursors pointing to mp */3082	mc->mc_pg[mc->mc_top] = np;
mdb_page_touch:复制内容并替换游标页

在前面分配新页并设置引用之后,显式调用 mdb_page_copy,恢复新页的页号与事务标识,再使当前游标指向新页。后续还会调整同事务内其他相关游标。

libraries/liblmdb/mdb.c700e10f91a65fae69520301926fb9819f16d292f · L2819–L2831
查看原文件
2819		last = *(txnid_t*)key.mv_data;2820		if (oldest <= last) {2821			if (!found_old) {2822				oldest = mdb_find_oldest(txn);2823				env->me_pgoldest = oldest;2824				found_old = 1;2825			}2826			if (oldest <= last)2827				break;2828		}2829		np = m2.mc_pg[m2.mc_top];2830		leaf = NODEPTR(np, m2.mc_ki[m2.mc_top]);2831		if ((rc = mdb_node_read(&m2, leaf, &data)) != MDB_SUCCESS)
mdb_page_alloc:按最老读事务限制复用

FREE_DBI 的记录键是回收事务号。若记录不早于 oldest,重新核查最老读事务后仍不安全就停止读取这批回收页;其后才读取页号列表。这解释了长读事务为何会增加空间压力。

六段分别覆盖读者登记、写入调用点、路径顺序、旧页替换、内容复制和安全复用。源码内存映射访问不应被解释为所有路径零复制;本例明确展示数据库自己的页复制。

收益与取舍#

工程分析

带来的收益

  • 读者可以在自己的树版本上继续查找和遍历,无需等每次页更新结束。
  • 单次修改围绕搜索路径复制页,不必复制整个数据库。
  • 已经属于当前事务的可写页可以继续修改,同一事务中的多次更新可共享已复制路径。

付出的代价

  • 复制与保留旧版本会增加写入量和空间占用;长时间不结束的读事务会推迟旧页复用。
  • 同一环境的顶层写事务仍通过写者互斥锁串行化,读写分离不等于多个写者同时修改树。
  • 读事务、游标和返回数据的生命周期必须遵守 API 约束;不能在事务结束后继续使用指向其数据的地址。

可比较的选择

页内更新与 undo 版本保留旧记录版本并沿版本链还原读视图,避免每次都复制完整页;代价转移到版本链访问、日志与回收管理。

树级读写锁生命周期和结构修改更直观,但长读操作可能阻塞写者;需要根据读事务持续时间评估。

版本与适用范围#

固定 LMDB 提交 700e10f91a65 的普通 B+ tree 页更新路径,重点分析旧快照页的写时复制及复用边界。LMDB 由自身代码分配和复制树页,不是依靠操作系统 fork 的 COW 自动完成事务。嵌套事务、spill、重复值子库与 MDB_WRITEMAP 的专门分支未完整展开;提交持久性还取决于刷盘路径和同步配置,本例不据局部页复制推导断电保证。

参考来源#

源码、官方文档与原始论文
01
lmdb · mdb_txn_renew0 / _mdb_cursor_put / mdb_cursor_touch / mdb_page_touch / mdb_page_alloc / mdb_find_oldest

libraries/liblmdb/mdb.c · mdb_txn_renew0 / _mdb_cursor_put / mdb_cursor_touch / mdb_page_touch / mdb_page_alloc / mdb_find_oldest

源码700e10f91a65fae69520301926fb9819f16d292f
02
lmdb · 事务与返回数据生命周期约定

libraries/liblmdb/lmdb.h · 事务与返回数据生命周期约定

源码700e10f91a65fae69520301926fb9819f16d292f

相关实现与比较#

2 个相关案例
SQLite·数据库文件与页version-3.46.0

SQLite 将表和索引组织成数据库页上的 B-tree,用一个节点容纳多个键,支持有序定位与范围遍历。

附源码片段 · 存储与数据放置
12 个匹配案例