SRE 运维解密 - 应对过载
前言
避免过载,是负载均衡策略的一个重要目标。但是无论你的负载均衡策略效率有多高,随着压力不断上升,系统的某个部位总会过载。运维一个可靠系统的一个根本要求,就是能够优雅地处理过载情况。
应对过载的一个选项是服务降级:返回一个精确度降低的回复,或者省略回复中一些需要大量计算的数据。例如:
平时在整个文档库中进行搜索,以针对某个查询返回最佳结果。而过载情况下仅仅在文档库的一小部分中进行搜索。
使用一份本地缓存的、可能不是最新的数据来回复,而不使用主存储系统。
然而,在极端的过载情况下,该服务甚至可能连这种降级回复都无法生成和发送。在这种情况下,该服务可能除了返回错误之外没有什么其他的好办法。
一种避免这种场景发生的方法是,通过在数据中心之间调度流量,做到每个数据中心都有足够的容量来处理请求。例如,如果一个数据中心运行了 100 个后端任务,其中每个后端任务可以处理 500 QPS 的请求,那么负载均衡策略就不会调度超过 50,000 QPS 的用户流量到这个数据中心。但是,在大规模部署时,这样的策略可能还是不够的。
无论如何,构建能良好处理资源限制的客户端和对应的后端任务是最好的:在可能的情况下重定向请求,在必要时返回降级回复,同时在最差情况下,同时在最差情况下,能够妥善地处理资源受限导致的错误。
一、QPS 陷进
不同的请求可能需要数量迥异的资源来处理。某个请求的成本可能由各种各样的因素决定,例如客户端代码的不同(有的服务有很多种客户端实现),或者甚至是当时的现实时间(家庭用户和企业用户,交互型请求和批量请求)。
按照 QPS/静态属性规划服务容量是错误的
Google 在多年的经验积累中得出:按照 QPS 来规划服务容量,或者是按照某种静态属性(认为其能指代处理所消耗的资源:例如某个请求所需要读取的键值数量)一般是错误的选择。就算这个指标在某一个时间段内看起来工作还算良好,早晚也会发生变化。有些变动是逐渐发生的,有些则是非常突然的(例如某个软件的新版本突然使得某些请求消耗的资源大幅减少)。这种不断变动的目标,使得设计和实现良好的负载均衡策略使用起来非常困难。
以可用资源规划服务容量
更好的解决方案是直接以可用资源来衡量可用容量。例如,某服务可能在某个数据中心内预留了 500 CPU 内核和 1TB 内存用以提供服务。用这些数字来建模该数据中心的服务容量是非常合理的。我们经常将某个请求的“成本”定义为该请求在正常情况下所消耗的 CPU 时间(这里要考虑到不同 CPU 类型的性能差异问题)。
在绝大部分情况下(当然总会有例外情况),我们发现简单地使用 CPU 数量作为资源配给的主要信号就可以工作得很好,原因如下:
在有垃圾回收(GC)机制的编程环境里,内存的压力通常自然而然地变成 CPU 的压力(在内存受限的情况下,GC 会增加)。
在其他编程环境里,其他资源一般可以通过某种比例进行配置,以便使这些资源的短缺情况非常罕见。
如果过量分配其他非 CPU 资源不可行的话,我们可以在计算资源消耗的时候将各种系统资源分别考虑在内。
二、给每个用户设置限制
过载应对策略设计的一个部分是决定如何处理全局过载(global overload)的情况。在理想情况下,每个团队都能和他们所依赖的后端服务团队之间协调功能发布,从而使后端服务永远有足够容量服务最终用户,这样全局过载情况就永远不会发生。不幸的是,现实总是残酷的。全局过载情况在实际运行中出现得非常频繁(尤其是那些被很多其他团队使用的内部服务)。
当全局过载情况真的发生时,使服务只针对某些“异常”客户返回错误是非常关键的,这样其他用户则不会受影响。为了达到这个目的,该服务的运维团队和客户团队协商一个合理的使用约定,同时使用这个约定来配置用户配额,并且配置相应的资源。
例如,如果一个后端服务在全世界范围内分配了 10,000 个 CPU(分布在多个数据中心中),它们的每用户限额可能与下面的类似:
邮件服务(Gmail)允许使用 4,000 CPU(每秒使用 4,000 个 CPU)。
日历服务(Calendar)允许使用 4,000 CPU。
安卓服务(Android)允许使用 3,000 CPU。
Google+允许使用 2,000 CPU。
其他用户允许使用 500 CPU。
这里要注意的是,上述这些数字的总和会超过该后端服务总共分配的 10000 CPU 容量,这是因为所有用户都同时将他们的资源配额用满是一种非常罕见的情况。
我们随后从所有的后端任务中实时获取用量信息,并且使用这些数据将配额调整信息推送给每个后端任务。该系统的实现细节,已经超出了本书讨论的范围,但是可以说的是,我们在后端任务中写了相当多的代码来做到这一点。这里比较有趣的一个技术难点是实时计算每个请求所消耗的资源(尤其是 CPU)。这种计算对某些不是按 “每个请求一个线程”模式设计的软件服务器尤其困难,这种软件用非阻塞 API 和线程池模式来处理每个请求的不同阶段。
三、客户端的节流机制
服务端节流的缺陷
当某个用户超过资源配额时,后端任务应该迅速拒绝该请求,返回一个“用户配额不足”类型的错误,该回复应该比真正处理该请求所消耗的资源少得多。然而,这种逻辑其实不适用于所有请求。
例如,拒绝一个执行简单内存查询的请求可能跟实际执行该请求消耗内存差不多(因为这里主要的消耗是在应用层协议解析中,结果的产生部分很简单)。就算在某些情况下,拒绝请求可以节省大量资源,发送这些拒绝回复仍然会消耗一定数量的资源。如果拒绝回复的数量也很多,这些资源消耗可能也十分可观。
在这种情况下,有可能该后端在忙着不停地发送拒绝回复时一样会进入过载状态。
客户端节流机制
客户端侧的节流机制可以解决这个问题。当某个客户端检测到最近的请求错误中的一大部分都是由于“配额不足”错误导致时,该客户端开始自行限制请求速度,限制它自己生成请求的数量。超过这个请求数量限制的请求直接在本地回复失败,而不会真正发到网络层。
我们使用一种称为自适应节流的技术来实现客户端节流。具体地说,每个客户端记录过去两分钟内的以下信息:
请求数量(requests):应用层代码发出的所有请求的数量总计(指运行于自适应节流系统之上的应用代码)。
请求接受数量(accepts):后端任务接受的请求数量。
客户端请求拒绝概率
在常规情况下,这两个值是相等的。随着后端任务开始拒绝请求,请求接受数量开始比请求数量小了。客户端可以继续发送请求直到 requests=K * accepts,一旦超过这个限制,客户端开始自行节流,新的请求会在本地直接以一定概率被拒绝(在客户端内部),概率使用公式 21-1 进行计算:
当客户端开始自己拒绝请求时,requests 会持续上升,而继续超过 accepts。这里虽然看起来有点反直觉,因为本地拒绝的请求实际没有到达后端,但这恰恰是这个算法的重点。随着客户端发送请求的速度加快(相对后端接受请求的速度来说),我们希望提高本地丢弃请求的概率。
我们发现自适应节流算法在实际中效果良好,可以整体上保持一个非常稳定的请求速率。即使在超大型的过载情况下,后端服务基本上可以保持 50%的处理率。这个方式的一大优势是客户端完全依靠本地信息来做出决定,同时实现算法相对简单:不增加额外的依赖,也不会影响延迟。
倍值 K 的调整
对那些处理请求消耗的资源和拒绝请求的资源相差无几的系统来说,允许用 50%的资源来发送拒绝请求可能是不合理的。在这种情况下,解决方案很简单:通过修改客户端中算法的 accepts 的倍值 K(例如,2)就可解决:
降低该倍值会使自适应节流算法更加激进。
增加该倍值会使该算法变得不再那么激进。
举例来说,假设将客户端请求的上限从 request=2 * accepts 调整为 request=1.1* accepts,那么就意味着每 10 个后端请求之中只有 1 个会被拒绝。
一般来说推荐采用 K=2,通过允许后端接收到比期望值更多的请求,浪费了一定数量的后端资源,但是却加快了后端状态到客户端的传递速度。举例来说,后端停止拒绝该客户端的请求之后,所有客户端检测到这个变化的耗时就会减小。
客户端节流机制不适用于请求频率低的客户端
另外一个考量是,客户端节流可能不适用于那些请求频率很低的客户端。在这种情况下,客户端对后端状态的记录非常有限,任何想提高状态可见度的手段相对来说成本都较高。
四、重要性
重要性(criticality)是另外一个在全局配额和限制机制中比较有用的信息。某个发往后端的请求都会被标记为以下 4 类中的一种,这说明了请求的重要性。
最重要 CRITICAL_PLUS:为最重要的请求预留的类型,拒绝这些请求会造成非常严重的用户可见的问题。
重要 CRITICAL:生产任务发出的默认请求类型。拒绝这些请求也会造成用户可见的问题,但是可能没有 CRITICAL_PLUS 那么严重。我们要求服务必须为所有的 CRITICAL 和 CRTICAL_PLUS 流量配置相应的资源。
可丢弃的 SHEDDABLE_PLUS:这些流量可以容忍某种程度的不可用性。这是批量任务发出的请求的默认值。这些请求通常可以过几分钟,或者几小时之后重试。
可丢弃的 SHEDDABLE:这些流量可能会经常遇到部分不可用情况,偶尔会完全不可用。
我们发现以上 4 种分类可以描述大部分服务。我们曾经数次讨论过在中间增加更多的分类,以便更细粒度地描述请求。但是增加额外的值需要增加处理这些信息的系统所需资源。
我们将重要性属性当成 RPC 系统的一级属性,花费了很多工夫将它集成进我们的很多控制手段中,以便这些系统在处理过载情况下可以使用该信息。例如:
当某个客户全局配额不够时,后端任务将会按请求优先级顺序分级拒绝请求(实际上,全局配额系统是可以按重要性分别设置的)。
当某个任务开始进入过载状态时,低优先级的请求会先被拒绝。
自适应节流系统也会根据每个优先级分别计数。
请求的优先级和该请求的延时性要求,也就是底层的网络服务质量(QoS)信息是不相关的。例如,当系统在用户输入搜索请求词语过程中实时显示搜索结果或者建议时,这些搜索请求的可丢弃性非常强(如果系统处于过载状态,这些结果也可以不显示),但是这些请求的延迟性仍然要求很高。
将重要性信息集成到消息中
我们同时增强了 RPC 系统,可以自动传递重要性信息。如果后端接收到请求 A,在处理过程中发出了请求 B 和 C 给其他后端,请求 B 和 C 会使用与 A 相同的重要性属性。
在过去一段时间内,Google 内部的许多系统都逐渐产生了一种与重要性类似的属性,但是通常不能跨服务兼容。通过标准化和在 RPC 系统中自动传递,我们现在可以在某些特定节点处统一设置重要性属性。这意味着,我们相信依赖的服务在过载情况下可以按正确的优先级来拒绝请求,不论它们处于整个处理栈中多深的位置。于是我们一般在离浏览器或者客户端最近的地方设置优先级——通常在 HTTP 前端服务器上。同时,我们可以在特殊情况下在处理栈的某处覆盖优先级设置。
五、资源利用率信号
我们的任务过载保护是基于资源利用率(utilization)实现的。在多数情况下,资源利用率仅仅是指目前 CPU 的消耗程度(目前 CPU 使用量除以全部预留 CPU 数量)。但是在某些情况下,同时也会考虑内存的使用率。随着资源利用率的上升,我们开始根据请求的重要性来拒绝一些请求(高重要性的请求对应高阈值)。
我们使用的资源利用率信号是完全基于本地信息计算的(因为这个信号的作用就是为了保护任务自身),针对不同的信号有具体的实现。一个比较有用的信号是基于进程的“负载”,这是通过一个所谓的执行器负载均值(executor load average)决定的。
执行器负载均值
要计算执行器负载均值,我们要统计整个进程中的活跃线程数。在这里,“活跃”指那些正在运行,或者已经准备好运行,但是正在等待空闲 CPU 的线程。我们同时利用指数性衰变算法(exponential decay)来平滑这个值,当活跃线程数量超过该任务分配的 CPU 数量时开始拒绝请求。这意味着,当某个请求展开成非常大量的请求时(例如,某个请求被展开成突发性的大批短时请求),会导致负载值急剧上升,但是这个平滑过程基本上会将这个突发情况处理掉。但是如果这些请求不是短时的(负载值长时间保持在较高的水平),这些任务会开始拒绝请求。
其他信号
虽然将执行器负载均值用在实践中被证实是一个非常有用的信号,但我们的系统可以接入后端自己定义的任意资源利用率信号。例如,我们可能会使用内存压力——表明了后端任务的内存使用率是否已经超出了正常运行范围——作为另一个可能的利用率信号。该系统还可以配置为同时使用多个信号,并且在超过综合(或者某个独立)目标利用率阈值的时候开始拒绝请求。
六、处理过载错误
在服务器端妥善处理过载请求之外,我们还仔细思考了在客户端接收到过载相关的错误信息时应该如何应对。在过载错误中,我们区分下列两种可能的情况:
数据中心中的大量后端任务都处于过载状态:如果跨数据中心负载均衡系统正在正常运行(意味着它可以传递状态,并且实时调度流量),这种情况应该不会出现。
数据中心中的一小部分后端任务处于过载状态:这种情况一般是由负载均衡系统的不完美造成的。例如,某个任务可能最近接收到了一个处理成本巨大的请求。在这种情况下,很有可能该数据中心仍然有其他容量可以处理该请求。
如果大部分后端任务都处于过载状态,请求应该不再重试,而应该一直向上传递给请求者(例如,向最终用户发送一个错误信息)。
在更常见的、小部分任务过载的情况下,我们更倾向于立即重试该请求。一般来说,我们的跨数据中心负载均衡系统试图将客户端请求发往最近的数据中心。在特殊情况下,最近的数据中心仍然很远(如某个客户端可用的后端在另外一个大陆上),但是我们往往可以用其他手段解决这个问题。在这种情况下,重试某个请求造成的延时——一般是几个网络 RTT——基本上是可以忽略的。
不区分重试请求与新请求
从负载均衡策略的角度看,重试请求和新请求是无法区分的。也就是说,我们并不会使用任何特殊的逻辑来保证某个重试请求真的会发往另外一个后端任务。在后端数量较多的情况下,这个“概率”本身就很大。增加新的逻辑确保请求发往不同的后端会将我们的 API 复杂度无谓地提高。
就算某个后端任务只是轻微过载,如果后端请求将重试和新请求同等对待,快速拒绝仍然是最好的选择。这些请求可以立刻在另外一个可能有空余资源的任务上重试。同等对待重试请求和新请求事实上形成了一种天然的负载均衡机制:可以将多余的负载自动转移给其他的任务。
决定何时重试
当某个客户端接收到“任务过载”错误时,需要决定是否要重试这个请求。我们针对大量后端任务过载的情况有几个方法来避免进行重试。
每次请求限制重试次数
第一,我们增加了每次请求重试次数限制,限制重试 3 次。某个请求如果已经失败 3 次,我们会将该错误回应给调用者。这里的逻辑是指,如果一个请求已经三次选择了过载的任务,再重试也很可能无济于事,这时整个数据中心可能都处于过载状态。
每客户端限制重试次数
第二,我们实现了一个每客户端的重试限制。每个客户端都跟踪重试与请求的比例。一个请求在这个比例低于 10%的时候才会重试。这里的逻辑是,如果仅仅只有一小部分任务处于过载状态,那么重试数量应该是相对较小的。
用一个实际例子来说(一个很糟糕的情况),我们假设一个数据中心正在接受一小部分请求,而大部分请求都被拒绝了。这里用 X 代表客户端逻辑向这个数据中心发出请求的速率。由于大部分请求会被重试,这里请求的数量将会增长得非常快,接近 3X(由于每个请求会被重试 3 次)。虽然我们在这里限制了重试导致的请求数量,3 倍的请求增长仍然是很多的,尤其是当拒绝请求的成本不能忽略的时候。然而,通过加入一个按客户端重试的限制(10% 重试比例),实际上我们将重试请求限制在大多数情况下仅增加为原来的 1.1 倍。这样的改进是很显著的。
客户端在请求元数据中加入重试计数
第三个方法是客户端在请求元数据中加入一个重试计数。例如,这个计数器在第一次尝试时以 0 值开始,每次重试时加 1,直到计数器为 2 时,请求重试限制会导致不再重试该请求。客户端会将近期信息记录为一个直方图。当某个后端需要拒绝请求时,它可以使用这个直方图信息来评判其他后端任务也处于过载的可能性。如果这些直方图信息表明大部分请求都有重试(意味着其他后端任务也可能处于过载状态),那么后端会直接返回“过载;无须重试”的错误,而不是标准的“任务过载”错误信息。
图 21-1 显示了在不同的情况下,每个后端接收到的请求的重试次数分布。这些数据是根据一个滑动窗口计算的(1000 个初始请求,不计算重试)。为了更简单,这里客户端重试限制被忽略了(这些数据假设唯一的限制是每个请求重试 3 次),同时后端子集也可能会影响这些数据。
重试应该在"被拒绝的层面的上一层"进行
Google 的大型服务通常是由一个层次很深的系统栈组成的,这些系统可能互相依赖。在这种架构下,请求只应该在被拒绝的层面上面的那一层进行重试。当我们决定某个请求无法被处理,同时不应该被重试时,返回一个“过载;无须重试”错误,以避免触发一个大型的重试爆炸。
我们来看图 21-2 中所示的例子(实际上我们的系统栈要比这个复杂得多)。假设数据库前端(DB Frontend)目前正处于过载状态,拒绝请求。在这里:
后端任务 B 会根据前述的规则重试请求。
然而当后端任务 B 确定数据库前端无法处理该请求时(例如,请求已经重试 3 次),后端任务 B 需要给后端任务 A 返回一个“过载;无须重试”错误,或者某个降级回复(假设它在无法连接数据库前端的情况下能产生某种可用的回复)。
后端任务 A 会根据它收到的回复进行处理。
这里的关键点是,数据库前端拒绝的请求应该仅仅在后端任务 B 处重试——在它的直接上层处。如果多层都要进行重试,会造成批量重试爆炸情况。
七、连接造成的负载
连接造成的负载是最后一个值得一提的因素。有时候我们仅仅考虑后端处理接收的请求所造成的负载(这也是用 QPS 来建模负载的一个问题),然而却忽略了其他因素,比如维护一个大型连接池的 CPU 和内存成本,或者是连接快速变动的成本。这样的问题在小型系统中可以忽略不计,但是在大型 RPC 系统中很快就会造成问题。
正如之前所说,我们的 RPC 协议需要不活跃的客户端定期执行健康检查。当某个连接空闲一段可配置的时间后,客户端放弃 TCP 连接,转为 UDP 健康检查。不幸的是,这种行为会对大量请求率很低的客户端造成问题:健康检查需要比实际处理请求更多的资源。通过仔细调节连接参数(如,大幅降低健康检查频率)或者动态创建和销毁连接可以优化这些场景。
处理突发性的新连接请求是另外一个(相关的)问题。我们观察到,超大规模的批处理任务会在短时间内建立大量的客户端。协商和维护这些超大数量的连接可以造成整个后端的过载。
应对"连接负载"的方法
在我们的经验里,以下策略可以帮助消除这些问题:
将负载传递给跨数据中心负载均衡算法(如,使用资源利用率进行负载均衡,而不仅仅是请求数量)。在这种情况下,过载请求会被转移到其他数据中心。
强制要求批处理任务使用某些特定的批处理代理后端任务,这些代理仅仅转发请求,同时将回复转发给客户端。于是,请求路线从“批处理客户端→后端”变为“批处理客户端→批处理代理→后端”。在这种情况下,当大型的批处理任务执行时,只有批处理代理任务会受到影响,而保护了真正的后端任务(也随之保护了其他的高优先级客户端)。这里,批处理代理任务实际充当了保险丝的角色。另外一个使用代理方式的优势是,我们可以减少后端连接的数量,同时提高负载均衡的效率(例如,代理任务可以使用更大的子集数量,同时可以更好地进行负载均衡)。
小结
防止过载
所以,我们认为保护某个具体任务,防止过载是非常重要的。简单地说:一个后端任务被配置为服务一定程度的流量,不管多少额外流量被指向这个任务,它都应该保证服务质量。并且由此得出,后端任务不应该在过载情况下崩溃。这些要求应该在一定流量范围内得到满足—有可能是两倍的配置流量,甚至 10 倍。我们可以接受超出某阈值时系统会崩溃的情况,但是应该将该阈值提升到某种很难发生的程度。
降级
这里的关键是严肃对待降级情况。当这些降级情况被忽略时,很多系统都会显示出非常糟糕的表现。随着工作堆积,任务最终导致内存超标而崩溃(或者基本上花费所有 CPU 进行 GC)、延迟上升、请求被忽略并且任务互相竞争资源。如果不解决这个问题,某个子系统的问题(如某一个后端的一个任务)可能会造成其他系统组件的失败,也有可能会造成整个系统(或者是显著的大部分)失败。这种层级传递的失败是每个大型部署系统都必须考虑的关键点(更多知识参见第 22 章)。
过载不应该拒绝和停止接受所有请求
一个常见的错误是认为过载后端应该拒绝和停止接受所有请求。然而,这个假设实际上是与可靠的负载均衡目标相违背的。我们实际上希望客户端可以尽可能地继续接受请求,然后在有可用资源时才处理。某个设计良好的后端程序,基于可靠的负载均衡策略的支持,应该仅仅接受它能处理的请求,而优雅地拒绝其他请求。
虽然我们有很多工具可以用来实现良好的负载均衡和过载保护机制,但是这里没有万能药:要进行负载均衡经常需要深入了解一个系统和它的请求处理语义。本章所描述的技术是根据 Google 内部很多系统的需求变化而产生的,可能会随着系统的变化而再次演进。
版权声明: 本文为 InfoQ 作者【董哥的黑板报】的原创文章。
原文链接:【http://xie.infoq.cn/article/42ebc5f64c02e2d6e7a096eac】。文章转载请联系作者。
评论