分布式系统给软件开发出了个特别的难题。这些系统要求我们保存多个需要保持同步的数据副本。并且我们不能依赖于计算节点的稳定运行,网络延迟可以轻易地导致不一致。尽管如此,许多公司仍然依赖一系列的核心分布式系统,使用它们来解决数据存储、消息通信、系统管理和分布式算力的问题。这些系统使用类似的方案来解决它们面临的共同问题。为了更好得理解、交流和教授分布式系统设计,我们需要设计模式来达成这一目标,所以本文识别出这些解决方案并发展为一系列的设计模式。

关于

在过去的几个月里,我一直在ThoughtWorks上进行有关分布式系统的研讨会。举办研讨会时面临的主要挑战之一是如何将分布式系统理论映射到诸如Kafka或Cassandra之类的开源代码中,同时保持讨论的通用性以涵盖广泛的解决方案。模式的概念提供了一个不错的出路。

模式结构本质上使我们能够专注于特定问题,从而很清楚地说明了为什么需要特定的解决方案。然后解决方案的描述允许我们给出一个代码结构,该结构足够具体以展示实际的解决方案,但又足够通用以覆盖各种变体的情况。模式技术还允许我们将各种模式组合在一起以构建一个完整的系统。这为讨论分布式系统实现提供了很好的参考。

接下来是在主流开源分布式系统中观察到的第一组模式。我希望这些模式对所有开发者都有帮助。

分布式系统-实现的角度

当今的企业架构充满了各种“天然”的分布式平台和框架。如果我们抽看当今典型企业架构的分布式系统使用列表,如下表格:

Type of platform/framework Example
数据库 Cassandra, HBase, Riak
消息中间件 Kafka, Pulsar
基础设施 Kubernetes, Mesos, Zookeeper, etcd, Consul
内存数据/网格计算 Hazelcast, Pivotal Gemfire
有状态微服务(Stateful Microservices) Akka Actors, Axon
文件系统 HDFS, Ceph

所有这些系统都是天然的“分布式”。说一个系统是分布式的到底是什么意思呢?这体现在两个方面:

  • 它们运行在多台服务器上。一个集群上的服务器数量可以从几台到几千台。
  • 它们管理数据。所有他们是天然的有状态系统。

当多台服务器一起存储数据时,有不少出错的方式。上面提到的所有系统都需要解决这些问题。这些系统的实现对这些问题有一些重复出现的解决方案。理解这些通用的解决方案,将帮助我们理解大部分的分布式系统,并且可以为构建新系统提供很好的指导。接着进入模式的讲解。

模式

模式是由Christopher Alexander引进的概念,在软件社区中被广泛接受,以记录用于构建软件系统的设计构造。

模式为解决问题提供了一种结构化的方法,而这种方法已经出现和被验证过多次了。

使用模式的一种有趣方式是以模式序列或模式语言的形式将多个模式一起使用,这将为实现一个“完整”的系统提供了一些指导。所以将分布式系统视为一系列的模式是一种深入理解其实现的有效方法。

问题及可复用解决方案

当数据存储在多台服务器上时,有几种情况会导致异常:

进程终止

进程可能会在任何时候终止。由于硬件故障或者软件错误,进程崩溃的方式有很多种:

  • 由于运维人员的日常维护导致进程终止。
  • 由于在磁盘已满的情况下做一些文件IO操作,并且没有正常处理异常。
  • 在云环境中,原因可能更加诡异,一些不相关的事件也可能导致服务器下线。

对于负责存储数据的线程,最重要的是,它必须对存储在服务器上的数据提供一致性保证。即使进程突然终止,它应该保存已通知用户成功入库的所有数据。根据其访问模式,不同的存储引擎有不同的存储结构,从简单的哈希表到复杂的图存储。因为写入数据到磁盘是最耗时的操作之一,所以即使是一个insert或者update操作也可能没写入到磁盘。大多数数据库的内存存储结构只会周期性的写入磁盘。那么如果进程突然终止,则可能会丢失所有数据。

一种称为Write-Ahead Log(预写日志)的技术用来应对这种场景。服务器以可附加文件(append-only)的形式将每次状态的变化作为命令存储在硬盘上。往文件中附加内容往往是一个很快的操作,因此可以在不影响性能的情况下完成。顺序附加的单个日志文件用于存储每次状态更新。在服务器启动时,可以重放日志以再次构建内存状态。

这就保证了持久性(durability)。就算服务器宕机后重启,数据也将不会丢失。但是在服务器完成数据恢复之前,客户端将不能够获取或存储任何数据。所以在服务器发生故障的情况下,我们缺少可用性。

一个显而易见的解决方案就是在多台服务器上存储数据,因此我们可以在多台服务器上复制预写日志。

而当涉及多台服务器时,会有更多的故障场景需要考虑。

网络延迟

TCP/IP协议栈中,在网络上传输消息的过程中造成延迟是没有上限的。延迟会根据网络的负载而变化。比如,一个1Gbps的网络可以被触发的大数据Job阻塞,从而填满网络缓存区,导致一些消息到达服务器的随机延迟。

在典型的数据中心里,服务器被装在货架上,这些货架和交换机连接着。可能会有一个交换机树将数据中心的一部分连接到另一部分。在某些情况下可能会出现:这一组服务器内可以互相通信,但是与另一组服务器断开连接。这种情况称作网络分区。那么服务器在网络上通信的基础问题之一就是:何时感知特定服务器的故障。

这里有两个问题需要解决:

  • 一台特定的服务器不能为了感知其他服务器的宕机而无期限地等待下去。
  • 不应该出现两组服务器互相认为对方发生故障,而继续继续为不同的客户端提供服务。这种现象称为split brain(脑裂)。

为了解决第一个问题,每台服务器周期性地向其他服务器发送HeartBeat(心跳)信息。如果丢失了一个心跳信息,那么发送这个心跳的服务器会被视作发生故障。发送心跳的间隔需要足够小,以确保不需要花费太多时间来检测服务器故障。正如下文我们可以描述的,在最坏的情况下,一台服务器可能已启动并在运行,但是其他集群组可以继续运行,并把它视作发生了故障。这确保了提供给客户端的服务不会被中断。

第二个问题是脑裂。在这种情况下,如果两组服务器独立地接受更新,不同的客户端可能获取并设置不一致的数据,一旦脑裂发生,就不可能自动解决数据冲突。

为了解决脑裂的问题,我们必须确保两组互相断连的服务器,不应该继续独立地提供服务。为了确保这一点,对于服务器上的每个操作,当且仅当大多数服务器都确认该操作时,这个操作才被视为成功的。如果服务器无法达成大多数确认,则它们将无法提供所需的服务,并且某些客户端可能无法接受到服务的结果,但是集群中服务器将时钟处于一致性状态。这个达到大多数确认的服务器数量被称为Quorum。那么如何去决定Quorum的数量呢?这取决于集群可以容忍多少数量的服务器故障。比如我们有一个五台节点的集群,我们需要的Quorum是三。一般来说,如果你需要容忍f台服务器故障,我们需要的集群数量是2f+1

Quorum数量则为f+1。译者注

Quorum机制确保我们拥有足够的数据副本以承受某些服务器的故障。但是这不足够对客户端保证强一致性。例如,一个客户端发起一个写操作在Quorum数量的服务器组上,但该写操作只在一台服务器上成功。组内的其他服务器依然保持着旧值。当一个客户端从Quorum集群中读取值时,它可能是最新的,如果拥有最新值的服务器可用时。但是它极有可能读到旧值,当客户端开始读取时拥有最新值的服务器不可用。为了避免这种情况,我们需要记录Quorum集群是否同意这个操作,并且只发送在所有服务器上可用的数据给客户端。Leader and Followers (领导者和跟随者)机制被用于这种场景中。其中的一台服务器被选为leader,其他的服务器作为followers。Leader控制并协调follower的数据复制。Leader现在需要决定哪些操作改变应该对客户端可见。 High-Water Mark(高水位标记) 被用来记录预写日志中的那些被成功复制到follower上的条目。所有在高水位标志之上的条目都是对客户端可见的。Leader也向follower传达高水位标记,因此在leader不可用的时候,followers之一将成为新的leader,对客户端来说将看不到不一致的情况。

进程暂停

但这还不是全部,即使有了Quorum和Leader Followers机制,仍然有一个棘手的问题需要解决。Leader进程随时都可能暂停。有很多原因可以造成进程暂停。对于支持垃圾回收的语言,可能会存在一段较长的垃圾回收暂停阶段。如果Leader处于垃圾回收暂停阶段,它就会和followers断开连接,并且在暂停阶段结束后继续向followers发送消息。同时,因为folloers接收不到任何来自leader的心跳,它们将会选举出一个新的leader并且接受来自客户端的更新。如果旧leader的请求按原样处理,它们可能会覆盖一些新的更新。所以我们需要一种机制来检测过时leader的请求。Generation Clock 用来标记和检测来自旧leader的请求,“Generation”是一个单调增长的数字。

时钟不同步及顺序事件

从新消息中检测旧Leader的消息的问题也就是维护消息顺序的问题。看起来我们可以使用系统时间戳对消息进行拍下,但是我们不能。我们不能使用系统时钟的主要原因是不同机器上的系统时钟不能保证是同步的。计算机中的时钟由石英晶体管理,并根据晶体的振荡来测量时间。

这种机制是容易出错的,因为晶体可以更快或更慢地振荡,所以不同的服务器可能是截然不同的时间。一组服务器上的时钟由NTP服务器同步。NTP服务器周期性的检查一组全局时间服务器,并相应地调整计算机时钟。

因为这种机制基于网络通信,并且网络延迟时间有长有短,就如上文所述的那样,因为网络延迟,时钟同步可能会有延迟。这可能会导致服务器时钟彼此存在偏差,并且在NTP同步发生后甚至会倒退。由于计算机时钟的这些问题,因此通常不将一天中的时间用于事件排序。取而代之的是使用一种称为Lamport时间戳的简单技术。 Generation Clock 就是它的一个例子。

整合-一个分布式系统的例子

我们可以看到理解这些模式是如何帮助我们从零开始构建一个完整的系统。我们将以分布式共识的实现为例。分布式共识是分布式系统实现的特例,它提供了最强的一致性保证。在当今企业中常用的有: ZookeeperetcdConsul。它们实现了像 zabRaft 这样的共识算法以提供复制和强一致性。还有其他流行的算法可以实现共识,, Paxos用于Google的Chubby的锁定服务、Viewstamped Replication和虚拟同步(virtual-synchrony)。用非常简单的术语来说,共识是指一组服务器在存储的数据、存储的顺序以及何时使该数据对客户端可见方面达成一致。

实现一致性的模式

分布式共识的实现使用state machine replication(状态机复制)来实现容错。在状态机复制过程中,存储服务(如键值存储)在所有服务器上复制,并且用户输入在每个服务器上以相同顺序执行。用于实现此功能的关键技术就是在所有服务器上复制Write-Ahead Log(预写日志)以具备“Replicated Wal”。

我们将这些模式放在整合在一起以实现“Replicated Wal”。

使用Write-Ahead Log来提供持久性保证。使用Segmented Log(分段日志)将预写日志被分为多个片段。这有助于 Low-Water Mark(低水位标志)进行日志清理。通过在多个服务器上复制预写日志来提供容错能力。服务器之间的复制由Leader and Followers机制管理。Quorum 机制用来更新High-Water Mark(高水位标志)来决定哪些值对客户端可见。 通过使用Singular Update Queue,所有请求均按严格的顺序处理。使用 Single Socket Channel(单套字节通道)在Leader的请求发送给Follwer时保证顺序。使用Request Pipeline 来优化单套字节通道的吞吐量和延迟。Follower根据Leader发送的心跳判断Leader是否可用。如果Leader由于网络分区而暂时从集群断开连接,则可以使用Generation Clock来检测。

Screen Shot 2021-01-17 at 21.45.14

这样,以一般形式理解问题以及可复用的解决方案,有助于理解完整系统的组件。

下一步

分布式系统是一个广泛的话题。本文涵盖的模式只是一小部分,其中包括了不同类别的模式,以展示模式是如何帮助理解和设计分布式系统的。我将持续添加模式以揽括下列在分布式系统中需要解决的问题:

  • Group Membership and Failure Detection(组成员和故障检测)
  • Partitioning(网络分区)
  • Replication and Consistency(复制和一致性)
  • Storage(存储)
  • Processing