想象一种数据结构:无论节点之间怎么同步,以什么顺序同步,最终结果都一样。不需要协调,不需要解决冲突。这就是 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 合并 A | max({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——两个代表性的现代分布式数据库。
上一篇:最终一致性:不强求,但终会一致
本系列: