CRDT无冲突复制数据类型深度实战:从算法原理到生产级Local-First应用架构

当你的应用在离线状态下依然能够正常工作,在网络恢复后数据能够自动合并且不产生冲突——这不是幻想,这是 CRDT(Conflict-free Replicated Data Types)带给现代分布式系统的能力。

一、引言:为什么我们需要 CRDT?

想象一下这个场景:你和团队成员同时编辑同一个文档段落,你们各自离线修改了内容。重新联网后,传统的解决方案会告诉你"该文件已被其他人修改,请手动合并"。CRDT 则优雅得多——它会自动、确定性地将你们的修改合并成一个一致的状态,无需人工干预,更无需中心服务器裁决。

这就是 Local-First Software(本地优先软件)运动的核心技术基石。Linear、Notion、Figma、Obsidian Sync、Apple Notes——这些产品的实时协作体验背后,CRDT 都扮演着关键角色。

CRDT 的核心承诺很简单:在任何网络条件下,多个副本最终会收敛到相同的状态。无论操作以什么顺序到达,无论中间经历多少次离线,结果都是一致的。

二、理论基础:让数学保证正确性

2.1 Join-Semilattice(并半格)

所有 State-based CRDT(又称 CvRDT)都构建在一个数学结构之上——Join-Semilattice:

  • 偏序关系 (≤):定义状态之间的"小于等于"关系。例如对于计数器,a ≤ b 表示 a 的每个分量的值都不大于 b。
  • Join 操作 (∨):两个状态的"最小上界",满足三个性质:
  • 幂等性:a ∨ a = a
  • 交换律:a ∨ b = b ∨ a
  • 结合律:(a ∨ b) ∨ c = a ∨ b ∨ c

这三个性质意味着:任意顺序、任意次数的合并,最终结果都相同。这就是 CRDT 能够离线工作的数学根基。

2.2 单调性增长

State-based CRDT 的状态只能单调递增(在偏序关系下)。每个副本的状态是所有已观察到操作的累积结果。当两个副本交换状态时,它们各自执行 join 操作,结果就是两个状态的并集。

这种设计带来的直接好处是: - 网络分区期间,副本可以继续演进(单调增长) - 网络恢复后,只需交换最新状态,join 即可收敛 - 无需保证消息顺序,无需向量时钟的全序关系

2.3 State-based vs Operation-based

维度 State-based (CvRDT) Operation-based (CmRDT)
传输内容 完整状态 + 元数据 操作/增量更新
网络开销 较大(状态可能很大) 较小
容错性 强(只需最终一次通信) 弱(操作不能丢失)
实现复杂度 中等 较高(需保证因果序)
代表实现 Yrs (Rust/Yjs 的 Rust 重写) Automerge (旧版)

实际上,现代高性能实现(如 Yjs、Yrs)通常采用混合策略:用 Operation-based 方式在本地生成增量更新,在远程合并时基于 state 做高效 diff,兼顾两者的优势。

三、经典 CRDT 类型详解

3.1 G-Counter(只增计数器)

最简单的 CRDT:一个节点 ID 到计数的映射,每个节点只能递增自己的槽位。

struct GCounter {
    counts: HashMap<NodeId, u64>,
}

impl GCounter {
    fn increment(&mut self, node: NodeId) {
        *self.counts.entry(node).or_insert(0) += 1;
    }

    fn value(&self) -> u64 {
        self.counts.values().sum()
    }

    fn merge(&mut self, other: &GCounter) {
        for (node, count) in &other.counts {
            let entry = self.counts.entry(*node).or_insert(0);
            *entry = (*entry).max(*count); // join: 取最大值
        }
    }
}

3.2 PN-Counter(正负计数器)

将 G-Counter 拆成两个:一个记录增量(P),一个记录减量(N)。增加值 = P.value() - N.value(),但底层状态仍然是单调递增的。

struct PNCounter {
    positive: GCounter,
    negative: GCounter,
}

impl PNCounter {
    fn increment(&mut self, node: NodeId) {
        self.positive.increment(node);
    }

    fn decrement(&mut self, node: NodeId) {
        self.negative.increment(node);
    }

    fn value(&self) -> i64 {
        self.positive.value() as i64 - self.negative.value() as i64
    }

    fn merge(&mut self, other: &PNCounter) {
        self.positive.merge(&other.positive);
        self.negative.merge(&other.negative);
    }
}

3.3 OR-Set(观察移除集合)

这是 CRDT 设计中最精妙的一种。核心思想:每次插入为元素生成唯一 tag,删除时不直接移除元素,而是将当前观察到的所有 tag 加入"墓碑集"。

struct ORSet<T> {
    entries: HashMap<T, HashSet<Tag>>,  // 元素 → 当前存活的 tag
    tombstones: HashMap<T, HashSet<Tag>>, // 元素 → 已被删除的 tag
}

impl<T: Eq + Hash + Clone> ORSet<T> {
    fn add(&mut self, element: T, tag: Tag) {
        self.entries.entry(element)
            .or_insert_with(HashSet::new)
            .insert(tag);
    }

    fn remove(&mut self, element: &T) {
        if let Some(tags) = self.entries.remove(element) {
            self.tombstones.entry(element.clone())
                .or_insert_with(HashSet::new)
                .extend(tags);
        }
    }

    fn contains(&self, element: &T) -> bool {
        // 存在活着的 tag 且不在墓碑中才算存在
        match self.entries.get(element) {
            Some(tags) => tags.iter().any(|t| {
                !self.tombstones.get(element)
                    .map(|ts| ts.contains(t))
                    .unwrap_or(false)
            }),
            None => false,
        }
    }

    fn merge(&mut self, other: &ORSet<T>) {
        // 合并 entries 和 tombstones,分别取并集
        for (elem, tags) in &other.entries {
            self.entries.entry(elem.clone())
                .or_insert_with(HashSet::new)
                .extend(tags);
        }
        for (elem, tags) in &other.tombstones {
            self.tombstones.entry(elem.clone())
                .or_insert_with(HashSet::new)
                .extend(tags);
        }
    }
}

OR-Set 的关键洞察:移除操作只移除当时观察到的 tag,而不是"当前所有 tag"。这就是为什么它不会与并发插入冲突——新的插入会生成新的 tag,不受旧的移除影响。

3.4 RGA(可增长复制数组)

文本编辑场景需要保持有序性。RGA 是最经典的序列 CRDT 算法,它将文档表示为带因果标识的链表:

每个字符/元素都有: - 唯一的 ID(作者 + 逻辑时钟) - 一个"左邻居"引用(在哪个字符之后) - 内容值

并发插入到同一位置时,按作者 ID 全序排列,保证确定性。删除只是标记 tombstone(用于正确处理并发插入到已删除位置的情况)。

Yjs 团队在 RGA 基础上做了大量优化,发明了 YATA(Yet Another Transformation Approach),通过引入更精巧的冲突解决策略将时间复杂度从 O(n²) 优化到了接近 O(n)。

四、生产级实战:基于 Yjs 构建实时协作应用

4.1 Yjs 核心架构

Yjs 是目前性能最优、生态最完善的 CRDT 库(JavaScript),底层 Yrs 是它的 Rust 实现。

┌────────────────────────────────────────────┐
│                  Client A                   │
│  ┌──────────┐  ┌──────────┐  �──────────┐  │
│  │ Y.Doc    │←→│ Awareness│←→│ Provider  │  │
│  │ (CRDT)   │  │ (Cursors)│  │ (Network) │  │
│  └──────────┘  └──────────┘  └──────────┘  │
└────────────────────────────────────────────┘
                    │
              ┌─────┴─────┐
              │  WebSocket │
              │   Server   │
              └─────┬─────┘
                    │
┌────────────────────────────────────────────┐
│                  Client B                   │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  │
│  │ Y.Doc    │←→│ Awareness│←→│ Provider  │  │
│  │ (CRDT)   │  │ (Cursors)│  │ (Network) │  │
│  └──────────┘  └──────────┘  └──────────┘  │
└────────────────────────────────────────────┘

4.2 实战:构建一个协作待办清单

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'

// 1. 创建文档(本地优先)
const ydoc = new Y.Doc()

// 2. 本地持久化(IndexedDB)——即使关闭浏览器,数据也在
const indexeddbProvider = new IndexeddbPersistence('my-todo-app', ydoc)

// 3. 网络同步
const wsProvider = new WebsocketProvider(
  'wss://my-server.com',
  'todo-room-1',
  ydoc
)

// 4. 共享数据类型
const yTodos = ydoc.getMap('todos') // Map<id, Todo>
const yOrder = ydoc.getArray('order') // Array<id> 保持顺序

// 5. 添加 Todo(离线也能操作!)
function addTodo(text: string) {
  const id = crypto.randomUUID()
  const todo = {
    id,
    text,
    completed: false,
    createdAt: Date.now()
  }
  yTodos.set(id, todo)
  yOrder.push([id])
}

// 6. 同步到 UI
yTodos.observe(() => {
  renderTodos(Array.from(yTodos.values()))
})

yOrder.observe(() => {
  const orderedIds = yOrder.toArray()
  const todos = orderedIds.map(id => yTodos.get(id))
  renderTodoList(todos.filter(Boolean))
})

// 7. 远程协作:Awareness 状态(光标位置、用户信息)
wsProvider.awareness.setLocalState({
  user: { name: 'Alice', color: '#ff6b6b' },
  cursor: null // 不在此 demo 中实现
})

4.3 性能优化:增量更新与快照

Yjs 的核心优势之一是增量更新(Update)设计。文档的每次变更都会生成一个二进制增量更新,而不是传输完整状态:

// 监听增量更新(用于自定义网络传输)
ydoc.on('update', (update: Uint8Array, origin: any) => {
  // update 是二进制编码的增量,通常只有几百字节
  websocket.send(update)
})

// 应用远程更新
websocket.onmessage = (event) => {
  Y.applyUpdate(ydoc, new Uint8Array(event.data), 'remote')
}

// 生成快照(用于首次连接或状态恢复)
const snapshot = Y.encodeStateAsUpdate(ydoc) // 完整状态的二进制编码
// 存储到数据库或本地文件...

// 从快照恢复
Y.applyUpdate(ydoc, storedSnapshot)

4.4 服务端部署(y-websocket 自定义服务器)

import { setupWSConnection, setPersistence } from 'y-websocket/bin/utils'
import * as Y from 'yjs'
import { LeveldbPersistence } from 'y-leveldb'

// 配置持久化
const ldb = new LeveldbPersistence('./yjs-storage')
setPersistence({
  bindState: async (docName, ydoc) => {
    const persistedYdoc = await ldb.getYDoc(docName)
    const newUpdates = Y.encodeStateAsUpdate(ydoc)
    ldb.storeUpdate(docName, newUpdates)
    Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc))
    ydoc.on('update', (update) => {
      ldb.storeUpdate(docName, update)
    })
  },
  writeState: async (docName, ydoc) => { /* 已在 bindState 中处理 */ }
})

// 集成到 Express
server.on('upgrade', (request, socket, head) => {
  const url = new URL(request.url!, `http://${request.headers.host}`)
  if (url.pathname.startsWith('/yjs/')) {
    wss.handleUpgrade(request, socket, head, (ws) => {
      wss.emit('connection', ws, request)
    })
  }
})

wss.on('connection', setupWSConnection)

五、CRDT vs OT:一场没有失败者的辩论

提到实时协作,绕不开 OT(Operational Transformation)。Google Docs 的协同编辑就是基于 OT(旧的 ShareJS 实现)或新式的衍生方案。

维度 CRDT OT
服务端依赖 可选(纯 P2P 可行) 必需(需中心服务器转换操作)
离线编辑 天然支持 极其困难
实现复杂度 数据结构复杂,但无需服务端逻辑 服务端转换逻辑复杂(CP1/CP2 条件)
网络要求 最终一致性,容忍任意延迟 需因果一致性保证
典型实现 Yjs, Automerge ShareJS, Google Docs
大文档性能 O(N) 到 O(log N) O(N),但常数更小
确定性保证 数学证明(Lattice 理论) 条件证明(CP1/CP2)

实战建议: - 新项目首选 CRDT(尤其 Yjs),生态成熟、离线能力强 - 超大文档(数万段落)文本编辑:考虑 OT + CRDT 混合(如 Google Docs 新架构) - 对实时性要求极高、且能保证低延迟的场景:OT 在纯文本编辑延迟上仍有优势

六、Local-First 架构全貌

CRDT 只是 Local-First 大厦的一块砖。一个完整的 Local-First 应用还需要:

6.1 数据存储层

┌─────────────────────────────────────────┐
│              Application                │
├─────────────────────────────────────────┤
│  CRDT Sync Layer (Yjs / Automerge)      │
├─────────────────────────────────────────┤
│  Local Persistence                      │
│  ├── IndexedDB (Browser)                │
│  ├── SQLite / DuckDB (Electron/Tauri)   │
│  └── File System API (PWA)              │
├─────────────────────────────────────────┤
│  Sync Transport                         │
│  ├── WebSocket (实时协作)                │
│  ├── HTTP + Delta Sync (低频同步)        │
│  └── P2P (WebRTC / BLE / LAN)          │
└─────────────────────────────────────────┘

6.2 离线优先的数据流

[用户操作] → [本地状态更新] → [立即反映到 UI]
     ↓
[生成 CRDT 增量] → [写入 IndexedDB]
     ↓
[队列待同步] ──(网络可用)──→ [发送到其他对等节点]

关键原则:本地操作绝不等待网络。这带来了两个直接收益: 1. 用户感知延迟为 0(操作即时生效) 2. 网络故障时用户完全无感(体验不间断)

6.3 冲突解决策略

尽管 CRDT 在数据结构层面消除了"合并冲突",业务层面的冲突仍需处理:

// 示例:业务层冲突解决——两个用户同时修改同一个字段
yMap.observe((event) => {
  event.changes.keys.forEach((change, key) => {
    if (change.action === 'update') {
      const currentValue = yMap.get(key)
      // 业务逻辑:以时间戳较新的操作为准
      // (CRDT 已经保证了结构一致,我们只需处理语义)
      detectSemanticConflict(key, currentValue)
    }
  })
})

三种常见策略:

  1. Last-Writer-Wins(LWW):以时间戳最新者为准。简单但可能丢数据。
  2. Multi-Value Register:保留所有并发值,呈现给用户选择。适合关键操作。
  3. 自定义合并函数:针对特定业务逻辑设计合并策略(如购物车合并 = 数量相加)。

七、性能深度优化

7.1 文档结构分区

不要把所有数据放在一个大 Y.Doc 里。按功能分区:

// 每个 ydoc 是一个独立的 CRDT 文档
const docA = new Y.Doc() // 文档正文
const docB = new Y.Doc() // 评论系统
const docC = new Y.Doc() // 用户光标位置(高频更新,独立同步)

// 好处:
// 1. 评论的更新不会干扰正文的性能
// 2. 光标位置的高频更新(每秒多次)独立传输
// 3. 每个文档可以独立持久化,按需加载

7.2 二进制编码优化

Yjs 的更新使用自定义的二进制编码(衍生自 lib0 encoding),比 JSON 紧凑 5-10 倍。对于一个典型的 1000 字文档编辑操作:

  • JSON 编码:约 2-5 KB
  • Yjs 增量更新:约 200-800 字节

自定义编码的关键技术: - VarInt 变长整数编码 - 重复字符串字典压缩 - 操作类型批量合并

7.3 大文件处理:Lazy Loading

对于超大文档(如 10 万字的书籍编辑):

// Yjs 支持 Document Structuring——按需加载段落
const yText = ydoc.getText('content')
// 超大文档可以拆分为多个 Y.Array / Y.Map 单元
// 仅当用户滚动到对应位置时才加载对应段落的 CRDT 状态

7.4 GC 与历史管理

CRDT 的 tombstone 会无限增长,长期运行需要 GC 策略:

// Yrs (Rust API) 支持自动 GC
let doc = Doc::new()
doc.transact_mut().apply_update(update)
// GC 删除不再可达的 tombstone
// 注意:GC 在 State-based CRDT 中更常见
// Operation-based CRDT 通常保留完整历史(可回滚)

实战权衡:保留历史意味着支持"时光机"功能(撤销到任意时间点),这对某些应用是必需的。如果不需要,定期 snapshot 并重建文档可有效控制存储膨胀。

八、Yrs:Rust 重写带来的性能革命

Yjs 的 Rust 实现(Yrs + 各语言绑定)在 2024-2025 年带来了 10-50x 的性能提升:

操作 Yjs (JS) Yrs (Rust/WASM)
10 万字符插入 ~800ms ~30ms
1000 次交替插入 ~200ms ~8ms
大文档首次合并 ~5s ~200ms

Yrs 还通过 WASM 提供了浏览器端使用 Rust 性能的能力,以及通过 FFI 提供了 Swift/Kotlin 原生绑定,使得 iOS/Android 应用也能享受 CRDT 的优势。

// 用 Rust/Yrs 创建文档
use yrs::{Doc, ReadTxn, StateVector, Transact, Update};
use yrs::updates::decoder::Decode;

let doc = Doc::new();
let text = doc.get_or_insert_text("content");

// 在事务中操作
let mut txn = doc.transact_mut();
text.push(&mut txn, "Hello, ");
text.push(&mut txn, "CRDT!");
drop(txn);

// 生成增量更新
let update = doc.transact().encode_diff_v1(&StateVector::default());
// 发送给其他对等节点...

九、行业应用案例深度剖析

9.1 Linear:工程管理的协作标杆

Linear 的键盘驱动交互体验部分得益于其自研的同步引擎(基于 CRDT 思想,非 Yjs)。核心设计: - 每个 Issue 是一个独立的 CRDT 文档 - 视图过滤和排序是本地计算(基于 CRDT 状态) - GraphQL Mutation 在本地乐观更新,后台异步同步

启示:CRDT 不仅用于文本编辑,任何需要"多方同时修改的共享数据结构"都可以用 CRDT。

9.2 Figma:设计工具的实时协作

Figma 使用的是自研的 CRDT 变体(Figma 的引擎从 OT 迁移到 CRDT),处理对象图而非纯文本。关键技术: - 场景图(Scene Graph)的 CRDT 表示 - 嵌套组件的引用一致性 - 属性级别的原子操作

9.3 Notion:块级 CRDT

Notion 的每个 Block(段落、图片、数据库行)都是独立 CRDT 操作单元。数据库特性带来了额外的挑战: - 排序冲突(两个用户同时拖拽行) - 过滤/视图定义的并发修改 - 跨 Block 引用的一致性

十、总结与实践建议

CRDT 已从学术概念成长为生产级技术。以下是我的实践总结:

  1. 能不用 CRDT 就不用——如果场景可以用简单的 LWW 或中心锁解决,不要引入 CRDT。复杂度是有成本的。
  2. 需要离线编辑/多端同步时,CRDT 是最优选——没有其他技术能如此优雅地处理分区容忍。
  3. 首选 Yjs/Yrs 生态——社区活跃、性能顶尖、文档完善(比 Automerge 快 10 倍以上)。
  4. 按功能分区文档——不要把所有数据塞进一个 Y.Doc。
  5. 注意 tombstone 膨胀——长期运行的协作应用需要 snapshot + GC 策略。
  6. 业务冲突仍需人工策略——CRDT 解决结构冲突,语义冲突需要 custom merge logic。
  7. Awareness 是独立系统——光标/在线状态等高频低价值数据应该走单独通道。

最后,CRDT 的真正价值不仅是"解决技术问题",更是重塑产品哲学:让应用信任用户设备,把用户数据从云端拿回用户手中,在保持实时协作体验的同时保障数据主权。

这就是 Local-Final 运动的终极愿景——数据属于用户,协作发生于对等网络,云端只是可选的加速器而非必要的基础设施。


参考资料: - Shapiro et al., "Conflict-free Replicated Data Types", INRIA 2011 - Yjs 官方文档: https://docs.yjs.dev/ - Local-First Web Development: https://localfirstweb.dev/ - Kevin Jahns, "Yjs - A CRDT framework for shared editing", 2021

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部