无论是SOA或者微服务架构,都是必须要面对和解决一些分布式场景下的问题。如果只是单服务、做个简单的主备,那么编程会成为一件简单幸福的事。只要没有bug,一切都会按照你的预期进行。然而在分布式系统中,如果想当然得去按照单服务思想编程和架构,那可能会收获很多意想不到的“惊喜”:网络延迟导致的重复提交、数据不一致、部分节点挂掉但是任务处理了一半等等。在分布式系统环境下编程和在单机器系统上写软件最大的差别就是,分布式环境下会有很多很‘诡异’的方式出错,所以我们需要理解哪些是不能依靠的,以及如何处理分布式系统的各种问题。
微服务架构跟SOA一样,也是服务化的思想。在微服务中,我们倾向于使用RESTful风格的接口进行通信,使用Docker来管理服务实例。我们的理想是希望分布式系统能像在单个机器中运行一样,就像客户端应用,再坏的情况,用户只要一键重启就可以重新恢复。然而现实是我们必须面对分布式环境下的由网络延迟、节点崩溃等导致的各种突发情况。
在决定使用分布式系统,或者微服务架构的时候,往往是因为我们希望获得更好的伸缩性、更好的性能、高可用性(容错)。虽然分布式系统环境更加复杂,但只要能了解分布式系统的问题以及找到适合自己应用场景的方案,便能更接近理想的开发环境,同时也能获得伸缩性、性能、可用性。
分布式系统从结构上来看,就是由多台机器节点,以及保证节点间通信的网络组成。所以我们需要关注节点、网络的特征:
如果系统中的某个节点挂掉了,但是其他节点可以正常提供服务。这种部分失败,不像单台机器或者本地服务那样好处理。单机的多线程对象可以通过机器的资源进行协调和同步,以及决定如何进行错误恢复。但分布式环境下,没有一个可以来进行协调同步、资源分配以及进行故障恢复的节点。部分失败一般是无法预测的,有时甚至无法知道请求任务是否有被成功处理。
所以在开发需要进行网络通讯的接口时(RPC或者异步消息),需要考虑到部分失败。让整个系统接受部分失败并做好容错机制。比如在网络传输失败时要能在服务层处理好,并且给用户一个好的反馈。
2. 网络延迟
网络是机器间通信的唯一路径,但这条唯一路径并不是可靠的,而且分布式系统中一定会存在网络延迟。网络延迟会影响系统对于“超时时间”,“心跳机制”的判断。如果是使用异步的系统模型,还会有各种环节可能出错:可能请求没有成功发出去、可能远程节点收到请求但是处理请求的进程突然挂掉、可能请求已经处理了但是在response可能在网络上传输失败(如数据包丢失)或者延迟。而且网络延迟一般无法辨别。
即使是TCP能够建立可靠的连接,不丢失数据并且按照顺序传输,但是也处理不不了网络延迟。对于网络延迟敏感的应用,使用UDP会更好,UDP不保证可靠传输,也不会丢失重发,但是可以避免一些网络延迟,适合处理音频和视频的应用。
3. 没有共享内存、锁、时钟
分布式系统的节点间没有共享的内存,不应理所当然认为本地对象和远程对象是同一个对象。分布式系统也不像单机器的情况,可以共享同一个CPU的信号量以及并发操作的控制。也没有共享的物理时钟,无法保证所有机器的时间是绝对一致的。时间的顺序是很重要的,夸张点说假如对于一个人来说,从一个时钟来看是7点起床8点出门,但可能因为不同时钟的时间不一致,从不同节点上来看可能是7点出门8点起床。
在分布式环境下开发,需要我们能够有意识地进行问题识别。以上只是举例了一部分场景和问题,不同的接口实现,会在分布式环境下有不同的性能、扩展性、可靠性的表现。下文会继续上述的问题进行探讨,如何实现一个更可靠的系统。
对于上述分布式系统中的一些问题,可以针对不同的特征做一些容错和处理。下面主要看一下错误检测以及时间和顺序的处理模型。在实际处理中,一般是综合多个方案以及应用的特点。
对于部分失败,需要一分为二的看待。
节点的部分失败,可以通过增加错误检测的机制,自动检测问题节点。在实际的应用中,比如有通过Load Balancer,自动排除问题节点,只将请求发送给有效的节点。对于需要有leader选举的服务集群来说,可以引入实现leader选举的算法,如果leader节点挂掉了,其余节点能选举出新的leader。实现选举算法也属于共识问题,在后续文章中会再涉及到几种算法的实现和应用。
网络问题:由于网络的不确定性,比较难说一个节点是否真正的“在工作”(有可能是网络延迟导致的错误),通过添加一些反馈机制可以在一定程度确定节点是否正常运行,比如:
在实际做错误检测处理时,除了需要节点、容器 做出积极的反馈,还要有一定的重试机制。重试的实现可以基于网络传输协议,如使用TCP的RTT。也可以在应用层实现,如Kafka的at-least-once的实现。基于Docker体系的应用,可以使用SpringCloud的retry,结合Hytrix、Ribbon等。对于需要给用户反馈的应用,不太建议使用过多重试,根据不同的场景进行判断,更多的时候需要应用做出积极的响应即可,比如用户的“个人中心页面”,当“User”微服务挂了,可以给个默认头像、默认昵称,然后正确展示其他信息,而不是反复请求“User”微服务。
在分布式系统中,时间可以作为所有执行操作的顺序先后的判定标准,也可以作为一些算法的边界条件。在分布式系统中决定操作的顺序是很重要的,比如对于提供分布式存储服务的系统来说,Repeated Read以及Serializable的隔离级别,需要确定事务的顺序,以及一些事件的因果关系等等。
每个机器都有两个不同的时钟。一个是time-of-day,也即我们常用的关于当前的日期、时间的信息。如此时是2018年6月23日23:08:00,在Java中可以用System.currentTimeMillis()获取。另一个是Monotonic时钟,代表着单调递增的时间,一般是测量时间间距,在Java中调用System.nanoTime()可以获得Monotonic时间,常常用于测量一个本地请求的返回时间,比如Apache commons中的StopWatch的实现。
在分布式环境中,一般不会使用Monotonic,测量两台不同的机器的Monotonic的时间差是无意义的。
不同机器的time-of-day一般也不同,就算使用NTP同步所有机器时间,也会存在毫秒级的差,NTP本身也允许存在前后0.05%的误差。如果需要同步所有机器的时间,还需要对所有机器时间值进行监控,如果一个机器的时间和其他的有很大差异,需要移除不一致的节点。因为能改变机器时间的因素比较多,比如无法判断是否有人登上某台机器改变了其本地时间。
虽然全局时钟很难实现,并且有一定的限制,但基于全局时钟的假设还是有一些实践上的应用。比如Facebook Cassandra使用NTP同步时间来实现LWW(Last Write Win)。Cassandra假设有一个全局时钟,并基于这个时钟的值,用最新的写入覆盖旧值。当然时钟上的最新不代表顺序的最新,LWW区分不了实际顺序。另外还有如Google Spanner 使用GPS和原子时钟进行时间同步,但节点之间还是会存在时间误差。
在分布式系统中,因为全局时钟很难实现,并且像NTP同步过程,也会受到网络传输时间的影响。一般不会使用刚才所述的全局同步时间,当然也肯定不能使用各个机器的本地时间。对于需要确认操作执行顺序的时候,不能简单依赖一个基于time-of-day的timestamps,所以我们需要一个逻辑时钟,来标记一些事件顺序、操作顺序的序列号。常见的方式是给所有操作加上递增的计数器。
这种所有操作都添加一个全局唯一的序列号的方式,提供了一种全局顺序的保证,全局顺序也包含了因果顺序一致的概念。关于分布式一致性的概念和实现会在后续文章详细介绍,我们先将关注点回归到时间和顺序上。下面看两种典型的逻辑时钟实现。
Lamport timestamps 是Leslie Lamport在1978年提出的一种逻辑时钟的实现方法。Lamport Timestamps 的算法实现,可以理解为基于每个节点的一对值(NodeId, Counter)的全局顺序的同步。在集群中的每个节点(Node)都有一个唯一标识,并且每个Node都持有一个本地的对于所有操作顺序的一个Counter(计数器)。
Lamport实现的核心思想就是把事件分成三类(节点内处理的事件,发送事件、接收事件):
简单画了个示例如下图:
初始的counter都是0,在Node1接收到请求,处理事件时counter+1(C:1表示),并且再发送消息带上C:1。
在Node1接受ClientA请求时,本地的Counter=1 > ClientA请求的值,所以处理事件时max(1,0)+1=2(C:2),然后再发送消息,带上Counter值,ClientA更新请求的最大Counter值=2,并在下一次对Node2的事件发送时会带上这个值。
这种序列号的全局顺序的递增,需要每次Client请求持续跟踪Node返回的Counter,并且再下一次请求时带上这个Counter。lamport维护了全局顺序,但是却不能更好的处理并发。在并发的情况下,因为网络延迟,可能导致先发生的事件被认为是后发生的事件。如图中红色的两个事件属于并发事件。虽然ClientB的事件先发出,但是因为延迟,所以在Node1中会先处理ClientA。也即在Lamport的算法中,认为Node1(C:4) happens before Node1(C:5)。
Lamport timestamps还有另一种并发冲突事件:不同的NodeId,但Counter值相同。这种冲突会通过Node的编号的比较进行并发处理。比如Node2(C:10),Node1(C:10)是两个并发事件,则认为 Node2的时间顺序值 > Node1的序列值,也就认为 Node1(C:10) happens before Node2(C:10)。
所以可见,Lamport时间戳是一种逻辑的时间戳,其可以表示全局的执行顺序,但是无法识别并发,以及因果顺序,并发情况无法很好地处理 偏序。
Vector clock又叫向量时钟,跟lamport Timestamps比较类似,也是使用SequenceNo实现逻辑时钟,但是最主要的区别是向量时钟有因果关系。可以区分两个并发操作,是否一个操作依赖于另外一个。
Lamport Timestamps通过不断把本地的counter更新成公共的MaxCounter来维持事件的全局顺序。Vector clock则各个节点维持自己本地的一个递增的Counter,并且会多记录其他节点事件的Counter。通过维护了一组[NodeId,Counter]值来记录事件的因果顺序,能更好得识别并发事件。也即,Vector clock的[NodeId,Counter]不仅记录了本地的,还记录了其他Node的Counter信息。
Vector clock的[NodeId,Counter]更新规则:
如下图简单示意了Vector clock 的时间顺序记录:
三个Node,初始counter都是0。NodeB在处理NodeC的请求时,记录了NodeC的Counter=1,并且处理事件时,本地逻辑时钟的counter=0+1,所以NodeB处理事件时更新了本地逻辑时钟为[B:1,C:1]。在事件处理时,通过不断更新本地的这组Counter,就可以根据一组[NodeId,Counter]值来确定请求的因果顺序了。比如对于NodeB,第二个处理事件[A:1,B:2,C:1]早于第三个事件:[A:1,B:3,C:1]。
在数值冲突的时候,如图中粉色剪头标记的。NodeC的[A:2,B:2,C:3] 和NodeB[A:3,B:4,C:1]。 C:3 > C:1, B:2 < B:4,种情况认为是没有因果关系,属于同时发生。
Vector clock可以通过各个节点的时间序列值的一组值,识别两个事件的先后顺序。Vector提供了发现数据冲突的方式,但是具体怎样解决冲突需要由发现冲突的节点决定。比如可以将并发冲突抛给Client决定,或者用Quorum-NRW 算法进行读取修复(read repair)。
Amazon Dynamo 就是通过Vector Clock来做并发检测的一个很好的分布式存储系统的例子。对于复制节点的数据冲突使用了Quorum NRW决议,以及读修复(read repair)处理最新更新数据的丢失。详细实现可以参考这篇论文Dynamo: Amazon’s Highly Available Key-value Store 。Dynamo是典型的高可用,可扩展的,提供弱一致性(最终一致性)保证的分布式K-V数据存储服务。后续文章介绍Quorums算法时,也会再次提到。Vector clock在实际应用中,还会有一些问题需要处理,比如如果一个上千节点的集群,那么随着时间的推移,每个Node将需要记录大量[NodeId,Counter]数据。Dynamo的解决方案是通过添加一个timestamp记录每个node更新[NodeId,Counter]值的时间,以及一个设定好的阀值,比如说阀值是10,那么本地只保存最新的10个[NodeId,Counter]组合数据。
本文引出了一些分布式系统的常见问题以及一些基础的分布式系统模型概念。微服务的架构目前已经被更广泛得应用,但微服务面临的问题其实也都是经典的分布式场景的问题。本文在分布式系统的问题中,主要介绍了关于错误检测以及时间和顺序的处理模型。
关于时间和顺序的问题处理中,没有一个绝对最优的方案,Cassandra使用了全局时钟以及LWW处理顺序判定;Dynamo使用了Vector clock发现冲突,加上Quorum算法处理事件并发。这两个存储系统都有很多优秀的分布式系统设计和思想,在后续文章中会更详细的介绍数据复制、一致性、共识算法等。
A Note on Distributed Computing
Distributed systems for fun and profit
Consul docker
Time, Clocks and the Ordering of Events in a Distributed System
Dynamo: Amazon’s Highly Available Key-value Store