当前位置: 首页 > 科技观察

分布式系统设计中的通用方法_1

时间:2023-03-13 06:44:37 科技观察

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