发展到一定阶段后,Web 应用程序就会增长到单服务器部署无法承受的地步。这时候企业要么提升可用性,要么提升可扩展性,甚至两者兼而有之。为此,他们会将应用程序部署在多台服务器上,并在服务器之前使用负载均衡器来分配传入的请求。大公司可能需要数千台运行其 Web 应用程序的服务器来处理负载。
在这篇文章中,我们将重点关注单个负载均衡器将 HTTP 请求分发到一组服务器的各种可行方式。我们将从底层开始,逐步引出现代负载均衡算法。
我们先从头开始:单个负载均衡器将请求发送到单台服务器,请求以每秒 1 个请求(RPS)的速率发送,并且每个请求在服务器处理时都会逐渐缩小。对于很多网站来说,这种设置挺好用。现代服务器性能强大,可以处理大量请求。但是当它们的性能跟不上时会发生什么事情?
从上面的模拟中我们可以看到,3RPS 的速率导致一些请求被丢弃了。如果在处理一个请求时,另一个请求到达了服务器,服务器将丢弃后者。这将导致向用户显示的错误,这是我们要避免的事情。我们可以将另一台服务器添加到负载均衡器来解决这个问题。
现在不再有丢弃的请求了!我们的负载均衡器在这里起到了作用,依次向每台服务器发送请求,这称为“循环法”负载均衡。它是最简单的负载均衡形式之一,当你的服务器都同样强大并且你的请求都同样昂贵时,这个机制很合适。
在现实世界中,服务器同样强大而请求同样昂贵的情况很少见。即使你使用完全相同的服务器硬件,它们的性能也可能不同。应用程序可能必须为许多不同类型的请求提供服务,并且这些请求可能具有不同的性能特征。
我们看看当我们改变请求成本时会发生什么事情。在以下模拟中,请求的成本并不相同。你可以看到一些请求比其他请求花费了更长的时间。
虽然大多数请求都得到了成功处理,但我们确实放弃了其中一些请求。缓解这种情况的方法之一是引入一个“请求队列”。
请求队列帮助我们应对不确定性,但这是一种权衡。我们将丢弃更少的请求,但代价是某些请求具有更高的延迟。如果你观看上面的模拟足够长的时间,你可能会注意到请求的颜色发生了微妙的变化。请求没有被送达的时间越长,它们的颜色变化的就越多。你还会注意到,由于请求成本差异,服务器开始表现出不均衡情况。队列会在运气不好的,必须连续服务多个昂贵请求的服务器上备份下来。如果队列已满,我们将丢弃请求。
上述情况同样适用于性能不一样的服务器组。在下一个模拟中,我们还改变了每台服务器的性能,在视觉上用较深的灰色阴影表示。
服务器被赋予一个随机的性能值,但很可能有些服务器的性能低于其他服务器,并且它们很快就会开始丢弃请求。与此同时,性能更强大的服务器大部分时间都处于闲置状态。这个场景展示了循环法的主要弱点:方差。
然而,尽管存在缺陷,循环法仍然是 nginx 的默认 HTTP 负载均衡方法。
我们可以改进循环法来更好地应对方差。有一种称为“加权循环法”的算法,其中用一个权重来标记每台服务器,该权重决定了向每台服务器发送多少请求。
在这个模拟中,我们使用每台服务器的已知性能值作为其权重,并在循环遍历它们时向更强大的服务器提供更多请求。
虽然这种机制能比普通循环法更好地处理服务器性能的差异,但我们仍然需要应对请求方差。在实践中,让人类手动设定权重的做法很快就会失败的。将服务器性能用某个数字来表示是很难的事情,并且需要对真实工作负载进行仔细的负载测试。实践中很少这样做,因此加权循环法的另一种变体会使用一个代理指标动态计算权重:它就是延迟。
按理说,如果一台服务器处理请求的性能是另一台服务器的 3 倍,那么它的速度可能就是另一台服务器的 3 倍,并且应该接收相当于另一台服务器 3 倍的请求数量。
这次我向每台服务器添加了文本,显示最后 3 个请求的平均延迟。然后我们根据延迟的相对差异决定是否向每台服务器发送 1、2 或 3 个请求。结果与初始的加权循环模拟非常相似,但无需预先指定每台服务器的权重。该算法还能适应服务器性能随时间的变化。这称为“动态加权循环法”。
我们来看看它如何处理服务器性能和请求成本都存在很大方差的复杂情况。以下模拟使用随机值,因此请随意刷新页面几次,查看它是否适应了新的情况。
动态加权循环似乎能很好地处理服务器性能和请求成本的方差。但我们有没有更好、更简单的算法呢?答案是肯定的。
这称为“最少连接”负载均衡法。
因为负载均衡器位于服务器和用户之间,所以它可以准确地跟踪每台服务器有多少未完成的请求。然后当一个新的请求进来并且该确定将它发送到哪里时,负载均衡器已经知道哪些服务器要做的工作是最少的,并且会优先考虑这些服务器。
无论存在多少方差,该算法都表现得非常好。它能准确掌握每台服务器正在做什么的信息,从而消除了不确定性。而且它的另一个好处是实施起来非常简单。由于这些原因,你会发现这个算法是 AWS 负载均衡器的默认 HTTP 负载均衡方法。它也是 nginx 中的一个选项,如果你从未更改过它的默认设置,这个算法非常值得一试。
我们来看看这个算法在类似的复杂模拟中的实际效果。这里用的参数同上面为动态加权循环算法提供的参数是一样的。同样,这些参数在给定范围内是随机的,因此请刷新页面以查看新情况。
虽然这个算法在简单性和性能之间取得了很好的均衡,但它无法避免丢弃请求的情况。但是你会注意到,这个算法只有在实际上没有更多可用队列空间的状况下才会丢弃请求。它能确保所有可用资源都被用上,这让它成为了大多数工作负载的绝佳默认选项。
到目前为止,我一直在回避讨论的一个关键部分:我们要优化什么指标。我之前一直把放弃请求当作是很糟糕的结果,并试图避免它们。这是一个不错的目标,但它并不是我们在 HTTP 负载均衡器中最想优化的指标。
我们更关心的指标一般是延迟。这是从创建请求到处理请求的时间,以毫秒为单位。当我们讨论延迟时,通常会谈论不同的“百分位数”。例如,第 50 个百分位数(也称为“中位数”)定义为 50%的请求低于该值(单位为毫秒),50%的请求高于该值。
我用相同的参数运行了 3 次模拟,持续 60 秒,每秒都会进行各种测量。3 次模拟的差异仅来源于所使用的负载均衡算法。我们来对比 3 个模拟的中值:
你可能没想到的是,循环法的延迟中值是最好的。如果我们不看其他数据点,得出的结论就会有问题。我们来看看第 95 个和第 99 个百分位数。
注意:每种负载均衡算法的不同百分位数之间没有颜色差异。更高的百分位数在图表上总是更高的。
我们看到循环法在较高的百分位数中表现不佳。可是为什么循环法的中位数表现很好,但第 95 个和第 99 个百分位数很差呢?
在循环法中不考虑每台服务器的状态,因此会有相当多的请求转到空闲服务器,于是第 50 个百分位的延迟就很低。另一方面,算法也很乐意将请求发送到过载的服务器上,因此第 95 和 99 个百分位数很差。
我们可以看看直方图形式的完整数据:
我为这些模拟调整了参数以避免丢弃任何请求,这样 3 种算法的数据点数量就是一样的。我们再次运行模拟,这次增加 RPS 值,目的是将所有算法推到它们可以处理的范围之外。以下是丢弃请求随时间积累的图表。
最少连接算法可以更好地处理过载,但这样做的代价是 95%和 99%的延迟略高。根据你的用例情况,这可能是一个值得接纳的权衡。
如果我们真的想针对延迟做优化,我们需要一种将延迟考虑在内的算法。如果我们可以将动态加权循环算法与最少连接算法结合起来,那不是很好吗?我们可以得到加权循环法的延迟优势和最少连接法的弹性优势。
事实证明,在我们之前就有人有了这样的想法。下面是对称为“峰值指数加权移动平均值”(或 PEWMA)的算法的模拟。这是一个又长又复杂的名字,但坚持住,我稍后会详细解释它的工作原理。
我为这个模拟设置了特定的参数,保证它表现出预期的行为。如果你仔细观察,你会注意到算法会在一段时间后停止向最左边的服务器发送请求。它这样做是因为它发现其他服务器都更快,并且不需要向最慢的服务器发送请求——这只会导致请求有更高的延迟。
那么它是如何做到的呢?它将动态加权循环与最少连接法结合了起来,并加上了一点独创的魔法。
对于每台服务器,该算法会跟踪最近 N 个请求的延迟。算法不是用这个数据来计算平均值,而是对值求和,但比例因子呈指数下降。这会产生一个值,其中延迟时间越长,它对总和的贡献就越小。最近的请求相比老的请求对计算的影响更大。
然后将该值乘以服务器的开启连接数,我们用得出来的结果值来选择将下一个请求发送到哪台服务器上。这个值越低越好。
那么它是如何做比较的呢?首先,我们来看一下第 50、95 和 99 个百分位数与之前的最少连接法数据的对比。
我们看到结果有了全方位的显著改善!新算法在较高的百分位数上优势更为明显,但中位数也一直有优势。下面我们来看直方图形式的相同数据。
请求丢弃的情况如何?
它开始表现得更好,但随着时间的推移开始差于最少连接法,这是有道理的。PEWMA 是机会主义的,因为它试图获得最佳延迟,这意味着它有时可能会让服务器负载不足。
我想在这里补充一点,PEWMA 有很多可以调整的参数。我为这篇文章编写的实现使用的配置似乎比较适合我的测试场景,但进一步调整参数可以获得比最少连接法更好的结果。这是 PEWMA 与最少连接法相比的一项劣势:额外的复杂性。
我在这篇文章上花了很长时间。我们很难在现实主义与简单易懂之间取得平衡,但我对最终成文还是很满意的。我希望读者能够通过本文理解这些复杂系统在理想和不太理想的情况下,实践的行为方式,这样可以帮助大家直观地了解它们在什么情况下最适用于你的工作负载。
免责声明:你一定要牢记自己的负载才是永远的基准,不要把网上看来的建议视为福音。我在这里的模拟忽略了一些现实场景中的限制(服务器启动慢、网络延迟之类),而且为了展示每个算法的特定属性做了参数调整。它们并不是反映现实情况的基准测试。