构建 CRDT 协同 Notebook —— Reconcile 系统与一致性
在理想状态下,CRDT 保证了所有副本的强最终一致性。但在现实当中,数据一致性并不等同于数据有效性。
这句话要怎么理解呢:
- 一致性:所有客户端最终都看到了相同的状态(比如都看到了 ID 为 A 的单元格存在于 Map 中但不在 Order 中)。
- 有效性:这个状态在业务逻辑上是合法的(单元格如果存在,就必须在 Order 中有一席之地,否则它就是孤儿单元格)。
我在设计 Notebook 的时候额外引入了一套完整的体系,包含 Reconciliation、Validation 以及 Auto-Stale 机制。我们的目标是构建一个这样的系统:不仅能容忍错误,还能主动发现并修复错误。
TL;DR;
- 通过 Reconciliation,我们针对实际环境下的不确定性,构建了自动纠错机制来应对熵增。
- 通过 Auto-Stale,我们将业务逻辑中的因果关系(代码变动 -> 结果失效)下沉到底层数据结构的响应机制中,实现了逻辑的自动化与去中心化。
Reconciliation 机制
即使算法(CRDT)是完美的,环境(网络、存储、并发)也是不完美的。
Y.js 的算法保证了在所有操作最终送达时,文档状态是数学上收敛的。但在现实中,我们面临着更复杂的情况:
- 部分更新(Partial Updates):网络中断可能导致某些 Update 块丢失或乱序。
- 逻辑漏洞(Logical Gaps):旧版本的客户端可能写入了新版本不兼容的数据结构。
- 并发竞态(Race Conditions):极端的并发操作可能让应用层逻辑(而非 CRDT 层)陷入不一致状态。
这就导致了系统的“熵增”:随着时间推移,数据结构逐渐变得无序、冗余或逻辑不自洽。为了对抗这种熵增,我们需要一种主动的、确定性的收敛机制,这就是 Reconciliation。
Source of Truth
我们的 Schema 采用了 Map+Order 的分离设计,如果你不太清楚我的 Schema 设计,你可以看我之前的推文:
https://blog.wibus.ren/posts/programming/crdt-notebook-1
在这种二元结构中,Reconciliation 的首要哲学是:确定事实。
- Source of Truth(事实):
NB_CELL_MAP。这是数据的物理存储,只要 Map 里有数据,它就客观存在。 - View(视图):
NB_CELL_ORDER。这只是对数据的一种排列方式。
当两者发生冲突——例如 Map 里有单元格 C,但 Order 里没有——Reconciliation 的判定逻辑非常明确:信任事实,修正视图。
因为视图只是数据的投影,视图错了可以重建,但数据丢了就永远丢了。因此,Reconciliation 的核心行为是将 Map 中的“孤儿数据”重新投影到 Order 中,而不是因为 Order 里没有就反向删除 Map 里的数据。
幂等性(Idempotency)与收敛方向
需要注意的是,Reconciliation 必须是一个幂等函数
无论我们在什么时候运行它——文档加载时、迁移后、甚至每次变更后——它都应该产生相同的结果,且不会产生副作用的叠加。
- 收敛方向:将文档状态从“非法”推向“合法”。
- 无副作用:如果文档已经是合法的(Map 和 Order 一一对应),Reconciliation 应该什么都不做(No-op),不产生任何新的 Y.js 事务。
这种特性使得我们可以放心地在系统的各个生命周期节点(Bootstrap, Migration, Runtime)挂载这个机制,将其作为一种常驻的免疫系统,而不是一次性的修复脚本。
Principle of Least Surprise
当系统自动修复数据时,最危险的事情是“自作聪明”。
场景:用户 A 删除了单元格 C,但由于网络问题,删除操作只成功了一半(移除了 Order,没来得及打墓碑标记)。Reconciliation 发现了这个处于“中间态”的 C(在 Map 中,不在 Order 中,没墓碑)。
- 激进策略:认为这是未完成的删除,帮用户删掉 Map 里的 C。
- 风险:如果这其实是用户 B 刚创建但还没来得及插入 Order 的新单元格呢?由于误判,用户 B 的数据被系统删除了。
- 保守策略(我们的选择):认为这是意外丢失的显示,帮用户把 C 找回来,追加到文档末尾。
- 结果:用户 A 可能会看到一个本该删除的单元格“复活”了。这虽然令人困惑,但只是体验问题,不是数据事故。用户可以再次点击删除。
我们选择保守策略,遵循“宁可复活僵尸,不可错杀活人”的原则。在自动化处理当中,保护用户数据的安全性永远高于维持 UI 的整洁性。
工程实现:Origin 隔离
Reconciliation 的操作本质上是系统行为,而非用户行为。因此,我们严格使用 MAINT_ORIGIN 来执行所有修复操作。
// 修复操作使用 MAINT_ORIGIN
withTransactOptional(nb, apply, MAINT_ORIGIN);这意味着:
- 不污染撤销栈:用户按 Ctrl+Z 时,不会意外地“撤销”系统的修复操作,导致文档退回到非法状态。
- 不触发用户侧副作用:其他协同端的
UndoManager也会忽略这些变更。
自动化维护:Auto-Stale 机制
Notebook 的核心价值在于代码与执行结果的对应关系。如果用户修改了代码,旧的执行结果就失去了意义,甚至会误导用户。这种关系称为因果一致性(Causal Consistency)。
在传统的服务端架构中,这通常由后端状态机管理。但在我们的协同架构中,没有中心服务器来时刻监控状态。我们需要在客户端实现一套维护机制。
观察者模式
我们实现了 enableAutoStaleOnSource 机制,这是一个基于 Y.js 细粒度观察者的自动化系统。
// /modules/collaborate/yjs/schema/quality/auto_stale.ts
export const enableAutoStaleOnSource = (nb: YNotebook) => {
// 监听所有单元格
cellMap.observe((event) => {
// 处理新增单元格:自动绑定监听器
event.changes.keys.forEach((change, key) => {
if (change.action === "add") bindCell(key);
});
});
const bindCell = (cellId) => {
const cell = cellMap.get(cellId);
const sourceText = cell.get(CELL_SOURCE);
// 核心逻辑:监听文本变更
sourceText.observe(() => {
// 任何代码修改,立即触发副作用
markCellOutputStale(nb, cellId);
});
};
};- 细粒度监听:我们直接监听
Y.Text对象的变更事件,而不是监听整个 Map。这意味着无论用户是在打字、粘贴还是通过协同接收了远程修改,事件都会被精确捕获。 - 副作用隔离:
markCellOutputStale操作使用EXECUTION_ORIGIN。这意味着当系统自动将结果标记为“过期(Stale)”时,这一操作不会进入用户的撤销栈。用户撤销代码修改时,不会意外地把“过期标记”撤销掉。 - 生命周期管理:该机制被封装在一个
MaintenanceCoordinator中,随组件挂载启动,随组件卸载销毁,杜绝内存泄漏。
防御性编程:Validation Layer
除了自动修复,我们还引入了被动校验机制。validateNotebook 函数定义了一套严格的数据完整性规则:
- 引用完整性:Order 中的 ID 必须在 Map 中存在。
- 类型安全:Output 中的
running必须是布尔值,result必须符合 Schema。 - 逻辑一致性:如果有
runId,则必须有startedAt。
// /modules/collaborate/yjs/schema/quality/validation.ts
export const validateNotebook = (nb: YNotebook): ValidationIssue[] => {
const issues = [];
// ... 执行数十项检查 ...
if (!map.has(id)) {
issues.push({
level: "error",
message: `Order references missing cell ${id}`,
});
}
return issues;
};- 开发阶段:作为 Debug 工具,快速定位 Schema 变更引入的 Bug。
- 迁移阶段:在执行版本迁移(Migration)前后运行,确保迁移脚本没有破坏数据。
- 运行时:配合埋点系统,抽样上报线上文档的健康度。
这套数据治理体系,使得 Notebook 不再是一个脆弱的数据容器,而是一个能够自我维持、自我修复的有机体。它能够从混乱的分布式编辑中,始终收敛到一个有序、合法且符合业务逻辑的状态。