编程 并发加粗谁说了算:Loro 富文本 CRDT 的重叠样式与交错插入合并语义

2026-09-10 00:06:25

并发加粗谁说了算:Loro 富文本 CRDT 的重叠样式与交错插入合并语义

两个人同时在一段文字上各加粗一半,并不总能合并出整句加粗;常见结果是只保住其中一段的粗体,甚至出现字符交错如 HHeil lo。CRDT 保证最终一致,但“最终一致成什么样”取决于算法怎么还原用户意图——协同富文本的语义难度主要在这一层。

Loro(官网 loro.dev,npm loro-crdt)是 Rust/JS(WASM)/Swift 实现的 local-first CRDT 库,1.0 起数据格式稳定。富文本设计说明在 Introduction to Loro's Rich Text CRDT,1.0 的格式与性能改动见 Loro 1.0。本文只讲合并语义,不铺历史。

先看三个“合并出来不对”的具体例子

例一:重叠加粗只粗一半。 原文 The quick fox jumped,A 给 The quick 加粗、B 给 quick fox jumped 加粗,期望整句粗。直接 merge Markdown 表示会丢 B 的粗,Yjs 在这条用例上也只得到 The **quick** fox jumped;Loro 收敛成整句加粗。

例二:格式与正文并发修改。 A 给 The quick 加粗、B 把 quick 改成 fast,期望加粗跟着新字走,得 The **fast** fox jumped,而不是只保住旧的 quick

例三:字符交错(interleaving)。 A 从左往右输入 Hello ,B 从右往左输入 Hi ,某些 List CRDT 合并后会出现 HHeil lo。Loro 的文本/列表走 Fugue 算法避免这类交错。

两条机制主线

1) 交错问题与 Fugue 的 leftOrigin / rightOrigin

多数 List CRDT 给每个字符分配带操作 ID 的唯一 ID,用相邻字符 ID 描述插入点,删除打 tombstone 占位。真正麻烦的是交错。

Fugue 先按 leftOrigin(插入时左侧原邻居的 ID)解前向交错,仍有歧义再用 rightOrigin(右侧原邻居)解后向交错,目标是 maximal non-interleaving——论文证明超过 2 个站点时有时无解,它只把交错概率压到可接受范围。选对底层 List CRDT 直接决定观感,纯 UUID/索引方案多人从不同方向补同一句必翻车。

2) 样式重叠与 style anchors

Loro 不用 Yjs 那种“控制字符表示整个 mark”的方式,而是把每个样式操作拆成一对开始锚 + 结束锚(style anchors)。每个锚是零长度元素:携带操作 ID、key-value、Lamport 时间戳和 expand 行为

字符的样式归属按锚对计算:找出所有覆盖该字符的锚对(同一次样式操作产生的起止锚配对),按 key 聚合;同一 key 多个不同 value 覆盖时取 Lamport 时间戳最大(相同则 peer ID 决出)。这解释了例一为什么能收敛成整句粗:两段不同 op ID 的 bold 范围在归属上不互斥,按 key 归并后取并集,覆盖整句。

删除样式等价于插入一对 value 为 null 的锚而不是改原锚;每个字符最终样式由包含它且时间戳最大的那对决定,让“先粗后不粗”也能自然收敛。

expand 行为:粗体与超链接想要的完全不同

粗体后面继续输入应继承粗;超链接后输入不该变成链接。Loro 用 expand 区分:

  • after / both:粗体类,新字应继承 → 插到结束锚左侧
  • none / before:链接/评论类,新字不该进入 → 插到结束锚右侧

零长度锚意味着某 Unicode 索引处叠 n 个锚则有 n+1 个候选插入点。选点规则:先排除“不应向后扩展”的样式避免误造新样式;向前扫找最后一个允许加粗扩的位置;再向后扫找第一个满足链接类样式不扩的位置。

doc.configTextStyle({ comment: { expand: "none" } });

没有显式配置 expand 时,默认行为可能和你编辑器预期相反;这是接入时最容易出错的地方。

Quill Delta 表达不了重叠样式,用 key:suffix

Delta 每个 segment 的属性里一个 key 只能有一份 value,表达不了“同一 key 同时挂 Alice 和 Bob 两条评论且范围不同”。把 value 改成数组,或拿 op ID 当 key,都要特判。Loro 用 : 前缀 + 自造唯一后缀拼出多个独立 key:

text.insert(0, "The fox jumped.");
text.mark({ start: 0, end: 7 }, "comment:alice", "Hi");
text.mark({ start: 4, end: 14 }, "comment:bob", "Jump");
// toDelta() → [{ insert:"The ", attributes:{"comment:alice":"Hi"} },
//              { insert:"fox", attributes:{"comment:alice":"Hi","comment:bob":"Jump"} },
//              { insert:" jumped", attributes:{"comment:bob":"Jump"} },
//              { insert:"." }]

CRDT 层不必为重叠样式开特例,代价是“同属一个逻辑样式”要靠调用方命名约定来保持。

与 Yjs / Automerge 选型时的取舍

  • 对齐用户意图:Yjs 控制字符方式在并发重叠合并时结果可能不合预期(见坏例);Loro 用配对锚 + Lamport 裁决把重叠收敛成整段粗。多人反复给同一段加格式时,这一点直接决定最终文本。
  • 数据格式与历史:Loro 存完整编辑 DAG,支持 checkout/fork/合并,类似 Git;Yjs 偏纯实时状态。
  • local-first 同步:基于 op 的同步只要双方收到 op 集合一致状态即一致,无需自处理幂等/乱序;两轮握手可传缺失 op。
  • 体积换速度:Loro 1.0 快照格式优先保证导入速度(官网实测百万级 op 文档从 16ms 降到 1ms),文档体积做到旧版约 2 倍,历史与状态分开编码、不复用。实时协同走增量 update,不传整快照,不受影响;冷存储/整包传输要掂量。该数据来自官方 v1.0 博文,未本机复测。
  • 应用层还得写不少:版本标签/分支存储、diff 展示、rebase/merge 交互,官方明说不在库内。

不适用边界

  • 单机状态管理、无多人并发,用 CRDT 徒增复杂度。
  • style 合并只覆盖“格式归属”意图;要真正做“冲突拒绝”需应用层另加规则,CRDT 自动合并反而是干扰。
  • 超 2 站点时交错理论存在不可解情形,Fugue 只能把概率最小化。

选库前拿自己真实的高频并发场景(同段加粗、多评论重叠、格式与改字并存)写 demo 跑一遍,实际合并结果比 star 数更能说明问题。

复制全文 生成海报 loro CRDT 协同编辑 富文本 local-first

推荐文章

程序员茄子在线接单