想象一种数据结构:无论节点之间怎么同步,以什么顺序同步,最终结果都一样。不需要协调,不需要解决冲突。这就是 CRDT 的魔法。

前情回顾

在上一篇中,我们看到了最终一致性的实现方式:

  • Quorum:控制读写副本数,平衡一致性和可用性
  • 向量时钟:追踪版本,检测冲突
  • 冲突解决:交给应用层处理

但「交给应用层」是个麻烦事:

购物车冲突:
  副本 A: ["苹果"]
  副本 B: ["香蕉"]

合并策略:取并集 → ["苹果", "香蕉"]

购物车还好,但如果是更复杂的数据呢?比如协作编辑一篇文档?

问题:有没有办法让数据结构自带合并能力,不需要人工干预?

协作编辑的噩梦

假设你和同事同时编辑一个文档:

原始文档:"Hello"

你(离线):        同事(离线):
  "Hello"            "Hello"
     ↓                  ↓
  "Hello World"      "Hello!"

联网后,合并?
  "Hello World!"  ?
  "Hello! World"  ?
  "HelloWorld!"   ?

这还是简单情况。如果是删除呢?

原始文档:"ABC"

你:删除 B → "AC"
同事:在 B 后插入 X → "ABXC"

合并后是 "AXC" 还是 "AXBC" 还是 "AC"?

传统方案:加锁,一次只能一个人编辑。但这样协作效率太低。

Google Docs 的方案:OT(Operational Transformation),非常复杂,需要中央服务器协调。

更优雅的方案:CRDT。

CRDT:数学保证不冲突

CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)是一类特殊的数据结构。

核心特性

无论以什么顺序合并,最终结果都相同。

这不是靠算法协调实现的,而是数学结构本身保证的

一个简单的例子:计数器

假设你要统计网站访问量,有 3 台服务器:

普通计数器的问题

初始状态:count = 0

服务器 A: count++ → count = 1
服务器 B: count++ → count = 1
服务器 C: count++ → count = 1

合并后应该是 3,但每台机器都是 1
怎么合并?取最大值?那只有 1
相加?那需要知道谁加过谁没加过

G-Counter(只增计数器)的解法

每台服务器维护自己的计数:
  A: {A: 0, B: 0, C: 0}
  B: {A: 0, B: 0, C: 0}
  C: {A: 0, B: 0, C: 0}

A 收到一次访问:
  A: {A: 1, B: 0, C: 0}

B 收到一次访问:
  B: {A: 0, B: 1, C: 0}

C 收到一次访问:
  C: {A: 0, B: 0, C: 1}

合并规则:每个分量取最大值

A 和 B 同步:
  合并后: {A: 1, B: 1, C: 0}

再和 C 同步:
  合并后: {A: 1, B: 1, C: 1}

总数 = 1 + 1 + 1 = 3 ✓

关键:每台服务器只改自己的分量,合并时取最大值。无论以什么顺序同步,最终结果都是一样的。

为什么这样能行?

数学上,G-Counter 的合并操作满足三个性质:

性质含义例子
交换律A 合并 B = B 合并 Amax({1,0}, {0,1}) = max({0,1}, {1,0})
结合律(A 合并 B) 合并 C = A 合并 (B 合并 C)同步顺序无所谓
幂等性A 合并 A = A同一个更新合并多次没影响

这三个性质保证了:无论同步顺序如何、无论重复同步多少次,最终结果都相同。

常见的 CRDT 类型

1. G-Counter:只增计数器

只能增加,不能减少。

用途:访问量、点赞数(只能点赞不能取消)

2. PN-Counter:可增可减计数器

用两个 G-Counter,一个记录增加,一个记录减少:

结构:
  P(正): {A: 5, B: 3}  总计 8
  N(负): {A: 2, B: 1}  总计 3

当前值 = P总计 - N总计 = 8 - 3 = 5

A 要减 1:
  N: {A: 3, B: 1}

当前值 = 8 - 4 = 4
用途:点赞/取消点赞(无严格约束的计数场景)
注意:不适合有业务约束的库存扣减(可能超卖)

3. LWW-Register:最后写入胜出

用时间戳决定胜负:

结构:{value: "数据", timestamp: 时间戳}

A: {value: "苹果", timestamp: 100}
B: {value: "香蕉", timestamp: 200}

合并:取 timestamp 更大的
结果: {value: "香蕉", timestamp: 200}

问题:依赖时间戳,时钟不同步会出问题。

用途:简单的单值覆盖场景

4. OR-Set:可添加可删除的集合

普通集合的问题:

A 添加 "苹果"
B 删除 "苹果"

同步后,苹果在还是不在?取决于同步顺序。

OR-Set(Observed-Remove Set)的解法:

每个元素带唯一标识:

A 添加苹果: {("苹果", uuid1)}
B 也添加苹果: {("苹果", uuid2)}

A 删除苹果: 只删除 A 见过的 uuid1
  A: {}
  B: {("苹果", uuid2)}

合并后: {("苹果", uuid2)}  苹果还在!

语义:「删除」只删除你见过的版本,不影响你没见过的添加。

用途:购物车、标签、好友列表

5. 文本 CRDT:协作编辑

这是最复杂的一类,用于实时协作编辑。

核心思想:给每个字符一个全局唯一的位置标识,使得插入操作永不冲突。

原始文档:"AC"
  位置:A(0.0), C(1.0)

用户 1 在 A 和 C 之间插入 B:
  位置:B(0.5)
  结果:A(0.0), B(0.5), C(1.0) → "ABC"

用户 2 在 A 和 C 之间插入 X:
  位置:X(0.3)
  结果:A(0.0), X(0.3), C(1.0) → "AXC"

合并后:
  A(0.0), X(0.3), B(0.5), C(1.0) → "AXBC"

⚠️ 上面的浮点数示例是极度简化的,实际有严重问题:

问题说明
精度耗尽连续在相邻位置插入,0.5→0.25→0.125…最终无法区分
并发冲突两人同时插入可能生成相同位置值
无限增长位置标识可能变得非常长

实际实现使用更复杂的位置标识方案:

  • Logoot/LSEQ:用整数数组 [1,2,3] 而非浮点数,可无限细分
  • RGA:用 (时间戳, 节点ID) 组合作为唯一标识
  • YATA:用链表结构,每个字符记录左右邻居的 ID

这些算法解决了上述问题,但实现复杂度也高得多。

用途:Google Docs 类应用、协作白板

CRDT 的两种风格

状态型(State-based / CvRDT)

同步整个状态,合并时用 merge 函数:

同步流程:
  节点 A 发送完整状态给 B
  B 用 merge(本地状态, A的状态) 合并

优点:简单,容错性好(丢包无所谓)缺点:状态大时传输成本高

操作型(Operation-based / CmRDT)

只同步操作,每个节点本地应用:

同步流程:
  节点 A 执行 add("苹果")
  A 把操作 add("苹果") 发给 B
  B 本地执行 add("苹果")

优点:传输数据量小缺点:需要可靠传输(操作不能丢、不能重复)

实际应用

Redis 的 CRDT

Redis Enterprise 支持 CRDT 类型,用于多活数据中心:

北京 Redis           上海 Redis
    │                    │
    │  CRDT Counter      │  CRDT Counter
    │  value = 100       │  value = 100
    │                    │
    │  +50               │  +30
    │  value = 150       │  value = 130
    │                    │
    │◄──── 同步 ─────────►│
    │                    │
    │  merge             │  merge
    │  value = 180       │  value = 180

两边可以独立写入,同步后自动合并,结果一致。

Figma 的协作编辑

Figma 是在线设计工具,支持多人实时协作。它的核心就是 CRDT:

用户 A (纽约)           用户 B (东京)
    │                      │
    │  移动矩形到 (10,20)    │
    │                      │
    │                      │  改变矩形颜色为红色
    │                      │
    │◄──── 同步 ───────────►│
    │                      │
    │  矩形在 (10,20),红色   │  矩形在 (10,20),红色

不同属性的修改自然合并,相同属性用 LWW(最后写入胜出)。

Riak 数据库

Riak 是一个 AP 优先的分布式数据库,内置多种 CRDT:

类型用途
Counter计数器
Set集合
Map嵌套结构
Flag布尔开关
Register单值存储

CRDT 的局限性

CRDT 不是银弹,它有明显的限制:

1. 不是所有操作都能 CRDT 化

银行转账:A 账户 -100,B 账户 +100

这两个操作必须原子执行
CRDT 无法保证跨多个值的原子性

2. 语义可能不符合直觉

OR-Set 的「删除」语义:

A 添加 "苹果"
B 添加 "苹果"(此时 A、B 都不知道对方)
A 删除 "苹果"

同步后:苹果还在(B 添加的那个)

这可能不是用户想要的

3. 存储开销

G-Counter 需要记录每个节点的计数:
  {节点1: 100, 节点2: 200, 节点3: 150, ...}

节点越多,存储越大

4. 垃圾回收难

OR-Set 需要记录所有「墓碑」(已删除的标识)
否则:A 删除 → 同步 → B 重新添加(因为 B 还有这个元素)

但墓碑越来越多,怎么清理?需要协调。

CRDT vs 其他方案

方案一致性可用性复杂度适用场景
强一致(Raft)网络分区时降低金融、配置
最终一致 + 冲突解决最终高(要写解决逻辑)通用
CRDT最终中(选对类型就行)计数、集合、协作
OT最终极高协作编辑

常见问题

Q:CRDT 能完全替代强一致性吗?

A:不能。

CRDT 只适合「合并语义明确」的场景:

  • ✅ 计数器:加法可合并
  • ✅ 集合:并集可合并
  • ✅ 文本:插入位置可排序
  • ❌ 银行转账:必须原子操作
  • ❌ 库存扣减:可能超卖

需要业务约束的场景,仍然需要强一致性。

Q:CRDT 和区块链有什么关系?

A:都是解决分布式一致性,但方式不同。

维度CRDT区块链
一致性最终一致最终一致
信任模型节点可信节点不可信
合并方式数学合并共识投票
性能
用途应用内协作跨组织共识

Q:学 CRDT 需要什么数学基础?

A:了解「半格」的概念就够了。

半格(Semilattice):有一个合并操作,满足交换律、结合律、幂等性。

G-Counter 的合并(逐分量取最大值)就是一个半格操作。

总结

CRDT 的核心思想

设计一种数据结构,使得合并操作满足交换律、结合律、幂等性。

这样无论以什么顺序同步,结果都一样。不需要协调,不需要冲突解决。

常用 CRDT 类型

类型用途特点
G-Counter只增计数简单可靠
PN-Counter可增可减两个 G-Counter 组合
LWW-Register单值存储依赖时间戳
OR-Set集合操作可添加可删除
文本 CRDT协作编辑复杂但强大

适用场景

  • 实时协作(文档、白板、设计工具)
  • 多活数据中心
  • 离线优先应用
  • 点赞、计数等简单场景

我们已经看过了分布式一致性的各种方案:

  • 2PC:强一致但有单点问题
  • Raft:多数派共识,强一致
  • 最终一致:AP 优先,牺牲一致性
  • CRDT:自动合并,无需协调

那现代的分布式数据库是怎么做的?它们如何在这些方案中取舍?

下一篇,我们来看 Spanner 和 TiDB——两个代表性的现代分布式数据库。


上一篇:最终一致性:不强求,但终会一致

下一篇:现代方案:从 Spanner 到 TiDB

本系列:

  1. 单机到分布式:一致性为何变难
  2. 2PC 与 CAP:理想的破灭
  3. Paxos 与 Raft:让多数人达成共识
  4. 最终一致性:不强求,但终会一致
  5. CRDT:无需协调的合并魔法(本篇)
  6. 现代方案:从 Spanner 到 TiDB
  7. 实战篇:方案选型与落地