← 返回信息流
AI 资讯Hacker News·3 小时前

CRDTs能合并并发编辑,为何难处理并发创建?

原标题:CRDTs merge concurrent edits. Why not concurrent creation?

速览

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 处理并发编辑的能力依赖于双方对“编辑对象”达成一致。在用户能够编辑同一个 ListText 之前,他们首先需要确认该键(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 扩展来引入新功能,确保了现有系统的平稳过渡。

查看原文 →loro.dev