为什么你的服务会变慢?-风君雪科技博客

  英文原文:Why are services slow sometimes?

  你开发了一个服务,调用它,它做了一些事情并返回结果。那么,它需要花多长时间?为什么有时候它花的时间比用户期望的要长?在这篇文章中,我将从最基础的讲起,然后逐步介绍一些标准的术语,同时着重强调一些需要知道的关键点。

  首先,我们需要一种方式来度量时长,还需要理解两个完全不同的度量角度。从调用服务的外部用户角度来看,我们需要度量响应时间。从服务处理请求的角度来看,我们需要度量服务时间。这就引出了第一个关键点,人们常常分不清一些指标。

对于用户来说是响应时间(Response Time),对于服务来说是服务时间(Service Time)。

  在真实世界里,每一个处理过程都包含了很多步骤,每个步骤都需要占用一些时间。步骤占用的时间叫作驻留时间(Residence Time),驻留时间由等待时间(Wait Time)和服务时间组成。以用户登录 App 为例,一个用户登录手机 App,App 会调用 Web 服务进行用户认证。为什么有时候会很慢?按理说,每一次手机上生成请求的时间、将请求传输给 Web 服务的时间、查询用户的时间、返回结果并显示下一个屏幕的时间应该是一样的。造成响应时间长短不一的是排队时间,也就是等待正在处理其他请求的资源。从手机到认证服务器之间的网络传输需要经过很多跳,每一跳前面都有等待被发送的数据包。如果队列是空的或者队列很短,那么响应速度就很快,如果队列很长,响应就很慢。当请求达到服务器时,也需要排队等待 CPU 处理。如果需要查询数据库,还需要排到另一个队列里。

排队等待是导致响应时间增加的主要原因。

  监控工具会提供一个叫作吞吐量(Throughput)的指标,用来度量处理频度。在某些情况下,我们也会得到一个叫作到达率(Arrival Rate)的指标,用于度量到达服务器的请求的速率。在理想情况下,比如一个具有稳定工作负载状态的 Web 服务,一个请求对应一个响应,那么吞吐量和到达率是一样的。不过,重试和错误会导致到达率增加,但吞吐量不会增加。对于快速变化的工作负载或者需要长时间处理的请求(比如批次作业),会出现到达率和吞吐量之间的不均衡,并产生更为复杂的请求模式。

吞吐量是指已经成功处理完的请求数量,它跟达到率是不一样的。

  我们可以通过一些跟踪系统(比如 Zipkin 或 AWS X-Ray)来跟踪单个请求流,不过我们这里讨论的是大量请求以及它们之间的交互关系。我们通过固定的时间间隔来度量均值,时间间隔可以是秒、分钟、小时或天。计算均值需要足够多的数据,一般来说每个均值至少需要 20 个数据点。

如果请求不是很频繁,请选择一个至少包含 20 个请求的时间间隔,这样才有可能得到比较有用的信息。

  如果选择的时间间隔太大,会导致工作负载的变化被隐藏掉。例如,对于视频会议系统来说,大部分会话会在一个小时的头一分钟左右启动,并且很容易在这些时间段达到峰值,让系统发生过载,如果时间间隔是小时,这些信息就会丢失掉。所以,对于这种情况,时间间隔设为秒更为恰当。

对于变化快的工作负载,可以使用秒级的均值。

  监控工具各种各样,但一般很少会直接告诉我们等待队列有多长或有多少并发度可用来处理队列。大多数网络每次只传输一个数据包,但 CPU 的每个核心或 vCPU 可以并行处理队列里的任务。数据库通常有一个固定的最大连接数,用来限制并发度。

对于处理请求的每一个步骤,可以记录或估计用于处理请求的并发度。

  如果系统运行稳定,有稳定的平均吞吐量和响应时间,那就很容易估算等待队列的长度,只需要将吞吐量和驻留时间相乘即可。这就是所谓的利特尔法则法则(Little’s Law)。这个法则很简单,监控工具经常用它来估算队列长度,但它只对具有稳定均值的系统有效。

根据利特尔法则,平均队列长度 = 平均吞吐量 * 平均驻留时间。

  为了更好地理解这个法则的原理,我们需要知道请求是如何到达服务器以及请求之间的间隔是怎样的。如果我们通过循环进行简单的性能测试,请求之间的间隔是固定的,那么利特尔法则就无效,因为这样出现的队列很短,而且这样的测试不真实。我们通常会进行这样的测试,以为很完美,但是在将服务部署到生产环境之后,眼睁睁地看着它越跑越慢,吞吐量越来越低。

这种速率固定的循环测试不会有队列出现,它们只是在模拟传送带。

  在真实的网络世界中,用户都是独立的,他们发送自己的请求,不同用户发送的请求之间的间隔是随机的。所以,在测试时,我们需要使用可以生成具有随机等待时间的请求的生成器。大多数系统会使用随机分布,虽然比模拟传送带要好,但也是不对的。要模拟真实的网络流量,并让利特尔法则生效,我们需要使用负指数分布(Negative Exponential Distribution)。Neil Cunther 博士在这篇文章中解释了什么是负指数分布。

要生成更加真实的队列,需要使用恰当的随机时间算法。

  但问题是,真实的网络流量并不是随机分布的,而是带有爆发性质的。想象一下,当一个用户打开一个手机 App,它不会只发出一个请求,而是很多个。在网络购物抢购活动中,会有很多用户同时打开 App,这会导致流量爆发。这种分布形态叫作帕累托或双曲线。另外,当网络经过重新配置,流量会被延迟,就会出现队列,而队列会给下游系统带来闪电式的冲击。Jim Brady 和 Neil Gunther 写了一些脚本,演示如何配置测试工具,从而获得更加真实的流量。Jim Brady 还写了一篇关于如何知道负载测试好坏的论文。

相比常用测试工具默认生成的流量负载,真实世界的流量负载更具爆发性,会导致更长的等待队列和响应时间。

  等待队列和响应时间应该是变化的,而且即使是在使用率很低的时候也会出现一些很慢的请求处理速度。那么,当处理步骤中的某一步开始变慢时会怎样?当使用率增加,一些处理步骤没有足够的可用资源(比如网络传输),那么请求相互争夺资源的情况就会增加,驻留时间也会增加。一般来说,当使用率达到 50% 到 70% 时,网络就会逐渐变慢。

将网络使用率保持在 50% 以下可用获得更好的延时。

  对于并行度高的 CPU,在使用率较高的情况下,速度会变得更慢,影响也更大,大到令你吃惊。如果你将最后一个可用的 CPU 看作争用点,那么这就很直观了。例如,如果有 16 个 vCPU,最后可用的 CPU 具有 6.25% 的处理能力,那么使用率就是 93.75%。对于具有 100 个 vCPU 的系统,它的使用率约为 99%。在稳定状态下,公式 R=S/(1-U^N) 可用来近似估算随机到达服务器的请求的行为。

在多处理器系统中,随着使用率的增加,平均驻留时间的膨胀会减少,但强度却增加了。

  使用率使用比例,而不是百分比,并将其作为处理器核数的幂底数。用 1 减掉使用率的 N 次幂,再用平均服务时间除以结果,就可以估算出平均驻留时间。如果使用率很低,平均驻留时间就会很接近平均服务时间。如果一个网络的 N=1 并且使用率为 70%,那么用平均服务时间除以 0.3,得到的平均驻留时间就是低使用率时的三倍。

通常情况下,我们需要将平均驻留时间保持在 2 到 3 倍以下,这样才能获得更短的用户响应时间。

  对于一个有 16 个 vCPU 并且使用率为 95% 的系统,0.95^16=0.44,再用平均服务时间除以 0.56,就会得到两倍的平均驻留时间。如果使用率为 98%,那么 0.98^16=0.72,再用平均服务时间除以 0.28,平均驻留时间就会变慢,变得不可接受,而此时使用率仅增加了 3%。

当多处理器系统的使用率很高时,一个很小的负载变化就会产生很大的影响,这是多处理器系统的一个问题。

  Unix/Linux 系统有一个指标叫作负载平均(Load Average),人们通常对它了解得不够透彻,它存在一些问题。Unix 系统(包括 Solaris、AIX、HPUX)会记录运行中的和等待 CPU 的线程数,Linux 还会记录等待 IO 阻塞的线程数,然后还使用了三种时间衰减值,分别是 1 分钟、5 分钟和 15 分钟。首先我们需要知道的是,这个指标可以追溯到 60 年代的单核 CPU 时代,所以我通常会将负载平均值除以 vCPU 的数量,从而得到具有可比性的值。其次,这个指标与其他指标不一样,它没有使用固定的时间间隔,所以它们不属于同一种平均值。第三,这个指标在 Linux 上的实现已经成了一个 bug,被制度化成一个系统特性,其结果也被夸大了。

负载平均这个指标不度量负载,也不是均值,所以最好把它忽略掉。

  如果一个系统超载,请求达到的速度超过了处理能力,使用率就达到了 100%,那么上面的那个公式的除数就是 0,这样会导致驻留时间无穷大。在实际当中,这种情况会更加糟糕,因为当系统变慢时,上游的用户会发送重试请求,这样会加大系统的负载,出现“重试风暴”。这个时候,系统就会出现很长的等待队列,无法做出响应。

当系统使用率达到 100% 时就会出现很长的等待队列,无法对请求做出响应。

  我发现系统的重试次数通常被配置得很大,超时被配置得很长,这样会增加工作负载,更有可能出现重试风暴。之前我有深入地探讨过这个问题,后来又新写了一篇文章。更好的做法是使用较短的超时时间和单次重试,如果有其他可用实例,最好把请求发给它们。

系统的重试时间不能全部设置成一样,前端部分应该设置得长一些,后端部分应该设置得短一些。

  一般情况下,人们会通过重启来清楚超载的队列,但一个经过精心设计的系统会限制队列的长度,并通过丢弃请求或做出“快速失败”的响应来削减流量。数据库和其他具有固定连接数限制的服务都使用了这种方式。当你无法获得可用的请求能力就会得到一个快速失败的响应。如果连接数限制设置得太低,数据库会拒绝它本该有能力处理的请求,如果设置得太高,数据库在拒绝更多请求之前就已经变慢了。

要多想想当系统使用率达到 100% 时该怎么办,以及如何设置恰当的限制连接数。

  要想在极端情况下还能保持较好的响应速度,最好的方式是使用快速失败响应和削减流量。真实世界中的大部分系统即使是在正常情况下也会出现很多慢响应。不过,我们可以通过正确的指标监控来解决这些问题,对系统进行精心的设计和测试,这样就有可能构建出具有最大化响应速度的系统。