周末,老王、老李和老张各揣一份手抄的购物清单。老王加上了“鸡蛋”,老李补了“牛奶”,老张写了“酱油”。傍晚三人对清单,最怕出现这样的情况:老王刚写下的鸡蛋,被老李当成旧字迹一笔划掉。谁都没做错什么,却有人白跑一趟。于是他们立下约定:谁都不能删掉别人新写的一行,只能往清单上补新字。这样一来,不管大家按什么顺序交换清单,最终每个人手上的清单都一模一样。这张“只能加、不能删”的购物清单,就是 CRDT 最早的雏形——它不会因为合并顺序不同而吵架。 在分布式系统中,“购物清单”就是一份数据,抄本叫做“副本”,三兄弟就是不同的服务器节点。网络延迟、机器宕机、消息重发都是家常便饭,多台机器同时修改同一份数据,很容易出现“你写你的、我写我的、最后对不上”的冲突。传统办法是加锁、排队,但代价是系统变慢、甚至短暂不可用。CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)给出另一条路:设计一种天生不怕合并顺序的数据结构,让所有副本在没有任何协调者的情况下,最终收敛到同一个状态。 本篇文章会用购物清单这条故事线,带你从分布式一致性的痛苦出发,走过最终一致性、CRDT 的代数法则、计数器家族、集合与映射、合并传播协议,最后落到工程选型。你会亲眼看到,G-Counter、PN-Counter、OR-Set、LWW-Map 这些听上去吓人的名字,其实都只是“购物清单”的某一种变体。

岗位关联

学完 CRDT,你会理解分布式系统如何在“网络不可靠、又想高可用”的现实里安全合并副本,这是设计在线协同、分布式计数器的核心知识,也是分布式系统面试中极具区分度的加分点。

目录
  1. 可视化演示
  2. 分布式协作困境
  3. 最终一致性
  4. CRDT 核心思想
  5. 计数器集群
  6. 集合与映射
  7. 合并传播协议
  8. 工程实施取舍
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

分布式一致性中的CRDT(Conflict-free Replicated Data Type)。你将学习CR... 切换模式查看不同维度。

本节配有交互动效(进入视口后自动加载,约 3KB)。

CRDT的核:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

分布式协作困境

老王、老李和老张三兄弟各拿一份手工抄写的购物清单。老王写上“鸡蛋”,老李补上“牛奶”,老张写下“酱油”。傍晚三人在客厅对清单,最怕看到这样的局面:老李觉得“鸡蛋”是自己上次划掉的旧项,于是把它抹掉。谁也没错,但老王想买的鸡蛋就这样没了。这个场景和分布式系统遇到的困境一模一样:同一份数据被复制到很多台机器上,每台机器都会收到用户的修改,可机器之间的网络可能延迟、断开,甚至消息反复重发。

在分布式系统里,那份“购物清单”就是数据副本。多台机器同时接受用户写入,叫做“多副本并发写”;机器之间无法实时互通,叫“网络分区”;两边对同一位置写出不同值,就产生了“写写冲突”。最粗暴的解决办法是“最后写入覆盖”:谁的时间戳晚,就以谁为准。可这等于让老李用橡皮擦掉老王的字,另一部分用户的意图被悄悄丢弃。如果系统要求所有副本先协商一致再回复用户,分区期间服务就会不可用——用户体验从“偶尔丢字”变成“直接罢工”。

所以工程师开始思考:是否存在一种数据结构,让并发修改不依赖协商也能安全合并?合并时既不丢字,也不重复计数?这种数据结构要天生具备“宽容”:无论修改按什么顺序到达,最终所有副本都收敛到同一份清单。这就是 CRDT 的出发点。它不解决网络分区本身,而是解决“分区之后,副本重新连通时如何优雅地拼数据”。

三兄弟最后约定:谁都不许擦掉别人新写的那一行,只允许往清单上补充新字。于是这张清单变成了一块无限大的黑板,所有补充最终都会被看到。CRDT 正是用类似的约定,让冲突从源头消失。

最终一致性

三兄弟白天各自采购,晚上合并清单。他们并不要求“下午三点整所有清单必须一模一样”,只要求“晚上大家坐下来交换之后,最终一样”。这种目标叫做最终一致性。在分布式系统中,CAP 定理告诉我们:网络发生分区时,系统必须在“线性一致”和“可用”之间二选一。线性一致要求读操作必须返回最新已确认的写入,这需要等待所有节点回应;一旦网络分区,等待就意味着超时,系统就不可用。

大部分互联网产品选择优先保证可用性(AP),接受“暂时不一致,最终收敛”。CRDT 正是 AP 路线上的利器:每个节点独立处理本地写请求,不同步时也不阻塞;当网络恢复,节点之间用合并操作把状态拼起来,所有副本向同一个终态收敛。CRDT 并不承诺“多快收敛”,它只保证“如果消息足够多次重试、传播最终完成,那么收敛一定会发生”。

三兄弟不需要每时每刻知道对方买了什么,只要最终对清单达成共识,今晚的菜就能备齐。最终一致性不是“妥协”,而是分布式系统在可用性约束下的明智契约。

lim⁡t→∞Vi(t)=Vj(t),∀i,j∈Nodes\lim_{t \to \infty} V_i(t) = V_j(t), \quad \forall i, j \in \text{Nodes}
这个公式的意思是:当时间足够长、所有合并消息都送达后,任意两个副本 i 和 j 的状态会完全相等。这是 CRDT 能提供的核心保证。
  1. 副本状态 \(V_i(t)\)第 \(i\) 个副本在时刻 \(t\) 看到的数据。对应老王手上那份清单此时此刻的完整内容。
    Vi(t)V_i(t)
  2. 极限符号表示“时间趋于无穷之后”。网络同步不是瞬间完成,所以 CRDT 只保证最终而不是立即。
    lim⁡t→∞\lim_{t \to \infty}
  3. 等于副本 j 的状态任意两个节点只要持续传播合并,状态间距会缩小到零,最终一模一样。
    =Vj(t)= V_j(t)
  4. 任意节点对不是某一对节点收敛,而是所有节点两两之间都收敛。
    ∀i,j∈Nodes\forall i, j \in \text{Nodes}

CRDT 核心思想

三兄弟的约定之所以有效,是因为“补充新字”这个操作遵循三条朴素的规则:先补后补都一样、几个人分头补最后合起来也一样、同一句话誊写两遍不会让东西变成两份。这三条规则在数学上分别叫交换律、结合律、幂等律,它们构成了 CRDT 的灵魂。一个合并操作如果同时满足这三条性质,无论消息以什么顺序、什么分组、重复多少次到达,最终结果都一样。

这三条性质让 CRDT 拥有了一个宝贵能力:不需要中央协调器。每个节点只要把自己看到的最新状态丢给邻居,邻居合并后再传给下一个,整个网络就能收敛到一致。回到购物清单的比喻:三兄弟不需要请一个裁判坐在中间指挥“谁先写谁后写”,只要人人遵守“只补不删”的规则,黑板上迟早会有所有条目。

a⊕b=b⊕a,(a⊕b)⊕c=a⊕(b⊕c),a⊕a=aa \oplus b = b \oplus a, \quad (a \oplus b) \oplus c = a \oplus (b \oplus c), \quad a \oplus a = a
这里的 \(\oplus\) 代表“合并两份状态”。交换律让谁先谁后无所谓,结合律让任意分组合并都可以,幂等律让同一份状态合并两遍不会产生双倍效果。
  1. 合并操作 \(a \oplus b\)把两个副本的状态合并成一个新状态。对应把老王和老李的清单拼成一张总清单。
    a⊕ba \oplus b
  2. 交换律先拿老王的清单去合老李的,还是先拿老李的合老王的,得到的结果完全一样。
    a⊕b=b⊕aa \oplus b = b \oplus a
  3. 结合律三个人分头合并时,先合并哪两组都不影响最终结果,所以合并可以并行、可以乱序。
    (a⊕b)⊕c=a⊕(b⊕c)(a \oplus b) \oplus c = a \oplus (b \oplus c)
  4. 幂等律同一份清单誊抄两遍,物品数量不会翻倍。这保证了消息重发不会导致状态错乱。
    a⊕a=aa \oplus a = a

计数器集群

购物清单解决“记了什么”,计数器要解决“记了多少”。假设三兄弟合用一个家庭账本,记录买菜的笔数。老王买了三样,老李买了五样,两人在各自的账本上写下一个总数。合并时如果直接相加,3 加 5 等于 8,看起来没问题;但如果老王又买了两样,账本从 3 变成 5,老李的账本也从 5 变成 8,两份账本都带着历史总额,再合并就会把老李买过的 5 样重复计算。问题出在“总数”不是单调的局部记录,它包含了别人的贡献。

G-Counter(增长计数器)的解法是:每个节点只维护属于自己的那一个“小隔间”计数,别人不能往你的隔间里写。合并时,对每个隔间取最大值,而不是对总数求和。这样 A 的隔间永远是 A 自己加过的次数,B 的隔间永远是 B 自己加过的次数,总值就是所有隔间之和。

G-Counter 只能加不能减,一旦要扣账就麻烦了。PN-Counter(正负计数器)想了个巧妙的办法:准备两本账本,一本只记买入(P 计数器),一本只记退货(N 计数器)。买入一本用 G-Counter 单调递增,退货一本也用 G-Counter 单调递增,最终余额等于 P 的总值减去 N 的总值。退货不是“擦掉一笔买入”,而是在另一本账本上“加一笔退货”,两本账本都只增不减,因此合并依旧无冲突。这就像三兄弟约定:想退掉鸡蛋,不是把鸡蛋从清单上擦掉,而是另外写一行“鸡蛋 -1”;晚上合并时,把加的总数和减的总数分别加起来,差值就是最终答案。

merge(C(A),C(B))=[max⁡(C1(A),C1(B)),…,max⁡(Cn(A),Cn(B))]\text{merge}(C^{(A)}, C^{(B)}) = \left[\max(C_1^{(A)}, C_1^{(B)}), \dots, \max(C_n^{(A)}, C_n^{(B)})\right]
整个 G-Counter 的值就是所有节点隔间之和,因为每个隔间都是单调递增且只属于一个节点,所以总值也是单调递增的。
  1. 求和符号把 n 个节点各自记录的本地增量全部加起来,得到整体计数。
    ∑i=1n\sum_{i=1}^{n}
  2. 分量值 \(C_i\)第 \(i\) 个节点自己完成的操作数,不包含任何别的节点的功劳。
    CiC_i

集合与映射

现在购物清单需要支持“删除”。老王写了鸡蛋,后来发现自己记错了,想把鸡蛋划掉;可同时老李又往清单上补了一行鸡蛋。划掉还是保留?如果直接擦除,老李的补写可能被误伤。OR-Set(观察删除集合)用了一个聪明的办法:每个元素不是一个孤零零的字符串,而是一个带着唯一标签的个体,例如“鸡蛋#老王-3”。删除的时候,只删除自己当前看到的那几个标签;如果别人并发添加了新的“鸡蛋”标签,那个标签是不受删除影响的。

合并 OR-Set 时,集合取并集。A 删掉“鸡蛋#A-1”,B 添加“鸡蛋#B-1”,合并结果是两个标签都在。A 再删一次只能删掉自己见过的标签,删不掉 B 刚创建的标签。这样“删了又加”的需求被正确表达为“旧标签已删,新标签仍在”。这种机制称为“墓碑删除”:被删掉的标签不会消失,而是留在集合里当墓碑,防止别人又把它合并回来。代价是状态会不断增长,墓碑越来越多,工程上需要周期性清理。

LWW-Map(最后写入获胜映射)是另一种思路:每个键值对都附带一个时钟值,合并时谁的时钟大就保留谁。它本质上是“按键粒度的最后写入覆盖”,不会出现整个文档被一份旧版本覆盖的悲剧。这里的关键是时钟不能用普通的墙上时间,因为各机器时钟可能漂移;实践中常用 Lamport 时钟维护偏序关系。

回到购物清单:OR-Set 是“划掉我见过的那一行字,别人新写的同一物品仍保留”;LWW-Map 是“同一栏里谁最后写就听谁的”。没有哪种绝对更好,只有哪种更贴合业务语义。

LWW(a,b)={aif ts(a)>ts(b)botherwise\text{LWW}(a, b) = \begin{cases} a & \text{if } ts(a) > ts(b) \\ b & \text{otherwise} \end{cases}
LWW-Map 对同一个键的两个候选值做取舍:时间戳更大的胜出。它适合“配置项更新”这类场景,但不适合“需要保留双方写入”的协同编辑。
  1. 候选值 \(a\) 和 \(b\)两个副本对同一个键写下的不同值,比如两份配置里的“主题色”。
    a,ba, b
  2. 时间戳 \(ts(a)\)写 \(a\) 这个值时附加的逻辑时钟读数。时钟必须能反映因果先后。
    ts(a)ts(a)
  3. 条件分支如果 \(a\) 的时间戳更大就保留 \(a\),否则保留 \(b\)。不存在双方都赢的情况,所以合并结果唯一。
    {aif ts(a)>ts(b)botherwise\begin{cases} a & \text{if } ts(a) > ts(b) \\ b & \text{otherwise} \end{cases}

合并传播协议

三兄弟的清单要如何汇聚到一起?可能是老王直接拿着自己的清单去老李家,也可能由老张当中间人传递。传播路径五花八门,但最终清单必须一致。CRDT 的传播协议主要分两类。第一类是状态合并:每隔一段时间,节点把自己的完整状态发给邻居,邻居执行 merge 后再继续传播。这种模式实现简单,不怕消息丢失——丢了就等下一次全量状态,反正 merge 是幂等的。缺点也明显:状态可能很大,尤其 OR-Set 攒了一堆墓碑时。

第二类是操作传播:节点只广播“我做了哪个操作”,比如“在集合里添加标签 X”。操作日志更节省带宽,但依赖可靠传输,如果一条操作丢失,后续合并就会缺项。两种模式都必须遵守同一条底线:合并规则要满足交换律、结合律、幂等律,否则传播顺序一变,结果就变。

要理解什么叫“顺序变了也没关系”,需要引入因果序的概念。如果事件 \(a\) 发生在事件 \(b\) 之前,我们记为 \(a \prec b\);同一进程内先发生的事件、或通过消息传递被对方感知到的事件,就构成这种偏序关系。两个互不可见的事件是并发的,CRDT 必须让并发事件也能确定性地合并。

购物清单的传播也一样:老张收到老王的清单后加上了自己的条目,这个过程就形成了因果链;而老李在自己本子上写酱油,与老王写鸡蛋互不知晓,两者并发,合并时谁先谁后无所谓。因果序帮我们判断“哪些操作必须保持顺序,哪些顺序可以自由调整”,这正是 CRDT 合并正确性的根基。

a≺b  ⟺  a happened-before ba \prec b \iff a \text{ happened-before } b
偏序关系"happened-before"是分布式系统判断因果的标尺。它不要求所有事件都有先后,只要求有因果联系的事件不会被合并规则颠倒。
  1. 偏序记号 \(a \prec b\)事件 \(a\) 在因果上先于事件 \(b\),表示 \(b\) 有可能知道 \(a\) 的存在。
    a≺ba \prec b
  2. 等价符号左边是数学记号,右边是定义。"happened-before"就是这条偏序关系的名字。
      ⟺  \iff
  3. happened-before 定义同一节点上先发生的事件、或者“收到消息再回复”这种通过消息传递形成的事件链。
    happened-before\text{happened-before}

工程实施取舍

终于到了把 CRDT 放进真实系统的时刻。选型第一条:业务能否容忍最终一致?如果能,CRDT 是很好的答案;如果必须线性一致,比如转账交易,那应该考虑 Raft 这类强一致协议,而不是强行套用 CRDT。第二条:数据形态是什么?计数类选 PN-Counter,集合类选 OR-Set,键值配置类选 LWW-Map,复杂文档可以组合多个 CRDT。第三条:状态合并还是操作传播?状态合并抗消息丢失、实现简单,但状态会无限膨胀;操作传播省带宽,但要求可靠通道。

工程上还需要关注状态大小。以 G-Counter 为例,每个节点都要在状态向量里占一个分量,节点数越多,状态越大。若节点数为 \(n\),每次合并都要遍历整个向量,因此合并代价也是 \(O(n)\)。

典型落地方案里,CRDT 已经活跃在在线协作文档中:每个人在本地维护一份文档片段,光标移动、字符插入都作为操作广播,断网重连后与其他人的片段合并;在视频观看量统计中,边缘节点用 PN-Counter 各自累加,定期汇总;在多端购物车场景中,OR-Set 让手机和电脑上的增删操作合并成一台购物车。选对模型,CRDT 就像一张永远不吵架的购物清单;选错模型,再精妙的合并也会变回一场混乱。

state size=O(n),merge cost=O(n)\text{state size} = O(n), \quad \text{merge cost} = O(n)
状态大小与合并成本都随节点数量线性增长。小规模集群毫无压力,超大规模集群需要引入分片或层次化合并来缓解。
  1. 状态大小每个节点的 CRDT 状态至少要为每个其他节点保留一个计数器槽位,所以状态量级是节点数 \(n\)。
    state size=O(n)\text{state size} = O(n)
  2. 合并成本合并时要逐位比较两个向量,遍历所有槽位,所以时间成本也随节点数线性上升。
    merge cost=O(n)\text{merge cost} = O(n)

AI 应用场景

实时协作文档

阶段:数据同步与一致性保障

解决的问题:多人同时编辑同一个文档时,传统以中心服务器为准的方式会产生明显延迟;一旦断网,本地编辑无法提交。CRDT 让每个客户端独立编辑,重连后通过合并自动收敛,不丢任何人的输入。

具体案例:在线文档输入“你好”,另一位用户在同一个段落输入“世界”,两个客户端分别记录操作并异步同步;合并后段落变成“你好世界”,而不是一方覆盖另一方。

分布式点赞与播放量计数

阶段:大规模指标采集

解决的问题:单点 Redis 计数器容易成为热 key,写入量一大就撑不住;用数据库行锁又太慢。视频播放量的精度不需要实时强一致,PN-Counter 允许各边缘节点本地自增,再周期合并。

具体案例:某视频平台把播放量统计部署到多个区域节点,每个节点维护 P 计数器和 N 计数器,每隔 30 秒互相合并一次,最终全局播放量误差为零。

多端购物车合并

阶段:用户状态跨设备同步

解决的问题:用户在手机上往购物车加了三件商品,又在电脑上加了另外两件,传统同步策略以“最后登录设备”覆盖,会导致一方商品丢失。OR-Set 的标签机制让两个设备各自保留新增项,合并后购物车是双方的并集。

具体案例:电商 App 使用 OR-Set 保存购物车,手机端离线添加“牛奶”标签 M1,电脑端在线添加“面包”标签 M2,两台设备同步后同时看到牛奶和面包。

Python 演示

python62 行
class GCounter:    def __init__(self, node_id):        self.node_id = node_id        self.counts = {}     def increment(self, delta=1):        self.counts[self.node_id] = self.counts.get(self.node_id, 0) + delta     def value(self):        return sum(self.counts.values())     def merge(self, other):        merged = GCounter(self.node_id)        for key in set(self.counts) | set(other.counts):            merged.counts[key] = max(self.counts.get(key, 0), other.counts.get(key, 0))        return merged  class PNCounter:    def __init__(self, node_id):        self.p = GCounter(node_id)        self.n = GCounter(node_id)     def increment(self, delta=1):        self.p.increment(delta)     def decrement(self, delta=1):        self.n.increment(delta)     def value(self):        return self.p.value() - self.n.value()     def merge(self, other):        result = PNCounter(self.p.node_id)        result.p = self.p.merge(other.p)        result.n = self.n.merge(other.n)        return result  def main():    a = GCounter('A')    b = GCounter('B')    a.increment(3)    b.increment(5)    a_merged = a.merge(b)    b_merged = b.merge(a)    assert a_merged.value() == b_merged.value() == 8    assert a_merged.merge(b_merged).value() == 8    print("G-Counter 合并结果:", a_merged.value())     pa = PNCounter('A')    pb = PNCounter('B')    pa.increment(3)    pb.increment(5)    pb.decrement(2)    merged_pn = pa.merge(pb)    assert merged_pn.value() == 6    print("PN-Counter 合并结果:", merged_pn.value())  if __name__ == '__main__':    main()
目的:用不到 50 行实现 G-Counter 与 PN-Counter 的核心结构、合并逻辑和幂等验证,帮助理解 CRDT 的收敛机制。
  1. self.counts = {}每个节点的状态是一个字典,键是节点编号,值是该节点的本地累计值,相当于“专属隔间”。
  2. self.counts.get(self.node_id, 0) + delta只更新自己的隔间,避免把别人的贡献算到自己头上。
  3. set(self.counts) | set(other.counts)取两个状态字典的键并集,保证合并时不会漏掉任何一个节点。
  4. max(self.counts.get(key, 0), other.counts.get(key, 0))逐位取最大值,这是 G-Counter 合并的核心,也是幂等性的来源。
  5. self.p.value() - self.n.value()PN-Counter 的最终值等于买入账本减去退货账本,两本账本都是 G-Counter。
  6. assert a_merged.merge(b_merged).value() == 8同一状态合并两遍结果不变,验证幂等性质。

常见误区

误区G-Counter 是计数器,所以加一减一都能支持;想减少的时候调用 decrement 就行。

正解G-Counter 的 G 是 Grow(增长),它的状态只允许单调递增。它的合并规则是取最大值,无法表达“减少”这一变化。如果想支持减少,必须用两个 G-Counter 组成 PN-Counter,一个记增加、一个记减少,最终值相减。这就像购物清单只能往上加字,想退货并不擦除原条目,而是另起一行记一笔负数。

为何常见:日常生活中的计数器往往可增可减,初学者容易把“计数器”的通用语义直接套到 G-Counter 上。

误区OR-Set 删除元素时,就和其他普通集合一样,把元素整个从集合中移除。

正解OR-Set 中的每个元素都带着唯一标签,删除操作只能移除“自己当前见过的那些标签”。如果另一个节点并发添加了同一个元素的新标签,老标签被删,新标签仍然存在。合并后“删了又加”的结果是保留新标签。删除本质是给标签建墓碑,墓碑留在状态里,防止旧标签通过合并回魂。

为何常见:大家习惯的 Set 是“元素存在或不存在”,难以理解“同一个元素可以同时存在多个版本标签”。

误区CRDT 只是一种数据结构,跟最终一致性没什么关系;它可以用在任何一致性模型里。

正解CRDT 的核心价值正是实现最终一致性。它不做锁、不等待全局确认,所有合并都向同一个终态收敛,这本质上就是在满足 AP 前提下的最终一致。它与 Raft 这类线性一致协议的目标恰好相反:Raft 保证任何时刻读到相同结果,CRDT 保证最终读到相同结果。

为何常见:很多人把 CRDT 当成普通类库,忽略了它设计背后的 CAP 背景。

误区最后写入获胜就是比较时间戳,直接用服务器本地时间 `time.time()` 就行。

正解墙上时钟会漂移、会被 NTP 调整,两节点的时间戳不可靠。正确做法是使用 Lamport 时钟或向量时钟,它们基于事件之间的因果关系产生逻辑时间,保证后发生的事件拿到更大的时钟值。工程上即使使用墙钟,也要配合逻辑时钟修正。

为何常见:“最后写入”四个字让人自然联想到日历时间,忽略了分布式系统里没有全局时钟这个基本事实。

误区既然 CRDT 这么厉害,以后所有分布式系统都用 CRDT,不用学 Raft 了。

正解CRDT 面向最终一致、高可用场景;Raft 面向线性一致、强一致场景。银行转账、订单扣款这类业务必须强一致,CRDT 的并发合并反而会掩盖错误。CRDT 的适用前提是“业务可以容忍短暂不一致”,两者解决的问题不同,不是替代关系。

为何常见:看到 CRDT 的合并很优雅,容易把它想象成能解决一切一致性问题的银弹,忽略了业务对一致性的硬性要求。

面试题

Q1. G-Counter 合并时的核心规则是什么?

单选题 · easy

  • A.每个位置取最大值← 正确答案
  • B.所有分量直接求和
  • C.以最后到达者为准
  • D.取两个状态的平均值

答案:A。G-Counter 每个节点只更新自己的分量,合并时逐位取 max,保证重复合并不会把同一节点的贡献算多次。直接求和会把双方共有的历史分量重复计算;最后到达者为准或取平均值都不满足交换律和幂等律。

Q2. CRDT 的合并操作必须满足哪些代数性质?

多选题 · easy

  • A.交换律、结合律、幂等律← 正确答案
  • B.分配律、结合律、幂等律
  • C.交换律、吸收律、分配律
  • D.只有交换律和幂等律

答案:A。合并必须满足交换律(顺序无关)、结合律(分组无关)、幂等律(重复无害)。分配律是加法和乘法的关系,吸收律是格的性质,都与 CRDT 合并无关。三条性质确保消息乱序、分组、重发都不会影响收敛结果。

Q3. PN-Counter 为什么要用两个 G-Counter?

单选题 · mid

  • A.为了提升写入吞吐量
  • B.因为 G-Counter 只能增不能减← 正确答案
  • C.为了支持事务回滚
  • D.为了避免网络分区

答案:B。G-Counter 单调递增,无法表达减少。PN-Counter 用 P 记录增加、N 记录减少,最终值为两者之差。退货不是删除买入记录,而是新增一条退货记录,这样两本账都保持 G-Counter 的单调性。

Q4. OR-Set 中删除一个元素时实际发生了什么?

单选题 · mid

  • A.从集合中整个移除
  • B.移除本地看到的部分标签← 正确答案
  • C.触发全量状态快照
  • D.用时间戳覆盖元素

答案:B。OR-Set 的每个元素都有唯一标签,删除只能移除自己见过的标签。别人并发添加的同名新标签不在删除范围内,因此“删了又加”能保留新版本。这是 OR-Set 与普通集合最大的区别。

Q5. 以下哪个场景最适合使用 CRDT?

单选题 · mid

  • A.银行账户跨行转账
  • B.多端购物车合并← 正确答案
  • C.单机数据库事务
  • D.证券交易撮合系统

答案:B。购物车可以容忍短暂不一致,多端并发添加商品需要在同步后合并为并集,正是 OR-Set 的强项。银行转账、证券交易要求强一致,不能接受最终一致带来的中间状态;单机事务根本不需要分布式复制。

Q6. 关于 CRDT 与 CAP 的关系,下列说法正确的是?

单选题 · hard

  • A.CRDT 强一致地满足 CP 场景
  • B.CRDT 偏好在 AP 场景提供最终一致← 正确答案
  • C.CRDT 同时满足一致性和可用性
  • D.CRDT 一定不满足分区容忍性

答案:B。CRDT 假设网络可能分区,但服务不阻塞,因此它走的是 AP 路线。它不保证读写实时线性一致,只保证消息同步后收敛。CA 在分布式系统中本就不可兼得,CRDT 也无法绕开 CAP。

Q7. LWW-Map 的“最后写入获胜”最需要使用哪种时钟?

单选题 · mid

  • A.服务器墙上时钟
  • B.Lamport 逻辑时钟← 正确答案
  • C.客户端本地计秒
  • D.数据中心的 NTP 时间

答案:B。墙上时钟会漂移,NTP 可能回拨,无法保证因果先后。Lamport 时钟基于事件传递关系递增,能保证后发生的事件获得更大的逻辑时间,因此 LWW-Map 正确性依赖逻辑时钟而不是物理时间。

Q8. 状态合并型 CRDT 的优点包括哪些?

多选题 · easy

  • A.即使消息丢失也能收敛← 正确答案
  • B.不要求消息精确排序← 正确答案
  • C.状态必然比操作型更小
  • D.天然支持线性一致

答案:AB。状态合并直接交换全量状态,丢一条消息等下一次全量同步即可,不需要严格排序。代价是状态可能膨胀,所以 C 错误;状态合并提供最终一致而非线性一致,D 错误。

Q9. 两个节点错误地同时对同一个 G-Counter 下标执行了自增,合并后该下标的值相当于增加了几次?

单选题 · extreme

  • A.最终增加一次← 正确答案
  • B.最终增加两次
  • C.最终增加三次
  • D.最终增加四次

答案:A。正常 G-Counter 要求每个节点只更新自己的下标,但即使同时更新同一下标,合并时取 max 只会保留较大的那个值。两个节点的本地值都是 1,合并后还是 1,相当于只增加一次。这正是幂等性在极端情况下的体现。

Q10. 关于 CRDT 的设计原则,下列说法正确的有?

多选题 · hard

  • A.合并满足三条代数法则← 正确答案
  • B.删除不会丢失并发添加← 正确答案
  • C.CRDT 需要中央协调器
  • D.CRDT 默认支持强一致

答案:AB。三条代数法则是 CRDT 的定义性要求;OR-Set 等结构通过标签机制保证删除不误伤并发添加。CRDT 正是为了去掉中央协调器而设计的,它提供的也不是强一致,而是最终一致,因此 C、D 错误。