我最近读了谷歌的Jeff Dean和Luiz André Barroso的论文"Tail at Scale"。这篇论文非常平易近人,是对大型分布式网络系统中如何考虑延迟问题的一个很好的介绍。
问题所在
在一个分布式的网络系统中,一个请求在返回之前可能要经过许多系统的响应。在一个基本模型中,可能有一个网络应用服务器和一个数据库。向网络应用服务器发出请求以获得一些数据,然后向数据库发出请求。如果在一个特定的请求期间,网络应用服务器或数据库发生故障或不可用,请求将失败。
许多分布式系统会比这复杂得多,一个请求在返回之前必须经过10个或可能100个不同的服务。随着服务数量的增加,在一个特定的请求期间,其中一个服务不可用的概率会上升。如果每个服务有1%的机会不可用,在我描述的第一个模型中,一个请求遇到不可用的数据库或不可用的网络服务器的机会相当低,但如果有100个不同的服务,它就变得非常可能。
在分布式系统中,有许多解决这个问题的方法--对单个服务不可用有弹性的系统被称为 "容错"。规模化的尾巴 "讨论了一个相关但不同的问题,即服务除了有可能因任何原因而不可用之外,还有可能在某些小比例的请求中比典型的慢得多。同样,虽然一个服务器在0.1%,甚至1%的请求中变慢,对有少量组件的系统来说并不构成大问题,但在单个请求的路径上有大量的系统,在某些系统上遇到慢速路径的机会就变得非常高。
延迟的差异
在深入探讨管理延迟差异的解决方案之前,Dean和Barroso花了一些时间来讨论思考延迟的正确方法,以及为什么不是所有对系统的请求都需要相同的时间的原因。
- 理解一个系统延迟的正确方法是通过百分位数,而不是通过平均请求数。以p50(中位数)、p90(第90百分位数)等请求所花费的时间来思考。这是因为一个慢的请求可能需要比中位数长10-100倍的时间,平均数给出的是一个相当没有意义的数字。这些较高百分位数的延迟通常被称为 "尾部延迟"。
- 网络系统经常在多用户环境中运行,这些进程经常在同一物理计算机上共享资源。这意味着在这些服务器上有很多事情要做,可能会导致请求的瞬间减速。例如,在一个特定的请求中,垃圾收集可能会启动,使其速度减慢,可能对一些共享的全局资源(如网卡)有争夺,或者计算机上可能有一些经常性的工作已经开始。其他问题,如服务器过热,也可能造成。
因此,作为一个例子,可能有0.1%的机会存在一些事件,无论是硬件还是软件相关的事件,导致请求变慢。考虑到这影响到的请求的数量,这可能不会影响到该机器上的p50延迟,但会影响到 "尾部延迟",即p999(99.9百分位数)。在现实中,数字并不那么干净,但这就是为什么p99或p999延迟会比p90高很多,而p90又会比p50延迟高很多。
正如第一节所讨论的,当你增加参与处理单个请求的系统数量时,在这些机器上遇到p99延迟的机会就会增加。由于一个请求的处理速度只能与处理该请求的最慢的服务器一样快,这将拖累该请求的总性能。有10台机器,在其中一台机器上遇到p99延迟的机会是。
1 - (.99)^{10} = 0.095 = 9.5\%
如果有100台机器,其中一台机器达到p99延迟的几率是。
1 - (.99)^{100} = 0.634 = 63.4\%
这意味着,在100台机器参与请求的情况下,请求将更经常地遇到p99延迟。这对于一个网络系统来说,是非常不可接受的性能。
一些解决方案
在深入研究解决方案之前,值得思考的是我们具体要解决什么问题。重述一下这个问题,当一个请求在返回响应之前需要接触的机器数量增加时,任何一个机器遇到p99延迟的概率都会增加,这就会拖慢整个请求。
然而,我们知道,每台机器都能以其p50延迟执行。所以问题就来了,在一个分布式系统中,我们如何提高请求路径中每台机器达到p50延迟的机会?
Dean和Barroso提出了一些解决方案,我在这里详细介绍了其中的几个值得注意的解决方案。
对冲请求
作者提出的第一个方案是 "对冲请求"。这个想法相当简单:假设一个请求首先到达机器A,然后是B,然后是C。如果A对B的请求花费的时间超过了一些配置的时间,只需向B发出另一个请求即可
这里的想法是,如果A的请求到B的时间超过了某个阈值,那么我们很可能已经达到了该服务的p99或更差的延迟。在两个请求之间,它们不太可能同时遇到一个延迟较差的路径。
论文中指出的一个缺点是,这将导致一定量的 "浪费 "工作。毕竟,原请求或新请求的结果都会被扔掉。这一点可以通过在任何一个服务的响应回来后向另一个服务发送信号来缓解,表明它可以取消该请求。
微分区
对冲请求有助于平滑 "尾巴 "事件,如垃圾收集的启动,或其他一些发生在服务上的因素导致延迟暂时增加。延迟变化的另一个原因是与特定系统中的数据分布有关。在分布式系统中,数据量太大,不能放在一个实例上,数据通常被分割在一堆不同的机器上。 例如,在一个简单的方案中,每个分区都存储一些ID范围。通常情况下,一个分区会被复制,所以每个分区都有一组复制的机器。
典型的方案是对数据进行分区,使每个分区的数据量大致相等,并且使每个分区适合在一台机器上。在这种方法中,如果某个分区的数据由于某种原因变得 "受欢迎",并开始比其他分区接收更多的流量,那么该分区的流量可能比其他分区的流量更多。
为了解决这个问题,Dean和Barroso提出了 "微分区",即分区的大小要比特定机器所能支持的要小得多。然后,这些微分区可以根据流量模式动态地被移动。因此,如果一个特定的微分区开始获得大量的流量,它可以被移到一个其他微分区较少的实例上。
延迟引起的缓刑
再次回到由对冲请求解决的问题,一些硬件或软件相关的问题可能会在一段时间内影响特定的机器。例如,如果一台机器碰巧有一些经常性的工作在运行,消耗了大量的资源,它可能会在潜在的几分钟内更慢地处理请求。
为了解决这个问题,作者提出了 "延迟诱导试探 "的想法,即如果机器在多个请求中表现不佳,则将其从集群中取出并置于 "试探 "状态。一旦一台机器处于 "试用期",它将不再接收实时请求。然而,他们仍然会收到影子请求,这是真实的生产流量,被复制并发送给这些实例,但对于这些实例,试用期的响应将被忽略。如果试探中的实例的延迟得到改善,它将被添加到集群中。
从表面上看,减少集群的临时规模实际上可能会改善延迟,这有点违反直觉。
什么是 "影子请求"?
在上一节中,我谈到了 "影子请求 "的概念。这里的一般想法是,在一个正常的、实时的、生产性的请求中,请求将进入集群中的一台机器,并返回响应。如果一个实例被配置为接收流量,那么这个请求,除了会被送到给它响应的机器上,也会被送到接收影子请求的机器上。这通常是由一些代理服务管理的,这些代理服务管理着向 "正常 "实例和接收影子请求的实例发送请求,它们将知道要返回什么响应。
关于读与写请求的简短说明
你可能已经注意到了本文中提到的一些解决方案,那就是如果一个请求真的需要在服务器端做出改变,那么其中的一些解决方案就无法发挥作用。例如,在对冲请求的情况下,如果一个服务只是发射了一个重复的请求,一个特定的改变可能会被做两次
同样地,在延迟引起的试用的情况下,如果 "影子请求 "是为 "写 "请求而做的,就不能安全地进行。这些解决方案对写请求不起作用。
然而,作者指出,在大多数网络系统中,写请求在所有请求中的比例比读请求要小,并且无论如何都倾向于对更高的延迟有更大的容忍度。
总结
以上只是Dean和Barroso提出的部分解决方案,他们在论文中还列出了一些其他的解决方案。如果你想了解更多,我肯定会推荐你阅读这篇论文--它是非常容易理解的。