☰
MPK持久内存多层图模型:对象引用与崩溃恢复的工程实践
2026/10/22 5:48:22 网站建设 项目流程

我最初拿到 MPK(Mirage Persistent Kernel)源码时,最先想看的不是分配器,也不是事务模块,而是它内部那套“多层结构化图模型”。原因很简单:持久内存编程里最麻烦的就是对象关系,指针在重启之后留在那里但没有被正确解析,一切等于白搭。MPK 把整个运行时对象体系抽象成一张多层有向图,节点是对象,边是引用和依赖,层则用来隔离生命周期和访问范围。这篇文章就是我读这套源码时留下的笔记,从设计动机、核心数据结构到实操建图、异常排查都会写到,希望能给要接触 MPK 或者类似持久化对象引擎的人提供一个可以照着走的参考。

1. MPK 为什么一定要用“图”来组织持久对象

1.1 持久内存里最要命的是引用关系

在普通内存里,对象之间可以直接保存指针,程序跑起来之后解引用就行。但到了持久内存环境,这个习惯就非常危险。一次断电、一次进程崩溃,进程地址空间重建后,所有绝对指针都变成无效地址,你没法保证下次启动时对象还落在同一个虚拟地址上。就算用固定映射,对象被重新分配后也可能搬到别处。

所以 MPK 在设计一开始就定了一个规矩:持久化内容里不允许出现裸指针。所有跨对象的引用都不能直接写地址,必须写成“对象 ID + 层 ID”这种逻辑标识,每次访问时再通过运行时解析成真实地址。而这个“逻辑标识网络”,天然就是一张有向图:每个对象是一个节点,每个引用是一条边。把引用关系图形化之后,内存分配、遍历、回收、恢复这些操作都能在统一的模型上做。

我实际调试时遇到过不少“下次启动后对象还能找到,但引用的内容不对了”的诡异问题,最后基本都指向同一个原因:某个结构体里还是有人贪方便存了一个绝对指针。所以 MPK 在头文件里反复强调,凡是标记为persistent的字段只能放mpk_oid,不能放void *。

1.2 为什么不是一棵树,而是图

有人会问,既然要组织对象关系,用一棵树不行吗?树结构简单,遍历也快。但在持久化对象模型里,树根本不够用。一个对象可以被多个上层对象共享,比如一个配置节点同时被业务 A 和业务 B 引用;一个缓存节点可以同时依赖数据和索引;一个事务日志节点可能指向旧版本快照,而旧版本快照又反向记录事务信息。这些都是多对多关系,树表达不了,硬用树就会产生大量重复节点,一致性很难维护。

MPK 采用的是有向图,并且允许环存在。这里要特别注意一点:允许环和允许无限遍历是两回事。图结构里如果出现强连通分量,引用计数回收逻辑就必须额外处理循环引用,否则节点会泄漏。MPK 的做法是在普通引用边之外增加了“弱引用边”和“依赖边”,回收算法只跟随强引用做可达性分析,弱引用不参与引用计数。这样既能表达多对多,又不会让简单引用计数模型失控。

1.3 层到底在分什么

如果只是一张平铺的大图,那么启动时要把所有节点都加载进内存,才能正确解析引用关系。几百万节点的图会拖垮恢复时间。MPK 引入了“层”的概念,把节点按照生命周期和访问范围划分。

我的理解是:

  • L0 是根层,只有少量全局对象,比如图结构本身、根节点、全局版本号;
  • L1 是持久对象层,存放用户真正要持久化的业务对象;
  • L2 是临时对象层,存放缓存、中间计算结果、内存视图,这些节点可以不落盘;
  • 更上层可以按业务域继续扩展,比如热数据层、归档数据层。

层与层之间通过“门(gate)”建立映射,而不是直接引用。这个设计带来了两个直接好处:第一,恢复时可以按层加载,用户只需要先解析 L0,再按需展开 L1、L2,不需要全图扫描;第二,热点对象可以放在 DRAM 里,冷对象留在持久内存中,层之间的访问通过 gate 做缓存和换入换出,有点像 CPU 的多级缓存思路。

2. 核心源码结构与数据布局

2.1 节点、边和层的定义

我翻到mpk_graph.h的时候,发现核心结构体并没有想象中复杂。节点是一个固定大小的元数据块,边是独立分配的小对象,层更像是一个配置表加一个映射表。下面这段是简化后的结构体,保留了源码里的主要字段。

typedef struct { uint64_t layer_id; // 所属层 uint64_t node_id; // 层内节点编号 } mpk_oid; typedef struct { mpk_oid oid; // 节点自身的 ID uint64_t size; // 对象大小 uint64_t magic; // 魔数校验 uint32_t ref_count; // 强引用计数 uint32_t flags; // 节点状态标志 mpk_oid owner; // 归属根节点 } mpk_node; typedef struct { mpk_oid src; // 源对象 mpk_oid dst; // 目标对象 uint32_t type; // 边类型 uint32_t flags; // 边属性 uint64_t version; // 边版本号 } mpk_edge;

mpk_oid就是前面说的“逻辑指针”,占 16 字节。它没有直接用平面 ID,而是分层内 ID 和层 ID,是为了让每一层可以独立分配和回收编号,不至于全局维护一个巨大的单调递增计数器。

节点里的owner字段很关键。每个节点必须声明自己的归属根,这么做是为了回收算法能从一个根节点开始做可达性分析,而不是每次遍历所有节点。ref_count只统计强引用边,弱引用和依赖边不进来。

mpk_edge的type区分几种关系:强引用、弱引用、依赖、缓存。缓存关系在恢复时可以丢弃,依赖关系则必须保证目标存在。version字段用于乐观并发控制和崩溃恢复时的校验,后面会专门说明。

2.2 层的组织方式

每一层在 MPK 内部对应一个mpk_layer对象,它保存三部分信息:节点分配表、空闲节点列表、层间入口映射表。

typedef struct { uint32_t layer_id; uint64_t node_capacity; uint64_t used_nodes; uint64_t free_head; mpk_oid parent_layer; uint32_t gate_count; } mpk_layer;

这里parent_layer并不是严格意义上的层级树,它更像是一种“访问方向提示”。L1 可以声明 L0 是父层,表示 L1 节点在解析时可以向 L0 找全局根;L2 可以声明 L1 是父层,表示它依赖 L1 中的业务对象。但边仍然可以跨越任意层建立,只要 src 和 dst 的 ID 都存在即可。

我在读源码时有一个体会:层和层之间没有做强制的拓扑约束,设计上故意保持松散。好处是灵活,坏处是如果使用者乱建跨层边,恢复时会非常难受。MPK 提供了一个mpk_layer_check接口,专门用来扫描跨层边,检查是否触发了“父层节点被删,但子层还在引用”的情况。

2.3 访问门:ID 到真实地址的解析

源码里有个很核心的函数,叫mpk_gate_resolve。它做的事情很简单:给定一个mpk_oid,返回一个可用的内存地址。但实现并不简单,因为要从层表、节点分配表、缓存映射里逐级查找。

我简化一下它的逻辑:

void *mpk_gate_resolve(mpk_oid oid, int flags) { mpk_layer *layer = get_layer(oid.layer_id); if (!layer) return NULL; mpk_node *node = get_node(layer, oid.node_id); if (!node) return NULL; // 检查节点是否已加载到内存 if (!is_loaded(node)) { load_node_from_storage(node, flags); } // 检查节点地址是否需要刷新 if (node->flags & NODE_ADDR_INVALID) { rebuild_addr_mapping(node); } return node_to_addr(node); }

这里有几个容易踩坑的细节:flags参数用来区分“只读解析”和“写解析”。只读解析不会触发加载,写解析则会把节点状态置为脏,方便事务模块统一提交。源码里注释写得很直白:不要拿着解析后的地址到处存,这个地址在下次恢复后会失效,只允许在本次进程生命周期里使用。

2.4 层间映射与版本号

每个层维护一张映射表,把“子层对象 ID”映射到“父层对象 ID”或者“逻辑路径”。这张映射表本身也是持久化对象,放在 L0 下的一个隐藏节点里。恢复时,MPK 先加载 L0 根节点,找到层映射表的位置,再逐层恢复节点。

版本号在这里的作用是防止“旧边指向新对象”。例如删除一个节点后,它的 ID 没有被立即回收,而是放到空闲列表里。如果分配器很快把同一个 ID 给了新对象,所有仍然指向这个 ID 的旧边就会静默指向新对象。MPK 的做法是给每个节点分配一个“世代号”,mpk_oid里只包含层 ID 和节点 ID,但在边记录里额外存了两个节点的世代号。解析时如果发现世代号不匹配,就直接报告一致性错误,而不是返回错误地址。

这个设计让我想起传统文件系统里的 inode 复用问题,但 MPK 把它做到了更细粒度:不是整个对象复用才检查,而是每一条边在访问时都会校验世代号。代价是多了一点存储和 CPU 开销,但换来了非常强的错误检测能力。

3. 实操:从源码构建一个多层图模型

3.1 初始化参数怎么定

MPK 源码提供了一个演示用的 API,核心初始化函数是mpk_graph_create。它的参数不多,但每个都影响后续行为。

mpk_graph_cfg cfg = {0}; cfg.layer_count = 3; cfg.fanout = 64; cfg.node_size = 256; cfg.enable_fsck = 1; mpk_graph *graph = mpk_graph_create("/dev/pmem0", &cfg); if (!graph) { // 错误处理 }

layer_count决定层数,fanout是每层节点分配器的扇出因子,node_size是默认节点大小,enable_fsck表示每次打开图时是否做一致性校验。

在小型测试工程里,3 层 64 扇出是足够用的。如果对象规模比较大,比如百万级节点,我建议把fanout调到 128 或者 256。注意fanout不是越大越好,它影响空闲列表的分组粒度,太大反而会让小节点浪费空间。

3.2 创建节点和建边

初始化之后,第一件事是创建根节点。

mpk_oid root = mpk_node_create(graph, 0, "root");

第二个参数是层 ID,这里 0 表示 L0 根层。然后创建业务对象和临时对象:

mpk_oid user_a = mpk_node_create(graph, 1, "user_a"); mpk_oid cache = mpk_node_create(graph, 2, "cache");

建边时用mpk_edge_add,需要明确边类型:

mpk_edge_add(graph, user_a, root, MPK_EDGE_STRONG); mpk_edge_add(graph, cache, user_a, MPK_EDGE_WEAK);

这里把cache对user_a的引用设成弱引用,表示缓存对象如果找得到就用,找不到也不会影响业务对象回收。这个语义和引用计数配合得很好。

我实际写代码时犯过一个错误:建边前没有先确认两个节点是否都已经成功持久化。MPK 允许先建边再持久化,但如果在同一个事务里先删除了源节点再提交边,恢复时会出现孤儿边。所以源码注释里建议:节点创建、边创建、删除操作尽量放在一个事务块里,不要手动拆开。

3.3 分层遍历与查询

MPK 支持带层约束的遍历,避免每次从全局根出发把整张图扫一遍。下面的示例代码会从 L0 根节点开始,先遍历 L1 中的强引用节点,再处理 L2 的弱引用节点:

mpk_walk_ctx ctx = {0}; ctx.start = root; ctx.layers = 1; // 只遍历 L1 ctx.edge_type = MPK_EDGE_STRONG; ctx.max_depth = 64; while (mpk_walk_next(graph, &ctx, &current_oid)) { // 处理 current_oid }

关键是mpk_walk_ctx里的访问集合。因为图允许有环,MPK 内部需要一个已访问集合,否则会无限循环。源码里已经实现了按(layer_id, node_id)去重,但如果你绕过 API 直接遍历邻接表,很容易踩坑。

我的建议是:所有遍历逻辑都走mpk_walk_*接口,不要自己递归遍历。这不仅仅是去重问题,还涉及到跨层解析和缓存预取,手写很容易忽略。

3.4 持久化与崩溃恢复

持久化操作的核心是mpk_graph_checkpoint。它会把所有脏节点和脏边的变化写入日志,然后刷新到持久内存。MPK 采用“先写日志、再写数据”的方式:

mpk_txn_begin(graph); mpk_node_update(graph, user_a, payload, sizeof(payload)); mpk_edge_add(graph, user_a, root, MPK_EDGE_STRONG); mpk_txn_commit(graph);

mpk_txn_begin会在日志区记录当前版本号和待修改对象列表;mpk_txn_commit先把日志刷到落盘区域,再写入实际数据。我特意看了恢复代码,mpk_graph_open会先扫描日志区,如果发现未提交事务就回滚,发现已提交但数据未完全写入就重放日志。

这里有个容易误解的地方:日志区并不是为了记录所有数据内容,而是记录“对象 ID + 旧值 + 新值”的差异块。好处是崩溃后恢复速度很快,不用全量加载图。坏处是如果日志区没有及时清理,会越积越大。MPK 提供一个mpk_graph_compact接口,用来合并历史日志。

4. 我在调试中踩过的几个坑

4.1 悬挂引用:跨层指针不能裸存

有一次我为了性能优化,在 L2 缓存节点里直接保存了一个 L1 对象的地址。当时测试一切正常,但模拟断电重启后,缓存节点指向的地址完全不对。排查了很久才发现,问题出在持久化字段里存了void *,而不是mpk_oid。

修复方式很简单:缓存节点的持久化结构里只存mpk_oid,每次访问前通过mpk_gate_resolve解析地址。如果对象已经被回收,解析会返回 NULL,再决定是重建缓存还是报错。后来我在代码里加了一个规则:凡是memcpy进持久化内存的结构体,不能用memcpy拷贝指针字段,必须先转换成 ID。

4.2 遍历死循环:有环图必须带访问上下文

我自己写一个“从根节点遍历所有可达对象”的工具时,没有复用mpk_walk_ctx,而是直接递归遍历邻接边。结果在遇到循环引用图时栈溢出。MPK 源码里对强连通分量有监测,但默认遍历不会阻止环,需要调用者自己保证检查重复。

正确做法是给遍历上下文加一个剪枝:所有边在遍历前先看目标节点是否已经访问过。如果已经访问过,可以跳过去,除非你确实需要统计重复引用路径。另外,MPK 对于深度过大的递归遍历有保护,max_depth超限后会返回错误码,这也是避免在嵌入式环境里爆栈的手段。

4.3 崩溃恢复后图结构不一致

模拟掉电测试时,我遇到过几次“节点找得到,但边指向的目标不存在”的错误。MPK 的fsck模式会报告“孤儿边”和“悬挂节点”。

根源通常有两个:一是跨层事务提交顺序不对,比如先提交了删除节点,再提交删除边的日志,结果删除边还没落盘系统就断电了;二是版本号没有同步更新,导致恢复时用旧版本号校验新节点失败。

MPK 的解决思路是把跨层操作包装进同一个事务块,且事务块里对所有涉及层都打上版本标记。我后来养成的习惯是:涉及多个层的操作,绝不手动逐条提交,而是一律走mpk_txn_begin/mpk_txn_commit,让事务模块统一协调版本号。

4.4 分层参数选不好,性能反而更差

刚开始我以为层数越多越能提升并发,于是把一个测试图配了 7 层。结果访问延迟暴涨,因为每次跨层都需要经过 gate 解析和缓存查询。后来我把测试数据跑了一轮,得到一个经验表:

层数适用场景主要代价
1小型对象图,节点数少,恢复快无法隔离热冷数据,全局扫描慢
3大多数业务系统,热数据隔离gate 解析开销可接受
5超大规模图,冷热分离明显需要批量预取,跨层操作成本高
7 及以上不建议gate 解析和版本校验开销明显上升

所以我的建议是:如果节点总量少于 10 万,3 层足够;超过百万再考虑增加层数,而且最好配合批量预取接口mpk_gate_prefetch,否则每次访问的解析成本会抵消分层带来的收益。

5. 基于这套图模型还能做哪些扩展

5.1 把图模型接到事务引擎上

MPK 的多层图模型最有意思的地方是,它可以当作事务引擎的对象目录。事务需要管理“哪些对象被修改、哪些对象参与回滚”,而图模型天然记录了对象之间的依赖。比如一个事务里修改了节点 A,而节点 B 有一条边依赖 A,那么事务提交时要检查 B 是否需要级联更新。

在源码里,mpk_txn_begin会把事务涉及到的所有对象 ID 收集进一个临时子图,提交时再把这个子图整体同步到持久化图里。这么做的好处是,回滚不需要恢复整个图,只要丢弃临时子图就行。我后来也用它实现过一个类似 MVCC 的机制:每个事务快照是一个临时图层,读取时按层号顺序查找可见版本。

5.2 用图模型组织索引与缓存

另一个扩展方向是用多层图来组织索引结构。比如把 B+ 树的内部节点、叶子节点都视为 L1 层对象,索引节点之间的关系用强引用边表示,缓存块放在 L2 层,用弱引用边指向叶子节点。这样索引修改天然纳入图的持久化逻辑,不用另外写一套日志。

我在实验中发现,这种组织的最大收益是回收逻辑统一了:缓存块如果不再被引用,可以直接通过图的可达性分析识别出来,不需要再手动维护一份缓存引用列表。当然,代价是索引访问每次都要经过 gate 解析,所以需要给热路径做地址缓存。

5.3 后续优化想法

如果继续做下去,我可能会在以下三件事上投入时间:

  • 在 L0 根层加一个“热节点摘要”,记录最近高频访问的节点 ID 和解析地址,减少 gate 查询次数;
  • 增加批量建边接口,一次性提交多条边到事务日志,减少版本号更新次数;
  • 给弱引用边增加一个异步清理机制,不用每次回收都扫描全图。

我操作 MPK 期间最大的心得是:图模型不是花架子,它真正解决了持久化对象关系最难追踪的问题。只要在写代码时坚持“持久化字段只存 ID、所有对象访问走 gate、跨层操作全进事务”,这套模型的稳定性就会高出很多。后续如果再碰类似的持久化引擎,我大概率还是会用同样的思路去组织和验证。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询