原文链接:https://www.cockroachlabs.com/blog/joint-consensus-raft/
Raft共识算法是CockroachDB的核心基础组件,我们在事务性、可扩展、分布式键值存储的较低层中依赖它们。实际上,大型集群可以包含数以万计的共识组,因为在 CockroachDB 中,每个 Range(分片)都是一个独立的共识组。我们的集群中运行了大量Raft(一种共识算法)实例,这带来了有趣的工程挑战。
副本变更是Range的配置更改,即该Range的一致副本应存储在何处的更改。让我们使用标准部署拓扑来说明这一点。
上述部署有三个区域(即数据中心)。CockroachDB 支持全球部署,因此这些区域很可能遍布全球。我们看到在每个区域 X、Y 和 Z 中都有两个节点,并且我们看到一个 Range,在每个区域中都有一个副本。这种部署可以在单个区域的故障中幸免于难:只要大多数副本可用,共识复制就会继续工作。如果某个区域发生故障,我们最多会丢失一个副本,并且会保留两个副本(大多数),因此数据库将在短暂的超时后继续正常运行。如果我们在 Z 中放置两个副本,在 Y 中放置第三个副本,则区域 X 的故障将带走其中的两个副本,只留下一个可用的副本;这个唯一的幸存者将无法满足请求:
CockroachDB 动态调整数据放置以应对节点利用率的变化。因此,它可能希望将副本从一个节点横向移动到另一个节点,例如区域 X。为了具体化,假设我们希望将其从X2 到 X1. 我们可能想要这样做,因为运营商已指定X2 应该停机维护,或 X2 CPU 使用率远高于 X1.
如图所示,正在考虑的 Raft 组最初位于 X2、Y1、Z2。 该组的活动配置将是多数配置X2,Y1,Z2,这意味着需要这三个中的两个来做出决定(例如选举领导者或提交日志条目)。
我们想横向移动 X2 到 X1,也就是说,我们希望在 X1,Y1, 和 Z2 在相应的配置中得到副本(X1, Y1, Z2). 但这实际上是如何发生的?
在 Raft 中,通过提出一个特殊的日志条目来启动配置更改,当副本接收到该日志条目时,将其切换到新配置。由于形成 Range 的副本在不同的时间点收到此命令,因此它们不会以协调的方式切换,必须小心避免可怕的“裂脑”场景,其中两个不同的对等组都认为他们已经做出决定的权利。在我们的特定示例中,这是如何发生的:
Y1(Leader)是第一个在其日志中收到配置更改的节点,它立即切换到 C2=X1, Y1, Z2. X1它赶上了Y1,因此也切换到 C2. X1 和 Y1 形成法定人数 C2,因此他们现在可以在不咨询任何一方的情况下追加日志条目 X2 或者 Z2. 但是 X2 和 Z2 仍在使用 C1=( X2, Y1, Z2) 并且不知道在其他地方有新的配置处于活动状态。如果他们碰巧没有及时从Y1获取最新的日志- 想象一个适当的网络中断 - 他们可能决定在他们之间选举一个领导者,并可能开始在冲突的日志位置附加他们自己的条目 。 脑裂使得成员变更很棘手!
上述问题根因是我们试图“一次改变太多”:我们同时添加了一个节点(X1) 并删除了一个节点 (X2) 。如果我们单独执行这些更改,在开始第二个更改之前等待大多数人收到第一个更改,也许事情会好起来?
事实证明这是真的。让我们添加X1 之前先删除X2. 这意味着在上图中我们将有C2=( X1, X2, Y1, Z2)。请注意,现在有四个节点,多数共识需要三个节点。这意味着当X1 和 Y1 都切换到新配置,他们还不能做出自己的决定——他们需要等待一个额外的对等点(并告诉它),也就是说,要么 X2 或者 Z2. 无论他们选择哪个有效地留下一个副本使用C1,但没有形成一个单独的法定人数。同样,我们可以说服自己一次删除一个节点也是安全的。
将横向移动等复杂的配置更改分解为“更安全”的单个部分是 CockroachDB 长期以来的工作方式。但是,它是否适用于我们上面的部署?让我们再看看添加后我们所处的中间状态X1,但在删除之前 X2 (如果我们先删除X2然后添加X1会出现类似的问题 ):
还记得我们之前是如何意识到将两个副本放置在一个区域中可能会遇到麻烦的吗?这正是我们被迫在这里做的事情。如果区域 X 发生故障,我们就有麻烦了:我们一次丢失了两个副本,留下两个幸存者无法召集健康多数所需的第三个副本(回想一下,大小为 4 的组需要三个副本才能取得进展)。因此,该 Range 将停止接受流量,直到区域 X 恢复 - 违反了我们对该部署拓扑的预期保证。我们可能会试图争辩说,配置更改很少,不会使其成为问题,但我们发现这并不成立。一个 CockroachDB 集群维护着数千个 Ranges;在任何给定时间,某些范围可能会发生配置更改。但即使不考虑这一点,直到最近,CockroachDB 通过尽快执行两个相邻的成员资格更改来缓解这个问题,以最大限度地减少在易受攻击的配置中花费的时间。但是,很明显我们不能永久接受这种状态,并着手在CockroachDB 19.2 版本中解决这个问题。
我们问题的解决方案在首次引入 Raft 共识算法的论文中进行了概述,并被命名为联合共识。我们的想法是选择一个比我们在上面的示例中所做的更好的中间配置 - 一个不会强迫我们将两个副本放入单个区域的配置。
如果我们的中间配置将初始配置和最终配置“连接”在一起,需要两者达成一致怎么办?这正是联合共识所做的。坚持我们的例子,我们将从我们的初始配置开始C1=X2, Y1, Z2到“联合配置” C1 && C2 = (X2, Y1, Z_2) &(X1、Y1、Z2):
在这种配置中,做出决定需要大多数人的同意 C1 以及大多数 C2. 回顾我们之前的反例,其中X2和 Z2 还没有收到旧的配置,我们发现裂脑是不可能的: X1 和 Y1 不联系任何一方都无法做出决定 X2 或者 Z2, 防止裂脑。同时,联合配置在区域中断中幸免于难,因为两者C1 和 C2单独做!
因此,计划很明确:实施联合配置更改并使用它们。这为回馈社区提供了一个受欢迎的机会,因为我们与etcd 项目共享 Raft 实现。etcd 是一种常用于配置管理的分布式键值存储(特别是,它支持Kubernetes),并且早在 Cockroach Labs 于 2015 年出现之前,我们就一直是它的etcd/raft库的活跃维护者(和用户)。
此时,是时候来个多汁的告白了:
etcd/raft 实际上并没有真正实现Raft 共识算法。
它在很大程度上严格遵循规范,但有一个显着区别:配置更改。我们在上面已经解释过,在 Raft 中,一个 peer 应该在它被附加到它的日志的那一刻切换到新的配置。在 etcd/raft 中,对等方在提交并应用于状态机的时刻切换到新配置。
差异可能看起来很小,但它具有重要意义。简而言之,
“Raft 方式”在论文中被证明是正确的,但在应用程序中使用起来更尴尬,而“etcd/raft 方式”带来了需要微妙修复的微妙问题,但具有更自然的外部 API。
我们借此机会与其他维护者讨论了 etcd/raft 是否应该符合规范。在这个过程中,我们发现了一些以前未知的潜在正确性问题。很快, 来自PingCap的Peng Qu(他们使用Rust实现一个非常类似于ETCD 的Raft)提醒我们注意另一个问题。
在我们为这两个问题找到并实施了解决方案之后,我们对真正使 etcd/raft 方法安全的附加不变量有了很好的理解。在这一点上,我们和维护者社区都不认为现在改用“Raft 方式”可以为对 etcd/raft 及其所有实现者(!)的巨额投资提供良好的回报。在这种特殊情况下,内部更复杂似乎更好,外部仍然易于使用(尽管有一两个疣),同时保持我们拥有的经过实战测试的代码基本完整。
绕道而行,我们继续实施联合配置更改。现在,几个月和 22 个拉取请求之后,任何使用etcd/raft 的人都可以享受我们维护良好的工作成果。此外,我们添加了数据 驱动的测试机制,显着简化了 Raft 节点中复杂交互的测试(参见此处的示例)。这大大简化了测试,并为未来的工作甚至只是探索提供了肥沃的土壤。
当然,我们也开始在 CockroachDB 最近的19.2 版本中使用这个新功能。如果您还没有尝试过,在本地或在云中都可以轻松尝试。
网友评论