为什么基于网络的分布式系统不靠谱?

2023-10-25 17:16:29 浏览数 (3)

DDIA 读书分享会,会逐章进行分享,结合我在工业界分布式存储和数据库的一些经验,补充一些细节。每两周左右分享一次,欢迎加入,Schedule 在这里[1]。我们有个对应的分布式&数据库讨论群,每次分享前会在群里通知。如想加入,可以加我的微信号:qtmuniao,简单自我介绍下,并注明:分布式系统群。

首先需要明确,本书讨论系统范畴是 share-nothing 架构:所有机器不共享资源(如内存、磁盘),通信的唯一途径就是网络。share-nothing 不是唯一的系统构建方式,但相比来说,它是最经济的,不需要特殊的硬件,并且可以通过异地冗余做高可用。但同时,构建这种风格的系统复杂度也最高。

互联网和数据中心(多是以太网)的内部网络多是异步封包网络asynchronous packet networks)。在这种类型网络中,一个机器向其他机器发送数据包时,不提供任何保证:你不知道数据包什么时候到、甚至不知道它是否能够到。具体来说,当我们的应用发送网络请求后,可能会面临以下诸多情况:

  1. 请求没有发出去就丢了(比如你的网线可能被拔了)
  2. 请求可能先排了会儿队,稍后才被发出去(比如网络或接收方负载过高)
  3. 对端节点挂了(比如遇到异常宕机或者断电了)
  4. 对端节点临时无响应(比如正在进行 GC),但稍后又能正常工作
  5. 对端节点处理了你的请求,但应答在网络回程中丢了(比如网关配错了)
  6. 对端节点处理了你的请求,但应答被推迟了(比如网络或你的机器负载过高)

几种网络通信失败情况

因此,在异步网络中,当你发送出一个请求,并在一段时间内没有收到应答,任何事情都有可能发生:由于没有收到任何信息,你无从得知具体原因是什么。甚至,你都不知道你的请求是否已被送达处理。

应对这种情况的惯常做法是——超时(timeout)。即,设定一个时限,到点后,我们便认为这个请求废了。但在实际上,该请求可能只是还在排队、可能稍后到达远端节点、甚至可能最终还会收到应答。

实践中的网络故障

虽然我们的网络已经有几十年的历史,但人们仍然没有找到一种使其变得更为可靠的方法。

有很多研究和经验表明,即使在专门管理的数据中心,网络问题也相当普遍。一项研究关于中型数据中心的研究表明,平均每月有 12 次网络故障,其中一半是单机失联,另一半是机架整个失联。另一项关于组件故障率的研究,包括 TOR 交换机、聚合交换机和负载均衡器。发现,增加网络冗余并不能如预期般减少故障,因为这不能避免造成网络中断的最主要原因——人为故障(如配置错误)。

像 EC2 的云服务,以网络暂时故障频发而著称;对比来说,管理良好的自建数据中心可能会相对稳定。但没有人能够真正逃脱网络故障:

  • 如交换机软件升级引发的拓扑重置,会导致期间网络延迟超过一分钟。
  • 鲨鱼可能会咬断海底光缆。
  • 有些奇葩的网口会只传送单向流量(即使一个方向通信正常,你也不能假设对向通信没问题

虽然,上述情况可能都比较极端。但如果你的软件不做任何处理或者处理不全,网络问题一旦发生,你将面临各种难以定位莫名其妙的问题,并且可能会导致服务停止和数据丢失。

不过,处理网络错误并不一定意味着网络出现了问题仍然要让系统能够正常工作(即容忍,tolerating it)。如果你确定你的环境里,网络问题很少发生,你甚至可以让系统在出现问题时停止运行,并打印一条错误提示信息给用户。但要保证,在网络恢复之后,服务也能够恢复,并且不会造成意外损失。为此,你需要使用混沌测试工具来主动模拟各种网络异常,在交付前确保你的软件有足够的鲁棒性。

故障检测

在很多系统里,我们需要自动检测故障节点,并据此做出一些决策:

  • 负载均衡器需要停止对故障节点流量的分发。
  • 在单主模型的分布式数据库中,如果主节点故障,需要选出一个从节点顶上。

不幸的是,由于网络的不确定性,你很难准确地判断一个远端节点是否发生了故障。当然,在某些特定的场景下,你可以通过一些旁路信号,来获取一些信息,来判断确实发生了故障:

  • 操作系统通知。如果你能触达服务所在机器,但发现没有进程在监听预期端口(比如对应服务进程挂了),操作系统会通过发送 RST 或 FIN 包来关闭 TCP 连接。但是如果对端节点在处理你的请求时整个宕机了,就很难得知你请求的具体处理进度。
  • daemon 脚本通知。可以通过一些 daemon 脚本,在本机服务进程死掉之后,主动通知其他节点。来避免其他节点通过发送请求超时来判断此节点宕机。当然这前提是,服务进程挂了,但所在节点没挂。
  • 数据链路层面。如果你是管理员,并且能访问到你数据中心的网络交换机,可以在数据链路层判断远端机器是否宕机。当然如果你访问不到交换机,那这种方法就不太行。
  • IP 不可达。如果路由器发现你要发送请求的 IP 地址不可达,它会直接回你一个 ICMP 不可达包。但路由器也并不能真正判断是否该机器不可用了。

尽管有上述手段可以快速检测远端节点是否宕机,但你并不能依赖它们。因为,即使 TCP 层已经收到某个请求的 ACK,但对端服务仍有可能在应用层面没有处理完该请求就宕机了。因此,如果你想确定某个请求确实成功了,只能在应用层进行显式确认

当然,如果对端出错,你可能会很快收到一个错误,但你并不能指望在任何情况下都能很快得到错误回复——可能过了一段时间我们仍然没有得到任何回复。因此,在应用代码里,必须设置一个合理的超时时限和重试次数。直到,你确认没有再重试的必要——即不管远端节点是否存活,我在重试几次后,都认为它不可用了(或者暂时不可用)。

超时和无界延迟(unbounded delays)

如上所述,超时是应用层唯一能动用的检测网络故障的手段,但另一个问题随之而来:超时间隔要设置多久呢?总的来说:

  1. 不能太长:过长会浪费很多时间在等待上。
  2. 不能太短:太短会造成误判,误将网络抖动也视为远端节点失败。

超时间隔是要视具体情况而定,通常会通过实验,给相应场景设置一个合适的值。

过早将一个正常节点视为故障会有诸多问题:

  1. 多次执行。如果节点已经成功执行了某动作,但却被认为故障,在另一个节点进行重试,可能会导致一次动作被执行两次(如发了两次邮件)。
  2. 恶性循环。如果系统本就处于高负载状态,此时还频繁错误的在其他节点上重试,可能会造成恶性循环,重试过多导致系统负载加重,系统负载加重反过来造成通信延迟增加,从而造成更多误判。

设有一个理想的网络系统,能够保证所有的网络通信延迟不超过 d:所有的网络包要么在 d 时间内送达对端、要么就会丢失,即不可能在超过 d 的时限后才到。如果网络能提供此种保证,则应用层可大为简化:假设我们预估出单个请求最大处理时间 r,则 2d r 是一个很好超时间隔。

然而,实际中的网络基本上都不提供此种保证,尤其是常见的——异步网络。并且,大多数服务也很难保证在所有请求的处理时间都不超过某个上界。

网络拥塞和数据包排队

在路网里,视交通拥堵情况,两点间的通行时间可能会有很大差异。类似的,在计算机网络中,数据包的延迟大小也通常和排队相关,且有很多环节可能会造成排队:

多个进程可能会把一个进程打死

  1. 去程网络排队。如果多个节点试图将数据包同时发给一个目的端,则交换机得将他们排队以逐个送达目的端(如上图)。如果流量进一步增大,超过交换机的处理能力,则其可能会随机进行丢包。
  2. 目的机器排队。当数据包到达目的端时,如果目标机器 CPU 负载很高,操作系统会将进来的数据包进行排队,直到有时间片分给他们。目的机器负载的不同决定了对应数据包被处理的延迟。
  3. 虚拟机排队。在虚拟化环境中,由于多个虚拟机共用物理机,因此经常会整体让出 CPU 一段时间的情况。在让出 CPU 等待期间,是不能处理任何外部请求的,又会进一步给网络请求的排队时延增加变数。
  4. TCP 流控。TCP 流量控制(又称拥塞避免或反压,backpressure,一种负反馈调节)为了避免网络过载或者目的端过载,会限制发送方的发送频率,也即,有些请求可能还没发出去就要在本机排队。

此外,TCP 中存在超时重传机制,虽然重传本身对应用层不可见,但是超时重传带来的延迟却是无法掩盖的。

TCP 和 UDP

一些对延迟敏感的场景,如视频会议和 IP 语音,常使用 UDP。由于 UDP 不需要提供额外保证,因此不需要做超时重传和流量控制,因此可以避免 TCP 很多排队造成的延迟。

当然,如果用户仍然需要某种程度的可靠性,可以基于 UDP 在应用层有针对性地做一些优化,比如在视频会议中,如果网络不好,可以主动问下:能再说一遍嘛?这是一种常用的思想,通用场景,可以使用屏蔽底层复杂度的协议;特化场景,可以使用相对底层、粗糙的协议,自己在应用层做有针对性的封装。是一个实现复杂度和效率的 tradeoff。

所有上述因素,都能造成网络延迟变化,且一个基本现象是:网络流量越满,单个请求延迟抖动越大

在公有云或者多租户系统中(比如几个人共用一个物理开发机),由于通信链路上的很多物理资源(交换机、网卡、CPU)都是共享的(虽然会做一定的资源隔离),如果其他用户突然运行某种巨占资源的任务(比如跑 MapReduce),则你的网络请求延迟就会变得非常不稳定。

静态设置。在这种环境中,如果你要为远端故障检测设置超时时间,就只能使用做实验的方式,经过足够长的时间,统计请求延迟分布。进而结合应用需求,在检测过久(设置长超时间隔)和故障误报(设置过短超时间隔)做一个权衡。

动态调整。当然,相比预先配置固定死超时间隔,更好的方式是,通过类似时间窗口的方式,不断监测过去一段时间内的请求时延抖动情况,来获取请求时延的分布情况,进而动态调整超时间隔。Phi 累积故障检测算法(The Φ Accrual Failure Detector)便是这样一种算法,Akka and Cassandra 中都用到了此种算法,它的工作原理和 TCP 重传间隔的动态调整类似。

同步网络和异步网络

如果我们的底层网络传输数据包时能够保证延迟上界、且不会丢包,那么基于此构建分布式系统将会容易的多。那为什么不在硬件层面解决相关问题让网络更可靠,从而让分布式软件免于关心这些复杂的细节呢?

为了回答这个问题,我们先来看一种历史产物——固定电话网(_fixed-line telephone network_,非 VOIP、非蜂窝网络)。在固话线路中,高延迟音频帧和意外断线都是非常罕见的。固话网会为每一次通话预留稳定低延迟充足的带宽链路以传输语音。如果计算机网络中也采用类似的技术,生活不会很美好吗?

当你在固网内拨打电话时,会建立一条贯穿贯穿全链路的保证足量带宽的固定链路,我们称之为电路(circuit),该电路会保持到通话结束才释放。以 ISDN 网络为例,其每秒能容纳 4000 帧语音信号,当发起通话时,它会在每个方向为每帧数据分配 16 比特空间。因此,在整个通话期间,两端各自允许每 250 微秒(250us * 4000 = 1s)发送 16 比特语音数据。

这种网络是同步(_synchronous_)的:尽管数据也会通过多个路由节点,但由于通信所需的资源(如上述 16 bit 空间)已经在下一跳中被提前预留出来了,因此这些数据帧不会面临排队问题。由于不存在排队,则端到端的最大延迟是固定的。我们也称此种网络为有界网络(bounded network)。

计算机网络为什么不能同样稳定?

电话电路和 TCP 连接有很大不同:

  1. 电路中的固定带宽一旦被预留,则其他任何电路不能够使用。
  2. TCP 连接中的数据包,只要余量允许,都有可能使用到任何网络带宽。

应用层给到 TCP 的任意大小的数据,都会在尽可能短的时间内被发送给对端。如果一个 TCP 连接暂时空闲,则他不会占用任何网络带宽。相比之下,在打电话时即使不说话,电路所占带宽也得一直被预留。

如果数据中心和互联网使用电路交换(_circuit-switched_)网络,他们应该能够建立一条保证稳定最大延迟的数据链路。但是事实上,由于以太网和 IP 网采用封包交换协议(_packet-switched protocols_,常翻译为分组交换,但我老感觉它不太直观),没有电路的概念,只能在数据包传送的时候对其进行排队,也不得不忍受由此带来的无界延迟。

那为什么数据中心网络和互联网要使用封包交换协议呢?答曰,为了应对互联网中无处不在的突发流量(_bursty traffic_)。在电话电路中,音频传输所需带宽是固定的;但在互联网中,各种多媒体数据(如电子邮件、网页、文件)所需带宽却是差异极大且动态变化的,我们对他们的唯一要求就是传得尽可能快。

设想你使用电路网络传输一个网页,你需要为它预留带宽,如果你预留过低,则传输速度会很慢;如果你预留过高,则可能电路都没法建立(带宽余量不够,就没法建立连接),如果建立了,也会浪费带宽。互联网数据的丰富性异构性,让使用电路网络不太可能。

不过,也有一些尝试来建立同时支持电路交换和封包交换的网络,如 ATM。IB 网络(InfiniBand)与 ATM 有一些类似之处,它在链路层实现了端到端的流量控制,从而减少了排队。具体来说,IB 使用 QoS (quality of service,对数据包区分优先级并进行调度)控制和准入控制(admission control,对发送方进行速率控制),在封包网络上模拟电路交换,或者提供概率意义上的有界时延。

但,现有的数据中心网络和互联网都不支持 QoS。因此,我们在设计分布式系统时,不能对网络传输的时延和稳定性有任何假设。我们必须要假定我们面对的网络会发生网络拥塞、会产生排队、会有无界延迟,在这种情况下,没有放之四海而皆准的超时间隔 。针对不同的具体情况,需要通过经验或者实验来确定它。

通信时延和资源利用 泛化一下,可以认为是资源的动态分配(dynamic resource partitioning导致了时延的不稳定。 设你有一条能够承载 10000 路通话的线路,其上的每个电路都要占其中一路。基于此,可以认为该线路是一种能够被至多 10000 个用户共享的资源,并且该资源以一种静态(static)的方式被分配:无论该线路中现在有包含你在内的 10000 个人在通话、还是只有你一个人在通话,被分配给你的资源都是固定的:1/10000。 与之对应,互联网中的通信会动态的(dynamically)共享网络资源。每个发送者都会将数据包尽可能快的推送到数据线路上,但在任意时刻,哪个数据包被真正发送(即资源分配给谁),则由交换机来动态决定。这种做法的劣势在于排队,但优势在于能够最大化线路资源利用。一条线路的造价是固定的,如果对其利用率越高,则单位数据发送成本越低。 类似的情形还发生在 CPU 的分时复用里。如果在多个线程间动态的共享每个 CPU,则一个线程使用 CPU 时,其他线程必须排队等待,且排队时间不确定。这种使用 CPU 的方式,比分配给每个线程固定的时间片要高效。类似的,使用虚拟化的方式共享同一台物理机,也会有更好的硬件利用率。 在资源静态分配的环境中,如专用的硬件、互斥的带宽分配,有界延迟能够被保证。但是,这种方式是以降低资源利用率为代价的,换句话说,更贵。反之,通过多租户方式动态的共享资源,更便宜,但代价是不稳定的延迟(_variable delays_)。不稳定的延迟并非什么不可变的自然法则,而仅是一种代价和收益权衡的结果罢了

参考资料

[1]

DDIA 读书分享会: https://ddia.qtmuniao.com/

DDIA 学习会

0 人点赞