分布式系统设计中的通用方法
之前翻译过一篇关于分布式系统的文章 lichuanyang.top/posts/3 ,在各个平台都取得了不错的反响。因此,最近又重新整理了一下相关的知识,结合一些这一年多里新的理解,重新整理了下这篇文章。
首先我们需要明确本文要讨论的分布式系统是什么,简单的说,就是满足多节点和有状态这两个条件即可。多节点很好理解,有状态则是指这个系统要维护一些数据,不然的话,其实我们无脑的水平扩容就没有任何问题,也就不存在分布式系统的问题了。
常见的分布式系统, 无论是mysql, cassandra, hbase这些数据库,还是rocketmq, kafka, pulsar这样的消息队列,还是zookeeper之类的基础设施,其实都满足这两个条件。
这些分布式系统的实现通常来说主要需要关注两个方面:一是自己本身功能的实现,二是在分布式环境下保持良好的性能与稳定性;即便是两个功能完全不一样的系统,其对第二类问题的处理方式也会有很多相似之处。本文的关注重点也即在对第二类问题的处理上。
接下来,我们列举一下分布式系统都有哪些常见目标,包括而不限于:
大量普通的服务器通过网络互联,对外作为整体提供服务;
随着集群规模增长,系统整体性能表现为线性增长;
能够自动容错,故障节点自动迁移,不同节点的数据要能保持一致性;
要达成这些目标,又有哪些挑战呢?大概有以下这些:
进程崩溃:原因很多,包括硬件故障、软件故障、正常的例行维护等等,在云环境下会有一些更加复杂的原因;
进程崩溃导致的最大问题就是会丢数。出于性能的考虑,很多情况下我们不会进行同步的写磁盘,而是会将数据暂时放在内存的缓冲区,再定期刷入磁盘。而在进程崩溃的时候,内存缓冲区中的数据显然会丢失。网络延迟和中断:节点的通信变到很慢时,一个节点如何确认另一个节点是否正常;
网络分区:集群中节点分裂成两个子集,子集内通信正常,子集之间断开(脑裂),这时候集群要如何提供服务。
这里插一个彩蛋,在CAP理论的前提下,现实中的系统通常只有两种模式:放弃高可用的CP模式和放弃强一致性的AP模式。为什么没有一种放弃分区容忍性的CA模式?就是因为我们无法假设网络通信一定正常,而一旦接受了集群变成两个分区,再想合并回来就不现实了。
进程暂停:比如full gc之类的原因导致进程出现短暂的不可用后又迅速恢复,不可用期间集群有可能已经做出了相关的反应,当这个节点再恢复的时候如何维持状态的一致性。
时钟不同步和消息乱序:集群内不同节点的操作,我们希望它的顺序是明确的;不同节点之间的时钟不同步,会导致我们无法利用时间戳确保这件事。而消息的乱序就给分布式系统的处理带来了更大的难度。
下面,我们就依次介绍,针对这些问题,都有什么处理方式。
对于进程崩溃的问题,首先要明确的是,单纯实现进程崩溃下不丢数,没有任何难度,重要的是怎么在保证系统性能的前提下达到这个目标。
首先要介绍的就是write-ahead log这种模式,服务器将每个状态更改作为命令存储在硬盘上的仅附加(append-only)文件中。append操作由于是顺序的磁盘写,通常是非常快的,因此可以在不影响性能的情况下完成。在服务器故障恢复时,可以重播日志以再次建立内存状态。
其关键思路是先以一个小成本的方式写入一份持久化数据,不一定局限于顺序写磁盘,此时就可以向client端确认数据已经写入,不用阻塞client端的其他行为。server端再异步的去进行接下来高消耗的操作。
典型场景及变体:mysql redo log; redis aof; kafka本身 ;业务开发中的常见行为:对于耗时较高的行为,先写一条数据库记录,表示这个任务将被执行,之后再异步进行实际的任务执行;
write-ahead log会附带一个小问题,日志会越攒越多,要如何处理其自身的存储问题呢?有两个很自然的思路:拆分和清理。
拆分即将大日志分割成多个小日志,由于WAL的逻辑一般都很简单,所以其拆分也不复杂,比一般的分库分表要容易很多。这种模式叫做 Segmented Log, 典型的实现场景就是kafka的分区。
关于清理,有一种模式叫做low-water mark(低水位模式), 低水位,即对于日志中已经可以被清理的部分的标记。标记的方式可以基于其数据情况(redolog), 也可以基于预设的保存时间(kafka),也可以做一些更精细的清理和压缩(aof)。
再来看网络环境下的问题,首先使用一个非常简单的心跳(HeartBeat)模式,就可以解决节点间状态同步的问题。一段时间内没有收到心跳,就将这个节点视为已宕机处理。
而关于脑裂的问题,通常会使用大多数(Quorum)这种模式,即要求集群内存活的节点数要能达到一个Quorum值,(通常集群内有2f+1个节点时,最多只能容忍f个节点下线,即quorum值为f+1),才可以对外提供服务。我们看很多分布式系统的实现时,比如rocketmq, zookeeper, 都会发现需要满足至少存活多少个节点才能正常工作,正是Quorum模式的要求。
Quorum解决了数据持久性的问题,也就是说,成功写入的数据,在节点失败的情况下,是不会丢失的。但是单靠这个,无法提供强一致性的保证,因为不同节点上的数据是会存在时间差的,client连接到不同节点上时,会产生不同的结果。可以通过主从模式(Leader and Followers) 解决一致性的问题。其中一个节点被选举为主节点,负责协调节点间数据的复制,以及决定哪些数据对client是可见的。
高水位(High-Water Mark)模式是用来决定哪些数据对client可见的模式。一般来说,在quorum个从节点上完成数据写入后,这条数据就可以标记为对client可见。完成复制的这条线,就是高水位。
主从模式的应用范围实在太广,这里就不做举例了。分布式选举算法很多,比如bully, ZAB, paxos, raft等。其中,paxos无论是理解还是实现难度都太大,bully在节点频繁上下线时会频繁的进行选举,而raft可以说是一种稳定性、实现难度等各方面相对均衡,使用也最广泛的一种分布式选举算法。像elastic search, 在7.0版本里,将选主算法由bully更换为raft;kafka 2.8里,也由利用zk的ZAB协议,修改为raft.
到这儿,我们先总结一下。实际上,一个对分布式系统的操作,基本上就可以概括为下边这么几步:
写主节点的Write-Ahead Log;
写1个从节点的 WAL
写主节点数据;
写1个从节点数据
写quorum个子节点WAL
写quorum个子节点数据
其中,2-5步之间的顺序不是固定的。分布式系统平衡性能和稳定性的最重要方式,实质上就是决定这几步操作的顺序,以及决定在哪个时间点向client端返回操作成功的确认信息。例如,mysql的同步复制、异步复制、半同步复制,就是典型的这种区别的场景。
关于进程暂停,造成的主要的问题场景是这样的:假如主节点暂停了,暂停期间如果选出了新的主节点,然后原来的主节点恢复了,这时候该怎么办。这时候,使用Generation Clock这种模式就可以,简单的说,就是给主节点设置一个单调递增的代编号,表示是第几代主节点。像raft里的term, ZAB里的epoch这些概念,都是generation clock这个思路的实现。
再看看时钟不同步问题,在分布式环境下,不同节点的时钟之间必然是会存在区别的。在主从模式下,这种问题其实已经被最大限度的减少了。很多系统会选择将所有操作都在主节点上进行,主从复制也是采取复制日志再重放日志的形式。这样,一般情况下,就不用考虑时钟的事情了。唯一可能出问题的时机就是主从切换的过程中,原主节点和新主节点给出的数就有可能存在乱序。
一种解决时钟不同步问题的方案就是搞一个专门的服务用来做同步,这种服务叫做NTP服务。但这种方案也不是完美的,毕竟涉及到网络操作,所以难免产生一些误差。所以想依靠NTP解决时钟不同步问题时,系统设计上需要能够容忍一些非常微弱的误差。
其实,除了强行去把时钟对齐之外,还有一些简单一些的思路可以考虑。首先思考一个问题,我们真的需要保证消息绝对的按照真实世界物理时间去排列吗?其实不是的,我们需要的只是 一个自洽、可重复的确定消息顺序的方式,让各个节点对于消息的顺序能够达成一致即可。也就是说,消息不一定按照物理上的先后排列,但是不同节点排出来的应该一样。
有一种叫Lamport Clock的技术就能达到这个目标。它的逻辑很简单,如图所示
就是本机上的操作会导致本机上的stamp加1,发生网络通信时,比如C接收到B的数据时,会比较自己当前的stamp, 和B的stamp+1, 选出较大的值,变成自己当前的戳。这样一个简单的操作,就可以保证任何有相关性的两个操作(包括出现在同一节点、有通信两种情况)的顺序在不同节点之间看来是一致的。
另外,还有一些相对简单些的事情,也是分布式系统设计中经常要考虑的,比如怎么让数据均匀的分布在各个节点上。对于这个问题,我们可能需要根据业务情况去找一个合适的分片key, 也可能需要找到一个合适的hash算法。另外,也有一致性哈希这种技术,让我们控制起来更自如。
分布式系统设计中还需要重点考虑的一块就是如何衡量系统性能,指标包括性能(延迟、吞吐量)、可用性、一致性、可扩展性等等,这些说起来都比较好理解,但要是想更完善的去衡量,尤其是想更方便的去观测这些指标的话,也是一个很大的话题。