CRDTs能合并并发编辑,为何难处理并发创建?
速览
CRDT(无冲突复制数据类型)已被广泛用于解决分布式系统中的并发编辑冲突。然而,并发创建对象(如新用户、新文档)的合并逻辑更为复杂,目前缺乏通用的CRDT解决方案。这一技术瓶颈限制了分布式系统在完全去中心化场景下的扩展能力。
AI 深度解读
CRDTs 能合并并发编辑,为何不能合并并发创建?
背景
在基于 JSON 结构的 Conflict-free Replicated Data Types (CRDTs) 系统中,协同编辑通常表现良好。例如,当多个用户同时编辑同一段文本或向同一个列表插入数据时,系统能够正确合并这些变更。然而,当用户处于离线状态并各自向同一个空笔记添加内容,随后上线同步时,可能会出现令人困惑的现象:其中一位用户的编辑似乎“消失”了。
实际上,数据并未从历史中删除,也没有报错。问题在于 note.get("body") 只能返回一个 Text 容器。另一个并发创建的容器虽然存在于历史中,但在当前文档状态下不可见。从应用程序的角度来看,这表现为数据丢失。
这一经典问题已在 Loro、Yjs 和 Automerge 等社区中被用户多次遇到。Loro 通过引入 Mergeable Containers(可合并容器) 解决了这一问题。其核心思想是:子容器的身份不再由创建它的操作 ID (OpID) 决定,而是由其逻辑位置(即在 Map 中的键)决定。
核心内容
1. 问题的根源:并发首次创建的冲突
CRDT 处理并发编辑的能力依赖于双方对“编辑对象”达成一致。在用户能够编辑同一个 List 或 Text 之前,他们首先需要确认该键(Key)指向的是哪一个子容器。
在引入 Mergeable Containers 之前,常规子容器的 ID 包含了创建它的 OpID。因此,如果两个离线用户并发地首次创建同一个键下的子容器,系统会生成两个不同的容器 ID。Map 的冲突解决规则会决定哪一个可见,导致另一个被“隐藏”。
- 常规做法的局限性:之前的推荐 workaround 是在父容器创建时初始化所有子容器。但这要求应用预先知道所有结构,无法应对动态 schema 迁移、按日期创建的日历文档或动态索引等场景。在这些需要“按需创建”的场景中,并发首次创建难以避免。
2. 解决方案:Mergeable Containers 的设计
Mergeable Containers 借鉴了 Root Container(根容器)的身份逻辑。在 Loro 和 Yjs 中,根容器通过名称(如 doc.getMap("state"))访问,该名称是稳定的,不依赖于创建者或操作 ID。只要多个用户访问相同的根名称,它们就指向同一个逻辑容器。
Mergeable Containers 将这种基于确定性名称的身份机制引入到子容器中:
- 身份来源:子容器的 ID 由父容器 ID、键(Key)和类型(Type)共同推导得出,而非创建操作 ID。
- 可见性控制:通过父 Map 中的二进制标记(Slot Marker)来决定哪个类型的子容器在当前键下可见。
3. API 变更与使用指南
API 的变化是显式的且向后兼容。开发者需要使用 ensureMergeable* 系列方法来创建可合并的子容器。
对比示例:
-
传统方式(存在冲突风险):
// Peer A doc.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "A"); // Peer B, offline doc.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "B"); // 同步后:只有其中一个 List 在 "2026-06-08" 下可见 -
Mergeable 方式(解决冲突):
// Peer A doc.getMap("days").ensureMergeableList("2026-06-08").insert(0, "A"); // Peer B, offline doc.getMap("days").ensureMergeableList("2026-06-08").insert(0, "B"); // 同步后:两个端点编辑的是同一个 List
使用规则:
当子容器应通过其逻辑位置(键)来标识时,使用 ensureMergeable*。这适用于那些对所有人来说应表现为单一共享子容器的字段(如一个共享的 Text、List 或 Map)。无论哪个 Peer 先创建,结果都应一致。
支持的 API 包括:
map.ensureMergeableText(key)map.ensureMergeableMap(key)map.ensureMergeableList(key)map.ensureMergeableMovableList(key)map.ensureMergeableTree(key)map.ensureMergeableCounter(key)
4. 底层实现机制
Mergeable Containers 由两个独立的表示层组成:
A. CID(容器 ID):合成根容器 ID
Mergeable Container 使用内部命名空间下的合成 ContainerID::Root。
- 格式:
🤝:<payload> - Payload 推导:基于父 Map 和键生成。例如,扁平化路径为
$<escaped-root-name>@<peer-base36>:<counter-base36>。 - 优势:所有 Peer 无需依赖创建操作的 OpID,即可推导出相同的子容器 ID。
- 优化:为了避免深层嵌套导致的 CID 递归增长,Loro 采用了扁平化的 CID 编码(如
🤝:$state>note-1>body),其中base-parent是最近的非 Mergeable Map 祖先。
B. Map Slot:二进制标记控制可见性
仅靠确定性 CID 不足以解决类型冲突。如果 Peer A 请求 ensureMergeableText("field") 而 Peer B 请求 ensureMergeableMap("field"),两个确定性 CID 都会存在。
- 可见性决策:父 Map 需要决定哪个类型在 "field" 键下当前可见。
- 状态保留:该决策必须是确定且可逆的。不可见的 Mergeable 子容器的状态仍保留在其确定性 ID 下。如果后续切换回该类型,其状态可以重新浮现。
- 幂等性:
ensure方法具有幂等性。如果键已持有常规标量值或常规子容器,API 将返回错误,而不是静默覆盖。
关键要点
- 问题本质:传统 CRDT 中,子容器身份绑定于创建操作 ID,导致离线并发首次创建产生冲突,引发“数据不可见”的假象。
- 解决方案:Loro 引入 Mergeable Containers,将子容器身份从“操作 ID”转变为“逻辑位置(父容器+键+类型)”。
- API 设计:使用
ensureMergeable*系列方法显式创建可合并容器,确保并发编辑指向同一逻辑对象。 - 身份与可见性分离:
- CID 决定 Peer 是否指向同一个 CRDT 对象(基于合成根 ID)。
- Map Slot 决定该对象在当前键下是否可见以及激活哪种类型。
- 类型冲突处理:若并发创建不同类型(如 Text vs Map),Map 的冲突解决规则决定可见类型,但非可见类型的状态会被保留,支持后续切换恢复。
- 适用场景:适用于需要按需创建、且希望同一键下始终指向唯一共享子容器的场景(如动态索引、日历条目等)。
意义与影响
Mergeable Containers 的引入填补了 CRDT 在“容器创建”层面的并发处理空白。在此之前,开发者必须通过预初始化或复杂的 Workaround 来规避并发创建冲突,这限制了应用的灵活性和动态性。
通过借鉴 Root Container 的身份逻辑,Loro 使得子容器也能具备稳定的、基于名称的身份。这不仅解决了数据“看似丢失”的用户体验问题,还降低了开发者处理复杂协同场景的认知负担。对于构建高度动态、支持离线优先的协作应用(如文档编辑器、项目管理工具、日历应用等),这一机制提供了更强大且符合直觉的数据一致性保障。
此外,该设计保持了向后兼容性,未改变现有的 setContainer 行为,而是通过显式的 API 扩展来引入新功能,确保了现有系统的平稳过渡。
