常见系统设计模式
1. Bloom Filters (布隆过滤器)
背景
如果我们有一组结构化数据(通过记录 ID 标识),存储在一组数据文件中,那么最有效的方式是什么来确定哪个文件可能包含我们所需的数据呢?我们不想逐个读取文件,因为那样会很慢,而且我们需要从磁盘读取大量数据。一种解决方案是为每个数据文件构建一个索引,并将其存储在一个单独的索引文件中。这个索引可以将每个记录 ID 映射到数据文件中的偏移量。每个索引文件将根据记录 ID 进行排序。现在,如果我们想在这个索引中搜索一个 ID,我们能做的最好的就是进行二分查找。我们能做得更好吗?
定义
使用布隆过滤器快速查找一个元素是否可能存在于一个集合中。布隆过滤器数据结构可以告诉我们一个元素 可能存在于一个集合中,或者肯定不在。唯一可能的错误是误报,即搜索一个不存在的元素可能会给出一个错误的结果。随着过滤器中元素数量的增加,错误率也会增加。一个空的布隆过滤器是一个位数组,包含 m
位,全部设置为 0。还有 k
个不同的哈希函数,每个函数都将集合中的元素映射到 m
位位置中的一个。
-
要添加一个元素,将其输入到哈希函数中,得到
k
个位位置,并将这些位置的位设置为 1。 -
要测试一个元素是否在集合中,将其输入到哈希函数中,得到
k
个位位置。 -
- 如果这些位置中的任何一个位是 0,那么该元素 肯定不在 集合中。
- 如果全部是 1,那么该元素 可能在 集合中。
这里有一个包含三个元素 P
、Q
和 R
的布隆过滤器。它由 20 位组成,并使用了三个哈希函数。彩色箭头指向集合元素所映射到的位。
- 元素 X 肯定不在集合中,因为它散列到的位位置包含 0。
- 对于固定的误报率,添加一个新元素和测试成员资格都是常数时间操作,一个可容纳 n 个元素的过滤器需要 O(n) 空间。
案例: BigTable
在 BigTable(以及Cassandra)中,任何读取操作都必须从构成一个 Tablet 的所有 SSTables 中读取数据。如果这些 SSTables不在内存中,读取操作可能最终需要进行多次磁盘访问。为了减少磁盘访问次数,BigTable 使用了布隆过滤器。
布隆过滤器是为 SSTables 创建的(特别是为了局部性组)。它们通过预测一个 SSTable 是否可能包含对应于特定行或列对的数据来帮助减少磁盘访问次数。对于某些应用,使用少量 Tablet 服务器内存来存储布隆过滤器可以大幅减少磁盘寻道次数,从而提高读取性能。
2. Consistent Hashing (一致性哈希)
背景
将数据分布到一组节点上的行为被称为数据分区。当我们尝试分布数据时,会遇到两个挑战:
- 我们如何知道特定数据将存储在哪个节点上?
- 当我们添加或移除节点时,我们如何知道哪些数据将从现有节点移动到新节点?此外,当节点加入或离开时,我们如何最小化数据移动?
一个简单的方法是使用一个合适的哈希函数,将数据键映射到一个数字。然后,通过将这个数字与服务器总数进行模运算来找到服务器。例如:
上述图表中描述的方案解决了为存储/检索数据找到服务器的问题。但当我们添加或移除服务器时,我们必须重新映射所有键,并根据新的服务器数量移动我们的数据,这将会变得非常混乱!
定义
使用一致性哈希算法在节点间分布数据。一致性哈希将数据映射到物理节点,并确保在添加或移除服务器时只有一小部分键会移动。一致性哈希技术将分布式系统管理的数据存储在一个环中。环中的每个节点被分配了一段数据。下面是一个一致性哈希环的例子:
使用一致性哈希,环被划分为更小的、预定义的范围。每个节点被分配其中一个范围。范围的开始称为令牌。这意味着每个节点将被分配一个令牌。分配给每个节点的范围计算如下:
- 范围开始: 令牌值
- 范围结束: 下一个令牌值 - 1
这里是上述图表中描述的四个节点的令牌和数据范围:
Server | Token | Range Start | Range End |
---|---|---|---|
Server 1 | 1 | 1 | 25 |
Server 2 | 26 | 26 | 50 |
Server 3 | 51 | 51 | 75 |
Server 4 | 76 | 76 | 100 |
每当系统需要读取或写入数据时,它执行的第一步是对键应用MD5哈希算法。这个哈希算法的输出决定了数据位于哪个范围,因此决定了数据将存储在哪个节点上。正如我们上面看到的,每个节点应该存储固定范围的数据。因此从键生成的哈希会告诉我们数据存储在哪个节点上。
上述一致性哈希方案在从环中添加或移除节点时效果很好,因为在这些情况下,只有下一个节点受到影响。例如,当一个节点被移除时,下一个节点将负责所有存储在退出节点上的键。然而,这个方案可能导致数据和负载分布不均匀。这个问题可以通过使用虚拟节点来解决。
虚拟节点
在任何分布式系统中添加和移除节点都是相当常见的。现有的节点可能会失效,可能需要退役。同样,为了满足不断增长的需求,可能会向现有集群添加新节点。为了有效地处理这些情况,一致性哈希利用了虚拟节点(或V节点)。
正如我们上面看到的,基本的一致性哈希算法为每个物理节点分配一个令牌(或连续的哈希范围)。这是一个静态的范围划分,需要根据给定的节点数量计算令牌。这种方案使得添加或替换节点成为一个昂贵的操作,因为在这种情况下,我们希望重新平衡并分配数据到所有其他节点,导致移动大量数据。以下是与手动和固定范围划分相关的一些潜在问题:
- 添加或移除节点:添加或移除节点将导致重新计算令牌,这在大型集群中会造成显著的管理工作负担。
- 热点:由于每个节点被分配一个较大的范围,如果数据分布不均匀,一些节点可能成为热点。
- 节点重建:由于每个节点的数据可能在固定数量的其他节点上进行复制(以实现容错),当我们需要重建一个节点时,只有它的复制节点可以提供数据。这给复制节点带来很大压力,并可能导致服务降级。
为了处理这些问题,一致性哈希引入了一种新的分配令牌给物理节点的方案。不是给节点分配一个单一的令牌,而是将哈希范围划分为多个较小的范围,每个物理节点被分配多个这样的较小范围。这些子范围中的每一个都被视为一个虚拟节点(Vnode)。有了虚拟节点,节点不再只负责一个令牌,而是负责多个令牌(或子范围)。
实际上,虚拟节点在集群中随机分布,通常不连续,以确保没有两个相邻的虚拟节点被分配到同一个物理节点或机架上。此外,节点还携带其他节点的副本以实现容错。同时,由于集群中可能存在异构机器,一些服务器可能会持有比其他服务器更多的虚拟节点。下图展示了物理节点A、B、C、D和E如何使用一致性哈希环的虚拟节点。每个物理节点被分配了一组虚拟节点,每个虚拟节点被复制一次。
虚拟节点提供了以下优势:
- 由于虚拟节点通过将哈希范围划分为更小的子范围,帮助在集群的物理节点之间更均匀地分散负载,这加快了添加或移除节点后的重新平衡过程。当添加一个新节点时,它会从现有节点接收许多虚拟节点以维持集群的平衡。同样,当需要重建一个节点时,许多节点参与重建过程,而不是从固定数量的副本中获取数据。
- 虚拟节点使得维护包含异构机器的集群变得更容易。这意味着,使用虚拟节点,我们可以为强大的服务器分配更多的子范围,为不那么强大的服务器分配较少的子范围。
- 与一个大范围相比,由于虚拟节点帮助为每个物理节点分配更小的范围,这降低了热点出现的概率。
案例
Dynamo和Cassandra使用一致性哈希来在节点间分配它们的数据。
3. Quorum (仲裁)
背景
在分布式系统中,为了容错和高可用性,数据会被复制到多个服务器上。一旦系统决定维护数据的多个副本,就会出现另一个问题:如何确保所有副本都是一致的,即它们是否都拥有数据的最新副本,以及所有客户端是否看到数据的相同视图?
定义
在分布式环境中,仲裁是分布式操作在声明操作总体成功之前需要成功执行的服务器的最小数量。
假设一个数据库被复制到五台机器上。在这种情况下,仲裁是指对于给定事务执行相同操作(提交或中止)的机器的最小数量,以决定该事务的最终操作。因此,在一组五台机器中,三台机器形成多数仲裁,如果它们达成一致,我们将提交该操作。仲裁执行了分布式操作所需的一致性要求。
在具有多个副本的系统中,存在用户读取不一致数据的可能性。例如,当一个集群中有三台副本,R1、R2和R3,用户向副本R1写入值v1。然后另一个用户从仍然落后于R1的副本R2或R3读取,因此它们不会有值v1,所以第二个用户将无法获得数据的一致状态。
我们应该选择什么值作为仲裁?超过集群中节点数的一半:(N/2+1),其中N是集群中节点的总数,例如:
- 在5节点集群中,必须有三台节点在线才能构成多数。
- 在4节点集群中,必须有三台节点在线才能构成多数。
- 对于5节点,系统可以承受两个节点故障,而对于4节点,它只能承受一个节点故障。正因为如此,建议集群中的总节点数总是奇数。
当节点遵循以下协议时,就实现了quorum:R+W>N,其中:
- N = quorum组中的节点数
- W = 最小写节点数
- R = 最小读节点数
如果分布式系统遵循R+W>N规则,那么每次读取都会看到至少一份最新值的副本。例如,一个常见的配置可能是(N=3, W=2, R=2)以确保强一致性。这里有其他几个例子:
- (N=3, W=1, R=3):快速写入,慢速读取,持久性不高
- (N=3, W=3, R=1):慢速写入,快速读取,持久性高
在决定读写quorum之前,应考虑以下两件事:
- R=1 和 W=N ⇒ 完全复制(全部写入,一次读取):当服务器可能不可用时不受欢迎,因为不能保证写入一定会完成。
- 当1<r<w<n时,最佳性能(吞吐量/可用性),因为在大多数应用程序中,读取比写入更频繁。
案例
- 对于领导者选举,Chubby 使用 Paxos,Paxos 使用仲裁来确保强一致性。
- 正如上面所述,仲裁也用于确保在发生故障的情况下至少有一个节点接收到更新。例如,在 Cassandra 中,为了确保数据一致性,每个写请求可以配置为只有在数据被写入至少仲裁(或多数)的副本节点时才成功。
- Dynamo 将写事件复制到系统中其他节点的sloppy quorum,而不是像 Paxos 那样严格的多数quorum。所有读写操作都在偏好列表中的第一个健康节点上执行,这些节点可能并不总是一致性哈希环遍历时遇到的第一个节点。
补充
在分布式系统中,仲裁 (Quorum) 是一个关键概念,它确保了数据的一致性和系统的可用性,尤其是在数据被复制到多台服务器上时。
什么是仲裁?
当数据在多台服务器上拥有副本时,我们面临一个挑战:如何确保所有副本都保持一致,并且所有客户端都能看到最新、相同的数据视图?仲裁正是为了解决这个问题而存在的。
简单来说,仲裁是指一个分布式操作在被认定为“成功”之前,必须成功执行的服务器的最小数量。
例如,如果一个数据库被复制到五台机器上,那么仲裁就是指,对于某个事务,至少有多少台机器(比如三台)需要达成一致(提交或中止),才能决定该事务的最终结果。这三台机器就构成了“多数仲裁”。仲裁机制强制了分布式操作所需的一致性要求。
为什么需要仲裁?
在没有仲裁机制的系统中,用户可能会读取到不一致的数据。想象一个拥有三个副本 (R1, R2, R3) 的集群。如果一个用户向 R1 写入了值
v1
,而另一个用户随后从 R2 或 R3 读取,如果 R2 或 R3 还没有收到v1
的更新,那么第二个用户将无法获取到数据的一致状态。仲裁通过确保读写操作覆盖足够多的副本,从而避免了这种不一致性。如何选择仲裁值?
通常,仲裁值会选择超过集群节点总数的一半:(N/2 + 1),其中 N 是集群中节点的总数。
- 在一个 5 节点集群中,至少需要 3 台节点在线才能构成多数仲裁。这意味着系统可以承受 2 个节点的故障。
- 在一个 4 节点集群中,同样需要至少 3 台节点在线才能构成多数仲裁。但这种情况下,系统只能承受 1 个节点的故障。
正因为如此,建议集群中的总节点数总是奇数,这样可以提供更好的故障容忍能力。
仲裁协议:R + W > N
当节点遵循 R + W > N 协议时,就能实现强一致性,其中:
- N = 仲裁组中的节点数
- W = 最小写入节点数(即,写入操作需要多少个节点确认才算成功)
- R = 最小读取节点数(即,读取操作需要查询多少个节点才算成功)
如果一个分布式系统遵循 R+W>N 的规则,那么每次读取都会确保至少读到一份包含最新值的副本。这是因为任何写入操作(覆盖 W 个节点)和任何读取操作(覆盖 R 个节点)都必然会有重叠,从而确保读取能够看到最新的数据。
以下是一些常见的配置示例及其权衡:
(N=3, W=2, R=2):这是一个常见的配置,用于确保强一致性。读写操作都需要多数节点确认,确保数据始终保持一致。
(N=3, W=1, R=3)
:
- 写入速度快:因为只需要一个节点确认写入。
- 读取速度慢:因为需要查询所有节点来获取最新数据。
- 持久性不高:如果那个接收写入的单一节点在数据传播之前发生故障,数据可能会丢失。
(N=3, W=3, R=1)
:
- 写入速度慢:因为所有节点都必须确认写入。
- 读取速度快:因为只需要查询一个节点。
- 持久性高:数据写入所有节点。但当服务器可能不可用时,这种配置并不受欢迎,因为只要有一个节点故障,写入就无法完成。
决定读写仲裁时的考量
在决定读写仲裁值之前,应考虑以下两点:
- R=1 和 W=N(完全复制:全部写入,一次读取): 当服务器可能不可用时,这种配置不被推荐。因为如果任何一个节点故障,写入操作就无法完成,会严重影响系统的可用性。
- 1<R<W<N 时,通常能获得最佳性能(吞吐量/可用性): 在大多数应用程序中,读取操作比写入操作更频繁。在这种配置下,可以通过优化读取性能来提高整体的系统效率,同时仍然保证一致性。
4. Leader and Follower (跟随者和领导者)
背景
分布式系统为了容错和更高的可用性而保留数据的多个副本。系统可以使用quorum来确保副本之间的数据一致性,即所有读写操作只有在大多数节点参与后才被视为成功。然而,使用quorum可能会导致另一个问题,那就是降低了可用性;在任何时候,系统都需要确保至少大多数副本是启动和可用的,否则操作将失败。quorum也不总是足够的,因为在某些故障场景下,客户端仍然可能看到不一致的数据。
定义
在任何时候,都会选举一个服务器作为领导者。这个领导者负责数据复制,并可以作为所有协调工作的中心点。跟随者只接受来自领导者的写入,并作为备份。如果领导者失败,其中一个跟随者可以成为领导者。在某些情况下,跟随者可以提供读取请求以进行负载均衡。
案例
- 在 Kafka 中,每个分区都有一个指定的领导者,负责该分区的所有读取和写入操作。每个跟随者的职责是复制领导者的数据,作为“备份”分区。这提供了分区中消息的冗余,因此如果领导者出现问题,跟随者可以接管领导权。
- 在 Kafka 集群中,会选举一个代理作为控制器。这个控制器负责管理操作,如创建/删除主题、添加分区、为分区分配领导者、监控代理故障等。此外,控制器还会定期检查系统中其他代理的健康状况。
- 为了确保强一致性,Paxos(因此是 Chubby)在启动时执行领导者选举。这个领导者负责数据复制和协调。
5. Write-ahead Log (预写日志)
背景
机器可能随时出现故障或重新启动。如果一个程序正在执行数据修改操作,当它运行的机器突然断电会发生什么?当机器重新启动时,程序可能需要知道它最后在做什么。根据其原子性和持久性需求,程序可能需要决定重做、撤销或完成它已经开始的工作。程序如何知道系统崩溃前它在做什么?
定义
为了确保持久性和数据完整性,对系统的每次修改首先被写入磁盘上的一个 仅追加日志。这个日志被称为 预写日志(WAL)或事务日志或提交日志。写入WAL保证了如果机器崩溃,系统将能够恢复并在必要时重新应用操作。
WAL背后的关键是,在将所有修改应用到系统之前,首先将它们写入磁盘上的日志文件。每个日志条目应包含足够的信息来重做或撤销修改。每次重启时都可以读取日志,通过重放所有日志条目来恢复先前的状态。使用WAL可以显著减少磁盘写入次数,因为只需要将日志文件刷新到磁盘,以保证事务已提交,而不需要将事务更改的每个数据文件都刷新到磁盘。
在分布式环境中,每个节点都维护自己的日志。WAL总是顺序追加,这简化了日志的处理。每个日志条目都被赋予一个唯一的标识符;这个标识符有助于实现某些其他操作,如日志分段(稍后讨论)或日志清除。
案例
- Cassandra:为了确保持久性,每当一个节点接收到写请求时,它立即将数据写入一个预写日志(WAL),即提交日志。Cassandra在将数据写入MemTable之前,首先将其写入提交日志。这在意外关闭的情况下提供了持久性。在启动时,提交日志中的任何修改都将应用于MemTables。
- Kafka实现了一个分布式提交日志,以持久化存储它接收到的所有消息。
- Chubby:为了容错,在领导者崩溃的情况下,所有数据库事务都存储在一个事务日志中,这是一个WAL。
6. Segmented Log (分段日志)
背景
单个日志文件可能变得难以管理。随着文件的增长,它还可能成为性能瓶颈,尤其是在启动时读取它时。需要定期清理旧日志,或者在某些情况下,进行合并。在单个大文件上执行这些操作是很难实现的。
定义
将日志分解成更小的片段以便于管理。单个日志文件被分割成多个部分,使得日志数据被划分成等大小的日志片段。系统可以根据滚动策略来滚动日志——要么是可配置的时间周期(例如,每4小时),要么是可配置的最大大小(例如,每1GB)。
案例
- Cassandra 使用分段日志策略,将其提交日志分割成多个较小的文件,而不是单个大文件,以便于操作。众所周知,当一个节点接收到写操作时,它会立即将数据写入提交日志。随着提交日志的大小增长并达到其大小阈值,就会创建一个新的提交日志。因此,随着时间的推移,会存在多个提交日志,每个提交日志都被称为一个片段。提交日志片段减少了写入磁盘所需的寻道次数。当 Cassandra 将相应数据刷新到 SSTables 后,会截断提交日志片段。一旦所有数据都被刷新到 SSTables,提交日志片段可以被归档、删除或回收。
- Kafka 使用日志分段来实现其分区的存储。由于 Kafka 经常需要在磁盘上查找消息以进行清除,一个单一的长文件可能是性能瓶颈且容易出错。为了更容易管理和更好的性能,分区被分割成多个片段。
7. High-Water Mark (高水位标记)
背景
分布式系统为了容错和更高的可用性而保存数据的多个副本。为了实现强一致性,一个选项是使用 领导者-跟随者 设置,其中领导者负责处理所有的写入操作,而跟随者从领导者复制数据。
领导者上的每笔事务都会提交到预写日志(WAL),这样领导者就可以从崩溃或故障中恢复。一旦写请求被提交到领导者的WAL,就被认为是成功的。复制可以异步进行;领导者可以将变更推送给跟随者,或者跟随者可以从领导者那里拉取。如果领导者崩溃并且无法恢复,其中一个跟随者将被选为新的领导者。现在,这个新的领导者可能比旧的领导者稍微落后,因为在旧领导者崩溃之前可能有一些事务没有完全传播。我们在旧领导者的WAL上有这些事务,但直到旧领导者再次活跃起来,这些日志条目才无法恢复。所以这些事务被视为丢失的。在这种情况下,客户端可能会看到一些数据不一致性,例如,客户端从旧领导者那里获取的最后的数据可能不再可用。在这种错误情况下,一些跟随者可能会在他们的日志中丢失条目,而另一些可能有比其他跟随者更多的条目。因此,对于领导者和跟随者来说,了解日志的哪一部分可以安全地暴露给客户端变得很重要。
定义
跟踪领导者上 最后一条已成功复制到多数跟随者的日志条目。这个条目在日志中的索引被称为 高水位标记索引。领导者只公开到高水位标记索引的数据。
对于每次数据变更,领导者首先将其追加到WAL,然后发送给所有跟随者。在接收到请求后,跟随者将其追加到它们各自的WAL,然后向领导者发送确认。领导者跟踪已在每个跟随者上成功复制的条目的索引。高水位标记索引是指已在多数跟随者中复制的最高索引。领导者可以将高水位标记索引作为常规心跳消息的一部分传播给所有跟随者。领导者和跟随者确保客户端只能读取到高水位标记索引的数据。这保证了即使当前领导者失败并且选举了另一个领导者,客户端也不会看到任何数据不一致性。
案例
Kafka:为了处理不可重复读取并确保数据一致性,Kafka 代理跟踪高水位标记,这是特定分区的所有同步副本(ISRs)共享的最大偏移量。消费者只能看到直到高水位标记的消息。
8. Lease (租约)
背景
在分布式系统中,很多时候客户端需要对某些资源指定权限。例如,一个客户端可能需要对文件内容进行更新的独占权限。满足这一要求的一种方式是通过分布式锁定。客户端首先获取与文件关联的独占(或写入)锁,然后继续更新文件。锁定的一个问题是,锁会在锁定客户端显式释放之前一直被授权。如果客户端由于任何原因未能释放锁,例如,进程崩溃、死锁或软件缺陷,资源将被无限期锁定。这将导致资源不可用,直到系统重置。有没有替代解决方案?
定义
使用 有时间限制 的租约来授予客户端对资源的权利。租约类似于锁,但它在客户端离开时也能工作。客户端请求一个有限期限的租约,租约到期后会自动失效。如果客户端想要延长租约,它可以在租约到期前续租。
案例
Chubby 客户端与领导者保持一个有时间限制的会话租约。在这段时间内,领导者保证不会单方面终止会话。
9. Heartbeat (心跳消息)
背景
在分布式环境中,工作/数据分布在服务器之间。为了在这样的设置中有效地路由请求,服务器需要知道哪些其他服务器是系统的一部分。此外,服务器应该知道其他服务器是否活跃并正常工作。在去中心化的系统中,每当请求到达服务器时,服务器应该有足够的信息来决定哪个服务器负责处理该请求。这使得及时检测服务器故障成为一个重要任务,这也使得系统能够采取纠正措施,将数据/工作转移到另一个健康的服务器,并阻止环境进一步恶化。
定义
每个服务器定期向中央监控服务器或系统中的其他服务器发送 心跳消息,以表明 它仍然 活跃并正常运行。心跳是分布式系统中 检测故障 的一种机制。如果有中央服务器,所有服务器都会定期向它发送心跳消息。如果没有中央服务器,所有服务器会随机选择一组服务器,并且每隔几秒钟向它们发送心跳消息。这样,如果一段时间没有收到某个服务器的心跳消息,系统可以怀疑该服务器可能已经崩溃。如果在配置的超时期间内没有心跳,系统可以得出结论,该服务器不再活跃,并停止向其发送请求,并开始处理其替代问题。
案例
- GFS:领导者定期通过心跳消息与每个块服务器通信,以给出指令并收集状态信息。
- HDFS:名称节点通过心跳机制跟踪数据节点。每个数据节点定期向名称节点发送心跳消息(每隔几秒钟)。如果一个数据节点死亡,那么发送到名称节点的心跳就会停止。如果未收到心跳消息的数量达到某个阈值,名称节点就会检测到数据节点已经死亡。然后名称节点将数据节点标记为死亡,并且不再向该数据节点转发任何I/O请求。
10. Gossip Protocol (八卦协议)
背景
在没有中央节点来跟踪所有节点以了解节点是否宕机的大型分布式环境中,节点如何知道每个其他节点的当前状态?最简单的方法是让每个节点与其他每个节点保持心跳。然后,当一个节点宕机时,它将停止发送心跳,其他所有节点会立即发现。但是,这意味着每个时间点会发送 O(N^2) 条消息(N 是节点总数),这是一个非常高的数量,会消耗大量的网络带宽,因此在任何规模较大的集群中都是不可行的。那么,有没有其他监控集群状态的选项呢?
定义
每个节点跟踪集群中其他节点的状态信息,并每秒向另一个随机节点传播(即分享)这些信息。这样最终每个节点都会了解到集群中每个其他节点的状态。八卦协议是一种点对点通信机制,节点定期交换关于自己和其他节点的状态信息。每个节点每秒启动一轮八卦,与另一个随机节点交换关于自己和其他节点的状态信息。这意味着任何状态变化最终都会在系统中传播,所有节点很快就会了解到集群中的所有其他节点。
补充
背景:去中心化状态同步的挑战
在大型分布式系统中,维护集群中所有节点的最新状态是一个巨大的挑战。如果存在一个中心节点来跟踪所有其他节点的健康状况(例如,通过心跳机制),那么这个中心节点将成为单点故障,并且随着集群规模的扩大,N 个节点需要向中心节点发送心跳,中心节点也可能需要回应或通知其他节点,这会导致中心节点的巨大压力。
更直接的方法是让每个节点都与其他所有节点保持心跳连接。当一个节点宕机时,它会停止发送心跳,其他所有节点会立即发现。然而,这种“全连接”的心跳模式会产生 O(N2) 的消息数量(N 是节点总数),在任何大规模的集群中,这都将消耗大量的网络带宽并变得不可行。
那么,有没有一种更高效、更去中心化的方式来监控集群状态呢?这就是“八卦协议”发挥作用的地方。
定义:点对点、随机、最终一致的状态传播
八卦协议(Gossip Protocol) 是一种点对点通信机制,其核心思想是让集群中的每个节点定期地(通常是随机地)与其他节点交换关于自身和它们所知道的其他节点的状态信息。
工作原理
:
- 定期启动:每个节点会周期性地(例如,每秒一次)发起一轮八卦。
- 随机选择:在每一轮八卦中,节点会随机选择集群中的一个或几个其他节点进行通信。
- 信息交换:被选中的节点之间会交换它们各自所知道的关于集群中所有其他节点的状态信息(例如,哪些节点是活跃的,哪些节点可能已经宕机,版本信息等)。这些信息通常包括一个时间戳或版本号,以确保只传播最新的状态。
- 信息传播:通过这种随机、迭代的传播方式,任何节点的状态变化(比如一个节点宕机)最终都会在整个集群中传播开来,所有节点最终都会了解到集群中其他节点的所有状态。
特点与优势
八卦协议之所以在大型分布式系统中广泛应用,是因为它具有以下显著的特点和优势:
- 去中心化 (Decentralized):没有单点故障。每个节点都独立地参与信息传播,即使部分节点失效,协议也能继续工作。
- 高容错性 (Highly Fault-Tolerant):由于信息的冗余传播和随机性,即使某些消息丢失或某些节点短暂离线,状态信息最终也能达到所有健康节点。
- 可伸缩性 (Scalability):消息数量是 O(N) 而不是 O(N2)。每个节点只与少数节点通信,而不是所有节点,因此它能很好地扩展到非常大的集群。随着节点数量的增加,每个节点发送的消息数量并没有显著增加。
- 最终一致性 (Eventual Consistency):状态信息不是瞬时同步的,但经过一定时间后,整个集群的节点状态会达到一致。对于节点健康状态的监控,这种最终一致性通常是可接受的。
- 简单性 (Simplicity):协议本身相对简单,易于实现和理解。
局限性
- 延迟 (Latency):状态传播存在一定的延迟,不适合需要严格实时一致性的场景。
- 不保证所有节点都收到所有信息 (No Guarantee of Receipt for All Information):由于随机性,理论上存在某些信息在某些节点之间传播缓慢或未到达的情况,但通常在实际系统中会通过重试和超时机制来缓解。
应用场景
八卦协议被广泛应用于需要去中心化集群成员管理、故障检测和状态同步的场景,例如:
- Cassandra:使用八卦协议进行节点间的数据复制、成员管理和故障检测。
- Riak:类似地,用于集群成员管理和数据复制。
- Kubernetes:在一些组件中也可能使用类似八卦的模式进行状态同步。
- Consul:其 SWIM 协议是八卦协议的一种优化形式,用于更高效的故障检测。
11. Phi Accrual Failure Detection (Phi 累积故障检测)
背景
在分布式系统中,准确检测故障是一个难以解决的问题,因为我们不能 100% 确定一个系统是真的宕机了,还是由于重负载、网络拥堵等原因响应非常慢。传统的故障检测机制,如心跳检测,输出一个布尔值告诉我们系统是否存活;没有中间地带。心跳检测使用固定的超时时间,如果没有从服务器收到心跳,系统在超时后假设服务器已经崩溃。在这里,超时值非常关键。如果我们将超时时间设置得很短,系统将能够快速检测到故障,但由于慢速机器或网络故障,会产生许多误报。另一方面,如果我们将超时时间设置得较长,误报会减少,但系统在检测故障方面的效率会降低,因为它在检测故障方面反应慢。
定义
使用 Phi Accrual Failure Detector 描述的自适应故障检测算法。Accrual 意味着积累,或者是随时间积累的行为。这个算法使用历史心跳信息来使阈值自适应。Accrual Failure Detector 不是告诉我们服务器是否存活,而是输出对服务器的 怀疑级别。怀疑级别越高,意味着服务器宕机的可能性越大。使用 Phi Accrual Failure Detector,如果一个节点没有响应,其怀疑级别会增加,并且可能在稍后被宣告死亡。随着一个节点的怀疑级别增加,系统可以逐渐停止向它发送新的请求。Phi Accrual Failure Detector 使分布式系统高效,因为它在宣布系统完全死亡之前,会考虑到网络环境的波动和其他间歇性的服务器问题。
案例
Cassandra 使用 Phi Accrual Failure Detector 算法来确定集群中节点的状态。
Phi Accrual Failure Detection (Phi 累积故障检测)
Phi Accrual Failure Detection 是一种在分布式系统中用于判断远程节点是否发生故障的算法。它由 Akka 框架的开发者提出,并被广泛应用于许多分布式系统(例如 Akka、Cassandra 等)中,作为一种比简单的心跳超时机制更智能、更鲁棒的故障检测方法。
为什么需要它?
传统的故障检测方法(如简单的心跳超时)通常是设置一个固定的超时时间:如果一个节点在 X 时间内没有收到另一个节点的心跳,就认为对方故障了。这种方法存在一个主要问题:
- 固定阈值的局限性:在网络状况不稳定、延迟波动大的分布式环境中,一个固定的超时时间很难设定。
- 如果超时时间设置得太短,可能会因为临时的网络延迟而误判节点故障(“假阳性”)。
- 如果超时时间设置得太长,则无法及时检测到真正的节点故障,影响系统的响应速度(“假阴性”)。
Phi Accrual Failure Detection 旨在解决这个痛点,它提供了一种“累积”的、自适应的故障判断方式,而不是一个简单的二元判断。
核心思想:概率与不确定性
Phi Accrual Failure Detection 的核心思想是:不直接判断一个节点是否故障,而是计算一个“不确定度”或“怀疑度”的值(Phi 值)。这个 Phi 值越大,就表示我们越有理由怀疑远程节点已经故障了。
它基于以下几个关键概念:
- 心跳间隔的统计分布:它会持续观察和记录从远程节点收到的心跳消息的到达时间间隔。
- 动态计算平均值和标准差:基于这些历史心跳间隔,算法会动态地计算出心跳间隔的平均值和标准差。这使得算法能够适应网络延迟的变化。
- 计算 Phi 值 (Φ):Phi 值代表了“在给定的历史心跳间隔统计数据下,当前收不到心跳的这种情况发生的概率有多小”。
- 具体来说,Phi 值是根据当前距离上次收到心跳的时间 tlatest,以及历史心跳间隔的分布(通常假设为正态分布或指数分布),来计算“从现在起,再等 tlatest 时间后仍然收不到心跳”的概率的负对数。
- 公式通常涉及到概率分布的累积分布函数(CDF)。一个简化理解:如果过去心跳都很准时,现在很久没收到心跳了,那么这个事件(很久没收到心跳)的概率就非常低,Phi 值就会很高。
Phi 值的含义
- Phi 值越小:表示当前情况(比如心跳延迟)是正常的、预期的,不太可能是故障。
- Phi 值越大:表示当前情况(比如心跳延迟)是异常的、不太可能仅仅是网络延迟,从而我们怀疑节点可能已经故障。
系统会设置一个故障阈值 (threshold),例如,当 Phi 值超过 1、3、5 或 10 时,系统就会认为该节点可能已经故障了。
- Φ=1: 意味着在给定历史数据的情况下,没有收到心跳的概率约为 1/3 (33%)。
- Φ=2: 意味着没有收到心跳的概率约为 1/7 (14%)。
- Φ=3: 意味着没有收到心跳的概率约为 1/13 (7.6%)。
- Φ=5: 意味着没有收到心跳的概率约为 1/76 (1.3%)。
- Φ=8: 意味着没有收到心跳的概率约为 1/2980 (0.03%)。
通常,在实际应用中,会将阈值设置为 3 到 8 之间,具体取决于系统对假阳性(误判)的容忍度。
优势
- 自适应性:能够根据网络延迟的波动自动调整判断标准,减少误判。
- 鲁棒性:在不稳定的网络环境下表现更好。
- 平滑性:故障判断不再是突然的二元切换,而是逐渐累积的怀疑度,这有助于系统更平滑地处理节点状态变化。
- 可配置性:可以通过调整 Phi 阈值来平衡误判率和故障检测速度。
局限性
- 计算开销:相比简单的超时机制,Phi 值的计算需要维护历史心跳数据并进行统计分析,开销略高。
- 初始阶段:在系统启动初期,由于缺乏足够的心跳数据,Phi 值可能不够准确。
总结
Phi Accrual Failure Detection 是一种先进的故障检测算法,它通过动态分析心跳间隔的统计分布来计算一个“怀疑度”值 (Phi),从而提供了一种自适应、鲁棒且可配置的故障检测机制。它特别适用于那些对网络延迟波动敏感,且需要高可用性和低误判率的分布式系统。
12. Split Brain (脑裂)
背景
在具有中央(或领导者)服务器的分布式环境中,如果中央服务器死亡,系统必须迅速找到替代品,否则系统可能会迅速恶化。一个问题是我们无法真正知道领导者是否永久停止了,或者是否经历了像全局垃圾回收暂停或临时网络中断这样的间歇性故障。尽管如此,集群必须继续前进并选择一个新的领导者。如果原始领导者经历了间歇性故障,我们现在发现自己面临着所谓的 僵尸领导者。僵尸领导者可以被定义为一个被系统认为已经死亡并且已经重新上线的领导者节点。另一个节点已经取代了它的位置,但僵尸领导者可能还不知道这一点。系统现在有两个活跃的领导者,它们可能会发出相互冲突的命令。系统如何检测这种情况,以便系统中的所有节点都可以忽略旧领导者的请求,旧领导者本身也可以检测到它不再是领导者?
定义
分布式系统中常见的一个场景是有两个或更多的活跃领导者,这种情况被称为 脑裂(split-brain)。通过使用 代钟(Generation Clock)来解决脑裂问题,代钟本质上是一个单调递增的数字,用来表示服务器的代(“代际” 或 “纪元”)。每次选举新领导者时,代数字就会增加。这意味着如果旧领导者的代数字是“1”,则新领导者的将是“2”。这个代数字包含在领导者发送给其他节点的每个请求中。这样,节点现在可以通过简单地信任数字最高的领导者来轻松区分真正的领导者。代数字应该保存在磁盘上,以便在服务器重启后仍然可用。一种方法是将其与预写日志中的每个条目一起存储。
案例
- Kafka:为了处理脑裂(我们可能拥有多个活跃的控制器代理),Kafka 使用“纪元号”,它是一个单调递增的数字,用来表示服务器的代。
- HDFS:使用 ZooKeeper 确保在任何时候只有一个 NameNode 是活跃的。每个事务 ID 都维护一个纪元号,以反映 NameNode 的代。
- Cassandra 使用代数字来区分节点在重启之前和之后的状态。每个节点存储一个代数字,每次节点重启时都会增加。这个代数字包含在节点之间交换的八卦消息中,并用来区分节点当前状态和重启之前的状态。只要节点处于活动状态,代数字就保持不变,并在每次节点重启时增加。接收八卦消息的节点可以比较它所知道的代数字和八卦消息中的代数字。如果八卦消息中的代数字更高,它就知道节点已经重启了。
补充
脑裂(Split-Brain)问题及其解决方案:代钟(Generation Clock)
在分布式系统中,脑裂(Split-Brain)是一个非常危险且需要极力避免的场景。它指的是在应该只有一个活跃领导者(Leader)或主节点(Primary)的情况下,系统中却出现了两个或更多个节点都认为自己是当前的领导者。这通常发生在网络分区(network partition)期间,当集群被切分成两个或多个互不通信的子集时,每个子集都可能独立地选举出自己的领导者。
脑裂带来的后果是灾难性的:
- 数据不一致:不同的领导者可能会接受并处理不同的写请求,导致数据在各个分区之间变得不一致。
- 服务中断或行为异常:客户端可能连接到“错误的”领导者,或者在不同领导者之间来回切换,导致请求失败或系统行为混乱。
- 资源冲突:如果领导者管理共享资源(如分布式锁、独占性服务),多个领导者可能会试图同时控制这些资源,造成冲突和损坏。
解决方案:使用代钟(Generation Clock)
为了解决脑裂问题,分布式系统引入了各种机制,其中代钟(Generation Clock)是一种非常有效且常用的策略。
代钟的本质
代钟本质上是一个单调递增的数字,用于表示服务器或领导者的“代际”或“纪元”。
- 递增机制:每当系统选举出新的领导者时,这个代数字就会递增。这意味着,如果旧领导者的代数字是
1
,那么新选举出的领导者的代数字将是2
。- 信息包含:新的领导者在发送给集群中其他所有节点(包括旧的领导者和Follower节点)的每一个请求或心跳消息中,都会包含这个当前的代数字。
- 判断依据:当任何一个节点收到来自领导者的消息时,它不再仅仅判断消息是否来自一个“领导者”,而是会比较消息中携带的代数字。它会简单地信任和服从代数字最高的那个领导者。
工作原理与脑裂解决
- 新领导者诞生:当发生网络分区或旧领导者宕机,系统触发新的领导者选举。
- 代数字增长:新选举出的领导者会将自己的代数字加一(例如,从
1
变为2
)。- 旧领导者被“废黜”:假设网络分区恢复,或者旧的领导者(带着旧的代数字
1
)再次尝试发送命令。当其他节点(包括可能还在运行的旧领导者自己)收到来自新领导者(带着代数字2
)的请求时,它们会立即识别出代数字2
更高。- 强制下线:任何发现自己代数字不是最高的节点(即旧的领导者)会自动退位,降级为Follower,或者直接停止服务,从而避免了与新领导者的冲突。
持久化:确保正确性
代数字必须被持久化存储在磁盘上,以确保在服务器重启后仍然可用。如果代数字只存在于内存中,服务器重启后可能会丢失最新的代数字,从而导致它以一个旧的代数字启动并错误地认为自己是领导者,再次引发脑裂。
一种常见的持久化方法是将其与预写日志(Write-Ahead Log, WAL)中的每个条目一起存储。这样,每当有重要的状态更新(包括领导者变更)时,代数字也会随之写入持久存储,确保数据的完整性和代际的正确性。
总结
代钟机制为分布式系统提供了一个简单而强大的方式来解决脑裂问题。通过引入一个单调递增的“代际”标识,系统能够清晰地区分出真正的领导者,并强制旧的或错误认知的领导者退位,从而维护系统的一致性和稳定性,避免了灾难性的数据不一致和行为异常。
13. Fencing (围栏)
背景
在领导者-跟随者设置中,当领导者失败时,我们无法确定领导者是否停止工作。例如,慢速网络或网络分区可能会触发新的领导者选举,尽管以前的领导者仍在运行并认为它仍然是活跃的领导者。在这种情况下,如果系统选举了一个新的领导者,我们如何确保旧的领导者没有在运行,并且可能发出冲突的命令?
定义
为前领导者设置“围栏”,以防止其造成任何损害或引起腐败。围栏的概念是在先前活跃的领导者周围设置围栏,使其无法访问集群资源,从而停止提供任何读/写请求。以下是使用的两种技术:
- 资源围栏:在这种方案中,系统阻止先前活跃的领导者访问执行基本任务所需的资源。例如,撤销其对共享存储目录的访问权限(通常通过使用供应商特定的网络文件系统(NFS)命令),或者通过远程管理命令禁用其网络端口。
- 节点围栏:在这种方案中,系统阻止先前活跃的领导者访问所有资源。一种常见的方法是关闭电源或重置节点。这是一种非常有效的方法,可以阻止它访问任何东西。这种技术也称为 STONIT 或“向另一个节点的头部射击”。
案例
HDFS 使用围栏技术阻止先前活跃的 NameNode 访问集群资源,从而阻止其处理请求。
代钟 (Generation Clock) 和围栏 (Fencing) 在分布式系统中都是为了解决领导者选举和脑裂问题的关键机制,但它们扮演的角色和作用的阶段是不同的,它们是互补而非互斥的。
代钟更像是一种“内部协调机制”,它通过版本号让所有节点对“谁是老大”达成共识,并期望旧老大能“自觉”退位。而围栏则是一种“外部强制执行机制”,它是在代钟无法确保旧老大完全停止作恶时,采取的最后一道防线,通过物理手段(如切断电源或撤销资源访问)来确保旧老大不再对系统造成影响。
在大多数高可用分布式系统中,两者都是不可或缺的互补组件。代钟确保了逻辑上的正确性,而围栏则提供了物理上的安全保障,共同维护系统的健壮性和数据一致性。
14. Checksum (校验和)
背景
在分布式系统中,在组件之间移动数据时,从节点获取的数据可能会损坏。这种损坏可能是由于存储设备、网络、软件等的故障造成的。分布式系统如何确保数据完整性,以便客户端接收到错误消息而不是损坏的数据?
定义
计算 checksum 并将其与数据一起存储。计算 checksum 时,使用 MD5、SHA-1、SHA-256 或 SHA-512 等加密哈希函数。哈希函数接受输入数据并产生一个固定长度的字符串(包含字母和数字);这个字符串被称为 checksum。
当系统存储一些数据时,它会计算数据的 checksum,并将 checksum 与数据一起存储。当客户端检索数据时,它会验证从服务器接收到的数据是否与存储的 checksum 匹配。如果没有匹配,那么客户端可以选择从另一个副本检索数据。
案例
HDFS 和 Chubby 将每个文件的 checksum 与数据一起存储。
补充
校验和碰撞(Checksum Collision):原始数据(data)和 checksum(old checksum) 都已经损坏,但计算出的新校验和(new checksum)却与传输过来的校验和(old checksum)恰好相等。
尽管对于加密哈希函数(如 SHA-256)来说,发生这种概率极低,但在理论上或在特定条件下(如使用了强度较低的哈希算法)是可能发生的。
不过,虽然理论上存在“损坏数据和旧校验和相同”的极小概率事件,但现代分布式系统通过以下多层防护机制,使得这种风险在实践中变得微乎其微:
- 使用强加密哈希函数来计算校验和。
- 实施多层、多粒度的校验和。
- 最重要的是,依赖数据冗余和多副本机制,结合后台审计和读修复,确保即使单个副本出现无法被校验和检测到的损坏,系统也能从其他健康副本中恢复。
因此,虽然单一的校验和可能无法解决所有问题,但它作为一套综合数据完整性策略的一部分,与其他机制协同工作,提供了极强的可靠性。
1. 使用强加密哈希函数
这是最基本也是最重要的措施。MD5 和 SHA-1 等算法已被证明存在弱点(容易被构造出碰撞),因此不应再用于高安全性或高完整性要求的场景。
- 应该使用更强壮的哈希算法,如 SHA-256、SHA-512。这些算法被设计成具有极低的碰撞概率。理论上,要找到两个不同输入产生相同 SHA-256 值的计算成本是天文数字,远远超过任何现实世界中的攻击能力或随机损坏的概率。
- 即使是存储设备损坏导致的随机位翻转,能够正好产生一个相同校验和的概率也是微乎其微。
2. 多层校验和 (Multi-level Checksumming)
不只在数据块层面计算校验和,还可以在多个粒度上进行校验:
- 文件级别校验和:对整个文件计算一个校验和。
- 块级别校验和:将文件分解成多个数据块,每个数据块都计算一个校验和。这是 HDFS 等分布式文件系统常用的方式。
- 网络传输校验和:TCP/IP 协议栈本身在数据链路层和传输层也有其自身的校验和机制(如 TCP checksum),但这些校验和通常较弱,只能检测到常见的传输错误,而非专门针对数据完整性。文件/应用层面的校验和是更高层级的保障。
如果校验和是在多个粒度上进行的,即使一个块的校验和“侥幸”通过了碰撞,整个文件的校验和或相邻块的校验和也更有可能暴露问题。
3. 数据冗余和副本 (Data Redundancy and Replication)
这是分布式系统对抗数据损坏的终极武器:
- 多副本存储:将同一份数据复制到至少两到三个不同的节点上。
- 独立校验:每个副本的数据和其校验和都是独立存储和验证的。
- 修复机制:如果某个副本的数据损坏(无论是否发生校验和碰撞),系统可以从健康的副本中检测到不一致性,并使用健康的副本重建或修复损坏的副本。
- N-Way Replication + Read Repair:例如,Cassandra 等系统会存储多个副本。在读取时,客户端可能从多个副本读取数据,如果发现数据不一致(包括校验和不匹配),会触发读修复(Read Repair)机制,用最新或最一致的副本去修复其他不一致的副本。
即使校验和算法本身无法检测到碰撞,多副本之间的一致性检查(例如,比较多个副本的校验和,或者直接比较数据内容)也能发现问题。
4. 定期数据审计/Scrubbing
大型分布式存储系统会定期在后台运行数据审计或“Scrubbing”进程。
- 这些进程会主动读取存储在磁盘上的数据块,重新计算它们的校验和,并与存储的校验和进行比较。
- 这是一种预防性措施,可以在客户端访问之前就发现并修复潜在的数据损坏。
5. 错误纠正码 (Error-Correcting Codes - ECC)
在某些对数据完整性要求极高的场景(例如内存、硬盘内部固件、某些分布式存储系统),会使用更复杂的错误纠正码。
- ECC 不仅能检测错误,还能在一定程度上自动纠正错误,而无需重新传输或从副本读取。这比单纯的校验和更强大,因为它能恢复数据。
15. Vector Clocks (向量时钟)
背景
当分布式系统允许并发写入时,可能会导致对象出现多个版本。不同副本的对象最终可能包含不同版本的数据。我们通过一个例子来理解这个问题。
在单台机器上,我们只需要知道绝对时间或系统时间:假设我们在时间戳 t1 时对键 k 执行了写入操作,然后在时间戳 t2 时对 k 执行了另一次写入操作。由于 t2 > t1,第二次写入必须比第一次写入更新,因此数据库可以安全地覆盖原始值。
在分布式系统中,这种假设并不成立。问题在于时钟偏移——不同的时钟倾向于以不同的速率运行,所以我们不能假设在节点 a 上的时间 t 发生在节点 b 上的时间 t + 1 之前。像 NTP 这样的最实用的时钟同步技术,仍然不能保证分布式系统中的每个时钟始终同步。因此,没有特殊的硬件,如 GPS 单元和原子钟,仅仅使用系统时间戳是不够的。
那么我们如何协调并捕捉同一对象不同版本之间的因果关系呢?
定义
使用向量时钟来跟踪值的历史记录,并在读取时协调不同的历史记录。
向量时钟 实际上是一个(节点,计数器)对。每个对象的每个版本都关联一个向量时钟。如果第一个对象的时钟上的计数器小于或等于第二个时钟上的所有节点,那么第一个是第二个的祖先,可以被遗忘。否则,两个更改被认为是有冲突的,需要协调。这种冲突在读取时解决,如果系统不能从其向量时钟协调对象的状态,它会将其发送给客户端应用程序进行协调(因为客户端对对象有更多的语义信息,可能能够协调它)。解决冲突类似于 Git 的工作方式。如果 Git 能够将不同版本合并为一个,合并就会自动完成。如果没有,客户端(即开发者)必须手动协调冲突。
案例
为了协调对象上的并发更新,Amazon的 Dynamo 使用向量时钟。
补充
向量时钟 (Vector Clocks)
背景:分布式系统中的并发写入与因果关系挑战
在单机环境中,处理对同一数据的并发写入相对简单:我们依赖绝对时间或系统时间戳。如果对键
k
在时间戳t1
发生了写入,随后在t2
发生了另一次写入,由于t2 > t1
,我们知道第二次写入是更新的,可以安全地覆盖第一次写入的值。然而,在分布式系统中,这种假设面临严峻挑战,核心问题在于时钟偏移 (Clock Skew):
- 时钟不同步:分布式系统中的各个节点拥有独立的本地时钟。这些时钟倾向于以不同的速率运行,即使有 NTP (网络时间协议) 这样的同步技术,也无法保证所有时钟始终精确同步。NTP 只能将时钟偏差控制在一定范围内,但不能消除。
- 无法推断因果:由于时钟偏移,我们不能简单地假设节点 A 上的时间
t
发生的操作一定早于节点 B 上的时间t+1
发生的操作。单纯依赖系统时间戳不足以正确推断操作之间的因果关系。- 版本冲突:当允许并发写入时,在没有有效因果追踪机制的情况下,对同一对象的操作可能在不同副本上导致多个“版本”,而系统无法判断哪个是最新或“正确”的版本。
那么,我们如何在没有全局一致时钟的情况下,协调并捕捉同一对象不同版本之间的因果关系呢?这就是向量时钟的用武之地。
定义:追踪值的历史与因果关系
向量时钟 (Vector Clock) 是一种用于追踪对象值历史记录的机制,它通过一个有序的元组来表示某个数据版本的因果历史。在读取数据时,向量时钟被用来协调(合并或检测)不同的历史记录,以解决版本冲突。
- 结构:向量时钟实际上是一个
(节点,计数器)
对的集合。例如,[(NodeA, 3), (NodeB, 5), (NodeC, 1)]
。
- 集合中的每个键代表一个参与写入的节点(或副本)。
- 每个值代表该节点对当前数据版本所做的逻辑写入次数(或该节点所看到的最新版本)。
- 关联性:每个对象的每个版本都关联一个向量时钟。当一个节点更新一个对象时,它会执行以下操作:
- 更新自己的计数器:将自己对应的计数器加一。
- 合并其他节点的计数器:如果它从其他节点收到了更早的版本(在读取或复制过程中),它会取其向量时钟中每个节点的最大计数器值。
冲突检测与协调规则
向量时钟的关键在于其因果关系判断规则:
假设有两个对象的版本 A 和 B,它们各自关联的向量时钟分别为 VCA 和 VCB。
- A 是 B 的祖先(A → B):如果 VCA 中所有节点的计数器都小于或等于VCB 中对应节点的计数器,并且至少有一个计数器严格小于,那么版本 A 是版本 B 的祖先。这意味着版本 B 是在版本 A 的基础上修改而来的,版本 A 可以被遗忘(被版本 B 覆盖)。
- 例子:
- VCA=[(X,1),(Y,2)]
- VCB=[(X,1),(Y,3)]
- 这里 VCA(X)=VCB(X) 且 VCA(Y)<VCB(Y),所以 A → B。
- B 是 A 的祖先(B → A):反之亦然。
- 冲突(Concurrent Changes):如果两个版本互不为对方的祖先(即 VCA 中有某些计数器大于 VCB 中对应的计数器,同时 VCB 中也有某些计数器大于 VCA 中对应的计数器),那么这两个更改就被认为是并发的,它们之间存在冲突。
冲突解决
当检测到冲突时,分布式系统通常会采取以下策略:
- 自动协调(Automatic Resolution):
- 如果系统能够根据预定义的规则或简单的启发式方法自动合并冲突(例如,取最新写入的值,或按照字母顺序合并),就会自动完成。
- 这类似于 Git 的自动合并:如果 Git 能够将不同版本自动合并为一个,合并就会自动完成。
- 客户端应用程序协调(Client-side Resolution):
- 如果系统无法从其向量时钟推断出明确的因果关系并自动协调对象的状态,它会将所有冲突的版本都发送给客户端应用程序。
- 原因:客户端应用程序对对象的语义信息有更深入的了解(例如,这是一个购物车,需要合并商品列表;这是一个用户资料,需要决定保留哪个字段),因此它可能能够根据业务逻辑手动协调冲突。
- 这类似于 Git 的手动协调:如果 Git 无法自动合并,客户端(即开发者)必须手动解决冲突。
16. CAP Theorem
背景
在分布式系统中,可能会发生不同类型的故障,例如,服务器可能会崩溃或永久性故障,磁盘可能会损坏导致数据丢失,或者网络连接可能会丢失,使系统的一部分无法访问。分布式系统如何 自我建模,以 最大限度地利用可用的不同资源?
定义
CAP 定理指出,分布式系统不可能同时提供以下三个理想属性:
- 一致性 (Consistency):所有节点同时看到相同的数据。这意味着用户可以从系统中的任何节点读取或写入,并将接收到相同的数据。它等同于拥有一个单一的最新数据副本。
- 可用性 (Availability):可用性意味着系统收到的每个请求都必须得到响应,即使在严重的网络故障发生时,每个请求都必须终止。简单来说,可用性指的是系统即使在一个或多个节点出现故障的情况下,仍然能够保持可访问性。
- 分区容错性 (Partition Tolerance):分区是系统内任意两个节点之间的通信中断(或网络故障),即两个节点都是活跃的,但无法相互通信。具有分区容错性的系统即使在系统内出现分区的情况下也能继续运行。这样的系统可以承受任何不导致整个网络故障的网络故障。数据在节点和网络的组合中足够复制,以保持系统在间歇性中断期间仍然运行。
根据 CAP 定理,任何分布式系统需要从这三个属性中选择两个。三个选项是 CA、CP 和 AP。然而,CA 并不是一个真正有意义的选项,因为一个不具有分区容错性的系统将在网络分区的情况下被迫放弃一致性或可用性。因此,该定理实际上可以这样表述:在网络分区的情况下,分布式系统必须选择一致性或可用性。
案例
- Dynamo:在 CAP 定理的术语中,Dynamo 属于 AP 系统类别,旨在牺牲强一致性以实现高可用性。设计 Dynamo 作为一个高可用系统的主要动机是观察到系统的可用性与服务的客户数量直接相关。
- BigTable:在 CAP 定理方面,BigTable 是一个 CP 系统,即它具有严格一致的读写操作。
(1)CAP 定理
在分布式系统中,由于其固有的复杂性,各种类型的故障是不可避免的:服务器可能崩溃、磁盘可能损坏导致数据丢失、网络连接可能中断导致部分系统无法访问。面对这些挑战,分布式系统如何设计其架构以最大限度地利用现有资源并保持功能性,这是一个核心问题。
这就是 CAP 定理 发挥作用的地方。
定义
CAP 定理 是分布式系统领域的一个基石理论,它指出一个分布式系统不可能同时满足以下三个理想属性:
- 一致性 (Consistency - C):
- 所有的节点在同一时间点都看到相同的数据。
- 这意味着,无论用户从系统中的哪个节点进行读写操作,都将接收到最新且完全一致的数据。
- 换句话说,它等同于拥有一个单一的、最新的数据副本,所有对数据的访问都像访问这个唯一的副本一样。
- 举例:当一个值
X
被更新为X'
后,所有后续的读操作都应该立即返回X'
,而不是旧值X
。- 可用性 (Availability - A):
- 可用性意味着系统收到的每一个请求都必须得到响应,无论系统内部是否发生故障。
- 即使在严重的网络故障发生时,每个请求也必须在有限的时间内终止(返回成功或失败),而不是无限期地挂起或超时。
- 简单来说,可用性指的是系统即使在一个或多个节点出现故障(如宕机)的情况下,仍然能够保持可访问性和响应性。
- 分区容错性 (Partition Tolerance - P):
- 分区 (Partition) 指的是分布式系统内任意两个节点之间的通信中断(或网络故障)。这意味着两个节点都是活跃的,但它们之间无法相互通信,导致系统被分割成多个独立的、无法互相联络的子集。
- 具有分区容错性的系统即使在系统内出现网络分区的情况下也能继续运行。
- 这样的系统可以承受任何不导致整个网络完全故障的网络故障。数据通常会在节点和网络的组合中进行足够多的复制,以保持系统在间歇性中断期间仍然运行。
CAP 定理的核心论断
CAP 定理的核心论断是:在分布式系统中,你无法同时拥有这三个属性。你必须在这三者中选择两个。
这意味着,在设计分布式系统时,你需要在以下三种组合中进行权衡和选择:
- CA (Consistency + Availability):
- 这意味着系统不具备分区容错性。
- 在发生网络分区时,为了保证一致性和可用性,系统将不得不牺牲其分布式特性,可能导致整个系统不可用或数据不一致。
- 实际上,严格的 CA 系统在分布式环境中几乎不存在,因为网络分区是分布式系统不可避免的现实。一个不具有分区容错性的系统将在网络分区的情况下被迫放弃一致性或可用性(通常是可用性,因为它无法对分区另一侧的请求做出响应)。
- CP (Consistency + Partition Tolerance):
- 牺牲可用性。
- 当发生网络分区时,系统会优先保证数据的一致性。为了确保数据的一致性,系统可能会拒绝服务(降低可用性),直到分区问题得到解决,并且所有节点的数据都达到一致状态。
- 举例:ZooKeeper、Consul、etcd 等分布式协调服务,以及绝大多数传统的关系型数据库集群(如果它们提供强一致性)。
- AP (Availability + Partition Tolerance):
- 牺牲一致性。
- 当发生网络分区时,系统会优先保证可用性。为了确保每个请求都能得到响应,系统可能会允许数据在分区期间不一致。
- 一旦分区恢复,系统会通过最终一致性 (Eventual Consistency) 机制来同步数据,解决冲突。
- 举例:Cassandra、DynamoDB、一些 NoSQL 数据库,以及许多大型互联网服务(如 DNS)。
实际意义:在网络分区下的权衡
由于网络分区在分布式系统中是不可避免的现实(P 几乎是任何分布式系统的必须属性),因此 CAP 定理实际上可以更简洁地表述为:
在网络分区的情况下,分布式系统必须在一致性 (C) 和可用性 (A) 之间做出选择。
这意味着,系统设计师在面对网络分区时,必须决定是优先保证数据的强一致性(宁愿暂时停止服务),还是优先保证服务的持续可用性(允许数据在短时间内不一致,待分区恢复后最终一致)。这个权衡是分布式系统设计的核心决策之一,没有“一劳永逸”的最佳选择,而是取决于具体的业务需求和对数据一致性与服务可用性的容忍度。
(2)网络分区
网络分区(Network Partition),也称为网络分裂或网络断裂,是指在一个分布式系统中,由于网络故障,节点之间失去通信能力,导致整个系统被分割成两个或多个互不通信的子集。即使每个子集内部的节点可能仍然能够正常通信,但它们无法与外部的子集进行通信。
网络分区之所以不可避免,是基于以下几个核心原因:
- 物理基础设施的固有复杂性和故障可能性:
- 网线故障:光纤、铜缆等物理介质可能损坏、老化或被意外切断(例如,施工挖掘)。
- 路由器/交换机故障:网络设备可能出现硬件故障、软件 bug 或配置错误,导致部分流量无法转发。
- 网卡故障:服务器的网卡可能损坏,导致该服务器无法进行网络通信。
- 电源故障:为网络设备供电的电源中断,直接导致设备停机。
- 机房问题:整个机房的电力中断、冷却系统故障、火灾等,都可能导致大量服务器和网络设备同时失效或隔离。
- 网络拓扑的复杂性:
- 现代分布式系统通常部署在复杂的网络拓扑中,涉及多层交换机、路由器、防火墙、负载均衡器等设备。任何一个环节的故障都可能导致局部或全局的网络分区。
- 跨地域/跨数据中心部署:为了高可用性和灾备,系统往往会部署在不同的地理区域或数据中心。这些数据中心之间的网络链路(广域网)更容易受到中断、高延迟和丢包的影响。即使是专线,也无法保证 100% 不故障。
- 软件和协议的限制与复杂性:
- 网络协议栈问题:操作系统内核的网络协议栈可能出现 bug,导致 TCP 连接异常断开或数据包丢失。
- 防火墙规则配置错误:不当的防火墙规则可能意外地阻止了部分关键通信。
- DNS 故障:域名解析服务故障可能导致节点无法解析其他节点的 IP 地址,从而无法建立连接。
- 应用层协议问题:即使底层网络正常,应用层协议的 bug 或超时配置不当也可能导致逻辑上的“分区”(例如,一个服务认为另一个服务超时不可达)。
- 容量限制和拥塞:
- 网络带宽饱和:当网络流量超过其承载能力时,数据包可能被丢弃,导致通信中断或严重延迟,从而模拟出分区。
- 设备处理能力瓶颈:路由器或交换机的 CPU 过载,无法及时处理所有数据包。
- 人为操作失误:
- 错误的配置变更:网络工程师或运维人员可能因为操作失误,更改了网络路由、防火墙规则或网络设备配置,导致通信中断。
- 误拔网线:在物理机房中,人为操作失误(例如,拔错网线)是真实存在的风险。
- 不可预测性:
- 网络环境是动态变化的,随时可能出现预料之外的故障。你无法设计一个“完美”的网络,能够百分之百防止所有类型的通信中断。
- 任何复杂的系统都逃不过 “两将军问题” (Two Generals' Problem) 的困扰,即在不可靠的信道上,无法保证双方对某个状态达成共识。在分布式系统中,这表现为:一个节点无法确定另一个节点是否真的宕机,还是仅仅因为网络问题无法通信。
结论
鉴于上述多种多样的故障源——从物理基础设施的损坏,到复杂的网络拓扑,再到软件缺陷和人为错误,甚至不可预测的拥塞——任何一个运行在现实网络环境中的分布式系统,都必须假设网络分区是会发生的。
因此,在设计分布式系统时,分区容错性 (P) 几乎被认为是必选项。这意味着,在面临网络分区时,设计师必须在一致性 (C) 和可用性 (A) 之间做出艰难的权衡,这是 CAP 定理的核心要义和现实指导。
(3)两将军问题
“两将军问题”(The Two Generals' Problem) 是分布式系统领域一个经典的思想实验,它深刻地阐述了在不可靠通信信道上达成共识的固有困难和局限性。这个问题最初由 Jim Gray 在 1978 年提出,后来被 Leslie Lamport 等人推广,并常被用作解释分布式系统一致性协议(如 TCP)为何无法保证端点之间状态完全一致的基础。
问题背景
想象一下这样的场景:
有两支军队的两位将军(我们称他们为将军 A 和将军 B),他们各自率领一支军队,分驻在敌军占领的山谷两侧。他们的目标是同时进攻敌军的城市。
- 成功的条件:两支军队必须同时进攻才能取胜。
- 失败的条件:如果只有一支军队进攻,它将孤军奋战并被击败。
- 通信限制:两位将军之间唯一的通信方式是派遣信使穿越敌军占领的山谷。
- 信使的不可靠性:信使在穿越山谷时有被俘虏的风险。这意味着,发送的任何消息(包括确认消息)都可能丢失。
问题核心:如何达成共识?
两位将军需要就一个精确的进攻时间达成共识。
- 将军 A 发送进攻计划:将军 A 决定在某个时间(比如“明天早上 6 点”)进攻,于是派信使带着这个计划去通知将军 B。
- 如果信使成功到达将军 B,将军 B 就知道了计划。
- 但将军 A 不知道信使是否到达。
- 将军 B 发送确认:为了让将军 A 知道自己收到了计划,将军 B 会派信使发送一封确认信(“我已收到,同意明天早上 6 点进攻!”)回给将军 A。
- 如果信使成功到达将军 A,将军 A 就知道了将军 B 已同意。
- 但将军 B 不知道确认信是否到达将军 A。
- 将军 A 发送确认的确认:将军 A 收到了将军 B 的确认后,为了让将军 B 知道自己收到了确认,可能会再发一封“确认的确认”信。
- 但现在,将军 A 又不知道这封“确认的确认”是否到达将军 B。
这个过程可以无限地持续下去:每当一方收到消息,它都需要发送一个确认,但它永远无法确定对方是否收到了自己的确认。
“无解”的证明
“两将军问题”之所以著名,在于它被证明是无解的。也就是说,不存在一个算法能保证两位将军在不可靠的通信信道上,最终能够百分之百确定对方已经收到了消息并且达成了共识。
非正式证明的思路如下:
假设存在这样一个算法,它使得两位将军最终达成了共识并成功进攻。
- 那么,必然存在一个最终的、使得将军 A 和将军 B 都决定进攻的消息 M。
- 考虑这个消息 M,如果发送 M 的信使被俘虏了,那么 M 之前的消息链是完整的。也就是说,如果 M 是最后一个关键消息,那么它的丢失会导致无法达成共识。
- 但是,由于信使的通信是不可靠的,任何消息都可能丢失。将军 A 永远无法确定将军 B 是否收到了 M,将军 B 也永远无法确定将军 A 是否收到了他对 M 的确认。
- 这就形成了一个无限递归:总有一个“最终确认”无法被最终确认。因此,在任何时候,总有一位将军无法确定对方是否真的已经做好了进攻的准备。
对分布式系统的启示
“两将军问题”虽然是一个假想的军事场景,但它深刻揭示了分布式系统在不可靠网络上进行协调和一致性决策的本质困难:
- 无法保证共同知识 (Common Knowledge):在不可靠信道上,你永远无法确凿地知道对方已经知道了你所知道的信息,并且也知道你知道他们已经知道了(以此类推)。这种共同知识是无法完全达成的。
- 网络分区下的共识难题:它直接说明了在存在网络分区的情况下,CAP 定理中的分区容错性 (P) 几乎是不可避免的,而要在一致性 (C) 和可用性 (A) 之间做出取舍的原因。你无法既保证分区容错,又同时保证所有节点对某个状态的强一致性(除非你牺牲可用性)。
- 最终一致性的现实意义:由于“两将军问题”的无解性,许多分布式系统不得不退而求其次,接受最终一致性 (Eventual Consistency) 模型。这意味着系统不保证在任何时刻都强一致,但会在网络恢复后通过某种机制(如向量时钟、读修复等)使数据最终达到一致状态。
- 协议设计复杂性:分布式一致性协议(如 Paxos、Raft)的复杂性,很大一部分原因就在于它们必须在面对这种不可靠通信和部分故障时,仍然努力地在性能、一致性和可用性之间找到平衡。它们并不能“解决”两将军问题,而是通过一系列巧妙的机制(如多数派、日志复制、领导者选举、超时重试等)来规避问题,或在牺牲部分属性的前提下达到实际可用的共识。
简而言之,两将军问题告诉我们:在真实世界的分布式网络中,你永远无法百分之百地确定对方收到了你的消息,也无法百分之百地确定对方收到了你对他们消息的确认。 这种不确定性是分布式系统设计必须面对和解决的核心挑战。
17. PACELC Theorem
背景
我们无法避免分布式系统中的 网络分区(P),因此,根据 CAP 定理,分布式系统应该在 一致性(C) 或 可用性(A) 之间做出选择。遵循 ACID(原子性、一致性、隔离性、持久性)原则的数据库,如关系型数据库管理系统(RDBMS)如 MySQL、Oracle 和 Microsoft SQL Server,选择了一致性(如果不能与同伴节点检查,则拒绝响应),而遵循 BASE(基本可用、软状态、最终一致性)原则的数据库,如 NoSQL 数据库如 MongoDB、Cassandra 和 Redis,选择了可用性(响应本地数据,而不确保它是与同伴节点的最新数据)。
CAP 定理没有说明的一个地方是,当没有网络分区时会发生什么?当没有分区时,分布式系统有哪些选择?
定义
PACELC 定理指出,在存在数据副本的系统中:
如果存在分区('P'),分布式系统可以在可用性和一致性之间进行权衡(即 'A' 和 'C');否则('E'),在没有分区的正常运行情况下,系统可以在延迟('L')和一致性('C')之间进行权衡。定理的第一部分(PAC)与 CAP 定理相同,ELC 是扩展。整个论点假设我们通过复制来维持高可用性。因此,当发生故障时,CAP 定理占主导地位。但如果不发生故障,我们仍然需要考虑复制系统在一致性和延迟之间的权衡。
案例
- Dynamo 和 Cassandra 是 PA/EL 系统:当发生分区时,它们选择可用性而不是一致性;否则,它们选择较低的延迟。
- BigTable 和 HBase 是 PC/EC 系统:它们总是选择一致性,放弃可用性和较低的延迟。
- MongoDB 可以被认为是 PA/EC(默认配置):MongoDB 在主/次配置中工作。在默认配置中,所有的写入和读取都在主节点上执行。由于所有的复制都是异步进行的(从主节点到次节点),当发生网络分区导致主节点丢失或成为少数派时,可能会丢失未复制到次节点的数据,因此在分区期间会有一致性的损失。因此可以得出结论,如果在网络分区的情况下,MongoDB 选择可用性,但否则保证一致性。或者,当 MongoDB 配置为在大多数副本上写入并从主节点读取时,它可以被归类为 PC/EC。
补充
PACELC 定理
背景:CAP 定理的局限性
我们已经知道,在分布式系统中,网络分区是不可避免的现实。根据 CAP 定理,这意味着在发生网络分区(
P
)时,系统必须在一致性(C)和可用性(A)之间做出权衡:
- CP 系统:如传统的关系型数据库(遵循 ACID 原则的 MySQL、Oracle 等),当出现分区时,会选择牺牲可用性来保证强一致性(即拒绝服务或停止写入,直到数据完全一致)。
- AP 系统:如许多 NoSQL 数据库(遵循 BASE 原则的 MongoDB、Cassandra、Redis 等),当出现分区时,会选择牺牲一致性来保证可用性(即响应本地数据,即使它可能不是最新的,并在分区恢复后处理冲突)。
然而,CAP 定理只关注了在网络分区发生时系统应该如何选择。它并没有说明:
- 当没有网络分区时(即系统正常运行时)会发生什么?
- 当没有分区时,分布式系统在一致性和可用性之外,还有哪些选择或权衡需要考虑?
PACELC 定理正是为了回答这个问题,它将考量扩展到了正常运行时的系统行为。
定义
PACELC 定理 是对 CAP 定理的扩展,它在 CAP 的基础上增加了对延迟(Latency - L)的考量。该定理指出,在一个存在数据副本的分布式系统中:
- 如果存在分区(P):分布式系统必须在可用性(A)和一致性(C)之间进行权衡。
- 这部分与 CAP 定理的论断完全一致。
- 例如:当网络分区发生时,选择
C
(强一致性)可能意味着牺牲A
(拒绝请求),而选择A
(始终响应)可能意味着牺牲C
(返回可能过时的数据)。- 否则(E,即没有分区):在没有网络分区的正常运行情况下,系统必须在延迟(L)和一致性(C)之间进行权衡。
- 这部分是 PACELC 定理的创新和扩展。
- 在分布式系统中,为了保证数据的一致性,通常需要进行多副本同步确认(例如,写入必须同步到多数副本才能返回成功)。这种同步操作会引入额外的延迟。
- 因此,系统设计者需要在“更强的一致性(C)”(可能意味着更高的延迟)和“更低的延迟(L)”(可能意味着更弱的一致性模型,例如最终一致性或读写冲突)之间做出选择。
PACELC 定理的全貌可以表示为:
- Partitions
- Availability or Consistency (when partitions exist)
- Else (no partitions)
- Latency or Consistency (when no partitions exist)
整个论点假设我们为了实现高可用性而通过复制来维护数据。因此:
- 当发生故障(即网络分区)时,CAP 定理(PAC 部分)占主导地位。系统首要任务是决定如何在一致性和可用性之间做选择。
- 但如果故障不发生(即系统正常运行),我们仍然需要考虑复制系统在一致性和延迟之间的权衡。即使网络一切正常,写入多个副本并等待其确认的同步过程,依然会引入可感知的延迟。如果追求更低延迟,可能就不能保证立即的强一致性。
PACELC 的意义与实际应用
PACELC 定理提供了一个更全面的视角来理解分布式系统的设计权衡:
- 更细致的权衡:它将 CAP 定理的二元选择扩展为一个在不同场景下的两层权衡。
- 强调正常运行时的考量:在实际生产环境中,系统大多数时间都是在正常运行(无分区)状态下。PACELC 定理提醒我们,即使没有分区,我们也需要在 性能(低延迟)和 一致性 之间做出选择。
- 对数据库设计的指导:
- CP/EC:像传统的强一致性数据库(如 PostgreSQL、MySQL 的主从复制等),在分区时选择 CP(牺牲可用性),在无分区时通常也选择追求强一致性,代价是写入操作可能会有更高的延迟(即
C
overL
)。- AP/EL:像 Cassandra 等 NoSQL 数据库,在分区时选择 AP(牺牲一致性),在无分区时也倾向于追求低延迟,允许牺牲一定的一致性(例如,允许最终一致性或读写冲突解决,即
L
overC
)。- AP/EC:某些系统可能在分区时选择 AP,而在无分区时选择追求最终一致性但牺牲一些延迟以保证最终的一致性(即
C
overL
)。PACELC 定理帮助系统设计者更全面地思考,不仅要考虑极端故障情况下的行为,还要考虑系统在常态下如何平衡其性能和数据一致性。
18. Hinted Handoff (提示移交)
背景
根据一致性级别,即使在节点宕机的情况下,分布式系统仍然可以处理写入请求。例如如果我们有三份副本,并且客户端以quorum 一致性级别进行写入。这意味着如果一个节点宕机,系统仍然可以在剩余的两个节点上写入,以满足一致性级别,使写入成功。现在,当宕机的节点重新上线时,我们应该如何向它写入数据?
定义
对于宕机的节点,系统会记录所有它们错过的写入请求的笔记(或提示)。一旦故障节点恢复,就会根据存储的提示将写入请求转发给它们。
当一个节点宕机或不响应写入请求时,协调操作的节点会在本地磁盘的文本文件中写入一个提示。这个提示包含数据本身以及数据属于哪个节点的信息。当协调节点发现它持有提示的节点已经恢复时,它会将每个提示的写入请求转发给目标节点。
案例
- Cassandra 节点使用 Hinted Handoff 来记住失败节点的写入操作。
- Dynamo 通过使用 Hinted Handoff(和 Sloppy Quorum)确保系统“始终可写”。
补充
Hinted Handoff (提示移交)
背景:节点故障时的写入处理与数据同步挑战
在分布式系统中,为了确保高可用性和数据持久性,数据通常会被复制到多个节点上。当客户端发起写入请求时,系统会根据预设的一致性级别 (Consistency Level) 来决定需要有多少个副本成功接收写入才算成功。
举个例子: 假设一个分布式系统有三份数据副本。如果客户端以 Quorum (法定数量) 的一致性级别进行写入,这意味着写入操作需要得到至少两份副本的确认才能被认为是成功。
现在设想一个场景: 当客户端发起写入时,其中的一个节点恰好宕机了。由于 Quorum 级别的写入只需要两个节点确认,系统仍然可以在剩余的两个健康节点上成功完成写入,满足了一致性级别。
问题来了: 当这个宕机的节点重新上线时,它如何才能获取到它在离线期间错过的所有写入数据,从而与集群中的其他副本保持同步呢? 如果没有一个有效的机制,这个节点将拥有“过期”的数据,导致数据不一致。
定义:记录并转发错过的写入
Hinted Handoff (提示移交) 机制正是为了解决这个问题而设计的。它的核心思想是:
记录“提示”(Hints)
:当一个节点(我们称之为
协调节点
,Coordinator Node)尝试将数据写入到某个
目标副本节点
(Target Replica Node)时,如果发现该目标副本节点宕机或不响应(例如,因为网络问题或过载),协调节点并不会让写入失败。相反,它会
在本地磁盘上创建一个“提示”(Hint)
。
- 这个提示通常是一个文本文件或其他形式的记录,其中包含以下关键信息:
- 数据本身:即目标节点在离线期间错过的完整的写入数据。
- 目标节点信息:指明这些数据应该发送给哪个宕机或不响应的节点。
- 时间戳:记录提示的创建时间。
恢复后转发
:协调节点会周期性地检查它所持有的提示所对应的
目标节点是否已经恢复上线
。
- 一旦发现目标节点恢复,协调节点就会将本地存储的所有相关提示(即这些提示中记录的写入请求)转发(或“移交”)给对应的目标节点。
- 目标节点接收到这些“提示”后,会将其中的数据应用到自己的存储中,从而追赶上在离线期间错过的所有写入。
示例工作流程
- 写入请求:客户端向分布式集群发送一个写入请求(例如,将键
K
的值设置为V
)。- 协调节点接收:假设节点 A 接收到这个写入请求,它成为协调节点。
- 确定副本:节点 A 确定数据
K=V
需要写入到节点 B、C 和 D(假设是三副本)。- 发现节点 D 宕机:节点 A 尝试向 B、C、D 写入。它成功写入到 B 和 C。但发现节点 D 当前宕机或不可达。
- 创建提示:为了确保写入的成功(即使 D 宕机,但 B 和 C 已经确认,满足 Quorum 级别),节点 A 会在自己的本地磁盘上为节点 D 创建一个提示 (Hint),记录下
K=V
这条写入,并标记它应该发送给节点 D。- 节点 D 恢复:一段时间后,节点 D 重新启动并恢复上线。
- 提示移交:节点 A 发现节点 D 已经恢复,于是读取所有为节点 D 存储的提示,并将这些错过的写入数据(包括
K=V
)转发给节点 D。- 节点 D 同步:节点 D 接收到这些数据并将其应用到自己的存储中,从而实现了与集群其他健康副本的数据同步。
提示移交的优势
- 高可用性:即使部分副本节点临时宕机,写入操作仍然可以成功,系统对外表现出高可用性。
- 最终一致性:保证了即使在节点宕机的情况下,数据也能最终在所有副本之间达到一致。
- 解耦:协调节点无需等待宕机节点恢复,可以继续处理其他请求,待时机合适再将提示移交。
局限性与考虑
- 协调节点存储开销:如果大量节点宕机或协调节点本身累积了过多的提示,会占用协调节点的本地存储空间。
- 协调节点故障:如果持有提示的协调节点在目标节点恢复之前也宕机了,那么这些提示可能会丢失,导致目标节点无法获取这些数据。这通常需要其他机制(如多副本之间的数据修复)来弥补。
- “黑洞”问题:如果目标节点长时间不恢复,协调节点会一直持有其提示,直到超出某个 TTL (Time To Live) 或磁盘空间耗尽。
Hinted Handoff 是许多高可用分布式数据库(如 Cassandra、Riak)中常用的机制,它在牺牲一定的立即强一致性(因为宕机节点的数据会暂时不一致)的情况下,极大地提升了系统的可用性和分区容错性。
19. Read Repair (读取修复)
背景
在分布式系统中,数据被复制到多个节点,一些节点可能最终会有过时的数据。想象一下这样一种情况:一个节点因为宕机或网络分区而未能接收到写入或更新请求。我们如何确保节点在恢复正常后获得数据的最新版本?
定义
在读取操作期间修复过时数据,因为在那个时刻,我们可以从多个节点读取数据进行比较,以发现哪些节点拥有过时的数据。这种机制称为读取修复(Read Repair)。一旦确定了拥有旧数据的节点,读取修复操作就会将数据的新版本推送到拥有旧版本的节点。
系统根据 quorum 从多个节点读取数据。例如,对于 quorum=2,系统从一个节点读取数据,并从第二个节点读取数据的摘要。摘要是数据的 checksum,用于节省网络带宽。如果摘要不匹配,这意味着一些副本没有数据的最新版本。在这种情况下,系统会从所有副本读取数据以找到最新数据。系统将最新数据返回给客户端,并发起一个读取修复请求。读取修复操作将最新版本的数据推送到拥有旧版本的节点。
当读取一致性级别低于“全部”时,一些系统会以概率方式执行读取修复,例如,10%的请求。在这种情况下,系统在满足一致性级别后立即向客户端发送响应,并在后台异步执行读取修复。
案例
Cassandra 和 Dynamo 使用“读取修复”将数据的最新版本推送到拥有旧版本的节点。
补充
Read Repair (读取修复):通过读取操作纠正数据不一致
在分布式系统中,为了实现高可用性和分区容错性,数据通常会被复制到多个节点上。然而,这种复制带来了新的挑战:数据不一致性。例如,一个节点可能因为临时宕机、网络分区、或者在写入期间响应缓慢等原因,未能接收到某个写入或更新请求。当这个节点恢复正常后,它的数据副本就会是过时(stale)的。
如何确保这些拥有过时数据的节点能够获取到数据的最新版本,从而最终达到一致呢?这就是 Read Repair (读取修复) 机制所要解决的问题。
定义:利用读取发现并修复过期数据
读取修复(Read Repair) 是一种在读取操作期间发现并修复过时数据的机制。其核心思想是:当客户端发起读取请求时,系统不仅是简单地返回数据,还会利用这次读取的机会,通过比较从多个副本读取到的数据,来发现哪些副本拥有旧数据,然后将最新版本的数据“推送”给这些过时的副本,从而实现数据的自我修复。
其工作流程通常如下:
- 根据一致性级别读取数据:
- 当客户端发起一个读取请求时,协调节点(Coordinator Node)会根据客户端指定的一致性级别(例如 Quorum)从集群中的多个副本节点读取数据。
- 为了节省网络带宽,协调节点可能不会一次性从所有副本读取完整的数据,而是从部分副本读取完整数据,从其他副本读取数据的摘要(Digest)。这个摘要通常是数据的 Checksum(校验和),例如 MD5 或 SHA-256 值。
- 比较数据/摘要:
- 协调节点会比较从不同副本读取到的完整数据或数据摘要。
- 发现不匹配:
- 如果发现不同副本返回的数据或摘要不匹配,这表明某些副本没有数据的最新版本,存在数据不一致。
- 识别最新版本:
- 在检测到不一致后,系统会从所有相关副本(包括可能没有最初响应的副本)读取完整的数据,并通过比较时间戳、版本号(如向量时钟)等元数据,来确定哪个副本持有数据的最新版本。
- 返回最新数据并启动修复:
- 系统会将识别出的最新数据版本返回给客户端。
- 同时,系统会异步地发起一个读取修复请求。这个请求会将最新版本的数据推送到所有那些拥有旧版本数据的节点。
- 概率性读取修复:
- 在某些情况下,特别是当读取一致性级别低于“全部”(
ALL
)时(这意味着不必从所有副本读取数据),一些系统会以概率方式执行读取修复。例如,只有 10% 的读取请求会触发读取修复。- 在这种情况下,系统会优先满足客户端的读取请求(即在满足了一致性级别后立即向客户端发送响应),而读取修复操作则在后台异步执行,这有助于降低读取延迟,但可能会延长数据最终一致所需的时间。
案例应用
Cassandra 和 DynamoDB 是典型的使用“读取修复”机制的分布式数据库。它们都采用了 AP(可用性和分区容错性)模型,在分区时优先保证可用性,并依赖读取修复等机制来保证最终一致性。
- 在 Cassandra 中,当一个读取请求到达时,协调节点会从多个副本获取数据。如果发现不一致,它会向拥有旧数据的副本发送更新,从而在读取路径上默默地修复数据。
总结
读取修复是分布式系统中实现最终一致性和自我修复能力的重要机制。它巧妙地利用了正常的读取操作来发现并纠正数据副本之间的不一致,从而减少了需要单独进行后台数据扫描和修复的频率,提高了数据的整体健康度和系统的鲁棒性。
20. Merkle Trees
背景
正如我们在 Read Repair 看到的,读取修复在处理读取请求时消除了冲突。但是,如果一个副本严重落后于其他副本,解决冲突可能需要很长时间。能够自动在后台解决一些冲突将是很好的。为了做到这一点,我们需要能够快速比较两个副本的范围,并准确找出哪些部分是不同的。在分布式环境中,我们如何能够快速比较存储在两个不同副本上的一段数据的两个副本,并准确找出哪些部分是不同的?
定义
副本可能包含大量数据。简单地分割整个范围来计算 checksum 进行比较是不切实际的;要传输的数据实在太多了。相反,我们可以使用默克尔树(Merkle trees)来比较范围的副本。Merkle Trees 是一种 哈希的二叉树,其中 每个内部节点是其两个子节点的哈希值,每个叶节点是原始数据的一部分的哈希值。
比较 Merkle Trees 在概念上很简单:
- 比较两棵树的根哈希值。
- 如果它们相等,停止。
- 对左右子节点递归进行比较。
最终,这意味着副本确切知道哪些部分的范围是不同的,但交换的数据量最小化。Merkle Trees 的主要优点是,树的每个分支都可以独立检查,而不需要节点下载整棵树或整个数据集。因此,Merkle Trees 最小化了同步所需的数据传输量,并减少了磁盘读取次数。
使用 Merkle Trees 的缺点是,当节点加入或离开时,许多关键范围可能会发生变化,这时需要重新计算树。
案例
为了对抗熵增和在后台解决冲突,Dynamo 使用Merkle Trees。
补充
Merkle Trees (默克尔树)
背景:高效的数据副本比较与冲突解决
在分布式系统中,数据被复制到多个节点以确保高可用性和分区容错性。尽管有像 Read Repair (读取修复) 这样的机制可以在读取时解决数据不一致问题,但读取修复通常是按需进行的,而且每次只修复少量不一致的数据。
如果一个副本节点因为长时间离线、网络分区或严重故障而严重落后于其他副本,那么通过零星的读取修复来使其完全同步将是一个漫长且低效的过程。在这种情况下,我们希望能够:
- 主动地在后台解决这些冲突。
- 快速比较两个副本的某个数据范围,精确找出它们之间哪些部分是不同的,而不是简单地知道“有不同”。
- 最小化数据传输量,避免在同步过程中不必要地传输大量相同的数据。
简单地将整个数据范围分割成小块并计算每个块的校验和(Checksum)来进行比较是不切实际的。即使是校验和,对于大规模数据来说,传输所有这些校验和仍然可能产生巨大的网络开销。那么,如何在分布式环境中高效地完成这项任务呢?
这就是 Merkle Trees (默克尔树) 的用武之地。
定义:哈希的二叉树,用于高效比较数据范围
默克尔树(Merkle Trees) 是一种特殊的哈希二叉树(Hash Binary Tree),它被设计用来高效地验证大规模数据结构或集合的完整性和一致性。在分布式系统中,它被用来快速比较存储在两个不同副本上的数据范围,并精确找出哪些部分是不同的,同时最小化数据传输量。
其结构特点如下:
- 叶节点 (Leaf Nodes):每个叶节点是原始数据的一部分(通常是一个数据块或一个数据范围)的哈希值。
- 内部节点 (Internal Nodes):每个内部节点是其两个子节点哈希值的哈希值(通常是将两个子节点的哈希值拼接起来再计算哈希)。
- 根哈希 (Root Hash / Merkle Root):树的顶端是唯一的根节点,其哈希值包含了整个数据范围所有底层数据的摘要信息。
Merkle Trees 的比较流程
比较两棵 Merkle Tree(即比较两个副本上的相同数据范围)在概念上非常高效和简单:
- 比较两棵树的根哈希值:协调节点首先从两个副本获取它们各自的 Merkle Tree 的根哈希值。
- 根哈希值相等?
- 如果它们相等:这说明两棵树所代表的整个数据范围是完全一致的。比较过程立即停止,无需进一步操作,无需传输任何实际数据或底层哈希。
- 根哈希值不相等?
- 如果它们不相等:这表明两个副本在某个地方存在不一致。此时,协调节点会请求并比较两棵树的左右子节点(下一层)的哈希值。
- 递归比较:对于那些哈希值不相等的子节点,协调节点会递归地深入,继续比较其下一层的子节点哈希值。
- 发现差异:这个递归过程会一直持续,直到达到叶节点。当比较到叶节点哈希值不相等时,就精确地找到了原始数据中不一致的数据块。
优点
Merkle Trees 的主要优点在于其高效性和最小化传输量:
- 增量同步:系统能够精确地知道哪些部分的数据是不同的。因此,在同步过程中,只需要传输并更新那些不一致的数据块,而不是传输整个数据范围或所有数据块的校验和。
- 最小化数据传输量:通过层层比较哈希值,可以剪掉大量相同的部分,只传输不相同的哈希值和最终不相同的数据块,从而大大减少了同步所需的数据传输量。
- 减少磁盘读取次数:只有当哈希值不匹配时,才需要进一步读取下层数据,减少了不必要的磁盘 I/O。
- 每个分支独立检查:树的每个分支都可以独立检查,这对于并发处理和大规模数据集非常有利。
缺点
- 重新计算开销:当节点加入或离开集群,或者数据在节点之间迁移(如在一致性哈希环中发生拓扑变化)时,许多关键的数据范围可能会发生变化。这意味着对应的 Merkle Trees 需要重新计算,这可能是一个计算密集型的操作,尤其对于包含大量数据的节点。
- 存储开销:维护 Merkle Tree 本身也需要一定的内存和存储开销。
案例
Cassandra 和 DynamoDB 等分布式数据库都广泛使用了 Merkle Trees 来进行后台的数据同步和反熵(Anti-Entropy)修复。它们会周期性地构建 Merkle Trees,并相互交换根哈希值来检测不一致性,然后高效地同步数据,确保所有副本的最终一致性。
总之,Merkle Trees 是分布式系统中一种优雅而强大的数据结构,它极大地提高了大规模数据副本之间一致性维护的效率,是实现高可用和最终一致性系统的关键技术之一。