复制修改路径,保留读事务正在访问的旧页
LMDB 修改旧快照中的树页时分配新页并调整当前事务的父子引用;读事务继续沿自己的根访问旧版本,旧页复用受最老读事务限制。
工程问题#
读事务可能持续扫描一棵树,而写事务同时更新其中的记录。直接覆盖共享页会改变读者正在观察的数据;长期用读写锁保护整棵树又会让写入等待慢读者。
实际实现#
读事务登记事务编号并取得对应元数据中的数据库根。修改已存在的记录路径时,_mdb_cursor_put 调用 mdb_cursor_touch,自根向叶使路径上的页可写;mdb_page_touch 对旧快照页分配新页、修改当前事务中的父节点或根页号,再复制页内容。旧页号加入待回收集合,空闲页分配时根据最老活跃读事务判断哪些旧页可以复用。
处理流程#
- 读事务选择稳定的事务编号,记录到 reader slot,并从对应元数据取得数据库信息。
- 写入通过游标找到目标;修改已有树路径前,先从根向叶调用 mdb_page_touch。
- 遇到旧快照页时分配新页,将旧页号加入当前写事务的回收记录,并将父节点引用或当前根改为新页号。
- 复制旧页内容,在新页上进行后续更新;本事务中已经可写的页可直接复用,不是每次修改都再复制一次。
- 后续分配器只复用早于最老活跃读事务界限的回收记录,避免把仍可见的旧页交给新写入。
源码片段#
固定版本 · 原文摘录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 }读者重复读取共享事务号以处理竞争,再选对应元数据页并保存 reader slot。随后函数把 meta->mm_dbs 复制到事务对象,因此事务拥有自己的根信息。
8695 } else {8696 /* make sure all cursor pages are writable */8697 rc2 = mdb_cursor_touch(mc);8698 if (rc2)8699 return rc2;8700 }已有树的更新分支明确先让游标路径可写,失败直接返回。新数据库首次创建根页的路径在上文,不能把这里的复制当作所有插入的必经操作。
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;从路径下标 0 开始逐页 touch,先确保父页属于可修改版本,再处理子页并更新引用。这里沿已有游标栈前进,不是在复制整棵树。
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 }只有旧快照页进入该分支。分配新页,登记旧页号,并更新当前事务路径中的父节点页号;触及根时更新事务的 md_root。旧页此时并没有直接放回可立即分配的空闲链表。
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_copy,恢复新页的页号与事务标识,再使当前游标指向新页。后续还会调整同事务内其他相关游标。
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)FREE_DBI 的记录键是回收事务号。若记录不早于 oldest,重新核查最老读事务后仍不安全就停止读取这批回收页;其后才读取页号列表。这解释了长读事务为何会增加空间压力。
六段分别覆盖读者登记、写入调用点、路径顺序、旧页替换、内容复制和安全复用。源码内存映射访问不应被解释为所有路径零复制;本例明确展示数据库自己的页复制。
收益与取舍#
工程分析带来的收益
- 读者可以在自己的树版本上继续查找和遍历,无需等每次页更新结束。
- 单次修改围绕搜索路径复制页,不必复制整个数据库。
- 已经属于当前事务的可写页可以继续修改,同一事务中的多次更新可共享已复制路径。
付出的代价
- 复制与保留旧版本会增加写入量和空间占用;长时间不结束的读事务会推迟旧页复用。
- 同一环境的顶层写事务仍通过写者互斥锁串行化,读写分离不等于多个写者同时修改树。
- 读事务、游标和返回数据的生命周期必须遵守 API 约束;不能在事务结束后继续使用指向其数据的地址。
可比较的选择
页内更新与 undo 版本保留旧记录版本并沿版本链还原读视图,避免每次都复制完整页;代价转移到版本链访问、日志与回收管理。
树级读写锁生命周期和结构修改更直观,但长读操作可能阻塞写者;需要根据读事务持续时间评估。
版本与适用范围#
固定 LMDB 提交 700e10f91a65 的普通 B+ tree 页更新路径,重点分析旧快照页的写时复制及复用边界。LMDB 由自身代码分配和复制树页,不是依靠操作系统 fork 的 COW 自动完成事务。嵌套事务、spill、重复值子库与 MDB_WRITEMAP 的专门分支未完整展开;提交持久性还取决于刷盘路径和同步配置,本例不据局部页复制推导断电保证。
参考来源#
源码、官方文档与原始论文libraries/liblmdb/mdb.c · mdb_txn_renew0 / _mdb_cursor_put / mdb_cursor_touch / mdb_page_touch / mdb_page_alloc / mdb_find_oldest
libraries/liblmdb/lmdb.h · 事务与返回数据生命周期约定
相关实现与比较#
2 个相关案例SQLite 将表和索引组织成数据库页上的 B-tree,用一个节点容纳多个键,支持有序定位与范围遍历。
InnoDB 插入聚簇索引时先尝试叶页内修改;空间或布局条件不满足才重新以允许修改树结构的方式下降,执行分裂或根提升。