流量治理

  • 容错策略
  • 容错具体操作
  • 流量控制的统计指标
  • 限流设计方法
  • 分布式系统中的限流手段

容错处理

容错处理是微服务架构中的一个重要原则,因为如果不处理容错,该错误可能会传导到系统的各处,导致整个系统都不可用,发生错误雪崩是一个应该极力避免的事情。

容错策略

除了时刻意识到错误的出现,还得掌握一定的容错策略:

  • 1 故障转移,高可用系统中,一个服务往往会部署多个副本节点,为了避免故障,不同的副本节点往往部署在不同的交换机,甚至不同的区域,不同的数据中心。故障转移就是,当请求的服务出现故障时,不会立刻返回错误,而是将请求转发到其他节点,从而实现服务的高可用。故障转移应该设置为有限的请求次数,因为一旦无限制的故障重试就可能造成整个调用链路的各个阶段的失败,就跟你买的火车票连续转车一样,第一站如果错过了,后续的所有转车都会失败。

  • 2 快速失败,对于不能进行故障转移的,比如并没有配置幂等性的服务,如果你重复的进行调用就会产生脏数据,所以直接选择快速失败即可,比如银行付款,绝对不能选择重试,对于付款失败的行为,立刻马上返回错误!!

  • 3 安全失败,当系统的某些非重要组件失败时,系统自动忽略,即便是失败也返回正常的相应,不过返回的值是一个设定的空值,这是因为某些组件并不会产生传播效应,它失败了并没有对系统本身产生影响,例如说日志系统,例如读取数据库的内容,但是不会对数据库本身,以及整个业务产生任何的影响,仅仅把读取的数据用作统计结果,诸如此类的服务都可以使用安全失败的方法。

  • 4 沉默失败,当某个请求失败时,就默认该组件失效,短时间内不再向它发起重试,比如大量的请求需要等到超时才宣布失败,就会对系统操作例如内存泄露,服务器宕机等后果,所以这个策略就是当某个请求失败时,直接默认它死了,不要再堆积同样的访问导致整个系统大受影响,这也是一种隔绝错误的方法

  • 5 故障恢复一般跟快速失败结合一起用,当某个访问错误时,立刻快速失败,但是把该失败操作放入一个消息队列中由系统异步开始故障恢复的重启操作。服务必须具有幂等性,由于它的重试是后台异步进行,即使最后调用成功了,原来的请求也早已经响应完毕,所以故障恢复策略一般用于实时性要求不高的主路,或者处理那些不需要返回值的旁路。为了避免内存溢出,故障恢复次数要有限制。

  • 6 并行调用,下面这两个跟上面的错误处理机制不同,这是两种正常操作的为了最大效率的策略机制,一开始就对于同一个服务进行多次并行调用,哪个成功了就直接返回成功的结果,其它的访问立刻停止,

  • 7 广播调用,这个跟并发调用很像,也是多个操作对一个服务进行请求,不过不同的是,并行调用是一个操作成功就宣布成功,而广播调用是要把结果广播给全部的并行操作,这个跟 go 语言并发库 singleflight 原理一致,多个请求只要有一个请求成功,那么就返回成功,并且把结果广播给全部的请求。

容错策略 优点 缺点 使用场景
故障转移 系统自动处理,调用者对于失败不可见 增加调用时间,额外的系统开销 要求必须幂等,以及对于调用时间不敏感的服务
快速失败 调用者有对失败的处理完全控制权,不依赖服务的幂等性 调用者必须正确处理失败逻辑,如果一味只是对外抛异常,容易引起服务雪崩 调用非幂等的服务或者超时阈值较低的场景
安全失败 不影响主路逻辑 只适用于旁路调用 调用链中的旁路服务
沉默失败 控制错误不影响全局 出错的地方将在一段时间内不可用 频繁超时的服务
故障恢复 调用失败后自动重试,也不影响主路逻辑 重试任务可能产生堆积,重试仍然可能失败 调用链中的旁路服务对实时性要求不高的主路逻辑也可以使用
并行调用 尽可能在最短时间内获得最高的成功率 额外消耗机器资源,大部分调用可能都是无用功 资源充足且对失败容忍度低的场景
广播调用 支持同时对批量的服务提供者发起调用 资源消耗大,失败概率高 只适用于批量操作的场景

容错设计模式

断路器模式

通过代理,也就是断路器来一对一的接管服务调用者的远程请求,一个远程服务对应一个断路器的对象,断路器会持续监控并且统计服务返回的成功,失败,超时,拒绝等结果。

当故障次数达到断路器的阈值时,它的状态就自动变成 “OPEN” (也就是断路器打开状态,打开就证明断掉了,不能再访问远程服务了),后续次断路器代理的远程访问都将直接返回调用失败的结果,看出来了把,这就是快速失败策略

说白了,断路器就是一个代理,它本身就是一个有限状态机,比如它可以有 OPEN,HALF-OPEN,CLOSED 等状态。有没有发现其实跟 sidecar 一样也是一个代理,通过这种代理的方法去处理,可以在代理身份放置多种处理手段,还不用干涉核心的代码逻辑。

有限:在这里指的是状态的数量是可数的、有边界的;

状态机:它就是通过定义一系列的状态以及状态之间转换的规则,来模拟某个对象或者系统的运行过程

  • CLOSED:正常状态,可以正常访问服务。

  • HALF-OPEN:这是一种中间状态。断路器必须带有自动的故障恢复能力,当进入 OPEN 状态一段时间以后,将自动 (一般是由下一次请求而不是计时器触发的) 切换到 HALF OPEN 状态。该状态下,会放行一次远程调用,然后根据这次调用的结果成功与否,转换为 CLOSED 或者 OPEN 状态,以实现断路器的弹性恢复。

  • OPEN:打开状态,不能访问服务,直接实现快速失败的策略

从 CLOSED 状态转化为 OPEN 状态的基本条件:

  • 一段时间内,比如10秒,请求数量达到阈值
  • 一段时间内,请求的故障达到阈值

断路器模式其实就是一种实现了微服务中的服务熔断操作,自动实现服务熔断,实现快速失败的容错策略,目前为止都是服务熔断的操作,那么如果当这个错误上报给系统之后,上游的服务主动的处理调用失败的后果,而不是让故障扩散,这里的处理就是服务降级,比如,我们的降级操作是把这个服务记录下来,等待之后重新处理,这个过程就是降级了。

所以服务先熔断,然后再降级处理。

下面我们描述一个场景,介绍一下各种用法:

你女朋友有事想召唤你,打你手机没人接,响了几声立刻气冲冲地挂断后 (快速失败),又给你微信视频,QQ 视频 (故障转移),都还是没能找到你 (重试超过阈值),这个时候她决定不找你了 (断路器直接默认找不到你,沉默失败,服务熔断),她生气地在微信上给你留言 “三分钟不回电话就分手” (服务降级),以此来与你取得联系

舱壁隔离模式

舱壁隔离模式是常见的实服务隔离的方法。

为了不让某一个远程局部失败变成全局的影响,就必须设置止损方案,即便是某个服务发生错误,隔离它即可,避免它对于其他服务的影响。

常见的错误分为几种,比如失败,400 500,拒绝 401 403,超时 408 504 等,其中超时错误对于微服务架构系统影响最大,极易引起全局性的风险

服务隔离的可行办法就是单独设置,就跟一个大船有很多舱室,每个舱室坏了并不影响其他的同行,举个例子,一共 500 个线程存在于一个线程池中,如果这个线程池的线程去访问一个服务并且超时了,那么剩下的 499 个线程都会被卡住,但是我们可以将这个线程池设置为 5 个,剩下的 495 个按照规律分为一个一个小的线程池,互不影响,有点像降低了锁的颗粒度那种感觉。

但是设置多个线程池,会增加系统的资源开销,所以我们其实并不需要真的设置多个局部线程池,完全可以使用信号量的方式,比如给服务设置一个信号量,每次被访问就增加 1,每次有返回值后就减去 1,如果访问的数量大于设置的信号量了,那么就自动拒绝被访问,这样其他的线程就可以去做真正的事情,而不用在这里等待了。

在宏观的场景也可以使用舱壁隔离机制,比如根据用户等级,用户的区域,用户的角色去划分区域,假如独立的服务集群就服务角色为 VIP 的人,其他普通用户使用另外一个单独的服务集群,如果这个普通用户的服务集群挂了,那么 VIP 用户是不受影响的。

常见的实现服务隔离的位置有

  • 服务层面 (聚焦于一个个具体的微服务个体,微观):sidecar 上,或者服务调用端
  • 系统层面 (从整体的角度去看,宏观):网关,DNS

重试模式

故障转移和故障恢复策略需要对服务进行重复调用,可以是同步的重试也可以是异步的重试,可能是重复调用同一个服务,也可以调用服务的其它副本。

重试模式主要解决系统中的瞬时故障,比如因为网络中断而未完成的网络调用,服务的临时过载等,这些错误都是可自愈的问题。

我们判断一个服务是否应该对于另外一个服务重试,主要是需要***同时***满足以下条件:

  • 仅仅在主路逻辑上的关键服务进行同步的重试,非关键服务一般不选择重试,而且不应该选择同步重试
  • 仅对瞬时故障进行重试,比如 404 401 你就不用去重试了,他们不属于瞬时故障
  • 仅仅对具有幂等性的服务进行重试,一般 POST 请求是非幂等性的,GET HEAD OPTION TRACE 因为不会改变资源的状态,所以应该设计为幂等的。
  • 重试必须要有明确的终止条件,比如
    • 超时终止
    • 次数终止

重试机制可以开启的位置:

  • 客户端自动重试
  • 网关自动重试
  • 负载均衡自动重试

不过,如果在多个位置都开启了重试,那么其实会对于系统造成很大的压力,如果我们设计了重试机制,阈值可以稍微设置低一些。

在进行服务治理的时候我们可以使用配置单去配置一定的重试阈值,配置参数等等,但是我们都知道微服务的分布式架构中,有一项非常关键的特性就是自动扩容,当服务突然增大的时候,整个微服务发生了自动扩容,那么这些配置肯定就变成了不合理的状态,所以在服务治理的时候,这些配置单就不应该设置为静态的配置,最好能根据整个流量的不同部位,不同状态自动生成动态的配置内容

流量控制

微服务的分布式架构中,即便是没有遇到雪蹦式的错误,面临超过预期的突发请求时,大部分请求直至超时都无法完成处理,所以我们就需要流量控制,即对请求进行限流,防止请求过多,系统崩溃。

我们要思考三件事情

  1. 限流指标的依据是什么?换言之,你按照什么条件去选择限制多少量的流?
  2. 限流的方法是什么?策略是什么?如何设计这个算法
  3. 超额流量如何处理?直接抛弃还是另有他法?进入服务降级还是直接返回失败?

流量统计指标

  • TPS (Transaction Per Second),每秒事务量,你可以把事务理解为原子性的一个操作逻辑
  • HPS (Hits Per Second),每秒请求数量,每秒钟从客户端发往服务器的请求数量,这里的 Hits 就是 requests 的数量,一般如果是简单的业务,HPS 基本上等于 TPS,但是有些操作并不是一次请求,比如支付过程,那么你的一次 TPS 支付操作可能要经过扫码,支付,查询等过程,所以 HPS 会比 TPS 多。
  • QPS (Queries Per Second),每秒查询量,QPS 在单体服务总跟 HPS 等价,但是如果是分布式集群中,一次 HPS 就要有很多个 QPS 的查询过程,毕竟内部的服务要调用内部的其它服务。

通常我们很难基于 TPS 作为指标进行限流,虽然一个 TPS 是一次原子性的真实操作,理论上它才是衡量的唯一标准,但是,系统中间的每一个过程并不是像测试的时候一样那么顺滑,很有可能用户的一次操作他卡在某个环境很长时间,这个时候我们如果使用 TPS 去作为衡量标准,那么卡的很长一段时间压根就没有计算这个数值不是吗,但是实际上这个过程中系统已经承受了压力,所以通常我们使用 HPS 这个值来作为衡量标准。

限流算法设计

  • 流量计数器
  • 滑动窗口
  • 漏桶
  • 令牌桶

流量计数器,设置一个计数器,比如 100,一秒钟中计算一次数值,如果超越 100 就达到限流标准,如果没有就放行,但是其实它很容易有 bug,举个例子,在一秒钟的最后 0.1秒的时候进入了 90 个访问,在下一秒的 0.1秒中进入了 90 个访问,完全不会触发限流对吧,但是你想想,0.2秒的时候进入了 180 个访问,这早就超越了最大访问设置了,或者10秒钟的时间内,前三秒每秒 120 个访问,后7秒 80 个访问,实际上按照10秒钟来说,前三秒的 120 算在10秒钟 1000 个里面根本没有超过限制,这就会误杀一些访问,这就是这种最简单的计数器的问题,所以这种方法是最粗糙的,因为它不能计算时间段只能计算时间点,所以滑动窗口来了,刚好是解决这个问题的。

滑动窗口,这个算法也是很经典的算法,比如 TCP 的流量控制用的就是这个,一般来说分布式系统中的统计任务都会使用滑动窗口来做。在不断向前流淌的时间轴上,漂浮着一个固定大小的窗口,窗口与时间一起平滑地向前滚动。任何时刻静态地通过窗口内观察到的信息,都等价于一段长度与窗口大小相等、动态流动中时间片段的信息。由于窗口观察的目标都是时间轴,所以它被形象地称为 “滑动时间窗模式”

当频率固定每秒一次的定时器被唤醒时,它应该完成以下几项工作,这就是滑动窗口的基本运行机制:

  1. 将数组 (假设这个数组就是这个滑动的窗口) 最后一位元素丢弃掉,并且把所有元素都后移一位,然后在数组的前面添加一个新的空元素,这个过程就是滑动的过程
  2. 计数器中的所有统计信息写入到第一位的空元素
  3. 对数组中的所有元素进行统计,复位清空计数器供下一个统计周期使用。

不过滑动窗口只用在否决限流,就是你超过了阈值就拒绝访问了,无法做到阻塞等待处理这个机制,漏桶和令牌桶可以做到。

漏桶,就如同一个水池一边装水,一边泄水,如果泄水的太慢了,水池的水就会溢出来,实现漏桶的方法,一个 FIFO 的队列,队列长度就是水池的大小,如果队列满了,那么就可以拒绝请求,流出率一般是一个固定的值。

令牌桶,这个很像去银行排队取票办理业务,如果取票机没有票了,那么你就无法办理业务了,预留在桶里的令牌数字就是最大的缓存容量,我们使用一个流速为 100 个/s 的速度往令牌桶中注入令牌,每个令牌注入的间隔是 1/100秒,桶里最多存放 N 个令牌,如果桶里满了,那么 n+1 个令牌就会被丢弃,一个请求就会从桶里取走一个令牌,如果没有令牌了,那么就进入服务降级的模式,要么阻塞要么直接拒绝。

实际上我们实现这个令牌桶的时候并不会真的按照速度和间隔去放置令牌,我们完全可以设置一个时间戳,用下一次的时间戳减去上一次的时间戳,来计算出要放入的令牌桶的总个数,一次性放进去就行了。比如我们设置每秒 100 个,那么 0.2秒的间隔时,我们就可以直接放进去 20 个令牌,而不用按照时间间隔来放置令牌,这样效率更高。而且也可以直接忽略放入令牌的过程,一般来说这个过程可以忽略不计

如果我们要使用 go 来实现令牌桶,那么我们可以使用 channel 来实现令牌桶,因为 go 的 channel 是线程安全的,我们可以使用一个 channel 来模拟令牌桶,然后使用一个 goroutine 来按照时间戳,计算数量并放入令牌,这样就可以做到定时放入令牌,并且是线程安全的。

分布式限流算法

上面讨论的限流算法用在单体服务中是完全可以的,但是用在微服务这种分布式架构中,一般只能用在人口,比如 API 网关,但是它无法精细的控制整个集群中各个服务之间的流量控制。

一种常见的设计方法是这样的,使用 Redis 存储所有的统计结果,通过分布式锁,信号量等手段,解决这些数据的读写访问并发控制问题,原本的单机限流方法也能用在分布式的环境中,但是这样会大大的增加网络访问,毕竟分布式系统并不是同一个内存空间,你访问 redis 的过程不就是一次网络访问吗?如果流量较大的时候,这个限流器的网络开销将非常的大。

我们可以改造令牌桶让它适合分布式的环境,比如把令牌改成货币,如果是 VIP 客户那么就领取的多一些,一般用户就少一些,一次请求分到的货币会在系统集群的各个区域进行花销,访问每一个服务的时候都要消耗一定的货币,如果可以规定,当某次请求,领取的额度减去花销的,一旦剩余的额度小于等于 0,那么就不允许它访问其它服务了,就需要再次发起一次网络请求。