转载自 jzhao.xyz,由 AI 翻译,手动润色
原文发表于 2022 年 11 月 16 日,链接 jzhao.xyz/posts/bft-j…
遵循 MIT 协议
CRDT(无冲突复制数据类型)是一类数据结构,设计被用来在多台计算机上复制,而无需担心人们在相同位置写入数据时产生的冲突。如果你曾经处理过棘手的git合并冲突,你就会知道解决这些问题有多痛苦。
CRDT 从数学上保证应用程序可以安全地更新其本地状态,而无需与所有对等方进行协调。通过避免额外的协调开销,它们具有很好的延迟特性,并在需要实时协作的场景中表现出色(例如文本编辑、存在感、聊天等)。
过去几年中,Automerge 和 Yjs 等优秀的开源 CRDT 库相继问世,使开发者能轻松将这类复制数据类型集成到自己的应用中。它们对 JSON 格式的支持意味着大多数网络应用只需简单"即插即用",就能轻松实现协作功能。
原本需要数周甚至数月工程时间来搭建的协作网络基础设施,现在只需一天就能完成。这让我们离"默认多用户互动、温暖生动"的未来互联网愿景更近了一步。
我通过实践学习得最好,因此决定从头开始编写自己的 CRDT 库,以深入理解其底层原理。初次学习这个概念时,我花了整整几个月时间研读论文却仍感困惑。大量文献需要我花费很长时间才能理解,还要求我掌握相当多的序理论和分布式系统知识。
这篇博文主要是写给曾经迷茫的自己的备忘录,将我后来学到的知识浓缩成一篇"希望当初能读到"的指南。希望对你也有所帮助。
需要提前说明的是,本文的目标读者是那些有过分布式系统开发经验,并对 CRDT 领域感到好奇的开发者。我会尽力解释相关术语,但提前了解这些主题会大有裨益!
本文篇幅较长,请善用顶部目录跳转到你最感兴趣的部分!
CRDT与传统数据库的差异
在深入探讨 CRDT 的内部机制之前,我们首先需要理解它们与传统数据库的区别。通常情况下,当我想到共享状态时,我会想到数据库。然而,数据库提供的保证与 CRDT 提供的保证是截然不同的。
传统数据库注重一种称为线性一致性(linearizability)的属性,它保证所有操作都像在数据的单一副本上执行一样。我们称这种规范视图为主站点(primary site)。在一个线性一致的系统中,无论你从哪个数据库节点读取数据,每次读取都会得到最新的值。
这对于让开发者更容易理解分布式应用程序非常有益(你只需将分布式数据库视为单一数据库即可)。根据定义,不存在冲突,因为只有一个权威视图来定义正确的状态。
然而,这并非没有缺点。实现这一特性会带来大量的开销,因为写操作和读操作都需要在所有数据库节点之间进行协调(如上图中的虚线所示),以确保一致性。这会导致可用性问题:如果无法访问大多数节点,就无法处理任何操作。1
CRDT(无冲突复制数据类型)则完全颠覆了这种传统思路,并接受了现实世界中最终一致性的本质。在一篇经常被引用的文章《数据库社区的某种倾向》(A Certain Tendency Of The Database Community)中,作者认为,试图提供“单一系统镜像”语义(即线性一致性)从根本上是有缺陷的,并且与物理世界中系统的运作方式相矛盾。“花园与溪流”(The Garden and the Stream)。
在现实世界中,我们了解周围和全球发生的事情需要时间。邮件发送需要时间,电话接通需要时间,光信号在跨越全球的服务器之间传输也需要时间。我们可以从现实世界中汲取灵感,采用一种设计理念,将系统中的每个成员都视为其生成数据的主站点。这种设计方式允许每个节点独立操作,而不需要立即与其他节点协调,从而更好地适应现实世界的延迟和不确定性。
这意味着我们允许各个节点在实际操作中拥有不同的状态,只要它们最终能够收敛到一个正确的结果即可。通过放宽对全局一致性结果的要求,我们消除了等待所有副本达成一致的需要。用更正式的分布式系统术语来说,我们用线性一致性换取了另一种特性,称为强最终一致性:
- 所有更新最终必定到达每个节点
- 每个接收到相同更新的节点都会拥有相同的状态
何时选择强最终一致性,而非线性一致性?
事实证明,在大多数情况下,强最终一致性已经足够好了。数据库领域的传统智慧认为,如果数据变更频繁,那么追求完美的数据一致性在延迟和带宽方面的代价都太高了。使用最终一致性的方法通常效果更好,因为大多数情况下,暂时的数据不一致最终都会得到解决。
两个用户可能会同时从一个账户中取款,最终取出的金额可能超过账户原本的余额。银行会希望出现这种行为吗?实际上,是的。在 ATM 与银行主服务器分区的情况下,ATM 能够正常取款(可用性)比临时不一致的代价更为重要。在账户透支的情况下,银行有一套明确的外部补偿措施:例如,向用户收取透支费用。
摘自《今日的最终一致性:局限、扩展与超越》
究竟什么是 CRDT?
经过千字铺垫,你或许在想:"CRDT 到底是个什么鬼?"。也许我们现在应该来定义一下。
CRDT 代表无冲突/可交换/收敛的复制数据类型(conflict-free/commutative/convergent replicated data type)。有趣的是,对于 C 到底代表什么并没有很强的共识2。不过,无论名字如何,核心要点是相同的:
- 你可以从本地数据副本中读写数据,而无需与其他节点协调。
- 随着时间的推移,所有节点通过相互发送对本地数据执行的状态修改/更新,最终会收敛到相同的状态。
- 由于消息传递的最终性,无法保证在任何给定时刻所有节点的状态是一致的。
- 如果 CRDT 实现正确,CRDT 的视图可能过时,但永远不会不正确。
- 每个操作都包含必要的元数据,以确定如何以确定性的方式合并可能同时发生的任何操作。
- 需要指出的是,“无冲突”这个术语有点误导性。并不是说冲突永远不会发生,而是 CRDT 总是能够预先确定如何解决冲突(无需用户干预)。
- CRDT 始终尝试保留用户意图,并尽可能不丢失数据。
- 如果两个人在字符串的同一位置插入字符,它会尝试保留这两个编辑。
- 请注意,这与共识方法有本质不同。协作涉及保留所有编辑并合并它们,而共识则涉及从多个提议值中选择一个并达成一致。
再次强调,CRDT 是一类数据结构。并没有单一的 CRDT。你可以创建生成单一值(寄存器)、列表、映射、图、JSON 等的 CRDT,还有许多未列出的类型。然而,它们并不能表示所有内容。一个根本的限制是,某些类型的数据结构(如集合)无法转化为 CRDT3。我们稍后会详细讨论这些限制。
现在,我们对CRDT有了一个高层次的概述,接下来让我们深入探讨它们如何解决冲突。
消息排序
小提示:这部分会涉及较多理论。如果这不是你的兴趣所在,你可以直接假设存在一种方法来排序操作,然后跳到标题为《CRDT背后的直觉》的部分。
有一整个数学分支专注于如何排序事物,称为序理论。在消息排序的情况下,我们希望定义某种方式来比较消息,使得没有两条消息在时间上被认为是相等的(学术术语中,我们定义了一个全序)。如果我们能做到这一点,就在数学上避免了冲突。那么我们该怎么做呢?
你的第一直觉可能是直接使用时钟。然而,天真地信任来自两台不同机器的两个不同时钟是不明智的。时钟可能会不同步,闰秒会发生,用户也可能更改系统时间。保持时间同步是出了名的困难。
如果我们不能信任实际时钟,我们能做什么呢?我们可以使用Lamport 时间戳。这些时间戳跟踪的是逻辑时间 而不是实际的物理时间,意味着我们计算的是发生的事件数量,而不是经过的秒数。
这个时间戳只是一个简单的计数器。在接下来的博客文章中,我们将这个计数器称为 seq。
- 所有节点的计数器从 0 开始
- 每次在本地执行操作时,我们将计数器加一
- 每次向对等节点广播消息时,我们附加这个计数器
- 每次收到消息时,我们将自己的计数器设置为
max(self.seq, incoming.seq) + 1
如果 a.seq > b.seq,那么事件 a 一定发生在事件 b 之后。然而,如果 a.seq == b.seq,我们无法确定哪个事件先发生。这意味着Lamport时间戳只提供了偏序,即两个相同的序列号可能不对应于同一个唯一事件。例如,两个节点可能都发出了 seq = 1 的事件,即使它们是不同的事件。
幸运的是,我们实际上可以通过使用某种任意(但确定性的)机制来打破平局,从而在分布式系统中创建事件的全序。对于 CRDT,如果我们为每个节点分配一个唯一 ID,我们可以通过这个 ID 来打破平局,从而提供一种确定性的方式来排序并发事件。
在伪代码中,我们可以创建一个比较操作,如下所示:
// We assume this is unique for every node
// 假设每个节点具有唯一作者 ID
type AuthorID = u8;
// A lamport timestamp
// lamport 时间戳
type SequenceNumber = u8;
// A CRDT Operation
// 一个 CRDT 操作
struct Op<T> {
author: AuthorID,
seq: SequenceNumber,
content: Option<T>
}
// Compare based off of sequence number
// If there's a tie, tie-break on unique author ID
// 优先比较序列号
// 序列号相同时,比较作者 ID
fn happens_before<T>(op1: Op<T>, op2: Op<T>) -> bool {
op1.seq < op2.seq ||
(op1.seq == op2.seq && op1.author < op2.author)
}
排序问题解决!
因果关系
解决了吗...让我们思考一下,什么时候我们可以安全地应用本地接收到的操作。
假设我们知道的最大序列号是 3。我们接收到一个序列号为 5 的操作。我们知道我们缺少序列号为 4 的操作。我们还能应用 5 吗?
如果 5 并不因果依赖于 4,那么我们实际上仍然可以安全地应用这个操作。
但我们无法仅从序列号中推断出这种因果关系。如果事件A导致了事件B的发生,那么 a.seq < b.seq。然而,我们无法反过来推断。也就是说,如果 a.seq < b.seq,我们不能说A导致了B的发生。
事实证明,解决这个问题其实非常简单。由于我们有一种唯一的方式来标识每个操作,我们可以发送一个它因果依赖的操作列表。这样,如果我们收到一条消息,并且知道我们已经接收了它所有的因果依赖,那么就可以安全地应用它。
如果我们收到一条消息,但尚未接收到它所有的因果依赖,那么我们可以将它加入队列,这样当消息被传递时,我们可以在之后应用它。在上面的例子中,如果消息 5 将 4 标记为因果依赖,它会等待 4 被传递后再应用5。
这意味着,只要我们声明了正确的因果依赖关系,就可以使某些看起来不可交换的操作(比如列表操作)实际上变得可交换。
CRDT背后的直觉
好了,理论部分到此为止,我们到底该如何实现一个 CRDT 呢?
让我们回顾一下我们目前拥有的工具(我们的假设):
- 我们可以在对等节点之间可靠地发送消息
- 我们对消息进行了全序排序,因此不会产生任何冲突
- 我们有某种方式来表示因果依赖关系
这给了我们一个不断增长的消息堆,这些消息被认为是“安全”的,可以应用。现在可能是一个好时机来区分 CRDT 中的数据本身和该数据的视图。
- 数据本身是传入的操作
- 视图是我们从中计算出的数据结构(也是应用程序最终看到的内容)
struct CRDT<T> {
data: Vec<Op<T>>,
}
impl<T> CRDT<T> {
// modify self.data to include op
// 修改 self.data 以包含 op
fn apply(&mut self, op: Op<T>);
// traverse self.data to produce the data structure
// we're actually interested in (a list in this example)
// 遍历 self.data 以得到我们真正想要的数据结构(在此例子中是一个列表)
fn view(&self) -> Vec<T>;
}
具体来说,我们永远不会4从内部数据表示中删除任何操作。我们最多只能使用墓碑标记将它们标记为已删除。因为从技术上讲,一个对等节点可能会引用任何过去的操作作为因果依赖,所以我们需要保留这些元数据。
当然,我们不能随意地尝试以这种方式建模每个数据结构。我们只能拥有那些不依赖于了解数据结构最新版本的不变量的数据结构的 CRDT。正如之前提到的,CRDT 始终保证拥有一个正确的状态,但它们可能没有最新的值。这意味着接收任何新操作都不应破坏 CRDT 的不变量。5
一个 CRDT 无法建模的例子是账户余额永远不会低于零。假设你的账户中有 100 美元。你同时花了 70 美元买笔记本电脑,又花了 40 美元买手机。如果不等待另一笔交易到达,CRDT无法知道这些交易是否有效!尽管每笔交易本身都是有效的,但当它们同时发生时,余额会变为负值。因此,CRDT 无法建模任何需要维护全局不变量的场景。
列表CRDT(RGA解释)
这似乎将我们引向了房间里的最大问题:列表 CRDT。
当你想到列表的 API 时,大多数操作通常是通过索引完成的。但这不是一个全局不变量吗?我们是否需要知道列表上已经执行了哪些操作,才能确定每个字符的索引?
这并不是一个容易解决的问题。这也可能是为什么绝大多数 CRDT 项目都专注于列表或文本编辑(本质上是一个字符列表)的原因。幸运的是,一些聪明的人已经为我们解决了这个问题,我们可以从他们的工作中寻找灵感。
这里的关键洞察是,我们可以使用绝对寻址(例如使用 ID)而不是相对寻址(例如位置索引)。
每次我们将一个元素插入列表时,我们需要知道我们插入的字符的 ID。然后,我们只需将其放置在该元素和其后的元素之间的某个位置。
因此,我们不是说“在位置4的字符后插入‘A’”,而是说“插入‘A’,ID为5,并将其插入ID为4的字符之后”。这与我们对文本编辑的直觉是一致的。当我们想象插入文本时,我们是在某个内容之后插入它。在插入字符“c”之前插入“cat”中的字符“a”是没有意义的。
我们可以想象说“c”导致了“a”,“a”又导致了“t”。方便的是,我们可以通过使每个项目的因果父项为它插入的字符来编码这种因果关系。还记得因果依赖吗?对,就是那些。
这形成了一种因果树,而这实际上就是 RGA(一种列表 CRDT)在内部结构化其数据的方式。让我们修改我们的 Op 以匹配这一点:
type OpID = (AuthorID, SequenceNumber);
struct Op<T> {
id: OpID,
origin: OpID, // causal dependency 因果依赖
author: AuthorID,
seq: SequenceNumber,
content: Option<T>,
is_deleted: bool // tombstone as we can't actually remove items 墓碑标记,毕竟我们不能真正删除项目
}
当插入一个字符时,我们:
- 找到起源(因果依赖项)。如果它不存在,我们将其加入队列,稍后再处理。
- 从这个节点开始,它的所有兄弟节点都是并发插入的。我们遍历兄弟节点列表,直到找到一个节点,该节点由我们之前定义的
happens_before函数比较6,确定为我们更大。回想一下,我们首先按序列号对操作进行排序,然后通过作者 ID 打破平局。
在上面的例子中,'s' 以 'a' 作为其因果起源。因此,我们查看在 'a' 的子节点列表中插入 's' 的位置。由于 't' 和 'b' 的序列号都较小,我们跳过它们。's' 和 'p' 的序列号都是5,因此我们通过 AuthorID 打破平局。由于 1 < 2,我们将 's' 插入到 'b' 和 'p' 之间。
为了获取这个 CRDT 表示的实际列表,我们对树进行中序遍历,并只保留未标记为删除的节点。
(几乎)免费实现拜占庭容错
到目前为止,我们讨论的 CRDT 都适用于可信场景。这些场景中,你知道所有参与者,并且相信他们不会破坏系统的正常运行。例如,在协作文本文档中,你可能将协作者限制为直接同事,你相信他们会正确运行 CRDT 算法,而不会尝试任何恶意行为。
然而,我们在网上的大多数互动并不发生在可信场景中。幸运的是,我们有服务器来帮助调解这些互动,规定什么是可能的,什么是不可能的。另一方面,点对点系统不能依赖节点始终按照系统设计者的意图运行。在这里,我们希望即使在面对某些节点崩溃、故障甚至恶意行为的情况下,也能保证系统继续正常运行。
到目前为止,我们探讨的 CRDT 在不可信场景中并不适用。也就是说,任何一个节点都可能做坏事,导致状态永久分歧。这可不是好事。
更具体地说,以下是一些恶意行为者可能做的事情:
- 发送格式错误的更新
- 不转发来自诚实节点的信息(日蚀攻击)
- 发送无效的更新
- 包含重复ID的消息
- 发送错误的序列号(双花攻击)
- 冒充其他用户
理想情况下,我们希望调整现有的 CRDT 算法,使其能够抵抗这些攻击,同时仍允许诚实节点正常运行。这将使我们能够在不可信环境中使用 CRDT,从而为许多酷炫的应用程序(如游戏、社交等)打开大门。
借用分布式系统的术语,我们希望使我们的 CRDT 具备拜占庭容错性7。名称中的“拜占庭”部分来源于拜占庭将军问题,即为了避免系统的灾难性故障,系统的参与者必须就一项协同策略达成一致,但其中一些参与者是不可靠的,甚至可能是恶意的。
在符号表示上,我们表示系统中的节点总数为 n,表示故障/拜占庭节点的数量为 f。大多数共识算法声称可以容忍 f < n / 3,这意味着它们可以容忍最多33%的节点出现故障。然而,CRDT 需要更宽松的界限。记住,我们并不是试图达成共识!我们真正需要做的只是确保拜占庭行为者无法干扰诚实节点的正常运行。
事实上,我们可以将这个问题简化为拜占庭广播问题。早在1983年,Dolev-Strong 就证明了可以容忍 f < n 的故障节点!这意味着,只要存在诚实节点并且它们彼此连接,它们仍然可以正常运行。8
Kleppmann 在他的论文《让 CRDT 实现拜占庭容错》(Making CRDTs Byzantine Fault Tolerant)中详细阐述了一种方法,这种方法无需改变大多数 CRDT 的内部机制;它可以完全在其之上进行改造。我们可以在传输层和应用层之间创建一个“BFT适配器”层,负责过滤掉任何拜占庭操作。
这种方法有两个主要组成部分需要理解:
- 如何确保拜占庭节点不会篡改消息并冒充他人(哈希作为 ID 和签名消息摘要)
- 如何确保消息不会被阻止到达诚实节点(积极的可靠因果广播和重试机制)
哈希值作为 ID 和签名消息摘要
这里有一个小技巧。我们对操作 ID 的唯一要求是它们能够唯一标识一个节点。我们可以通过对操作的部分内容进行 SHA256 哈希来生成这个 ID:
// Set the ID to the hash of its contents
// 把 ID 设置为其内容本身的哈希值
pub fn set_id(&mut self) {
self.id = self.hash_to_id()
}
// SHA256 computation over an operation
// 对一个操作计算 SHA256 哈希值
pub fn hash_to_id(&self) -> OpID {
let fmt_str = format!(
"{},{},{},{},{}",
self.origin, self.author, self.seq, self.is_deleted, self.content
);
sha256(fmt_str)
}
然后,为了检查操作是否有效,我们可以再次对内容进行哈希,看看是否与 ID 匹配。如果拜占庭行为者试图在传递操作时更改任何属性,同时试图冒充不同的 ID,哈希值将不匹配。
然而,我们如何确定拜占庭行为者没有冒充其他人呢?毕竟,他们可以创建一个操作,将作者字段设置为其他人,然后对内容进行哈希,这样哈希和 ID 之间就不会出现不匹配。
幸运的是,我们可以使用公钥加密来帮助我们解决这个问题。每个节点都有一个他们自己保管的私钥。之前我们还提到,每个节点都应该有一个唯一的标识符。我们实际上可以将每个节点的公钥作为其AuthorID。
然后,每当我们向另一个对等节点发送消息时,我们对 op.id 进行哈希以创建摘要,然后用我们的私钥对其进行签名。这样,我们就可以验证签署消息的人确实是真正的作者。
// Create a digest to be signed
// 创建一个用于签名的摘要
fn digest(&self) -> [u8; 32] {
// note that self.dependencies here is *different* from self.origin
// this will allow us to indicate causal dependencies *across* CRDTs
// which we will cover in more detail later
// 注意到这里的 self.dependencies 不同于 self.origin
// 这将允许我们表达 CRDT 之间的因果依赖关系
// 之后将详细介绍
let fmt_str = format!("{},{},{}", self.id(), self.path, self.dependencies);
sha256(fmt_str)
}
// Sign this digest with the given keypair
// 用指定的 keypair 对这个摘要签名
fn sign_digest(&mut self, keypair: &Ed25519KeyPair) {
self.signed_digest = sign(keypair, &self.digest()).sig.to_bytes()
}
// Ensure digest was actually signed by the author it claims to be signed by
// 确保摘要的确是由其所声称的作者签名的
pub fn is_valid_digest<T>(op: Op<T>) -> bool {
let digest = Ed25519Signature::from_bytes(&self.signed_digest);
let pubkey = Ed25519PublicKey::from_bytes(&self.author());
match (digest, pubkey) {
(Ok(digest), Ok(pubkey)) => pubkey.verify(&self.digest(), &digest).is_ok(),
(_, _) => false,
}
}
太好了!我们现在有一种唯一的方式来标识对等节点和操作。但这里还有一个有点棘手的问题需要处理:可变性。
当我们在这个内部表示中创建对操作的引用时(例如,我们需要声明一个因果依赖),我们期望操作的 OpID 在其生命周期内保持静态。然而,由于我们现在将 OpID 设置为内容的哈希值,更新节点会不断改变其 OpID!
这是否意味着我们必须放弃为了容错而对消息内容进行哈希?幸运的是,不必!有一个小技巧可以让我们仍然使用这种哈希方法。
与其复制原始操作并仅更改内容(这会导致操作被我们的哈希检查视为无效!),我们可以生成一个全新的操作,将原始 ID 作为因果依赖项。这些“修改”操作实际上并不包含在 CRDT 的内部表示中,而是直接修改它。
我们可以以列表 CRDT 为例。这个删除函数生成一个完全有效的操作。从因果关系的角度来看,这也是有道理的——我们需要在尝试删除它之前先传递原始操作!
fn delete<T>(&mut self, id: OpID, keypair: &Ed25519Keypair) -> Op<T> {
let mut op = Op {
id: PLACEHOLDER_ID,
origin: id, // the actual operation we are deleting 我们实际正在删除的操作
author: self.our_id,
seq: self.our_seq + 1,
is_deleted: true,
content: None,
signed_digest: PLACEHOLDER_DIGEST,
};
op.id = op.hash_to_id();
op.signed_digest = op.sign_digest(&keypair)
self.apply(op.clone);
op
}
当我们应用它时,我们以不同于插入事件的方式处理这些修改事件。我们寻找起源并更新其 deleted 字段。
随着这个小问题的解决,我们知道我们的操作现在具有防篡改性!
积极的可靠因果广播和重试
现在,我们只需要确保有一种方法可以在诚实节点之间传递消息,使得拜占庭故障节点无法阻止它。
最简单(可能也是最天真)的方法是通过积极的可靠广播:
- 每次节点收到一个从未见过的
OpID的消息时,它会将该消息重新广播给所有连接的对等节点。 - 如果我们长时间缺少某个因果依赖项,偶尔会询问我们的对等节点是否拥有它。
这确保了只要存在一个由诚实节点组成的连通子图,它们仍然可以相互通信。
然而,这里有很多潜在的优化空间。这种方法虽然可靠,但我们为每个实际操作广播了 O(n²) 条消息。这非常昂贵,可能会淹没网络!
幸运的是,我们可以从git中汲取灵感,找到更高效的方法。Kleppmann在《让 CRDT 实现拜占庭容错》(Making CRDTs Byzantine Fault Tolerant)中再次提到了这种方法。
使用更新的加密哈希具有几个吸引人的特性。其中之一是,如果两个节点
p和q交换它们当前头部的哈希值,并发现它们相同,那么它们可以确定它们观察到的更新集也是相同的,因为头部的哈希间接覆盖了所有更新。如果p和q的头部不匹配,节点可以运行图遍历算法来确定它们共有的部分,并互相发送对方缺少的那部分图。
这个项目没有包括这种更高级的哈希图协调,但这是未来工作的一个方向。
JSON CRDT
好了,我们现在已经了解了如何创建一个拜占庭容错的列表 CRDT。那么,我们如何用它来构建一个 JSON CRDT 呢?
通常情况下,JSON CRDT 只是一堆嵌套的 CRDT:
- 值是 LWW(Last-Write-Wins)寄存器
- 列表是 RGA(Replicated Growable Array)列表
- 映射是键值对的列表
每个嵌套的 CRDT 还会跟踪其路径(例如 inventory[0].properties.damage),这样当 CRDT 生成事件时,这些信息也会被包含在内。这确保了对等节点知道如何将消息路由到正确的 CRDT。
然而,我们必须小心如何存储这个路径。我们不能天真地只使用列表中的索引,因为正如我们之前看到的,这是不稳定的。这里的一个小技巧是让 OpID 作为索引。
此外,除了 origin 字段,我们还添加了一个 dependencies 字段。两者的区别在于,origin字段用于同一CRDT的因果依赖,而 dependencies 字段允许跨CRDT的依赖。如果我们希望,例如,库存更新依赖于 LWW 寄存器 CRDT 的更新,这一点非常重要。
最后一个需要解决的挑战是:JSON 没有模式;数据类型可以改变!例如:
- A 设置
"a": ["b"] - B 设置
"a": {"c": "d"}
我们如何解决这个问题?Automerge 和 Yjs 的解决方式本质上是使用一个多值寄存器:它们保留两个值,并将选择正确答案的责任交给应用程序。
但CRDT的整个意义不就是没有冲突吗?对于大多数应用程序来说,允许用户设置任意的 JSON 实际上并不可取。我们可以通过允许应用程序开发者提前定义一个固定的模式,并通过该模式验证所有操作,来在一定程度上缓解这些问题。9
将所有内容整合到一个crate中
如果我们利用像 Rust 这样的语言的类型安全和元编程能力,从程序员定义的数据结构中自动派生出这些严格模式的 BFT CRDT 会怎么样呢?
#[add_crdt_fields]
#[derive(Clone, CRDTNode)]
struct Player {
inventory: ListCRDT<Item>,
x: LWWRegisterCRDT<f64>,
y: LWWRegisterCRDT<f64>,
}
#[add_crdt_fields]
#[derive(Clone, CRDTNode)]
struct Item {
name: LWWRegisterCRDT<String>,
soulbound: LWWRegisterCRDT<bool>,
}
在写了5000字之后,我向您介绍 bft-json-crdt Rust crate。通过使用提供的CRDT类型,程序员可以立即为他们的项目添加 CRDT 功能。
BFT 操作和非 BFT 操作之间有明确的界限,通过数据类型来区分,以确保您不会意外地将操作应用到错误的地方。
// initialize a new CRDT with a new keypair
// 根据一个新的 keypair 初始化一个新的 CRDT
let keypair = make_keypair();
let mut base = BaseCRDT::<Player>::new(&keypair);
let _add_money = base.doc.balance.set(5000.0).sign(&keypair);
let _initial_balance = base
.doc
.balance
.set(3000.0)
.sign(&keypair);
let sword: Value = json!({
"name": "Sword",
"soulbound": true,
}).into();
let _new_inventory_item = base
.doc
.inventory
.insert_idx(0, sword)
.sign_with_dependencies(&kp1, vec![&_initial_balance]);
// do something here to send _new_inventory_item to our peers
// and on a remote peer...
// 做一些事情来把 _new_inventory_item 发送给对等节点
// 在一个远端对等节点上...
base.apply(_new_inventory_item)
最后,我想留下一句警告。这绝不是一个生产就绪的库。虽然我认为这是一个非常扎实的概念验证,展示了 BFT CRDT 的潜力,但它首先还是以教育为目的。
我并不认为自己精通 Rust,所以代码中可能散布着许多不良代码气味或错误(如果有任何修复或建议,请提交PR!)。
CRDT的未来方向
CRDT 领域仍然相当年轻。我真的认为在探索如何利用 CRDT 在网络上实现协作方面,有很多有前景的工作正在进行。
James Addison 一直在努力使用 CRDT 创建一个实时 3D 引擎。像 BLOOM 这样的项目正在尝试在编译时确定程序状态的哪些部分需要协调,哪些部分不需要。
我希望看到更多关于将 CRDT 应用于游戏和其他实时领域的研究。我认为在操作之间进行插值方面有很多非常酷的工作可以做。想象一下 GGPO 或 perfect-cursors,但适用于通用的 CRDT。
我希望这篇博客文章能成为对这个领域感兴趣的新人的起点,让他们真正动手实践,看看他们能用这项技术做些什么。再次强调,如果你对内部实现感兴趣,请查看代码库,并随时尝试解决 README 中“进一步工作”标题下的任何问题!
致谢
如果你读到了这里,我想向你表示衷心的感谢。
这可能是我迄今为止尝试过的最具技术挑战性的项目,更不用说完成了。我感觉自己的能力多次受到考验,但最终我成为了一名更优秀的工程师。感谢那些在我摸索和挣扎时支持我的人,他们帮助我完成了这个项目。
我想特别感谢几个人。感谢 Anson 倾听我冗长且语无伦次的唠叨,并为我的小胜利庆祝。感谢 Scott Sunarto 和 James Addison 的校对。感谢 Nalin Bhardwaj 帮助我解答密码学问题,以及 Martin Kleppmann 的教学材料和讲座,它们让我学到了关于分布式系统和 CRDT 的许多知识。
Footnotes
-
有一些方法可以通过“预测”成功的结果来缓解这个问题。然而,如果实际的写/读操作失败,我们可能需要回滚用户看到的内容,这从用户体验的角度来看并不理想。 ↩
-
注意:CRDT 实际上有两个主要子类型。CmRDT(可交换复制数据类型)基于交换包含单个操作的消息。CvRDT(收敛复制数据类型)发送其整个状态。它们有时也被分别称为基于操作和基于状态的 CRDT。本文的其余部分假设 CRDT 指基于操作的 CRDT。 ↩
-
CALM 定理指出,任何逻辑上单调(即仅追加)的内容都可以转化为 CRDT。非单调的内容可能会“撤回”之前的声明。 ↩
-
垃圾收集和重新平衡技术需要节点之间达成共识才能完成。“因此,据我所知,我们需要在 CRDT 上附加一个共识协议来实现垃圾收集/压缩。”(#2) ↩
-
这在 I-Confluence 中以更正式的方式表述。 ↩
-
注意:性能爱好者会很快指出如何使其更快。这是一个非常不平衡的树。平均而言,找到起源需要
O(n),插入树中也需要O(n)。这里显然有优化空间。Yjs 使用双向链表以实现更快的插入。他们还使用游标来跟踪最后访问的约 5-10 个位置。它假设编辑模式不是随机的(这在大多数应用中是正确的)。Diamond Types 和新的 Automerge 使用范围树来实现O(logn)的查找和插入。 ↩ -
对于来自更传统分布式系统背景的人,我会澄清 BFT 在共识上下文中的含义有所不同。传统上,这意味着让
n−f个节点就某个特定值达成一致。然而,由于 CRDT 更关注协作而非共识,我们只是希望防止拜占庭行为者破坏系统的正常运行。拜占庭行为者仍然可以发送一堆“有效”但可能不需要的更新。想象一下,你将一个 Google Docs 链接发送给一群人,其中一个人捣乱,插入一堆图片并删除文档中的信息。这些操作在“正确”运行的节点可能做同样事情的意义上都被认为是“有效”的。 ↩ -
参见 PSL-FLM 不可能性结果的说明。我们使用PKI假设来绕过这一结果。 ↩
-
正如 Bartosz Sypytkowski 指出的,这假设模式是静态的且从不改变,但在许多实际场景中可能并非如此。此外,由于 CRDT 的性质,模式更新可能会在不同时间被对等节点确认。这是一个正在研究的领域。 ↩