201705 孙鸣老师关于《Handling Overload》的导读 - xiaoxianfaye/Learning GitHub Wiki

孙鸣老师 导读 2017.3 陈雅菲 整理 2017.3-2017.5

Preface

大家好,前段时间,我们展开了以翻译为手段、目的是精读的活动。这样的活动一般分为四个步骤:

  1. 首先是读懂英文原文,翻译成中文;
  2. 接下来,希望能够将文章的逻辑理顺,走进作者的“思维”中,体会他的因果和构文(文章组织),这一步至关重要,因为只有将逻辑理顺了,才能表明这篇文章真正读懂了;
  3. 更进一步,在精读过程中,如果发现作者的逻辑以及阐述的知识和自己以往的经验产生了共鸣和启发,能够结合自己以往碰到过的实际场景进行深挖和思考,通过对这些场景的逻辑推演导出文中的经验(rule of thumb),那就更好了。
  4. 最后一步就是在目前从事的项目产品,以及未来还未从事的项目产品开发中,在全新的、和文章所述不同的领域上下文中,能将文中的逻辑、知识、原则用起来形成对项目的实际指导、以及在设计和实现方面的决策,那就说明这篇文章的内容,你是真正内化在心中了,这个知识也确实被你收入囊中,可以举一反三了。这就是我们精读要达到的目的,定义问题、解决问题的能力。

针对每篇我挑选出来的文章,我都会为大家进行导读。出于上述目的,导读不会是逐字逐句地对原文的翻译(这部分工作,之前每篇文章的精读负责人已经做了第一稿的翻译)。大家可以在 https://gitlab.com/CS-Translation 中查看到每篇文章中英对照格式的翻译(感谢@肖锋钢为我们所见的精读库)。在我导读过程中,可以通过这个去了解背景内容。导读的重点之一是在对原文初步了解的基础上,为大家理清文中知识点的逻辑关系,这些逻辑关系是否合理,又该如何理解;另一个重点就是将文中比较难懂的地方加以详细阐述;第三个重点就是对文中的关键点结合具体实例,辅助去更好地理解知识和逻辑。

接下来的一段时间,我们先从《Handling Overload》这篇讲述Erlang过载处理的文章开始。在大型高可用分布式系统的设计中,这是个至关重要的方面。虽然这篇文章是以Erlang语言来写的,文中提供的工具和实现也是基于Erlang的,但首先,文中提到的分析问题、解决问题的方法具有普适性;其次,无论你在实际中采用哪种语言来构建这种高可用分布式系统,要想贴合这个领域的语义,最终都会趋同于Erlang的语义。

原文链接:http://ferd.ca/handling-overload.html。

Overview of Handling Overload

这篇文章的重点概括如下:

从一些重要的基本概念开始,什么叫系统能力?如何预定义?基于这个概念,再来谈什么叫过载?过载又分为由系统内部和系统外部原因导致的。从系统设计的角度来说,首先要把系统内部的、由设计问题产生的、不该有的瓶颈找出来,将由这类原因导致的系统内部的过载规避掉,逐步将过载的边界向外推,最终推到我们定义的系统边界处,这时我们就能达到系统能力极限,此时如果继续过载,那就是系统外部过载,例如请求处理量超过了系统能力,我们可能有不同的方法处理,例如泄洪(不处理,将包丢弃)。

另外,本文的思想对于平台该如何开发也有很大的指导借鉴意义,我们做平台时,总倾向于将很多功能都集成到平台中去,目的是为了减轻上层应用的负担,但是这样做却往往适得其反。因为应用的实际场景千差万别,不可能在平台中通过一种或几种特定的实现、模版策略来满足所有的需求。所以平台要旨在提供机制和工具,将策略放在应用层去解决。这篇文章对平台如何提供工具、应用又该如何根据实际需要灵活运用工具方面也给出了不少思路。

文章中提供了很多的工具,虽然都是用Erlang实现的,但是它的设计思想是普适的,做类似系统时完全可以拿来参考。

Part 1 of Handling Overload

接下来,我们逐段讲解。

文章第一段,提到Erlang中的进程队列--mailbox,是没有限制的。所以,如果某个进程是热点,很多进程都给它发消息,这些消息都会存进它的消息对列中,直到将整个虚拟机的内存耗尽,最终crash。这是Erlang语言otp19以前的设计。在otp19以后,就为mailbox添加了max_heap_size标记,可以设置一个进程所占用的最大堆内存大小,超过这个限制,进程会死,但不会耗尽虚拟机内存,虚拟机不会crash。

接下来,文章总结了Erlang中会碰到的一些常见的过载模式以及针对这些过载模式的常用解决方案。

作者也特别强调本文总结的这些解决过载的流控策略和思想同样适用于目前其它的一些并发平台,特别是和Erlang类似的抢先式调度平台,例如Go(作者举例),其实Go并非完全抢先式,只能说是部分抢先式的;而对于协作式调度平台,例如Akka或者node.js,因为是协作式的,所以本身就隐含(内置)了避免过载的机制。为什么呢?因为它们是非抢先式的,如果一个并发体很忙,就会将CPU全部占用,别的并发体就无法给其发消息。从某种程度上就规避了一些过载的问题。但无论如何,本质上不能避免这些问题,还是会碰到同样的问题。

文章提到了一个很简单、但却非常重要的法则--little's law。一个排队系统在稳定状态下,在系统里面的个体的数量的平均值 L,等于平均个体到达率λ(单位是“个每单位时间”)乘以个体的平均逗留时间W。

对应到我们的计算机系统,那么系统的处理能力在根本上由什么决定呢?其实就是由单个任务(或者请求)的处理时间以及同时能够处理的任务或者请求的个数决定的。通过这个公式,我们可以估算出系统的实际处理能力,从而知道系统的极限边界以及该从哪些方面去提升这个能力。

例如,给定一个系统,可以同时处理10个请求,每个请求的平均处理时间是1s,那么系统的能力就是每秒10个请求,或者说在1秒内系统内部存留的请求数目是10个,如果请求发送的速度少于10个/s,那么系统内的请求数目不会超过10个,如果请求发送速度超过10个/s,那么系统内的存留请求数目就开始累积,从而增长单个请求的平均处理时间,并且这些累积的请求会占有内存,这都是过载的后果。如果过载持续那么系统的响应时间和内存都会恶化。

如何提升系统能力么?要么就是增加系统可以并行处理的请求个数,要么就是缩短每个请求的处理时间。根据实际的应用,还可以估算出系统理想情况下的能力边界,如果实际测试发现没能达到的话,就表明存在优化空间。

增加可以并行处理的个数可以通过增加CPU来达成,缩短单个请求的处理时间则需要把处理能力用在刀刃上,比如:优化算法,避免不必要的阻塞,避免或者减少共享资源的争用等等。

如果过载了,怎么办呢?对于过载,有两类主要的处理策略,一种是反压(Backpressure),一种是泄洪(Load-shedding 卸载)。反压就是接收者明确告知发送者不要发送请求了,这里有一个限制,就是接收者是能够控制发送者的;泄洪就是无法控制发送者的情况下,过载时就丢弃请求,不处理。

接下来,文章举了一个“The naive and fast system”例子,这是一个比较简陋的系统,以该系统为例,对过载处理相关的概念做详细的阐述。在该系统中,所有任务都是异步处理的,先是输入控制器,然后做三个变换(a,b,c),然后经过输出控制器输出。大家可以简单将图中的每个方框都看成是一个进程。它们之间都是通过消息的方式来通信的。

图1

因为消息都是异步的,如何判断系统是否正常工作呢?只能通过观察系统的变化,例如b相关的数据变化确实发生了,才知道b变换是正常工作的;或者通过在每个阶段log来说明系统正常。除此外,系统是没有任何反馈的。当然了,我们可以在系统中添加少许同步特性,让input handler去观察output handler,如果output handler发送消息后给input handler一个通知,那么input handler就知道一个消息处理完了,就可以去处理下一个消息了。但是三个transformer a、b、c仍然可以保持异步,没有问题。在系统设计中,如果想让请求处理同步化,那么可以在output handler和input handler之间添加一个通知。

这个系统之所以被称之为“naive and fast”,是因为它太理想了,在现实中基本不存在。可以看到请求的处理流程之间完全没有关系,所以,我们很容易将一路处理变成多路,达到可伸缩的效果,在多核情况下,能够充分地利用多核的处理能力。例如,4个核,就变成4路;8个核,就变成8路。变成多核后,系统的处理能力仍然能够利用little‘s law来预测,八个核就是同时可以处理8个,再乘上每个请求的处理时间就是系统的最大能力。因为这些请求的处理都是完全独立的。

图2

但现实中,情况都是错综复杂的。请求处理往往都是相关的,有依赖的,而且依赖关系有时还是隐式的。下图中,1、2、4这三条处理线共享一个transform b,情况就变得复杂了。1、2、4这三条并行处理线中,transform b就是个瓶颈,因为它无法并行,就会成为系统的热点。发现这个热点,是非常有助于我们去解决系统的性能问题的。而且热点往往会决定整个系统的能力。

图3

此时,一个请求在系统中的请求处理时间,以及请求在系统中的逗留时间,都是由transform b决定的。而且,可能系统的输入请求越多,它的处理可能就越慢,所以逗留时间累积得就越长。来不及处理的请求只能被放进transform b的队列,直到耗尽系统的内存,整个虚拟机crash。 所以,很多人就抱怨能不能为进程队列设置一个最大值,这样只有transform b进程死,vm不会死,其它进程也不会死了。但实际上,max_heap_size只是一个工具,如果对问题没有很好的认识,还是可能会用错,在某种程度上掩盖问题,例如我们的例子中,不能只是让b进程死,而是要认识到b是一个热点,这是一个设计上的问题,此时更应该去考虑是如何让b并行化,这是从根本上解决问题的方法,而不是仅仅设置一个门限,让b死了再重启。重启之后可能会暂时缓解,但最终还会出问题。

Part 2 of Handling Overload

关于过载的流控策略,我们先来谈谈反压。有些反压是我们使用的工具所内在的,不可避免的。反压其实就是阻塞发送者,控制发送的流量。这个通常是和我们直觉认识相吻合的:即便处理得晚一些也要比不处理扔掉的好。但其实这种认识在某些应用场景下是不正确的。有时,处理晚了对特定业务而言就是错误的,此时把请求放进队列中等待处理,再去占用资源就是没有意义的。但如果业务的性质就是:即便处理晚些,也最好要处理。那么将请求放进等待队列就比扔掉要好。

反压无处不在,在我们所使用的大部分编程语言中,它其实是一种缺省的机制。因为绝大多数情况下,我们调用的函数或者方法,固有就是同步的。你只能等待方法返回,才能执行下一步。另外就是争用,比如说,有个共享资源是通过mutex保护的,那么对这个共享资源的存取其实就是同步阻塞的,如果这个处理不好,就会出现不必要的争用、阻塞情况,那么整个系统表现出来的指征就是它变慢了,CPU占有率不高,但是处理性能低下。大家应该都做过电信产品,应该对此比较熟悉了。

这种情况并非只针对于其他语言,对于Erlang语言也是如此。虽然我们都知道Erlang语言是基于进程并发,进程间完全是异步消息通信的。但是在Erlang的每个单一进程中,函数的调用机制和其他语言是完全一样的,也是同步、一步一步顺序执行的。在单进程中由于内在的同步反压,是无需外在调节的。如果这些进程之间没有什么关系,那么对于要有多少并发执行的进程来处理请求就是由我们的设计或物理的限制决定了。

如果说一个系统中,同步调用都存在单一进程中,进程之间完全独立,就是一个非常理想的系统,而且这个系统的处理能力也很好估算。

图4

假设服务器能够同时处理500个并发的请求,一旦超过500,就不再接受请求即可。在做系统容量规划时,我们就可以将服务器配置成只能处理500个请求,对这种理想系统来说,请求处理上限比较容易得到。但在现实中,无论是web server,socketserver,还是其他种类的server,很少有服务器会愿意为自己的系统施加这样的限制,即便它们可以这么做。原因很简单:我们都喜欢听到系统可以线性伸缩的说法,而不太喜欢出于稳定、可靠的考虑而采用的一种限定并发体数目的做法。不过,采用简明的方法达成系统的稳定可靠性,往往是更好的做法。

对这种完美并行的系统进行容量规划非常简单:需要对它做性能测试,直到碰到一个转折点(也就是说,此时系统性能在下降了),然后把达到转折点之前的那个并发体数量作为服务器的物理限制,可以把这种服务器看作一个并发池,如果刚才转折点之前的数字是500,那么池中就包含500个并发的处理进程,如果同时处理的请求个数超过500,池中的进程都处于忙碌状态,就不再继续接受请求了。

这样,就可以通过增加新的服务器来实现水平伸缩。不过这种方法对系统全局层面来说不太适用,因为很少有整个系统会是这样的完美并行系统。但如果系统中的某个组件符合这样的性质,那就可以用这样的处理方式;或者可以作为某些优化的一部分(例如系统中的并发连接池、数据库连接池)。

对于并发池的实现,Erlang中提供了很多现成的工具和库。文中列举出了:poolboy、pooler、gproc等。大家有兴趣可以去看看。其他语言或者平台中也会有并发池的概念,并发池本身不是一个新概念,应该算是应用得比较多的一种模式。记住一点,它主要应对完美并行的情况。

文中特别指出,他所列出的工具都是一些通用的并发处理池,并非只能做一些特定的任务,像是数据库连接池,或者接收网络请求处理池等,这些是都专用的处理池。而文中提供的这些通用的并发处理池,是可以辅助你结合实际业务开发出特定处理池的。

这一小节“不可避免的反压(The unavoidable backpressure)”,核心思想概括起来就是,如果系统是个完美并行的系统,那么我们可以有多个独立的、没有关系的并发;而在一个并发体中,天然就是反压的,所以整个系统的容量是非常容易估算的,可以用并发池的方案来限制和实现。

但在现实的系统中,或者对规模较大的系统来说,完美并行可能不存在,很多情况下,不能将系统泾渭分明地分割成完全无关的并发体。例如下图:

图5

我们以Erlang为例来阐述,在Erlang中,一旦引入多个并发的进程,异步就是一个缺省的通信方式。在上图中,1、2、3、4标号可以看做是对进程的标识。进程3负责处理transform b的变换,1、2、4进程都会给进程3发消息,所以进程3就存在潜在的风险,如果其他进程给它发送的消息过多,会将它的mailbox(邮箱)撑爆。

那这个问题如何解决呢?最容易想到的解决方法就是反压,我们将所有给进程3发消息的调用变成同步的。这就意味着,进程1、2、4给进程3发的消息要得到确认。1、2、4中的任何一个进程在没有得到确认的情况下,都不能继续发送下一个消息。这种简单的通过确认同步的反压称为“The naive backpressure”,也就是比较原始、基本的意思。

显而易见,这样的做法使得进程3不会过载了(邮箱不被撑爆了),但整个系统的并发度却下降了。从外部观察系统,会发现整个系统变慢了。我们在做系统设计时,往往会忽视这些隐式的限制。

接下来这段比较重要,作者提到了一个我们通常会采用,并且认为比较好的问题分解方法——那就是按照请求的处理步骤进行划分。我们可以把上图实例化成一个具体的例子:

  1. 接收一个某个特定协议的请求,HTTP、TCP、UDP等协议都可以;
  2. 解析这个请求,包括一些鉴权、认证之类的操作;
  3. 将请求发送给某个工作者进程去做业务逻辑处理,或者向一个工作者进程去请求一个需要在处理过程中使用的资源;当然这些工作者进程也可能会去调用其他的子系统,而这些子系统本身具备自己的反压或者泄洪的机制;
  4. 处理完毕之后,将响应格式化,并且将响应发送回去;
  5. 最后记录日志,做一些度量统计。

这是一种我们普遍使用的分解问题的方式,这种分解方式既正确又合理,同时也是我们期望的(将整个处理流被分解成步骤后,会很清晰)。问题在于这种分解方式会使得运行时,处理流和它的源头也进行了分离。换句话说,处理请求的进程和接收外部请求的进程也被分开了,而这个分离是会造成问题的,是我们不希望的。

基于上面的认识,问题就比较容易看清楚了:我们对那些做数据处理、或者说和数据库交互的工作进程施加的限制,和对那些接收并且解析请求的进程的限制之间是脱节的。这个脱节会造成很多问题。

图6

如上图,尽管这种情况下,接收和解析请求的进程,以及工作进程之间是有反压的(可以加消息同步反压),但是持续到来的外部请求,产生了很多新的接收和解析请求的进程,它们会源源不断地给工作者进程发消息,虽然说每个接收解析请求进程和工作者进程之间是反压的,但是接收解析请求很多的情况下,工作者进程的mailbox中也会不断地累积很多请求,最终导致系统崩溃。

因此,我们还是需要去限制同时能够接收的并发请求的个数,从而避免工作者进程的mailbox由于不加限制的接收解析请求进程而导致的溢出。

你可能会说,我们可以增加工作者进程来分担压力呀。但是,因为这种依靠消息同步的反压是一种隐式的对资源施加的限制。当工作者进程增加时,这种做法会变得越来越困难。作者用了一个比较有趣的例子来说明这个问题(其实这个作者的行文风格一向比较“别致”,而且很多问题的真正理解都需要较强的背景和基础知识)。这个例子的理解也需要建立起清晰的逻辑上下文关系。

这个例子说:现实中,请求的处理成本是千差万别的,有些请求处理起来很快,有些请求处理起来很慢。90%的请求可能1ms就处理完了,5%的请求可能需要50ms,而另外5%的请求则需要250ms。大家想想,对这种处理成本不平衡的请求交替来袭,我们该怎么办呢?你可以有两种做法,一种悲观一些,虽然统计意义上,只有5%的请求需要耗时250ms,但仍然要去考虑最差场景,就是某个时间点上,突然来了一批都是耗时需要250ms的请求,该怎么办?对悲观策略来说,肯定就是阻止更多的请求进来。也就是说,对悲观策略说来说,是按照最差的场景来设定截流的。因为大部分情况下,都不是最坏的场景,所以,悲观策略使用的截流限制就比实际需要的更加严苛。而另一种做法,乐观一些,去更多地接收请求,但是如果出现最差情况,系统就面临过载的风向,不太安全。但一般情况下,都会选择这种不太安全,但乐观一些的方法。

这个在绝大部分的并发语言中,特别是依赖于消息同步反压来解决问题的并发语言中,是个现实的情况。尤其对那种抢先式调度的并发语言(例如,Erlang)来说,更是如此。因为一个进程不可能通过将CPU全部占用的方式(即便是无限循环做计算来占用CPU)来阻止其他进程的执行。例如体现在上述例子中,就是无论是你有意为之,还是无意之举,工作者进程都不能通过完全占用CPU,从而阻止更多的接收解析请求进程去接收外部消息。

在Erlang中,要达成这种简单的消息同步反压,可以使用OTP库中以gen开头的behaviour,它中间为所有想要的交互都提供了同步调用。

学员发言: 以前所做的产品“处理请求的进程”和“接收请求的进程”就是相互独立的,进程的消息队列大小固定,如果队列满了,后面的请求就会被丢弃。

孙鸣老师答复: 丢弃请求的做法一般适用于系统边界,系统内部要定位并消除瓶颈,达到平衡,并在关键点上进行到系统边界的反压。

Part 3 of Handling Overload

基于“拉”的流控

上一小节中的流控是在发送者“推”的情况下进行的,接收者只有针对单个发送者的控制权。如果发送者多时,照样会让接收进程的邮箱爆满。本小节中讲的是一种基于“拉”的流控策略,此时接收者的控制权会高很多。不仅可以选择何时接收请求,还可以选择发送者。这样就可以避免多个发送者发送时造成的过载情况。

拉的示意图如下:

图7

不过“拉”的方式有个缺点:就是不管有没有请求,都要去询问发送者,会有些许浪费,如果想减少浪费拉长询问时间间隔的话,又会增加请求的响应时间。所以,具体的策略还是要根据应用的实际情况来定。

有一点要注意,本节以及上节讲的流量策略要想生效,有一个前提:就是生产者的数目受限,并且生产者能够控制请求的产生。否则的话,大家想想,生产者自己就会过载。

像磁盘文件或者从Kafka数据流中读取数据处理就是生产者能控制的例子。

如果生产者自己无法控制请求的产生,那么往往生产者自身就是一个被push的消费者,我们可以再追溯它的生产者,直至我们系统边界。像下图:

图8

我们把OS内核中的TCP协议栈也考虑进来,那么它显然是一个不受我们控制的生产者。

其实在TCP协议栈自身也有对应的过载处理方法。由于TCP客户端侧同样无法控制,因此服务器的协议栈采取的措施以泄洪为主,要么拒绝,要么丢弃。

文中提到出现这种情况会造成的后果:这种发生在TCP协议栈(内核空间)中的泄载无法被用户空间中的度量计数系统(性能统计)获取。系统的使用者会发现系统很慢,总是在连接中,但是系统的度量日志中也没啥异常。大家都很困惑。

因此,在构建系统时,要定义出系统的边界,要把边界外的一些隐式的队列纳进考虑范围,要把它们的度量数据和系统内部的度量数据一起显式化出来。会避免很多抓狂的情况。

在Erlang有两个比较有名的基于拉的流控工具实现:erl_streams和goldrush。

有界队列

前面的方案中,在请求过载时,来不及处理的请求都隐式地存储在进程的邮箱中,虽然Erlang提供了一些操控邮箱的方法,但是有不少限制,也不够灵活。一种简单的方法是把这些请求先一股脑儿地从邮箱中搬出来存到用户自定义的一个队列中,让这些请求都“显式化”出来,然后再看如何处理它们。当然,用户自定义队列也不可能是无限大的,应该有个限制,所以称为有界队列。

如果请求个数超出了队列的界限,就丢弃掉。

可以看出有界队列和前面小节中的同步方式有个重要的区别:有界队列在对付过载时采用的是快速失败(fail-fast)的策略。采用同步通信时,请求都是放在接收者的邮箱中,没有处理前,发送者被阻塞住。此时想把某些请求从中去除,不太容易做到,这也是为啥会导致邮箱爆满的原因。

如果采用有界队列,由于这个队列是用户自定义的,因此比较容易进行操控。比如,可以比较容易地知道队列满了,于是就可以决定丢弃请求或者给发送者返回一个指示,说明现在忙,让请求者稍后再试等等。入队列操作本身也是同步的,不过当队列满时,可以立即失败。

下图是使用了自定义的有界队列的情况:

图9

可以看出,原来隐式的进程邮箱,现在被显式化了。

如何实现这个有界队列呢?如果使用Erlang语言,很容易想到的就是用进程来实现。不过,要格外小心,因为这个充当有界队列的进程也有自己的邮箱,也存在出现前面所说的各种问题的可能,也就是说它会成为瓶颈。

因此在设计时要花点心思,既要让队列实现中的单个进程有高的处理性能,又得保证这个队列实现能够有好的伸缩性(多核,分布式)等。常用的方法是通过一种公平的hash算法,让多个进程分担队列的负载。在实现时有个问题要格外注意:一旦请求会hash到不同的队列进程上,如何保证多个请求间的顺序关系(如果业务有要求的话)。具体策略因应用不同而不同。

如果不通过进程来实现,还有一种性能更高的方法,就是使用ETS表。ETS表提供了一些非常高效的并发更新一个计数值的方法,并保证原子性。那么我们就可以用ETS表作为一个看门人,把队列的上限放到ETS表中用计数值实现,每个计数值对应一个队列。发送者进程在把消息放到队列前先更新这个计数值(具体方法是ets:update_counter),如果超过上限就拒绝。只有接收者进程可以在处理完一个请求后减少这个计数值。这种方法可以实现极为高性能的有界队列和消息分发机制,也避免了很多不必要的消息拷贝环节。

由于对于大多数情况,Erlang的进程邮箱就足够用了,因此基于Erlang实现的有界队列库并不多。pobox和sbroker是两个比较有名的。

hash算法方面可以直接使用erlang:phash2/1-2。chash是Riak中使用的一致性hash算法。

用计数器来保护进程队列

其实,我们可以结合Erlang进程都具备自己的邮箱这一点,把上面的有界队列做点简化,尤其是如果我们仅仅只想限制队列中的消息个数时。我们可以把这个计数值附着在进程本身的邮箱上,而无需再实现一个单独的队列。发送者进程在发送消息到接收者进程的邮箱中时,先检查这个计数值即可。如下图:

图10

这种方法只是对Erlang进程邮箱做点增强,无需在架构层面引入一个独立的消息有界队列。

导读到现在,概括下来有如下几个要点:

  1. 警惕所有的没有消息反馈的异步通信点
  2. 警惕发送者进程没有数量限制的情况
  3. 尽量在设计时做到消息处理流清晰独立,采取一致的流控机制。

否则的话,会造成系统理解层面的困难。

Part 4 of Handling Overload

前面的小节中介绍了一些基本的概念、技术和方法,后面的内容则讲解了在系统层面应用这些技术工具的一些思考。其实,本文中讲解的概念、技术和方法对于我们电信领域的开发者而言,根本就不陌生。

这些问题及其解决方法在电信领域已经有了很好的认识、理解,其对应的解决方案,绝大部分也都标准化并沉淀到对应的协议中。

遗憾的是,我们现在把精力过多地放在去追逐其他领域那些可能随时会凋零的“热门”技术上,而忽略了自己领域中这些真正的瑰宝。

比如,对于电信网络系统来说,保证其健康工作的机制其实很简单:

  1. 依靠带外信令或者消息来检测通信是否正常(例如:ICMP消息)
  2. 通过对带内消息的监测,评估服务质量
  3. 根据1、2来对消息的发送、调度进行调节。

有一点非常重要,在对带内消息进行监测时,要严格遵循End to End的原则,千万不要在局部的环节花大力气。(这个原则非常重要,对于大型、复杂分布式系统的设计有极为重要的指导意义。这个原则的起源论文为:END-TO-END ARGUMENTS IN SYSTEM DESIGN, http://web.mit.edu/Saltzer/www/publications/endtoend/endtoend.pdf ,强烈推荐精读)。可以在系统层面的处理流的两端插入检测点,就可以容易地了解整个系统的健康状况。

现在越来越多的互联网系统开始考虑从电信领域借鉴成熟的系统设计方案,其中最有趣、最为核心的一点就是:检测服务质量,据此调节系统的行为。

在应用这个思想时要切记根据实际情况,一般来讲,互联网系统中的请求差异较大(处理花费时间迥异)。因此在检测时,度量请求的进入和离开的总时间意义不大。更为一般的方法通常会在处理流中间放置一个队列,然后在该队列的两端进行检测。

图11

检测要做的事情就是计算某个任务在队列中的停留时间。这样,就可以获取到请求即时的等待时间,这个等待时间是和系统的能力相关的。

基于这个检测,就可以对等待时间不断增加的队列进行相应的配置调节,一般来讲,等待时间越长,系统越过载,就要加上更多的限制:

  1. 如果希望新到来的请求总延时低,那么就可以把队列配置成栈的形式(后进先出)。
  2. 可以设置一个总的等待上限,超过上限即丢弃(泄载)。
  3. 等待时间过长,则拒绝入队(反压)。
  4. 随机丢弃请求,且随着等待时间的增长,丢弃的数量越多(泄载)。
  5. 在入队前随机拒绝请求(反压)。
  6. 采用CoDel算法(https://en.wikipedia.org/wiki/CoDel)。

需要注意的是,在泄载丢弃请求时,要考虑到应用的上下文,也就是这些请求是否准许丢弃,并据此设计对应对策。比如客户端最终会重试失败的请求等。如果应用的情况比较复杂,则可以考虑参考一些成熟的协议、算法:RED、MRED、ARED、RRED等等。

Erlang中有些库实现了上述机制:sborker、safetyvalve、jobs等。

在进行检测时,可以有多种度量类型,有些和具体业务无关,比如CPU、内存、网络缓冲区。有些则和具体的应用有关:特定API的失败率,应用资源池使用率等。 值得一提的是,jobs这个框架提供了一种系统级的检测和调节的方案。无需手工添加一系列不同类型的检测器,jobs可以为队列装配上多样的度量检测。

图12

jobs框架可以获取检测采样,根据采样类型的权重进行速率控制。一般来讲,jobs会被应用在系统的边缘,再结合系统内部的关键点上应用circuit breaker型控制手段,可以对系统进行全方位的保护。

用Erlang编写的circuit breaker库有:circuit_breaker、fuse、breaky等。

总的来讲,过载处理有很多方法,过载的种类也有很多。要解决好过载问题,首先要仔细测量你的系统,并通过更改设计让系统变得可理解,然后寻找最好的解决方法。

过载设计全部内容导读结束,祝阅读愉快。

⚠️ **GitHub.com Fallback** ⚠️