引言
分布式系统的核心是构建可靠的系统,对于系统中可能出现的任何状况,我们都要保证满足用户的需求.但是现实是无比复杂的分布式系统相比与单机系统有着千奇百怪的错误,所以我们要对此做出一个悲观的假设,即:所有可能出现的错误一定会出错.
单机与分布式
单机上开发程序的时候一般都是确定性的,也就是说要么工作,要么出错.这是计算机设计上一个谨慎的选择:如果出现了某种内部错误,宁可计算机崩溃也不能返回一个错误的结果.这是一个理想化的系统模型,它可大大简化我们的工作,因为正常情况下它总是按照我们的想法去做,我们不用担心出错,即一定条件下多次运行一定会产生相同的结果.对于分布式系统来说就不一样了,在这样一个系统中我们需要考虑任何出现错误的情况.尤其是一个系统中的某些部分出现不确定的故障,而且出现时间是不确定,出现问题的节点也不确定,这大大提高了整个系统的复杂性.就算有着如此的复杂性,我们也需要注意以下问题:
- 需要提供7*24小时的低延迟服务,这意味着可用性及其重要.
- 一般的分布式系统都使用一般的通用机器,这意味着故障率很高.
- 因为以上原因.系统越大,出现组件失效的概率就越大.
- 如果可能容忍部分失效,对于整体可用性是很有帮助的.(现在一般分布式算法可容忍少于一半的节点失效)
- 对于多数据中心系统,通信是经过广域网的,速度更慢也不可靠.
这其中我们面临的最大问题就是如下两点:
- 不可靠的网络
- 不可靠的时钟
不可靠的网络
一般来说我们使用分布式无共享系统,即使用网络连接的多个节点.而且因为同步的局限性,一般在网络通信中也是用异步通信来实现的,着也许会出现以下问题:
- 请求丢失,即本端机器出现问题.
- 远端节点失效,无法处理请求,远端机器出现问题.
- 请求在队列中等待(接受方缓冲)
- 远端节点无法马上响应.(进程当前暂停)
- 远端完成请求,回复丢失.
- 远端完成请求,因为发送端TCP窗口过小,或其他原因(Nagle,机器超负荷)回复被延迟处理.
这对于发送者来说是很迷茫的,因为它只是知道消息未回复,却不知道是因为哪种原因,所以一般的处理方案就是超时(TCP中也是这样做的).在现实中的网络问题仍然非常严峻,就像我们前面说的,只要可能发生,就一定会发生.事实上,只要有网络故障,就可能会出现故障.这也是为什么CAP理论中CA总无法同时满足的原因.所以我们需要对可能出现的网络问题进行检测和处理.
检测故障
节点故障检测对于我们来说一定是一个非常重要的问题,因为节点失效是可能发生的,如果我们不加以防范,出现时就只能祈祷不要被炒鱿鱼了.有很多时候这种功能都是必要的:
- 负载均衡器不需要向已宕机节点分发请求.
- 在主从复制模型中,当主节点下线的时候我们需要的使一个从节点升级.
但是判断节点下线确显得非常困难.以下方法是问题解决的方法之一:
- 仅仅是进程崩溃的话,操作系统会不同情况会返回RST包或者是FIN包,协助对端关闭连接.
- 如果服务器进程崩溃,操作系统依然正常运行,那么可以通过脚本通知其他节点.
- 如果路由确认目标不可达会返回ICMP"目标不可达"数据包.
以上显然无法解决我们的问题,一个简单且严峻的条件就是断电.最普遍的解决的方法是投票(后面详说).
从以上来看超时确实是一种行之有效的方案,那么该设置为多长时间呢?较长的时间可能使得更长的时间无法提供服务,较短的时间可能使得错误的判断,比如说只是进程出现阻塞或者网络的暂时波动而已.
设想一个虚拟系统,网络可以保证最大时延在一定范围内,可以在时间d内完成交付或者丢失,且一个非故障节点可以在r内完成请求处理,这意味着一个正常的请求可以在2d+r内收到请求.但是遗憾的是这种保证一般是不存在的(传统固话网络存在,但TCP协议中并不存在).这也就意味着d和r都是不确定的,所以一般网络的延迟是个很大的不确定因素.这也一般和网络中的拥塞相关,虽然TCP中也有拥塞控制流量控制相关算法,但也基本都是基于发送端窗口的,在网络吞吐量较大的时候这仍是一个不可忽略的问题.
因为TCP是可靠的,所以在发生超时的时候会进行重传,这在一定程度上仍会增加延迟,所以一些对延迟敏感的引用使用UDP作为传输控制层的协议.比如实时视屏,语音等.这些场景下显然重传没有什么意义,所以会在出现丢包的时候用上一个画面(卡顿),或者静音来填充(声音中断).
同步与异步网络
上面我们提到了如果网络可以保证数据可以在某个时间内送到或丢失,那么分布式系统可以很容易的解决判断节点宕机这个问题.可以对比传统的固定电话网络(就是小时候家里面装的有线的那个东西).奇怪的是一般来说这种通信设备非常稳定,为什么呢?是因为它为每个信道分配了唯一的通信链路,保证了通信的速度(每秒4000帧,每帧16bit),一直持续到通话结束,这种网络本质上是同步的,它不会受到排队的影响,所以延迟是固定的,我们称为有界延迟.
那么网络通信模型是否也可以这样呢?答案是否定的.因为电路方式总是预留宽带,建立以后其他人无法使用,而TCP数据包则是尝试所有可用的带宽,所以TCP可用传输任意的数据块,且就算在连接时空闲,也不会占用很多的带宽.为什么不使用前者作为通信的方式呢?原因是它们针对突发流量做了很多优化,电话和视频的网络带宽往往容易事前确定,然而访问网页和传输文件无法事先确定带宽需求,我们只是希望它们尽快完成.这在某些角度类似于线程与CPU,它们之间的关系总是抢夺的,如果每个线程负责某个特定的CPU,这样可以更好的利用硬件(阻塞时无法使用,绑核除外).
不可靠的时钟
时钟对我们的程序来说至关重要,很多程序都是依赖于时钟的,我们总是寄希望与时钟能够正常运行.但在分布式系统中这是个棘手的问题,因为你无法保证每个机器的时钟都是及其精确的,哪怕有NTP(Net Working Protocol)协议的保证.现代计算机至少实现两个时钟:墙上时钟
和单调时钟
.且每台机器都有其时钟硬件设备,一般为石英晶体震荡器.
时钟总会出现一些奇怪的问题,这意味着时钟时间并不完全可靠.如下:
- 温度会影响计算机中的石英钟,时钟漂移限制了可以达到的最大精度.
- 如果时钟和 NTP 服务器差距过大,可能会出现拒绝同步或者强制重置,就可能出现时钟后退的情况.
- NTP 同步受限于网络环境,由于网络延迟可能产生偏差.
- NTP 服务器配置错误导致偏差.
- 如果运行不完全可控的设备上,不能完全相信设备上的时钟,因为硬件时间是可篡改的.
这也就是在分布式系统中进行并发写入时使用LWW(最后写入者胜利)可能丢失数据的原因,因为时间戳并不是可信的,可能出现先写入数据覆盖后写入的数据的情况.那么NTP时钟是否能做到已极高的精度避免这种问题呢?很难,因为其精度是受限于网络延迟的.而且也无法区分因果关系,还是需要版本矢量来解决这个问题.
进程暂停
因为进程可能出现暂停,这使得时钟更不可相信,最典型的例子就是分布式锁中获得一个有效时间段,但却在时间段内未完成处理,这就使得锁可能被多个用户获取,从而出现不可预料的错误.这里一般的解决方案是让获得锁的线程开启一个守护线程,用来给快要过期的锁“续航”,并在完成任务是显式关闭,线程的消耗在有时也是不可忽视的,所以有了其他的解决方案,我们后面说.先来看看哪些情况可能导致进程暂停:
- GC 通常会时不时地暂停活动的进程.
- 休眠,笔记本电脑休眠.
- 操作系统上下文切换.
- 同步磁盘,同步磁盘会引起进程挂起,虽然有些代码没有明显的同步磁盘操作,但是依然可能有意外的引入磁盘IO的操作.
- 有时内存访问可能出现缺页中断,需要从更低级别的缓存或者磁盘中加载数据.
- SIGSTOP信号,即ctrl+Z.这个信号挂起进程,当接收到SIGCOUT时继续运行.
分布式锁的问题
显然在分布式锁中我们期望在一个时间内只有一个用户拿到锁,但是这不总是生效的,如果持有锁的进程因为某些原因进程暂停,导致在分配的锁以后的时间持有锁这就意味着锁不是被一个用户占用,那么此时行为是未定义的.当然守护线程(进程)也可解决问题,但是并不优雅且效率欠佳,这里的解决方法是使用Fencing令牌
.
即每个获取锁的操作都获取一个令牌,如果在写时发现存在令牌值高于自己的记录,则代表当前出现其他用户持有锁,即放弃修改.改了一半也可执行回滚.你可能会问,如果redis做分布式锁的话怎么办呢?redis官方给出的通用解决的方案是Redlock.
更多严峻的问题
通过以上的议论我们不难得到一个结论,那就是:“节点无法根据自身信息来判断自身的状态,所以分布式系统不能依赖单个节点”.这是很有意思的,意味着分布式系统中一个结点需要和其他结点交换信息以获取其他结点(这里包含多个结点)的状态.这就是一般分布式算法很大程度上依赖于法定投票(quorum).最常见的投票方式就是得到半数以上的肯定(非拜占庭式错误),这样可以在一次选举内只有一个leader,且可以容忍少于一半的结点失效.
拜占庭问题
我们以上的讨论的模型均基于错误类型为节点不可用,但其实存在一种更为复杂的错误模型,即拜占庭错误,它假设节点可能会出现任何问题,包括欺骗其他节点.这时问题就复杂了.有很多计算机科学家和数学家已经给出了实用的解决方案,其中一种为PBFT(见参考)
这种错误是真实存在的,如下:
- 在航空航天领域,辐射可能造成内存或 CPU 的故障,以不可预知的方式响应其他节点,系统下线十分昂贵,所以需要飞机控制系统容忍拜占庭式错误.
理论系统模型
在描述算法之前我们需要一个系统模型来描述它,比如raft就是建立在非拜占庭错误的基础上的.
在分布式系统中谈共识算法我们首先需要明确同步模型,即:
同步模型(synchrony)
: 同步模型假设有上界的网络延时,有上界的进程暂停和有上界的时钟误差.它意味着这些延迟不会超过某个固定上限.大多数系统并非同步模型,因为无限延迟和暂停确实可能发生.异步模型(asynchrony)
: 只是异步,可能丢失所有消息,算法不会对时机做任何假设.部分同步模型(partial synchrony)
: 意味着系统部分情况下像一个同步系统运行,但是剩下部分会使用异步,数据保证最终一致性.这是最常用的一种模型.
我们还需要考虑失效模型:
崩溃-中止模型(crash-down)
: 假设节点只可能发生崩溃,并永不上线.崩溃-恢复模型
: 崩溃后会再次上线,其持久性数据会在崩溃后恢复.拜占庭失效模型(Byzantine)
: 可能发任意错误,包括欺骗其他节点.
在这些模型上的进行的算法会在一定程度保证安全性.把这些理论模型映射到我们真实的程序中将是一个更严峻的考验,因为我们在模型中可能会定义很多不可能发生的事情,这些我们在真实环境中不一定遇不到,所以需要一些简单的判断,哪怕只是不处理,显示在日志中,这对于分析错误也是有好处的.为什么会出现这些问题呢,究其本质还是真实程序的实现可能会违背某些系统的前提条件,所以没有什么是不可能的.这也很好的体现了计算机科学与软件工程这两个学科的差异.
总结
构建分布式系统中的挑战无疑是严峻的,无论是网络还是时钟都使问题变得更为复杂.但最重要的是我们需要对于某个系统以不同的错误类型进行建模,这样才可以对分布式理解的更为透彻.
参考:
绑核命令 taskset
Castro, Miguel, and Barbara Liskov. “Practical Byzantine fault tolerance.” OSDI. Vol. 99. 1999.
Designing Data-Intensive Applications