分布式系统(Distributed systems), 通常有多份数据,并且需要保持数据间的同步。然而,我们不能依赖处理节点可靠地工作,网络延迟很容易导致不一致。
分布式系统 - 实现例子
类型 | 例子 |
---|---|
数据库(Databases) | Cassandra, HBase, Riak |
消息代理 (Message Brokers) | Kafka, Pulsar |
基础设施(Infrastructure) | Kubernetes, Mesos, Zookeeper, etcd, Consul |
内存数据/计算网格(In Memory Data/Compute Grids) | Hazelcast, Pivotal Gemfire |
有状态的微服务(Stateful Microservices) | Akka Actors, Axon |
文件系统(File Systems) | HDFS, Ceph |
分布式系统,
- 它们在多台服务器上运行。集群中的服务器数量可以从少至三台服务器到几千台服务器不等。
- 他们管理数据。所以这些本质上是“有状态的”系统
当多个服务器都在存储同样的数据时,容易导致数据的不一致。上述所有系统都需要解决这些问题。这些系统的实施对这些问题有一些反复出现的解决方案。
分布式系统遇到的问题
进程崩溃(Process Crashes)
进程崩溃,可能由于硬件或者软件的错误
- 系统管理员可以将进程下线,进行日常维护。
- 由于磁盘已满且未正确处理异常,因此可进程能会在执行某些文件 IO 时被操作系统关掉。
如果进程负责存储数据,它们必须为存储在服务器上的数据提供持久性保证。即使一个进程突然崩溃,它也应该保留它已通知用户它已成功存储的所有数据。根据访问模式,不同的存储引擎具有不同的存储结构,从简单的哈希映射到复杂的图存储。由于将数据刷新到磁盘是最耗时的操作之一,因此并非每次对存储的插入或更新都可以刷新到磁盘。所以大多数数据库都有内存中的存储结构,这些结构只会定期刷新到磁盘。如果进程突然崩溃,这会带来丢失所有数据的风险。
预写日志( Write-Ahead Log , WAL)的方法可以解决这种情况。服务器将每个状态更改作为命令存储在硬盘上的只追加文件(append-only file)中。只追加文件通常是一个非常快的操作,因此可以在不影响性能的情况下完成。按顺序附加的单个日志用于存储每个更新。在服务器启动时,可以重播日志以再次构建内存状态。
Write-Ahead lof 提供了数据持久性保证。即使服务器突然崩溃然后重新启动,数据也不会丢失。但在服务器备份之前,客户端将无法获取或存储任何数据。所以我们在服务器故障的情况下缺乏可用性(availability)。
一个解决方案是将数据存储在多个服务器上。所以我们可以在多台服务器上复制预写日志(Write-Ahead Log , WAL)。
当涉及多个服务器时,需要考虑更多的故障场景。
网络延迟(Network delay)
在典型的数据中心中,服务器被打包在机架中,并且有多个机架通过架顶式交换机( top-of-the-rack switch)连接。可能有一棵交换机树将数据中心的一部分连接到另一部分。在某些情况下,一组服务器可以相互通信,但与另一组服务器断开连接。这种情况称为网络分区(network partition)。服务器通过网络进行通信的基本问题之一是如何知道特定服务器发生故障。
这里有两个问题需要解决:
- 一台服务器不能无限期地等待知道另一台服务器是否已经崩溃。
- 不应该有两组服务器,每组都认为另一组发生故障,因此继续为不同的客户端组提供服务。这被称为脑裂(split brain)。
为了解决第一个问题,每个服务器都会定期向其他服务器发送 心跳 消息( HeartBeat message )。如果错过了心跳,则发送心跳的服务器被认为已崩溃。心跳间隔足够小,可以确保检测服务器故障不会花费太多时间。最坏的情况下,服务器可能已重亲并正在运行,但集群任务服务器一崩溃,并且继续运行。
第二个问题是脑裂(split brain)。在脑裂情况下,两套服务器独立接受更新,不同的客户端可以获取和设置不同的数据,一旦裂脑解决,就无法自动解决更新冲突。
为了解决脑裂问题,我们必须确保彼此断开连接的两组服务器不能够独立运行。只有当大多数服务器可以确认该操作时,服务器采取的每个操作才被认为是成功的。如果服务器不能获得多数,它们将无法提供所需的服务,并且某些客户端可能无法接收服务,但集群中的服务器将始终处于一致状态。占多数的服务器数量称为Quorum( Quorum)。如何决定Quorum?这是根据集群可以容忍的故障数量决定的。因此,如果我们有一个由五个节点组成的集群,我们需要三个Quorum。一般来说,如果我们想容忍 f 个故障,我们需要一个 2f + 1 的集群大小。
Quorum 确保我们有足够的数据副本来承受一些服务器故障。但是仅仅给客户端提供强一致性保证是不够的。假设客户端在集群上启动写操作,但写操作仅在一台服务器上成功。集群的其他服务器仍然具有旧值。当客户端从集群读取值时,如果具有最新值的服务器可用,它可能会获取最新值。但是如果在客户端开始读取该值时,具有最新值的服务器不可用,则它可能从其他服务器上获取旧值。为了避免这种情况,有人需要跟踪集群是否同意特定操作,并且只向客户端发送保证在所有服务器上可用的值。在这种情况下使用Leader和Follower( Leader and Followers)。其中一个服务器被选为Leader,其他服务器充当Follower。Leader控制和协调Follower上的复制。Leader现在需要决定哪些更改应该对客户可见。高水位标记( High-Water Mark )用于跟踪已知已成功复制到Quorum数量的追随者的预写日志中的条目。客户可以看到所有达到高水位线的条目。领导者还将高水位标记传播给追随者。因此,如果Leader失败并且其中一个Follower成为新的Leader,客户端看到的内容不会出现不一致。
进程暂停(Process Pauses)
即使有Quorum、Leader和Follower,也有一个棘手的问题需要解决。Leader进程可能暂停。进程暂停的原因有很多。对于支持垃圾收集的语言,可能会有很长的垃圾收集暂停。一个长时间的垃圾回收暂停的Leader,与Follower断开连接,并在暂停结束后继续向Follower发送消息。同时,由于Follower没有收到Leader的心跳,他们可能已经选举了一个新的Leader并接受了来自客户端的更新。如果来自旧Leader的请求按原样处理,它们可能会覆盖一些更新。所以我们需要一种机制来检测来自过时Leader的请求。在这里,Generation Clock(Generation Clock ) 用于标记和检测来自Leader的请求。Generation是一个单调递增的数字。
不同步的时钟和排序事件
从Leader发出的消息中,检测出是否来自新旧Leader,关键在于维护消息的排序。我们不能使用系统时间戳来排序一组消息,主要原因是不能保证跨服务器的系统时钟是同步的,因为晶体可以更快或更慢地振荡,因此不同的服务器可以有不同的时间。服务器使用NTP来获取时间。NTP定期检查一组全球时间服务器,并相应地调整计算机时钟。
由于通过网络进行通信时会发生这种情况,并且网络延迟可能会有所不同,如以上部分所述,因此时钟同步可能会因网络问题而延迟。这可能会导致服务器时钟彼此偏离,并且在 NTP 同步发生后,时间甚至会向后移动。由于计算机时钟的这些问题,一天中的时间通常不用于排序事件。取而代之的是一种称为 Lamport Clock(Lamport Clock) 的技术,Generation Clock( Generation Clock )就是一个例子。 Lamport Clocks 只是简单的数字,仅当系统中发生某些事件时才会增加。在数据库中,事件是关于写入和读取值的,因此 Lamport Clock仅在写入值时递增。 Lamport Clock 编号也在发送给其他进程的消息中传递。然后,接收进程可以选择两个数字中较大的一个,即它在消息中接收的一个和它维护的一个。通过这种方式,Lamport Clocks 还可以跟踪相互通信的进程之间的事件之间的发生前(heppen-before)关系。这方面的一个例子是参与事务的服务器。虽然 Lamport Clock 允许对事件进行排序,但它与时钟的时间没有任何关系。为了弥补这一差距,使用了一种称为混合时钟( Hybrid Clock )的变体。混合时钟使用系统时间和一个单独的数字来确保值单调增加,并且可以像 Lamport Clock 一样使用。
Lamport Clock用于确定一组服务器的事件顺序。但它不能检测不同副本服务器中,发生的对相同值的并发更新。Version Vector( Version Vector )用于检测一组副本之间的冲突。
Lamport Clock或Version Vector需要与存储的值相关联,以检测哪些值存储在另一个之后或是否存在冲突。因此服务器将值存储为版本化值(Versioned Value.)。