全序广播
全序广播
如果你的程序只运行在单个 CPU 核上,那么定义一个操作全序是很容易的:可以简单地就是 CPU 执行这些操作的顺序。但是在分布式系统中,让所有节点对同一个全局操作顺序达成一致可能相当棘手。我们讨论过按时间戳或序列号进行排序,但发现它还不如单主复制给力(如果你使用时间戳排序来实现唯一性约束,而且不能容忍任何错误)。
单主复制通过选择一个节点作为主库来确定操作的全序,并在主库的单个 CPU 核上对所有操作进行排序。接下来的挑战是,如果吞吐量超出单个主库的处理能力,这种情况下如何扩展系统;以及,如果主库失效,如何处理故障切换。在分布式系统文献中,这个问题被称为全序广播(total order broadcast)或原子广播(atomic broadcast)、
全序广播通常被描述为在节点间交换消息的协议。非正式地讲,它要满足两个安全属性:
-
可靠交付(reliable delivery):没有消息丢失:如果消息被传递到一个节点,它将被传递到所有节点。
-
全序交付(totally ordered delivery):消息以相同的顺序传递给每个节点。
正确的全序广播算法必须始终保证可靠性和有序性,即使节点或网络出现故障。当然在网络中断的时候,消息是传不出去的,但是算法可以不断重试,以便在网络最终修复时,消息能及时通过并送达(当然它们必须仍然按照正确的顺序传递)。
使用全序广播
像 ZooKeeper 和 etcd 这样的共识服务实际上实现了全序广播,这一事实暗示了全序广播与共识之间有着紧密联系。全序广播正是数据库复制所需的:如果每个消息都代表一次数据库的写入,且每个副本都按相同的顺序处理相同的写入,那么副本间将相互保持一致(除了临时的复制延迟)。这个原理被称为状态机复制(state machine replication)。与之类似,可以使用全序广播来实现可序列化的事务,如果每个消息都表示一个确定性事务,以存储过程的形式来执行,且每个节点都以相同的顺序处理这些消息,那么数据库的分区和副本就可以相互保持一致。
全序广播的一个重要表现是,顺序在消息送达时被固化:如果后续的消息已经送达,节点就不允许追溯地将(先前)消息插入顺序中的较早位置。这个事实使得全序广播比时间戳命令更强。考量全序广播的另一种方式是,这是一种创建日志的方式(如在复制日志,事务日志或预写式日志中):传递消息就像附加写入日志。由于所有节点必须以相同的顺序传递相同的消息,因此所有节点都可以读取日志,并看到相同的消息序列。
全序广播对于实现提供防护令牌的锁服务也很有用。每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用,因为它是单调递增的。在 ZooKeeper 中,这个序列号被称为 zxid。
使用全序广播实现线性一致的存储
在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系。从形式上讲,线性一致读写寄存器是一个“更容易”的问题。全序广播等价于共识,而共识问题在异步的崩溃-停止模型中没有确定性的解决方案,而线性一致的读写寄存器可以在这种模型中实现。然而,支持诸如比较并设置(CAS, compare-and-set),或自增并返回(increment-and-get)的原子操作使它等价于共识问题。因此,共识问题与线性一致寄存器问题密切相关。
全序广播是异步的:消息被保证以固定的顺序可靠地传送,但是不能保证消息何时被送达(所以一个接收者可能落后于其他接收者)。相比之下,线性一致性是新鲜性的保证:读取一定能看见最新的写入值。但如果有了全序广播,你就可以在此基础上构建线性一致的存储。例如,你可以确保用户名能唯一标识用户帐户。
设想对于每一个可能的用户名,你都可以有一个带有 CAS 原子操作的线性一致寄存器。每个寄存器最初的值为空值(表示不使用用户名)。当用户想要创建一个用户名时,对该用户名的寄存器执行 CAS 操作,在先前寄存器值为空的条件,将其值设置为用户的账号 ID。如果多个用户试图同时获取相同的用户名,则只有一个 CAS 操作会成功,因为其他用户会看到非空的值(由于线性一致性)。你可以通过将全序广播当成仅追加日志的方式来实现这种线性一致的 CAS 操作:
-
在日志中追加一条消息,试探性地指明你要声明的用户名。
-
读日志,并等待你所附加的信息被回送。
-
检查是否有任何消息声称目标用户名的所有权。如果这些消息中的第一条就你自己的消息,那么你就成功了:你可以提交声称的用户名(也许是通过向日志追加另一条消息)并向客户端确认。如果所需用户名的第一条消息来自其他用户,则中止操作。
由于日志项是以相同顺序送达至所有节点,因此如果有多个并发写入,则所有节点会对最先到达者达成一致。选择冲突写入中的第一个作为胜利者,并中止后来者,以此确定所有节点对某个写入是提交还是中止达成一致。类似的方法可以在一个日志的基础上实现可序列化的多对象事务。尽管这一过程保证写入是线性一致的,但它并不保证读取也是线性一致的,如果你从与日志异步更新的存储中读取数据,结果可能是陈旧的。(精确地说,这里描述的过程提供了顺序一致性(sequential consistency),有时也称为时间线一致性(timeline consistency),比线性一致性稍微弱一些的保证)。为了使读取也线性一致,有几个选项:
-
你可以通过追加一条消息,当消息回送时读取日志,执行实际的读取。消息在日志中的位置因此定义了读取发生的时间点。(etcd 的法定人数读取有些类似这种情况。)
-
如果日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待直到该位置前的所有消息都传达到你,然后执行读取。(这是 Zookeeper sync() 操作背后的思想)。
-
你可以从同步更新的副本中进行读取,因此可以确保结果是最新的。(这种技术用于链式复制;参阅“复制研究”。)
使用线性一致性存储实现全序广播
上一节介绍了如何从全序广播构建一个线性一致的 CAS 操作。我们也可以把它反过来,假设我们有线性一致的存储,接下来会展示如何在此基础上构建全序广播。最简单的方法是假设你有一个线性一致的寄存器来存储一个整数,并且有一个原子自增并返回操作。或者原子 CAS 操作也可以完成这项工作。该算法很简单:每个要通过全序广播发送的消息首先对线性一致寄存器执行自增并返回操作。然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点(重新发送任何丢失的消息),而收件人将按序列号连续发送消息。
请注意,与兰伯特时间戳不同,通过自增线性一致性寄存器获得的数字形式上是一个没有间隙的序列。因此,如果一个节点已经发送了消息 4 并且接收到序列号为 6 的传入消息,则它知道它在传递消息 6 之前必须等待消息 5 。兰伯特时间戳则与之不同,事实上,这是全序广播和时间戳排序间的关键区别。实现一个带有原子性自增并返回操作的线性一致寄存器有多困难?像往常一样,如果事情从来不出差错,那很容易:你可以简单地把它保存在单个节点内的变量中。问题在于处理当该节点的网络连接中断时的情况,并在该节点失效时能恢复这个值。一般来说,如果你对线性一致性的序列号生成器进行深入过足够深入的思考,你不可避免地会得出一个共识算法。
这并非巧合:可以证明,线性一致的 CAS(或自增并返回)寄存器与全序广播都都等价于共识问题。也就是说,如果你能解决其中的一个问题,你可以把它转化成为其他问题的解决方案。这是相当深刻和令人惊讶的洞察!