chapter8.Distributed Systems Trouble

Faults and Partial Failures

单机被设计成出现错误就宕机

但是在写运行在多个计算机上的软件时,通过网络连接,事情变得不一样了。在分布式系统中,我们无法在完美系统模型中操作 -- 别无选择只能面对这个操蛋的现实世界。在现实世界中,有一系列问题出现。

分布式系统中,有多种方式部分系统会以不可预料的方式宕机,即使其他部分仍然正常工作。这被称为 partial failure,困难在于这是不确定的: 从调用方角度来看时而正常,时而不正常。因此,你不能确定调用是否完成,并且消息也是时而可达,时而不可达。

这种不确定性使得分布式系统变得难

Cloud Computing and Supercomputing

不可靠的时钟

时钟和时间是重要的。应用通过多种方式依赖时钟:

  1. 请求是否超时?
  2. 该服务的 99%的响应时间是多少?
  3. 过去的五分钟里该服务平均每秒能响应多少查询?
  4. 用户花了多长时间在站点?
  5. 该文章何时发布的?
  6. 具体什么时间催单邮件该被发送?
  7. 缓存何时过期?
  8. 日志中的错误信息时间戳是什么?

问题 1-4 度量的是时间段(比如请求到来-回应返回的时间差),问题 5-8 描述的是时间点(时间发生的特定日期,时间)。

在分布式系统中,时间是一件棘手的事务,因为通信不是即时的:通过网络从一台机器传递到另一台机器的消息需要花费一些时间。由于网络延迟的存在,我们不知道接受者接收消息的时间比发送时晚多少。这个现象有时使得确认涉及多台机器的事件发生顺序变得困难。

此外,网络中的每台机器都有自己的时钟,这通常是石英晶体震荡器这种硬件设备。这些设备的精度不是很高,所以每台机器都有自己的时间,可能相对于其他机器是或快或慢的。可以在某种程度上同步时钟:最常用的机制是 NTP 服务,它可以根据一组服务器上报的时间来调整计算机时钟。服务器轮流通过比如 GPS 接收器这种更精确的时钟源获取时间。

单调时钟与日期时钟

现代计算机至少有两种时钟:日期时钟和单调时钟,尽管它们都是用来度量时间,但是需要要知道,这两种时钟是为了不同的目的设计的。

日期时钟

日期时钟符合你的直观期望:获取具体的日期和时间(也称为挂钟时间)。例如,类 Linux 操作系统中的clock_gettime(CLOCK_REAL_TIME)和 Java 中的System.currentTimeMillis()返回的是自 UTC 时间 1970 年 1 月 1 日 0 点的秒数(或者毫秒数)PS: 忽略闰秒。一些系统也已其他时间点作为参考。

日期时钟通常通过 NTP 服务来同步,这意味着一台计算机上的时间戳与另一台计算机上的相同。然而,如同下一节描述的一样,日期时钟也有不同的可能性。特别地,如果本地时钟距离 NTP 服务器太远,则可能会被强制跳转到上一个时间点。这些跳跃以及经常忽略闰秒的事实,使得日期时钟不适合度量时间段。

从历史角度,日期时钟有过很粗糙的分辨率,比如老的 windows 系统上,时钟以 10ms 的步长前进。当然在最新的系统中,这已经不是问题了。

####单调时钟

单调时钟非常适合用于度量时间段,比如超时或者服务的响应时间:Linux 系统的clock_gettime(CLOCK_MONOTONIC)和 Java 的System.nanoTime()都是获取单调时钟。名字来源于总是向前移动的事实,而日期时钟可能会由于 NTP 等导致往回的跳变。

你可以在某个时间点检查单调时钟的值,然后执行一些操作,稍后再次检查单调时钟的值,差值就是时间段。要注意的是,单调时钟的绝对值是没有意义的:它可能是计算机启动以来的纳秒数或者类似的什么值。特别注意的是,比较两台计算机的单调时钟更加没有意义。

在具有多个 CPU 的计算机上,每个 CPU 可能有一个有别于其他 CPU 的单独的计时器。操作系统会尝试补偿 CPU 之间的差异,并为应用线程提供一个单调的时钟值,即使它们在不同的 CPU 之间调度。当然,更聪明的做法是有所保留地相信单调时钟。

如果 NTP 检测到计算机本地的时钟运行频率的快慢与 NTP 服务不同,会调整单调时钟向前移动的频率(被称为slewing the clock)。默认情况下,NTP 允许加快或者降低频率最多 0.05%,但是 NTP 不会使得单调时钟向回跳跃。单调时钟的频率通常很高,在大多数系统中可以度量毫秒或者更短的时间段。

在分布式系统中,使用单调时钟来度量时间段是优秀的,因为它不假定不同节点时钟之间存在同步,并且对于微小的度量误差不敏感。

时钟同步和准确性

单调时钟不需要同步,但是日期时钟必须根据 NTP 服务器或者其他外部时钟源同步自身时钟。不幸的是,我们同步时间的结果不总是能得到期望的那样,因为硬件时钟或者 NTP 服务器可能出现问题,下面举几个例子:

  • 计算机中的石英钟不总是很准确的:它会漂移。时钟漂移取决于计算机机器温度。Google 假设其服务器时钟漂移为 200ppm(pars per million,百万分之一),相当于每 30s 有 6ms 的时钟差异,或者每天 17s。时钟漂移限制了你能得到的时钟精度,即使看起来一切正常。
  • 如果计算机的时钟跟 NTP 服务器差异巨大,可能就会拒绝同步,或者直接重置。应用在重置前后得到的时间可能会有大幅向前或者向后的跳变。
  • 如果一个节点偶然被 NTP 服务隔离,错误配置好长一段时间没有被发现。证据表明这种事情真实发生。
  • NTP 同步依赖与网络延迟稳定,在拥塞导致的延迟不稳定的网络中 NTP 的准确性不是很好。一个实验表明,一秒内的偶然延迟尖峰会导致最小 35ms 的差错。根据配置,较大的网络延迟可以使 NTP 客户端放弃同步
  • 有些 NTP 服务器运行错误或者配置错误,报告的时间少了几个小时。NTP 客户端非常强大,因为它们查询多个服务器并且忽略异常值。尽管如此,有时从网络上未知者发来的时间正确性还是需要担忧。
  • 闰秒导致的一分钟有 59 秒或者 61 秒,这在没有考虑闰秒的系统中会造成时间系统混乱时序。事实表明,闰秒造成过很多大型系统的崩溃,说明时钟的错误假设很容易潜伏在系统中。最好处理闰秒的方式是使 NTP 服务器逐步慢慢地在一天的时间内修复这个时间误差(被称为smearing),尽管真实的 NTP 服务行为会在修正过程有所不同。
  • 虚拟机中,硬件时钟是虚拟化的,这为需要精确计时的应用带来了额外的挑战。当一个 CPU 内核在多个虚拟机之间共享时,每个虚拟机会在另一个虚拟机运行时暂停数十毫秒。从应用程序的角度来看就是时钟突然向前跳变。
  • 如果将软件运行在你不能完全控制的设备上(比如手机或者嵌入式设备),你可能根本不能信任设备的硬件时钟。有些用户会特意错误地设置本地的硬件日期和时间,比如规避游戏中的时间限制。结果时钟可以被设置为过去或者未来的某个时间。

如果投入大量资源,你是可以获取足够准确性的时钟的。比如,针对金融机构的欧洲法规草案 MiFID II 要求所有的高频交易基金将其时钟同步在 UTC 的 100 毫秒以内,以帮助调试市场异常来帮助进行市场操控。

高级别的精确时钟可以通过 GPS 接收器,PTP 和仔细的部署和监控获得。然而这需要大量的精力和专业的只是,并且可以采用多种方式进行时钟同步。如果你的 NTP 服务错误配置,或者 NTP 流量被拦截,由于漂移导致的时钟错误会迅速扩大。

对同步时钟的依赖

时钟的问题在于,尽管它们看起来简单易用,但是可能有令人意外的陷阱:

处理停滞

让我们考虑另一个在分布式系统中使用危险时钟的示例。假设有一个每个分区只有一个领导者的数据库,只有领导者被允许执行写操作,节点如何知道它仍然是领导者(没有被其它节点声明为死亡状态),并且可以安全地接收写入请求?

一种选择是使领导者从其他节点获取租约,这类似一个具有超时的锁。任意时间,只有一个节点可以持有未到期的租约,这样它就知道自己是领导者。为了保持租约,领导节点必须不断续期。如果领导节点续期失败,另一个节点就会接管租约。

可以想象这样一个处理请求的循环例子:

while(true) {
  request = getIncomingRequest();
  // 保证租约持有 10s
  if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000) {
    lease = lease.renew();
  }
  if (lease.isValid()) {
    process(request);
  }
}

这段代码有什么问题?首先,它依赖同步时钟:租约的过期时间是由另一台不同的计算机设置的(例如,过期时间可以设置为当前时间加上 30s),然后将其与本地时间进行比较。如果始终不同步超过几秒钟,这份代码可能就不会按照预期的逻辑运行。

第二,即使我们将协议更改为仅使用本地单调时钟,也存在一个问题:代码假定在检查时间和处理请求之间的时间很短。通常,此代码运行地很快,因此 10s 的缓冲区足以确保在处理请求的过程中租约不会到期。但是,如果在代码执行过程中存在意料之外的停滞会发生什么?比如,假设有一个线程在lease.isValid继续执行之前停滞了 15s,在这种情况下,到了处理请求的时候,租约已经过期,另一个节点已经变更成为领导者节点。然而,执行该处理的节点并不知道自己停滞了这么长时间,因此此代码在下一次循环之前不会注意到自己的租约已经到期了,届时它已经通过处理该请求做了一些不安全的事。

难道假定一个线程停滞了这么久很难以置信?不幸的是并没有,有很多理由证明这发生过:

  • 许多程序设计语言(比如 Java 虚拟机)有垃圾收集(GC)机制,使得偶尔需要停滞所有正在运行的线程。这些“stop-the-world”的 GC 暂停可能会持续几分钟。即使所谓的并发垃圾收集器(比如 HotSpot JVM's CMS)也不能与应用层代码并行运行,即它们也需要时不时地停滞整个世界的运行。尽管这些暂停可以通过更改分配模式或者调节 GC 参数来减少,但是如果想要提供可靠性保证,就必须假定最坏的情况会发生。
  • 在虚拟机环境中,虚拟机可以挂起(暂停所有进程并保存上下文到磁盘)或者恢复(从磁盘重新加载到内存并继续执行)。这种暂停可以发生在执行程序的任何时间并持续任意时间。有时,这个特性用来做不需重启的热迁移到另一台主机,这种情况下,暂停的时长取决于进程写入存储的速度。
  • 在个人终端比如笔记本电脑设备商,也可以任意暂停并恢复,比如当用户关闭笔记本电源时
  • 当操作系统进行上下文切换到另一个线程时,或者虚拟化程序调度另一台虚拟机的运行时,当前运行的线程就会在代码的任一行挂起。对于虚拟机,在其他虚拟机上的 CPU 时间消耗被称为时间窃取 (steal time)。如果计算机负载很高(即,等待执行的队列很长),则可能需要一段时间才能使暂停的线程恢复运行
  • 如果应用程序执行同步磁盘访问,线程可能会由于慢速的磁盘 IO 而挂起。在许多语言中,即使代码中看起来没有访问文件,也可能在执行过程中进行磁盘访问,比如 Java 类加载器会延迟执行加载类文件直到第一次使用该类,这就意味着可能发生在代码执行的任何时间。IO 挂起和 GC 挂起可能组合起来表现成延迟。如果是访问网络文件系统或者网络块设备,IO 延迟可以和网络延迟组合起来。
  • 如果操作系统配置成允许使用交换区,在缺页时就会触发磁盘访问(从磁盘加载到内存)。在执行慢速 IO 操作时,线程就会挂起。如果内存的压力过大,可能需要将不同的页面换到磁盘中存储,在极端情况下,换页频繁发生,而实际的工作很少完成(被称为thrashing)。为了避免这个问题,如果宁可杀死一个进程也要避免出现 thrashing,就应该关闭服务器的交换区功能(PS. 译者注:OOM)
  • Unix 进程可能会被SIGSTOP信号停滞,比如在 shell 中按下Ctrl-Z组合键。这个信号会立即停止直到获取SIGCONT信号,才会继续执行。即使你不会使用SIGSTOP信号,也无法保证其他的操作者不会。

在线程不知情的情况下,上述的任何情况发生都会抢占到当前运行线程的 CPU,然后再稍后恢复。这个问题有点像在同一台机器上的多进程代码所谓的线程安全:不能假定任何时间相关的前提,因为二进制层面的切换可能会在任何时刻发生。

当写多进程代码时,我们有相当多好用的工具来保证线程安全:锁,信号量,原子计数器,lock-free 数据结构,阻塞队列等。不幸的是,这些工具不能直接借用到分布式系统,因为分布式系统没有共享的存储,只能通过不可靠的网络来同步消息。

一个分布式系统中的节点必须假定,即使在函数中间,也能在任何时间将其暂停相当长的时间。在暂停期间,其他节点可以正常运行,甚至可能会因为没有响应而宣告暂停的节点死亡。最终,暂停的节点可能会恢复运行,甚至没有注意到自己有过暂停,直到稍后某个时间检查时钟。

响应的时间保证