故障与部分失效
部分失效(partial failure) :在分布式系统中,系统的某些部分以某种不可预知的方式被破坏。麻烦在于部分失效是 不确定性的(nonderterministic),这种不确定性和部分失效的可能性,让分布式系统难以工作。
云计算与超级计算机
云计算与超级计算机对比:
| 级计算机 | 云计算机 | |
|---|---|---|
| 服务 | 离线(批处理) | 在线 |
| 硬件 | 专用硬件 | 普通商用硬件 |
| 通信 | 共享内存和RDMA | 网络 |
| 网络拓扑 | 专用的网络拓扑图 | CLOS拓扑 |
| 错误处理策略 | 将部分失败升级为完全失败来处理部分失败 | 容忍部分失败 |
| 部署方式 | 集中部署 | 地理上分散部署 |
不可靠的网络
无共享 不是构建系统的唯一方式的,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。
互联网和数据中心(通常是以太网)中的大多数内部网络都是 异步分组网络(asynchronous packet networks) 。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。
如果发送请求并没有得到响应,则无法区分(a)请求是否丢失,(b)远程节点是否关闭,或(c)响应是否丢失。
处理这个问题的通常方法是 超时(Timeout) :在一段时间之后放弃等待,并且认为响应不会到达。但是,当发生超时时,你仍然不知道远程节点是否收到了请求(如果请求仍然在某个地方排队,那么即使发送者已经放弃了该请求,仍然可能会将其发送给接收者)。
真实世界的网络故障
真实世界的网络故障五花八门,即使网络故障在你的环境你的环境中非常罕见,故障还是有可能会发生,那么你的软件需要能够处理它们。
如果网络故障的错误处理没有定义与测试,各种错误可能都会发生。
处理网络故障并不意味着容忍它们:如果你的网络相当可靠,一个有效的方法是当你遇到网络问题时,向用户显示一条错误信息。但是,你需要知道你的软件如何应对网络问题,并确保系统能够从中恢复。有意识地触发网络问题并测试系统响应(这是Chaos Monkey背后的想法)。
检测故障
许多系统需要自动检测故障节点。例如:
- 负载平衡器需要停止向已死亡的节点转发请求(从轮询列表移出,即 out of rotation)。
- 在单主复制功能的分布式数据库中,如果主库失效,则需要将从库之一升级为新主库
但由于网络的不确定性,很难判断一个节点是否工作。而有一些情况可以明确知道节点是否工作:
- 如果你可以连接到运行节点的机器,但没有进程正在侦听目标端口(例如,因为进程崩溃),操作系统将通过发送 FIN 或 RST 来关闭并重用 TCP 连接。但是,如果节点在处理请求时发生崩溃,则无法知道远程节点实际处理了多少数据
- 如果节点进程崩溃(或被管理员杀死),但节点的操作系统仍在运行,则脚本可以通知其他节点有关该崩溃的信息,以便另一个节点可以快速接管,而无需等待超时到期。例如,HBase 就是这么做的。
- 如果你有权访问数据中心网络交换机的管理界面,则可以通过它们检测硬件级别的链路故障(例如,远程机器是否关闭电源)。如果你通过互联网连接,或者如果你处于共享数据中心而无法访问交换机,或者由于网络问题而无法访问管理界面,则排除此选项。
- 如果路由器确认你尝试连接的 IP 地址不可用,则可能会使用 ICMP 目标不可达数据包回复你。但是,路由器不具备神奇的故障检测能力 —— 它受到与网络其他参与者相同的限制。
超时与无穷的延迟
如果超时是检测故障的唯一可靠的方法,那么超时应该设置多久?
- 过长 ⇒ 需要长时间的等待
- 过短 ⇒ 由于网络负载的波动,可能存在误判
过早地声明节点死亡可能带来的问题:
- 操作被重复执行
- 级联失效(cascading failure,表示在极端情况下,所有节点都宣告对方死亡,所有节点都将停止工作)
无穷的时延:异步网络无法保证时延 + 服务器无法保证可以一定的最大时间内处理请求
网络拥塞和排队
网络上的数据包延迟的可变性通常是由于排队。
- 多个节点往同一个目的地发送数据,需要在网络交换机处排队
- 数据包到达目标机器时,如果所有CPU都繁忙,需要在网卡缓冲中排队
- TCP 执行 流量控制(flow control,也称为 拥塞避免,即 congestion avoidance,或 背压,即 backpressure),其中节点会限制自己的发送速率以避免网络链路或接收节点过载
总之,排队无处不在。所有这些因素都会造成网络延迟的变化。
- 在这种环境下,你只能通过实验方式选择超时:在一段较长的时期内、在多台机器上测量网络往返时间的分布,以确定延迟的预期变化。然后,考虑到应用程序的特性,可以确定 故障检测延迟 与 过早超时风险 之间的适当折衷。
- 更好的一种做法是,系统不是使用配置的常量超时时间,而是连续测量响应时间及其变化(抖动),并根据观察到的响应时间分布自动调整超时时间。这可以通过 Phi Accrual 故障检测器【30】来完成,该检测器在例如 Akka 和 Cassandra 【31】中使用。 TCP 的超时重传机制也是以类似的方式工作【27】。
同步网络与异步网络
-
为什么数据中心网络和互联网无法提供延迟保证?
因为它们采用的是分组交换网络,这是一种异步网络,必须受排队的折磨。
-
为什么数据中心网络和互联网使用分组交换?
针对 突发流量(bursty traffic) 进行了优化
延迟与资源利用率
为了提高资源的利用率,互联网动态分配带宽。发送者互相争夺,以让它们的数据报尽可能快地通过网络,并且网络交换机决定从一个时刻到另一个时刻发送哪个分组(即,带宽分配)。这种方法有排队的缺点,但其优点是最大限度地利用了线路。
CPU也有类似的情况:如果在多个线程间动态共享每个CPU内核,则一个线程有时必须在操作系统的运行队列里等待,而另一个线程正在运行,这样每个线程都有可能被暂停一个不定的时间长度。但是,与为每个线程分配静态数量的CPU周期相比,这会那更好地利用硬件。
如果资源是静态分区的(例如,专用硬件和专用带宽分配),则在某些环境中可以实现延迟保证。但是,这将以降低利用率为代价。
网络中的可变延迟不是一种自然规律,而只是成本 / 收益权衡的结果。
不可靠的时钟
时间和时钟很重要。它们可以用来测量持续时间,时间点等。它可以用来回答以下问题:
- 这个请求是否超时了?
- 这项服务的第 99 百分位响应时间是多少?
- 在过去五分钟内,该服务平均每秒处理多少个查询?
- …
在分布式系统中,由于存在可变延迟,我们很难确定在涉及多台机器时发生事情的顺序。
而且,每台机器都有自己的时钟——通常是石英晶体振荡器。这些设备不是完全准确的,所有每台机器都有自己的时间概念,可能比其他机器稍快或稍慢。
可以利用**网络时间协议(NTP)**机制一定程度上同步时钟,它允许根据一组服务器报告的时间来调整计算机时钟。服务器则从更精确的时间源(如 GPS 接收机)获取时间。
单调钟与日历时钟
现代计算机至少有两种不同的时钟:日历时钟(time-of-day clock)和单调钟(monotonic clock)。尽管它们都衡量时间,但区分这两者很重要,因为它们有不同的目的。
日历时钟
它根据某个日历(也称为 挂钟时间,即 wall-clock time)返回当前日期和时间。
日历时钟通常与 NTP 同步,这意味着来自一台机器的时间戳(理想情况下)与另一台机器上的时间戳相同。
单调钟
单调钟适用于测量持续时间(时间间隔) ,例如超时或服务的响应时间:Linux 上的 clock_gettime(CLOCK_MONOTONIC),和 Java 中的 System.nanoTime() 都是单调时钟。这个名字来源于他们保证总是往前走的事实(而日历时钟可以往回跳)
但单调钟的绝对值是毫无意义的:它可能是计算机启动以来的纳秒数,或类似的任意值。特别是比较来自两台不同计算机的单调钟的值是没有意义的,因为它们并不是一回事。
在分布式系统中,使用单调钟测量 经过时间(elapsed time,比如超时)通常很好,因为它不假定不同节点的时钟之间存在任何同步,并且对测量的轻微不准确性不敏感。
时钟同步与准确性
单调钟不需要同步,但是日历时钟需要根据 NTP 服务器或其他外部时间源来设置才能有用。
但获取时钟的方法可能并不可靠或准确——硬件时钟和 NTP 可能会变幻莫测。例如:
- 计算机中的石英钟不够精确:它会 漂移(drifts,即运行速度快于或慢于预期)。时钟漂移取决于机器的温度。
- 如果计算机的时钟与 NTP 服务器的时钟差别太大,可能会拒绝同步,或者本地时钟将被强制重置。
- 一些 NTP 服务器是错误的或者配置错误的,报告的时间可能相差几个小时
- …
依赖同步时钟
时钟的问题:一天可能不会有精确的 86,400 秒,日历时钟 可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。
尽管大多数时间时钟都工作得很好,但软件还是需要处理不正确的时钟。
如果使用需要同步时钟的软件,必须仔细监控所有机器之间的时钟偏移。时钟偏离其他时钟太远的节点应当被宣告死亡,并从集群中移除。这样的监控可以确保你在损失发生之前注意到破损的时钟。
有序事件的时间戳
场景:依赖时钟,在多个节点上对事件进行排序。 例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近?
哪个写入的时间最近,就以哪个值为准。这种冲突解决策略被称为 最后写入胜利(LWW)。
LWW存在一些根本问题:
- • 数据库写入可能会神秘地消失:具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值,直到节点之间的时钟偏差消逝。此方案可能导致一定数量的数据被悄悄丢弃,而未向应用报告任何错误。
- LWW 无法区分 高频顺序写入 和 真正并发写入(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系
- 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的 决胜值(tiebreaker,可以简单地是一个大随机数),但这种方法也可能会导致违背因果关系
如何定义“最近”?
-
👎日历时钟和NTP时钟都无法保证足够准确。
-
👍**逻辑时钟(logic clock):**是基于递增计数器而不是振荡石英晶体,对于排序事件来说是更安全的选择。
逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。
相反,用来测量实际经过时间的 日历时钟 和 单调钟 也被称为 物理时钟(physical clock) 。
时钟读数存在置信区间
将时钟读数视为一个时间点是没有意义的 —— 它更像是一段时间范围:例如,一个系统可能以 95% 的置信度认为当前时间处于本分钟内的第 10.3 秒和 10.5 秒之间,它可能没法比这更精确了。如果我们只知道 ±100 毫秒的时间,那么时间戳中的微秒数字部分基本上是没有意义的。
不确定性界限可以根据你的时间源来计算。如果你的 GPS 接收器或原子(铯)时钟直接连接到你的计算机上,预期的错误范围由制造商告知。如果从服务器获得时间,则不确定性取决于自上次与服务器同步以来的石英钟漂移的期望值,加上 NTP 服务器的不确定性,再加上到服务器的网络往返时间(只是获取粗略近似值,并假设服务器是可信的)。
全局快照的同步时钟
在单节点数据上,可以用一个简单的计数器来生成事物ID。但当数据库分布在多台机器上时,由于需要协调,全局单调递增的事物ID会很难生成。事物ID必须反应因果关系,否则快照就无法保持一致。
💡 有一些分布式序列号生成器,例如Twitter的雪花(Snowflake),其可以以伸缩的方式近似单调递增地增加唯一ID。但是,它们通常无法保证与因果关系一致的排序,因为分配的 ID 块的时间范围比数据库读取和写入的时间范围要长。那么可以使用同步时钟的时间戳作为事务 ID 吗?如果我们能够获得足够好的同步性,那么这种方法将具有很合适的属性:更晚的事务会有更大的时间戳。当然,问题在于时钟精度的不确定性。
Spanner 以这种方式实现跨数据中心的快照隔离。为了确保事务时间戳反映因果关系,在提交读写事务之前,Spanner 在提交读写事务时,会故意等待置信区间长度的时间。通过这样,它可以确保任何可能读取数据的事务处于足够晚的时间,因此它们的置信区间不会重叠。为了保持尽可能短的等待时间,Spanner 需要保持尽可能小的时钟不确定性,为此,Google 在每个数据中心都部署了一个 GPS 接收器或原子钟,这允许时钟同步到大约 7 毫秒以内。
进程暂停
假设你有一个数据库,每个分区只有一个领导者。只有领导被允许接受写入。一个节点如何知道它仍然是领导者(它并没有被别人宣告为死亡),并且它可以安全地接受写入?
一种选择是领导者从其他节点获得一个 租约(lease) ,类似一个带超时的锁。任一时刻只有一个节点可以持有租约 —— 因此,当一个节点获得一个租约时,它知道它在某段时间内自己是领导者,直到租约到期。为了保持领导地位,节点必须周期性地在租约过期前续期。
如果节点发生故障,就会停止续期,所以当租约过期时,另一个节点可以接管。
这里的问题有:
- 依赖同时时钟。租约的到期时间由另一台机器设置,并将其与本地系统的时钟进行比较。
- 程序意外暂停。即使将到期时间设置为本地时间,如果程序执行中意外暂停了,恢复执行的时候租约可能已经过期,而另一个节点已经成为了新的领导者。然而,没有什么可以告诉这个线程已经暂停了这么长时间,所以该线程不会注意到租约已经到期了,它依然会继续执行,因此可能会产生错误。
暂停可能的原因:
- STW GC
- 虚拟化环境中,**挂起(suspend)**虚拟机并恢复
- 上下文切换
- swap
- …
所有这些事件都可以随时 抢占(preempt) 正在运行的线程,并在稍后的时间恢复运行,而线程甚至不会注意到这一点。这个问题类似于在单个机器上使多线程代码线程安全:你不能对时序做任何假设,因为随时可能发生上下文切换,或者出现并行运行。
在单台机器上编写多线程代码时,有很多工具可以实现线程安全:互斥锁、信号量、原子计数器、无锁数据结构、阻塞队列等等。但这些工具并不能直接转化为分布式系统操作,因为分布式系统没有共享内存,只有通过不可靠网络发送的消息。
分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间,即使是在一个函数的中间。在暂停期间,世界的其它部分在继续运转,甚至可能因为该节点没有响应,而宣告暂停节点的死亡。最终暂停的节点可能会继续运行,在再次检查自己的时钟之前,甚至可能不会意识到自己进入了睡眠。
响应时间保证
在系统中提供 实时保证 需要各级软件栈的支持:一个实时操作系统(RTOS),允许在指定的时间间隔内保证 CPU 时间的分配。库函数必须申明最坏情况下的执行时间;动态内存分配可能受到限制或完全不允许(实时垃圾收集器存在,但是应用程序仍然必须确保它不会给 GC 太多的负担);必须进行大量的测试和测量,以确保达到保证。
所有这些都需要大量额外的工作,严重限制了可以使用的编程语言、库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,并且它们通常用于安全关键的嵌入式设备。而且,“实时” 与 “高性能” 不一样 —— 事实上,实时系统可能具有较低的吞吐量,因为他们必须让及时响应的优先级高于一切
对于大多数服务器端数据处理系统来说,实时保证是不经济或不合适的。因此,这些系统必须承受在非实时环境中运行的暂停和时钟不稳定性。
限制垃圾收集的影响
如何限制垃圾收集对应用的影响?
- 将 GC 暂停视为一个节点的短暂计划中断,并在这个节点收集其垃圾的同时,让其他节点处理来自客户端的请求。
- 只用垃圾收集器来处理短命对象(这些对象可以快速收集),并定期在积累大量长寿对象(因此需要完整 GC)之前重新启动进程
知识、真相与谎言
真相由多数所定义
设想以下场景:
- 半断开节点无法响应其他节点,一段时间后被其他节点宣告死亡
- 经过长时间SWT GC的节点,会被其他节点宣布死亡
节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票:决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
这也包括关于宣告节点死亡的决定。如果法定数量的节点宣告另一个节点已经死亡,那么即使该节点仍感觉自己活着,它也必须被认为是死的。个体节点必须遵守法定决定并下台。
最常见的法定人数是超过一半的绝对多数(尽管其他类型的法定人数也是可能的)。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障)。系统仍然是安全的,因为在这个制度中只能有一个多数 —— 不能同时存在两个相互冲突的多数决定。
领导者和锁
“天选者(the choosen one)“:分区的负责人,锁的持有者,成功获取用户名的用户的请求处理程序
如果一个节点继续表现为 天选者,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。
假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图对此写入,该文件将被损坏。你尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。
图 8-4 分布式锁的实现不正确:客户端 1 认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件
问题:如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它认为(不正确)它仍然有一个有效的租约,并继续写入文件。结果,客户的写入将产生冲突并损坏文件。
防护令牌
当使用锁或租约来保护对某些资源的访问时,需要确保一个被误认为自己是 “天选者” 的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是 防护(fencing) ,如图所示
图 8-5 只允许以增加防护令牌的顺序进行写操作,从而保证存储安全
我们假设每次锁定服务器授予锁或租约时,它还会返回一个 防护令牌(fencing token) ,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,并拒绝小于当前最高令牌编号的请求。
如果将 ZooKeeper 用作锁定服务,则可将事务标识
zxid或节点版本cversion用作防护令牌。由于它们保证单调递增,因此它们具有所需的属性。
拜占庭故障
拜占庭故障(Byzantine fault):节点可能声称其实际上没有收到特定的消息。
拜占庭将军问题:在不信任的环境中达成共识的问题被称为拜占庭将军问题。
拜占庭容错(Byzantine fault-tolerant):当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作。
系统模型与实现
算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可以假设的事情。
关于时序假设,三种系统模型是常用的:
-
同步模型
同步模型(synchronous model) 假设网络延迟、进程暂停和和时钟误差都是受限的。这并不意味着完全同步的时钟或零网络延迟;这只意味着你知道网络延迟、暂停和时钟漂移将永远不会超过某个固定的上限【88】。同步模型并不是大多数实际系统的现实模型,因为(如本章所讨论的)无限延迟和暂停确实会发生。
-
部分同步模型
部分同步(partial synchronous) 意味着一个系统在大多数情况下像一个同步系统一样运行,但有时候会超出网络延迟,进程暂停和时钟漂移的界限【88】。这是很多系统的现实模型:大多数情况下,网络和进程表现良好,否则我们永远无法完成任何事情,但是我们必须承认,在任何时刻都存在时序假设偶然被破坏的事实。发生这种情况时,网络延迟、暂停和时钟错误可能会变得相当大。
-
异步模型
在这个模型中,一个算法不允许对时序做任何假设 —— 事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。
进一步来说,除了时序问题,我们还要考虑 节点失效。三种最常见的节点系统模型是:
-
崩溃 - 停止故障
在 崩溃停止(crash-stop) 模型中,算法可能会假设一个节点只能以一种方式失效,即通过崩溃。这意味着节点可能在任意时刻突然停止响应,此后该节点永远消失 —— 它永远不会回来。
-
崩溃 - 恢复故障
我们假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。在 崩溃 - 恢复(crash-recovery) 模型中,假设节点具有稳定的存储(即,非易失性磁盘存储)且会在崩溃中保留,而内存中的状态会丢失。
-
拜占庭(任意)故障
节点可以做(绝对意义上的)任何事情,包括试图戏弄和欺骗其他节点,如上一节所述。
对于真实系统的建模,具有 崩溃 - 恢复故障(crash-recovery) 的 部分同步模型(partial synchronous) 通常是最有用的模型。分布式算法如何应对这种模型?
算法的正确性
为了定义算法是正确的,我们可以描述它的属性。例如,排序算法的输出具有如下特性:对于输出列表中的任何两个不同的元素,左边的元素比右边的元素小。这只是定义对列表进行排序含义的一种形式方式。
同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌,我们可能要求算法具有以下属性:
-
唯一性(uniqueness)
没有两个防护令牌请求返回相同的值。
-
单调序列(monotonic sequence)
如果请求 x 返回了令牌 tx,并且请求 y 返回了令牌 ty,并且 x 在 y 开始之前已经完成,那么 tx<ty。
-
可用性(availability)
请求防护令牌并且不会崩溃的节点,最终会收到响应。
如果一个系统模型中的算法总是满足它在所有我们假设可能发生的情况下的性质,那么这个算法是正确的。但这如何有意义?如果所有的节点崩溃,或者所有的网络延迟突然变得无限长,那么没有任何算法能够完成任何事情。
安全性和活性
在上面给出的例子中,唯一性 和 单调序列 是安全属性,而 可用性 是活性属性。
区分安全性和活性:
安全(safety)性:没有坏事发生
活性(liveness):最终好事发生(最终一致性是一个活性属性)
精确定义:
- 如果安全属性被违反,我们可以指向一个特定的安全属性被破坏的时间点(例如,如果违反了唯一性属性,我们可以确定重复的防护令牌被返回的特定操作)。违反安全属性后,违规行为不能被撤销 —— 损失已经发生。
- 活性属性反过来:在某个时间点(例如,一个节点可能发送了一个请求,但还没有收到响应),它可能不成立,但总是希望在未来能成立(即通过接受答复)。
区分安全属性和活性属性的一个优点是可以帮助我们处理困难的系统模型。对于分布式算法,在系统模型的所有可能情况下,要求 始终 保持安全属性是常见的。也就是说,即使所有节点崩溃,或者整个网络出现故障,算法仍然必须确保它不会返回错误的结果(即保证安全属性得到满足)。
但是,对于活性属性,我们可以提出一些注意事项:例如,只有在大多数节点没有崩溃的情况下,只有当网络最终从中断中恢复时,我们才可以说请求需要接收响应。部分同步模型的定义要求系统最终返回到同步状态 —— 即任何网络中断的时间段只会持续一段有限的时间,然后进行修复。