Web 协同应用如何设计存储和传输 —— 对 CRDT 的赞美和叹息

X1a0t,7 min read

CRDT(Conflict-free Replicated Data Type)是一种约定,大体上约定了使用的数据结构,这些数据在不同的机器产生的操作(决定了无法绝对对齐时钟)如何决定顺序,以及顺序无法区分先后时解决冲突的语义,他最终的价值是可以在分布式系统中实现最终一致性。

只要浅浅思考两个问题,就会发现 CRDT 的价值:

  1. 对于 Web 协同应用,如果 Client 正在编辑文档,应该如何将这份更新回传给 Server。对于本地应用来说,覆盖原文件是最简单有效的方式(像 VSCode,Jetbrains 这些 IDE,Markdown 编辑器,Office 都是这么来做),但是对于网络应用来说,覆盖意味着每次更新都要回传整份文档;这会浪费大量带宽。再进一步想,协同应用为了让其他 Client 可以尽快看到更新,有任何更新都会立刻回传给 Server。这会使带宽消耗更大。

  2. 对于协同的内容可能出现冲突的情况,例如两个用户同时编辑一份文档,合理的结果应该是将他们的内容合并,而不是简单的覆盖。

CRDT 开箱即用地解决了这两个问题;每次最小粒度的更新都被定义为带有 时间戳 和 ClientID 的 Update,这使得只要使用 CRDT 的数据结构保存应用数据,Client 的任何更新都可以增量的回传给 Server。而逻辑时间戳(部分)解决了不同机器很难对齐时钟的问题,(至少)可以实现最终一致性。而对于冲突的情况,CRDT 最大的意义就在于此,只要使用实现了它的框架 api(也参与过一点这部分的实现),最终会按照 CRDT 的规则来产生可以被预测的结果。

其实更进一步,CRDT 还可以在更严苛的环境下保证最终一致性,只要 Client 之间可以有某些时刻可以互相通信:

  1. 只有 Client 没有 Server

  2. Client 可能离线无限久并且可能在离线时修改数据

但过分的利用他的潜能,就像一把双刃剑...可能会给选择 CRDT 作为数据层表示的应用带来伏笔,像任何其他的工程实践一样,这样做的代价是什么/会损失什么好处?

比较轻微的代价是会使得 Client 之间的数据同步更加复杂,并且由于 CRDT 的实现需要使用逻辑时间戳,这使得 CRDT 无法保证顺序无法区分先后时的合并结果是最合理的。例如,两个用户同时在一份文档的同一行写字,这两个用户的 Client 会产生两个 Update,这两个 Update 的逻辑时间戳是一样的,这时 CRDT 会使用预先设定的规则来决定合并结果,例如谁的 ClientID 更大,谁的内容优先。这导致如果两份文档如果都从头开始写,这会导致他们文字的插入位置和逻辑时间戳都将大量重合,这两份文档对应的所有增量数据都将逐个进行基于预先设定规则的合并,导致两份文档像双手交叉一样揉在一起。但这毕竟是少数情况。

更严重的代价是随着 Client 数量变多,所有 Client 的内容收敛到一致状态的难度指数增加(想象一个 Mesh 网络)。这对一个成熟的产品来说几乎是不可接受的,因此使用 CRDT 作为数据表示层的 AFFiNE 和 Figma 都毫不犹豫(至少在 AFFiNE,以及没有看到 Figma 的相关讨论)选择了有 Server 的架构。

AFFiNE 为了给应用赋予本地优先隐私优先的能力,使用了能力 2,以系统设计的复杂度换来了本地优先,隐私优先的能力。Figma 放弃了 1 和 2,甚至几乎避免了 Array CRDT 类型的使用,使得他们的实现更加简单和有更易于理解的合并结果。

可能工程上永远无法避免权衡;也曾经设想过一种更轻量地使用 CRDT 的方式:更着重使用他增量更新表示的能力,让协同应用能够体面的传输增量更新。只使用他冲突解决能力的子集,阉割掉逻辑时间戳,以 Server 接收到的 Update 先后顺序为准,这样消灭了无法区分先后顺序时靠冲突解决语义的情况(是正确的但并不好),产生比最终一致性更合理的结果;(从效果上来看也算是一种开箱即用的 OT (opens in a new tab) 方式),可能只是也许,体感上会更纯粹优雅一些...

CC BY-NC 4.0 2023 © Powered by Nextra.