想象你们班有四本《班级日志》,四个值日小组各拿一本,每天在上面记录班级大事。周五要把四本合订成一本“总日志”。可问题来了:甲组写了“周三篮球赛获奖”,乙组也写了同一件事,合订时重复了;丙组把某条记录删掉了,丁组却又把它加上去——合订时这条到底该不该存在?这就是分布式系统每天都在面对的“合订难题”:数据有多份副本,副本各自更新,最后必须拼成一份统一的结果,还不能吵架。 最朴素的解法是“合订时大家拉个群商量”——先锁死、再排序、后合并。但一旦网络断开或有人离线,“商量”就进行不下去。聪明的做法是换一种记账方式:让每本日志天生就不会产生分歧。比如规定“每组的记录只能写在自己专属的页码上”“每条记录都贴上唯一编号”“删除某条不是涂掉,而是登记它已失效”。只要四个小组都用这套记账规则,合订时就只剩机械性的“归拢”,不需要仲裁——这就是无冲突复制数据类型(CRDT)的思想。 这篇文章带你从“班级日志”出发,理解 CRDT 的数学根基(半格)、三种最常用类型(只增计数器 G-Counter、可删除集合 OR-Set、后写覆盖寄存器 LWW-Register),最后落地到协同编辑与离线优先应用的工程实战。
对 AI 从业者而言,掌握 CRDT 意味着能在大模型服务、RAG 知识库同步、多机缓存副本等分布式场景中,用数学确定性消灭“数据打架”,把最终一致性从玄学变成可证明的工程能力。
目录
可视化演示
演示CRDT副本通过数学结构自动合并的无冲突收敛,展示G-Counter、OR-Set与LWW-Register等核心类型的合并语义,并应用于协同编辑与离线优先场景。
本节配有交互动效(进入视口后自动加载,约 3KB)。
CRDT 无冲突收敛 副本合并:自动收敛 G-Counter:计数累计 OR-Set:集合运算 merge×N 保证最终一致
初识CRDT:合订日志的困局
四本《班级日志》各记各的,周五合订时最怕什么?最怕“同一件事记了两遍”、“删除的记录又回来了”。其实只要提前约定记账规则,合订就不会有争议。分布式系统里的多副本就是那四本日志:用户在任意副本上写数据(离线也能写),之后通过网络把更新传播给其他副本。关键在于——传播的顺序、时机、路径都不可控,如何保证最终结果一致?
先看背景:CAP 定理告诉我们,一致性(C)、可用性(A)、分区容忍性(P)三者不可兼得。网络分区是常态,所以多数互联网系统选择 AP 路线,放弃“任何时候强一致”,改用“最终一致性”:允许副本暂时不一致,但一旦停止写入并完成同步,所有副本必须收敛到同一状态。传统做法是用操作转换(OT)或中心化版本号仲裁,复杂度高;CRDT 则换了个思路——设计数据结构本身,让任意合并天然无冲突。
CRDT 的合并函数 $m$ 只要满足三条律,就能保证“无论怎么合、合多少次,都不吵架”:
回到班级日志:有了这三条律,值日组长再也不用在周五晚上开“仲裁会”。他们只需要机械地执行同一条合订工序,无论先合哪两本、中间同步多少次,最终四组手上拿到的总日志一定是同一份。CRDT 的价值就在这里——把“冲突要不要解决”变成“冲突根本不存在”。
- 幂等律 $m(a,a)=a$同一个副本把自己的状态拿来回合并两遍,结果不变。相当于合订时不小心把同一本日志抄了两遍,也不会多出内容。
- 交换律 $m(a,b)=m(b,a)$先合甲再合乙,与先合乙再合甲,结果完全一样。网络消息先到后到都不影响。
- 结合律 $m(m(a,b),c)=m(a,m(b,c))$三本日志可以两两先合,再与第三本合,中间怎么分组都行。这给了工程上极大的同步自由度。
数学基础:半格与合并
为什么三条律就能保证不乱?想想值日表的“完整程度”:乙表把甲表没写的都补上了,我们就说乙表“更完整”。合并两本日志,本质是生成“比两者都完整,但又最克制”的一本新日志。日语中这种比较关系,数学上叫做偏序关系,记作 $\sqsubseteq$:$a \sqsubseteq b$ 表示 $a$ 的信息量不超过 $b$。
如果任意两个状态 $a, b$ 都存在一个“刚好覆盖两者、又最小”的状态,称为最小上界 $a \sqcup b$(读作 a join b),那么整个状态空间就构成一个 join 半格。CRDT 的合并操作 $merge(a,b)$ 直接定义为 $a \sqcup b$——只需证明“状态空间是半格”,就自动获得了幂等、交换、结合三条律。而且合并后状态只会更完整,不会回退,这就是单调性:
来看 G-Counter 这类具体例子时,半格会变得非常直观。此刻你只需记住:CRDT 设计者的工作,就是为某种业务数据类型找出一个“比较谁更完整”的偏序,以及对应的“求最小上界”的合并方法。班级日志的“完整程度”可以是“谁覆盖了谁的记录”,也可以是“谁的时间戳更新”——不同的偏序,会导出不同的 CRDT 类型,这正是接下来三节的故事。
- 单调上界 $a \sqsubseteq a\sqcup b$合并后的状态一定包含 $a$ 的全部信息,不会丢失已有记录——日志只会越合越厚,不会越合越薄。
- 交换律 $a\sqcup b=b\sqcup a$最小上界的概念本身不分先后,天然满足交换。
- 幂等律 $a\sqcup a=a$自己与自己求上界还是自己,合订不会把内容翻倍。
- 结合律 $(a\sqcup b)\sqcup c=a\sqcup(b\sqcup c)$多状态合并可以任意分批,网络怎么乱序都无所谓。
G-Counter:只增计数器
班级要统计每个值日小组这学期做了几次卫生。最稳妥的记法是什么?每组固定写在自己专属的页码上:“一组 12 次、二组 9 次……”这样无论合订多少次,每组都只认自己那一页的数字,别人碰不到。最后总数 = 各页数字相加。这就是 G-Counter(Grow-only Counter,只增计数器)。
G-Counter 的每个副本维护一个 $n$ 维向量($n$ 为节点数),节点 $i$ 只能自增自己的分量 $c_i$,合并时逐分量取最大值。读总数时对全部分量求和。因为“自己只写自己的页码”,并发自增永不丢失;因为“合订时每页取更大的数字”,重复同步也不会把计数翻倍:
G-Counter 只解决“只增不减”的需求。如果业务需要减(比如库存、余额),就用 PN-Counter:把“增加量”和“减少量”分别做成两个 G-Counter,总数等于两者之差。这就像班级想把“文明加分”和“违纪扣分”分开记两页,互不干扰。合订时各页取 max,最终差值依然收敛——这就是“给 CRDT 做组合”的典型思路。
- 分量约束节点 $j$ 的分量 $c_j$ 只能由节点 $j$ 自己增加,别人只能读取。这保证了并发自增不会互相覆盖。
- 逐分量取 max合并时,每个分量的新值 = 两副本该分量的最大值,即“每页取最新的数字”。
- 总和求和读取计数器时把 $n$ 个分量全部相加,得到全局总次数。
OR-Set:带墓碑的集合
图书角有两本台账,管理员分别记录“《三体》在架”。突然一本标注“借出”,另一本没标。合订时到底在不在架?问题出在“同一本书”的身份上——如果每次入库的书都贴一张唯一编号贴纸,事情就清楚了:入库 = 贴编号;借出 = 在编号旁记一笔“已出”。不同管理员贴的编号不同,合订时只需把编号并在一起,冲突自然消失。
OR-Set(Observed-Removed Set,观察移除集合)正是这个思路。集合状态由两部分组成:添加集 $A_{add}$(所有曾添加的“元素-标签”对)和删除集 $A_{rem}$(已被删除的标签,即“墓碑”)。每次 add(e) 都生成一个全新标签 $t$;remove(e) 把元素当前所有标签送入删除集。查询时,只要存在一枚标签既在添加集中、又不在删除集中,元素就算“在集合中”:
墓碑机制牺牲了一点存储(删除记录不会消失),换来的是并发添加与并发删除永远可判定。回到图书角:管理员 A 贴了编号 001 的书,管理员 B 在另一本台账上把 001 标“借出”,合订时 001 在删除集中,书显示“借出”;如果 B 标的是自己贴的 002,那么 001 仍在架。谁贴的标谁负责删,永远不会吵起来。
- 唯一标签 $t$每次添加都生成新标签,即使两个副本同时添加同一个元素,也被视为两个独立事实。
- 墓碑删除 $A_{rem}$删除不是从集合中抹掉元素,而是把该元素所有标签记入删除集——就像在台账上记“编号 X 已还”,而不是涂掉书名。
- 可见条件 $\exists\,t$只要有一枚标签没进墓碑,元素就被看到。删掉一个副本的添加,不影响另一个副本的同名元素。
- 并集合并 $A'=A_1\cup A_2$两副本合订 = 两份台账的入库记录、退库记录全部归拢,无先后依赖。
LWW-Register:时间戳说了算
教室前的值日安排板经常被反复改写:小明写“周三:小红”,小红路过改成“周三:小绿”。多块副板同步时,以哪块为准?最直接的办法是看板上最后落笔的时间——谁最后写,谁说了算。这就是 LWW-Register(Last-Writer-Wins Register,后写覆盖寄存器)。
LWW-Register 把每次写入变成一个二元组 $(v, t)$:$v$ 是业务值,$t$ 是时间戳。本地写入时,若新时间戳比当前记录更大,就覆盖;合并时同样比较两副本的时间戳,取大者。这个类型本身极其简单,复杂度和风险全在时间戳的质量上:
LWW-Register 的诱惑在于“无脑简单”,陷阱在于时间戳。如果各节点用本地物理时钟,时钟漂移或 NTP 校正导致时间倒退时,看似更新的写入反而可能被旧写入覆盖。生产上的补救措施是使用 Lamport 时钟、向量时钟等逻辑时钟来捕捉因果顺序,或者在全局同步时钟(如 Google TrueTime)之上构建。简单说:落笔时间不可靠,LWW 就会变成“乱写覆盖认真写”的捣蛋鬼。
- 写入选 $(v,t)$每次写入不是孤立的“值”,而是“值+时间戳”的组合,落笔即带时间。
- 比较时间戳 $t_a>t_b$合订时比“谁更新”,而不是比“谁的逻辑更复杂”。
- 取大者覆盖时间戳大的状态胜出,旧笔迹被新笔迹覆盖。
- 平局兜底时间戳相等时按节点 ID 等确定性规则裁决,保证单调前进。
工程落地:协同编辑与离线优先
回想四本日志合订的故事——当我们把“固定页码”“唯一编号”“落笔时间”这些约定落实成代码,就得到了协同编辑软件的基石。你用的在线文档、设计稿协作、笔记软件,本质上都是“每个用户持有一本日志,离线时记自己的,联网后自动合订”。
工程实现上有两条主流路线。一是专业协同编辑库:Yjs(基于 YATA 算法,被 Notion、Linear 等使用)和 Automerge(基于多值寄存器)。它们把文档建模为一串“带唯一 ID 的字符序列”,每个字符类似 OR-Set 的元素,删除只是给它盖墓碑;光标、选区等元数据则用 LWW-Register 的思路。二是通用数据同步框架:MongoDB Realm、Triplit 等将 CRDT 用于离线优先应用的字段级同步。选型经验如下:
落地时的关键决策包括:计数用 G-Counter/PN-Counter;集合增删用 OR-Set;“最新值覆盖”用 LWW-Register 但必须配合逻辑时钟;富文本/文档直接挑选 Yjs 这类成熟的 CRDT 引擎,不要自己造轮子。还有一个常被忽视的坑:字符级 CRDT 在超长文档高并发输入时可能退化为 $O(n^2)$ 合并,需要用区块化(block)策略优化。CRDT 不是银弹,但它是“合订日志”这类问题的数学最优解——把冲突在设计期消解,而不是在运行时仲裁。
- 任意副本对 $S_i, S_j$网络里任何两个节点两两同步,不需要全局协调者。
- 合并 $= S_i\sqcup S_j$执行的就是半格上的最小上界运算,等价于把两份日志归拢。
- 收敛 $S_i=S_j$合并完成后双方状态一致,这就是最终一致性的数学保证。
AI 应用场景
场景一:大模型知识库的多副本同步
阶段:数据工程 / 向量库索引同步
解决的问题:RAG 应用通常把文档切片后写入向量数据库,并在多区域部署副本。传统主从同步在分区时要么阻塞写入(牺牲可用性),要么用 LWW 覆盖导致“旧切片覆盖新切片”。CRDT 让每个区域先落本地,再异步合并,文档的增删改以 OR-Set 语义收敛,不丢更新。
具体案例:某智能客服系统在美东、美西两地域各部署一套知识库,运营人员离线批量更新 FAQ。上线 CRDT 同步层后,两地合并时重复文档自动去重,已删除文档不会因旧副本回传而“复活”。
场景二:AI 数据标注的多人协同
阶段:数据工程 / 标注质量保障
解决的问题:多标注员并发修改同一批训练数据的标签,A 改“猫”B 改“狗”时传统方案要么锁标注(降低效率),要么静默覆盖(丢标签)。LWW-Register 保证“最新标注生效”,OR-Set 追踪标签的增删历史,审计可追溯。
具体案例:自动驾驶数据标注平台中,同一张点云图由多人在弱网环境下框选目标。接入 CRDT 后,各客户端离线框选、联网自动合并,重复框自动去重,冲突框按时间戳裁决,标注返工率下降约 30%。
场景三:离线优先的移动端 AI 应用
阶段:边缘计算 / 端云数据同步
解决的问题:AI 质检 App 在工厂地库、矿井等无信号环境采集数据,传统方案要么等待网络(阻塞生产),要么本地覆盖(丢数据)。CRDT 让移动端先写本地日志,联网后与云端自动合订,保证“每个端上采集的样本都不丢、不重、不乱”。
具体案例:某工业视觉质检系统,巡检员在天车、地坑等弱网区域拍照并离线标注缺陷。基于 CRDT 的表单同步层让 20 台终端离线记录 2000+ 条样本,网络恢复后在 3 秒内完成收敛合并。
Python 演示
class GCounter: def __init__(self, node_id, node_count): self.node_id = node_id self.values = [0] * node_count def inc(self, delta=1): self.values[self.node_id] += delta def merge(self, other): for i in range(len(self.values)): self.values[i] = max(self.values[i], other.values[i]) def total(self): return sum(self.values) class ORSet: def __init__(self): self.added = {} self.removed = set() def add(self, e, tag): self.added.setdefault(e, set()).add(tag) def remove(self, e): for tag in self.added.get(e, set()): self.removed.add(tag) def lookup(self, e): return any(t not in self.removed for t in self.added.get(e, set())) def merge(self, other): for e, tags in other.added.items(): self.added.setdefault(e, set()).update(tags) self.removed.update(other.removed) def demo(): a = GCounter(0, 2) b = GCounter(1, 2) a.inc(5); b.inc(3) a.merge(b); b.merge(a) print("G-Counter 收敛:", a.values, b.values, "总数", a.total()) x = ORSet() y = ORSet() x.add("apple", "x1") y.add("apple", "y1") y.remove("apple") x.merge(y); y.merge(x) print("OR-Set 合并后 apple 可见:", x.lookup("apple"), "| 墓碑集合:", sorted(x.removed)) if __name__ == "__main__": demo()
merge 自动收敛。self.values[self.node_id] += delta只改自己的分量。这是 G-Counter 并发不冲突的根源:我的页码只有我能写。self.values[i] = max(self.values[i], other.values[i])逐分量取 max 合并,重复同步也不会翻倍计数。self.added.setdefault(e, set()).add(tag)每次add都生成独立标签,不覆盖别人对同一元素的添加。for tag in self.added.get(e, set()): self.removed.add(tag)删除是把该元素当前所有标签送入墓碑,而不是物理删除数据。return any(t not in self.removed for t in ...)只要有一枚标签没进墓碑,元素就仍“在集合中”。a.merge(b); b.merge(a)双向同步后两边状态一致,这就是最终收敛的直观体现。
常见误区
误区很多同学以为用了 MongoDB/Redis 之类的数据库就自动获得 CRDT 能力,数据合并冲突由存储引擎解决,业务代码不用关心。
正解CRDT 是“数据结构+合并规则”的设计,不是数据库的一个开关。数据库或框架只是载体,你必须为每个业务数据类型选定或实现正确的 CRDT 类型,并确保合并操作满足幂等、交换、结合三律。就像班级日志本——“合订不吵架”靠的是记账规则,不是靠柜子(数据库)本身。
为何常见:因为 CRDT 常以库的形式(如 Yjs)被集成,使用者只看到“自动同步”,误以为底层机制与己无关。
误区常常有人认为只要每次写入带 Unix 毫秒时间戳,合并时取大者,就能保证“最新覆盖旧数据”。
正解物理时钟会漂移、会因 NTP 校正而倒退。节点 A 的时钟快 5 秒,它写了一条“过期逻辑”,却可能覆盖节点 B 真正的新数据。必须改用 Lamport 时钟、向量时钟等逻辑时钟,或依赖 TrueTime 这类全局同步时钟,才能保证时间戳反映真实的“先后”。
为何常见:直觉上“时间戳大 = 时间晚 = 更正确”,人们忽略了分布式系统没有全局时钟这一基本事实。
误区初学者认为在任意副本上 `remove(e)`,合并后所有副本都应该看不到 `e`。
正解OR-Set 的删除只针对“当前副本已看到的标签”。如果另一个副本在删除发起后又并发添加了新的标签,合并后该元素可能仍然可见。这并非 bug,而是“并发添加与删除都算数”的正确语义——想要删除“绝对权威”,需要配合 LWW 时间戳仲裁。
为何常见:把单机“删除就是抹掉”的直觉带入了多副本并发场景。
误区有人以为用 CRDT 后,任意时刻读任意副本都能拿到最新写入。
正解CRDT 只保证“停止写入并合并完成后”收敛一致(最终一致性),不保证某次读取是最新的(强一致)。从 A 副本读到的可能是几秒前的状态。读多写少、要求实时一致的场景,仍需配合版本号或租约机制。
为何常见:把“最终收敛”与“实时一致”两个强度级别的承诺混为一谈。
误区有人直接对 G-Counter 调用 `decrement`,期望合订后正确反映增减净值。
正解G-Counter 是 Grow-only(只增)的,减操作在数学上会破坏单调性,导致合并不收敛。要支持减法必须用 PN-Counter——用两个 G-Counter 分别记录“增加总量”和“减少总量”,最终值等于两者之差。凡是“允许回退”的业务,都要用组合式 CRDT 设计。
为何常见:真实业务中“减”很常见,而 G-Counter 的名字容易让人忽略其“只增”的限制。
面试题
Q1. 以下哪组性质是 CRDT 合并操作必须满足的?
- A.结合律、交换律、幂等律← 正确答案
- B.分配律、吸收律、幂等律
- C.交换律、分配律、单调律
- D.结合律、分配律、互补律
答案:A。CRDT 合并函数必须满足幂等(合并两次等于一次)、交换(谁先谁后无所谓)、结合(任意分组都等价)。这正是把“半格上的最小上界”作为合并规则带来的三个性质。分配律、吸收律、互补律是格论中与 join 合并无关的性质,不构成 CRDT 的收敛保证。
Q2. 根据 CAP 定理,多数互联网系统在分布式存储上选择哪条路线?
- A.CP,放弃可用性
- B.AP,选择最终一致性← 正确答案
- C.CA,放弃分区容忍性
- D.CAP 三者同时满足
答案:B。CAP 定理指出一致性、可用性、分区容忍性三者不可兼得。由于网络分区无法避免,系统必须保留分区容忍性(P),只能在 C 和 A 之间取舍。CRDT 属于 AP 路线的典型代表:保留可用性(各副本随时可写),用最终一致性替代强一致。
Q3. G-Counter 合并时各分量采取什么操作?
- A.各分量直接相加
- B.各分量取最大值← 正确答案
- C.各分量取最小值
- D.各分量与本地异或
答案:B。G-Counter 合并的规则是“逐分量取 max”:每个节点只自增自己的分量,合并时从两份向量中挑出每个分量的较大者。如果相加会导致重复同步时计数翻倍;取最小值则会让已同步的计数回退,破坏单调性。
Q4. 某三节点 G-Counter 中,副本 A 的向量为 [3,0,0],副本 B 的向量为 [0,2,4],两者合并后总数为?
- A.合并后总数为 9← 正确答案
- B.合并后总数为 5
- C.合并后总数为 7
- D.合并后总数为 6
答案:A。合并后各分量取 max,得到 [3,2,4],对分量求和得 3+2+4=9。选项 B 是只算了非零分量个数,选项 C/D 是把某些分量相加但漏了 4,均不符合“逐分量取 max 后求和”的规则。
Q5. OR-Set 中删除一个元素时,真正被记录进删除集的是什么?
- A.删除元素本身
- B.元素的所有标签← 正确答案
- C.什么都不用记录
- D.仅记录当前时间戳
答案:B。OR-Set 的删除是把元素当前所有标签(tag)加入删除集(墓碑)。直接删除元素会导致并发添加的标签复活;只记时间戳则无法区分“删除哪一次添加”。墓碑机制保证“删除只针对已观察到的添加”。
Q6. OR-Set 能保证合并收敛的原因包括?
- A.每次添加都有唯一标签← 正确答案
- B.删除以墓碑形式记录← 正确答案
- C.合并时添加集删除集取并集← 正确答案
- D.依赖全局时钟来排序操作
答案:ABC。唯一标签让“同名元素的多次添加”互不干扰;墓碑让删除不依赖物理删除;添加集与删除集各取并集的合并天然满足幂等、交换、结合。OR-Set 完全不依赖全局时钟,因此 D 错误。
Q7. LWW-Register 合并时两个状态时间戳相等,通常如何处理?
- A.两个值都保留即可
- B.按节点ID确定性裁决← 正确答案
- C.随机挑选其中一个
- D.抛出异常结束合并
答案:B。时间戳相等时要求合并结果确定且单调,必须用可比较的规则(如节点 ID、操作序号)打破平局。随机选择会导致同一对副本反复合并结果不一致;保留两个值则偏离了 LWW“单值覆盖”的定义。
Q8. 物理时钟不适合直接作为 LWW-Register 的时间戳,原因包括?
- A.时钟漂移导致误判先后← 正确答案
- B.消息乱序影响时间排序
- C.NTP校正可能使时钟倒退← 正确答案
- D.时钟精度制约合并效率
答案:AC。时钟漂移使不同节点对“谁更晚”判断不一致;NTP 校正确实可能让本地时钟回拨,导致新写入带上更小的时间戳。消息乱序是网络问题,与时钟本身无关;精度也不直接影响合并正确性,而 LWW 的正确性恰恰取决于时间的单调与可比。
Q9. 实现一个“点赞数只增不减”的统计功能,最适合使用哪种 CRDT?
- A.使用 OR-Set 集合类型
- B.使用 G-Counter 计数器← 正确答案
- C.使用 LWW-Register 寄存器
- D.使用普通 JSON 文件记录
答案:B。点赞数只增不减正是 G-Counter 的典型场景:各节点自增自己的分量,合并时逐分量取 max,保证并发点赞不丢、重复同步不翻倍。OR-Set 适合集合增删,LWW 适合“最新值覆盖”,普通 JSON 无并发合并能力。
Q10. 与 OT(操作转换)相比,CRDT 在协同编辑场景的优势包括?
- A.无需中心服务器排序← 正确答案
- B.天然支持离线优先← 正确答案
- C.数学保证合并收敛← 正确答案
- D.传输带宽一定更小
答案:ABC。CRDT 合并满足交换律,不需要中心服务器统一操作顺序;副本本地可写,离线后同步即可收敛;收敛性由半格数学结构保证。但 CRDT 常携带标签、墓碑等元数据,带宽不必然更小,因此 D 错误。