微服务限流

在高并发访问下,系统所依赖的服务的稳定性对系统的影响非常大,依赖有很多不可控的因素,比如网络连接变慢,资源突然繁忙,暂时不可用,服务脱机等。我们要构建稳定、可靠的分布式系统,就必须要有这样一套容错机制。常用的的容错技术如:隔离,降级,熔断,限流等策略,本文将详细的介绍微服务中的容错机制。

隔离机制

为什么要隔离? 比如我们现在某个接口所在的服务A需要调用服务 B,而服务 B 同时需要调用 C 服务,此时服务 C 突然宕机同时此时流量暴涨,调用全部打到服务 B 上,此时 B 服务调用 C 超时大量的线程资源被该接口所占全部夯住,慢慢服务 B 中的线程数量则会持续增加直致 CPU 资源耗尽到 100%,整个服务对外不可用渐渐蔓延到B服务集群中的其他节点,导致服务级联故障。

48_服务限流.png

此时我们就需要对服务出现异常的情况进行隔离,防止级联故障效应,常用的隔离策略有线程池隔离和信号量隔离。

线程池隔离

线程池隔离顾名思义就是通过 Java 的线程池进行隔离,B 服务调用 C 服务给予固定的线程数量比如 10 个线程,如果此时 C 服务宕机了就算大量的请求过来,调用 C 服务的接口只会占用 10 个线程不会占用其他工作线程资源,因此 B 服务就不会出现级联故障。

49_服务限流.png

信号量隔离

另一种隔离信号量隔离是使用 JUC 下的 Semaphore 来实现的,当拿不到信号量的时候直接拒接因此不会出现超时占用其他工作线程的情况。

Semaphore semaphore = new Semaphore(10,true); //获取信号量 semaphore.acquire(); //do something here //释放信号量 semaphore.release();

比较

线程池隔离针对不同的资源分别创建不同的线程池,不同服务调用都发生在不同的线程池中,在线程池排队、超时等阻塞情况时可以快速失败。线程池隔离的好处是隔离度比较高,可以针对某个资源的线程池去进行处理而不影响其它资源,但是代价就是线程上下文切换的 overhead 比较大,特别是对低延时的调用有比较大的影响。而信号量隔离非常轻量级,仅限制对某个资源调用的并发数,而不是显式地去创建线程池,所以 overhead 比较小,但是效果不错,也支持超时失败。

比较项 线程池隔离 信号量隔离
线程 与调用线程不同,使用的是线程池创建的线程 与调用线程相同
开销 排队,切换,调度等开销 无线程切换性能更高
是否支持异步 支持 不支持
是否支持超时 支持超时 支持超时(新版本支持)
并发支持 支持通过线程池大小控制 支持通过最大信号量控制

降级熔断机制

什么是降级和熔断?降级和熔断有什么区别?虽然很多人把降级熔断当着一个词来说的,但是降级和熔断是完全不同的概念的,看看下面几种场景:

场景一:比如我们每天上班坐公交,1 路和 2 路公交都能到公司,但是 2 路公交需要下车走点路,所以平时都是坐 1 路公交,突然有一天等了 1 路公交好久都没来,于是就坐了 2 路公交作为替代方案总不能迟到吧!下次再等 1 路车。

场景二:第二天,第三天 … 已经一个星期了都没看到 1 路公交,心里觉得可能是 1 路公交改路线了,于是直接坐 2 路公交了,在接下来的日子里都是直接忽略 1 路车直接坐 2 路车。

场景三:突然有一天在等 2 路车的时候看到了 1 路车,是不是 1 路车现在恢复了,于是天天开心的坐着 1 路车上班去了,领导再也不担心我迟到了。

场景一在 1 路车没等到的情况下采取降级方案坐 2 路车,这就是降级策略,场景二如果多次都没有等到 1 路车就直接不等了下次直接坐 2 路车,这就是熔断策略,场景三如果过段时间 1 路车恢复了就使用 2 路车,这就是熔断恢复!

降级机制

常用的降级策略如:熔断器降级,限流降级,超时降级,异常降级,平均响应时间降级等

50_服务限流.png

  • 熔断器降级:即熔断器开启的时间直接熔断走降级的策略。
  • 限流降级:对流量进行限制达到降级的效果,如:Hystrix 中的线程池,信号量都能达到限流的效果。
  • 超时降级:课时设置对应的超时时间如果服务调用超时了就执行降级策略,如:Hystrix 中默认为 1s。
  • 异常降级:异常降级很简单就是服务出现异常了执行降级策略。
  • 平均响应时间降级:服务响应时间持续飙高的时候实现降级策略,如 Sentinel 中默认的 RT 上限是 4900 ms。

熔断机制

熔断其实是一个框架级的处理,那么这套熔断机制的设计,基本上业内用的是 Martin Fowler 提出的断路器模式,断路器的基本原理非常简单。您将受保护的函数调用包装在断路器对象中,该对象将监视故障。一旦故障达到某个阈值,断路器将跳闸,并且所有进一步的断路器调用都会返回错误,而根本不会进行受保护的调用。常见的断路器模式有基本模式和扩展模式。

51_服务限流.png

基本模式:

  • 如果断路器状态为 close,则调用断路器将调用 supplier 服务模块;
  • 如果断路器状态为 open 则直接返回错误;
  • 如果超时,我们将增加失败计数器,成功的调用会将其重置为零;
  • 通过比较故障计数和阈值来确定断路器的状态;

扩展模式:

基础模式的断路器避免了在电路断开时发出受保护的呼叫,但是当情况恢复正常时,将需要外部干预才能将其重置。对于建筑物中的电路断路器,这是一种合理的方法,但是对于软件断路器,我们可以让断路器本身检测基础调用是否再次正常工作。我们可以通过在适当的时间间隔后再次尝试受保护的调用来实现这种自我重置行为,并在成功后重置断路器。于是就出现了扩展模式:

52_服务限流.png

  • 最开始处于 closed 状态,一旦检测到错误到达一定阈值,便转为 open 状态;
  • 这时候会有个 reset timeout,到了这个时间了,会转移到 half open 状态;
  • 尝试放行一部分请求到后端,一旦检测成功便回归到 closed 状态,即恢复服务;

熔断策略

我们通常用以下几种方式来衡量资源是否处于稳定的状态:

  • 平均响应时间:如 Sentinel 中的熔断就使用了平均响应时间,当 1s 内持续进入 5 个请求,对应时刻的平均响应时间(秒级)均超过阈值(count,以 ms 为单位),那么在接下的时间窗口之内,对这个方法的调用都会自动地熔断。
  • 异常比例 :主流的容错框架 Hystrix 和 sentinel 中都使用了异常比例熔断策略,比如当资源的每秒请求量 >= 5,并且每秒异常总数占通过量的比值超过阈值之后,资源进入熔断状态,即在接下的时间窗口之内,对这个方法的调用都会自动地返回。异常比率的阈值范围是 [0.0, 1.0],代表 0% - 100%。
  • 异常数:如 Sentinel 中的熔断就使用了异常数熔断策略,当资源近 1 分钟的异常数目超过阈值之后会进行熔断。注意由于统计时间窗口是分钟级别的,若 timeWindow 小于 60s,则结束熔断状态后仍可能再进入熔断状态。

限流机制

限流也是提高系统的容错性的一种方案,不同的场景对 “流” 的定义也是不同的,可以是网络流量,带宽,每秒处理的事务数 (TPS),每秒请求数 (hits per second),并发请求数,甚至还可能是业务上的某个指标,比如用户在某段时间内允许的最多请求短信验证码次数。

我们常说的限流都是限制每秒请求数,从分布式角度来看,限流可分为分布式限流 (比如基于 Sentinel 或者 Redis 的集群限流)和单机限流 。从算法实现角度来看,限流算法可分为漏桶算法、 令牌桶算法和滑动时间窗口算法。

计数器限流

你要是仔细看了上面的内容,就会发现上面举例的每秒阈值 1000 的那个例子就是一个计数器限流的思想,计数器限流的本质是一定时间内,访问量到达设置的限制后,在这个时间段没有过去之前,超过阈值的访问量拒绝处理,举个例,你告诉老板我一个小时只处理 10 件事,这是你的处理能力,但领导半个小内就断续断续给你分派了 10 件事,这时已经到达你的极限了,在后面的半个小时内,领导再派出的活你是拒绝处理的,直到下一个小时的时间段开始。

首先我们定义一个计数限流的结构体,结构体中至少满足 3 个字段,阈值,单位时间,当前请求数,结构体如下:

type CountLimiter struct { count int64 //阈值 unitTime time.Duration //单位时间(每秒或者每分钟) index *atomic.Int64 //计数累加 }

我们需要一个为这个结构体提供创建对象的方法,同时初始化各个字段,其中有些字段是可以从外部当作此参数传入的,完成之后同时启动一个定时器。

//创建一个计数器限流结构体 func NewCountLimiter(count int64, unitTime time.Duration) *CountLimiter { countLimiter := &CountLimiter{ count: count, unitTime: unitTime, index: atomic.NewInt64(0), } //开启一个新的协程 go timer(countLimiter) return countLimiter }

这个定时器干嘛呢,需要在经过单位时间后把当前请求数清 0,从而开启下一个单位时间内的请求统计。

//相当于一个定时器,每经过过单位时间后把index置为0,重新累加 func timer(limiter *CountLimiter) { ticker := time.NewTicker(limiter.unitTime) for { <-ticker.C limiter.index.Store(0) } }

最后最重要的是这个计数器限流对象需要提高一个判断当前请求是否限流的方法,返回值应该是一个 bool 值,true 代表请求通过,false 代表请求被限流。

func (cl *CountLimiter) IsAllow() bool { //如果index累加已经超过阈值,不允许请求通过 if cl.index.Load() >= cl.count { return false } //index加1 cl.index.Add(1) return true }

这样一个计数器限流就实现完成了,有没有什么问题呢?还是前面举的例子,每秒 1000 的阈值,假设在前 100 毫秒内,计数器 index 就累加到 1000 了,那么剩余的 900 毫秒内就无法处理任何请求了,这种限流很容易造成热点,再来分析一种情况,在一秒内最后 100 毫秒时间内突发请求 800 个,这时进入下一个单位时间内,在这个单位时间的前 100 毫秒内,突发请求 700 个,这时你会发现 200 毫秒处理了请求 1500 个,好像限流不起作用了,是的,这是一个边界问题,是计数器限流的缺点。,如下图,黄线是第一个单位时间内,红线是第二个单位时间内。

53_服务限流.png

令牌桶限流

令牌桶限流-顾名思义,手中握有令牌才能通过,系统只处理含有令牌的请求,如果一个请求获取不到令牌,系统拒绝处理,再通俗一点,医院每天接待病人是有限的,只有挂了号才能看病,挂不上号,对不起,医院不给你看病。
令牌桶,有一个固定大小的容器,每隔一定的时间往桶内放入固定数量的定牌,当请求到来时去容器内先获取令牌,拿到了,开始处理,拿不到拒绝处理(或者短暂的等待,再此获取还是获取不到就放弃)

首先我们定义一个令牌桶结构体,根据令牌桶算法我们结构体中字段至少需要有桶容量,令牌容器,时间间隔,初始令牌数核心字段,代码如下:

type TokenBucket struct { interval time.Duration //时间间隔 ticker *time.Ticker //定时器 cap int // 桶容量 avail int //桶内一开始令牌数 tokenArray []int //存储令牌的数组 intervalInToken int //时间间隔内放入令牌的个数 index int //数组放入令牌的下标处 mutex sync.Mutex }

同样的,我们需要提供一个创建令牌桶对象的方法,并且初始化所有字段的值,一些字段需要根据外部传参来决定,同时开启一个新的协程定时放入一定数量的令牌:

//创建一个令牌通,入参为令牌桶的容量 func NewTokenBucket(cap int) *TokenBucket { if cap < 100{ return nil } tokenBucket := &TokenBucket{ interval: time.Second * 1, cap: cap, avail: 100, tokenArray: make([]int, cap, cap), intervalInToken: 100, index: 0, mutex: sync.Mutex{}, } //开启一个协程往容器内定时放入令牌 go adjustTokenDaemon(tokenBucket) return tokenBucket }

这个方法的核心是初始化令牌桶的初始数量,然后启动定时器,定时调用放入令牌方法

//调整令牌桶令牌的方法 func adjustTokenDaemon(tokenBucket *TokenBucket) { //如果桶内一开始的令牌小于初始令牌,开始放入初始令牌 for tokenBucket.index < tokenBucket.avail { tokenBucket.tokenArray[tokenBucket.index] = 1 tokenBucket.index++ } tokenBucket.ticker = time.NewTicker(tokenBucket.interval) go func(t *time.Ticker) { for { <-t.C putToken(tokenBucket) } }(tokenBucket.ticker) }

往令牌容器中添加令牌,记得加锁,因为涉及到多协程操作,一个放令牌,一个取令牌,所以可能存在并发安全情况。

//放入令牌 func putToken(tokenBucket *TokenBucket) { tokenBucket.mutex.Lock() for i := 0; i < tokenBucket.intervalInToken; i++ { //容器满了,无法放入令牌了,终止 if tokenBucket.index > tokenBucket.cap-1 { break } tokenBucket.tokenArray[tokenBucket.index] = 1 tokenBucket.index++ } defer tokenBucket.mutex.Unlock() }

最后当有请求到来时,我们从令牌桶内取出一个令牌,如果取出成功,则代表请求通过,否则,请求失败,相当于限流了。

//从令牌桶弹出一个令牌,如果令牌通有令牌,返回true,否则返回false func (tokenBucket *TokenBucket) PopToken() bool { defer tokenBucket.mutex.Unlock() tokenBucket.mutex.Lock() if tokenBucket.index <= 0 { return false } tokenBucket.tokenArray[tokenBucket.index-1] = 0 tokenBucket.index-- return true }

上面代码就是令牌桶的限流的实现代码了,相对与计数器限流会比较复杂一些,令牌桶限流能够更方便的调整放入令牌的频率和每次获取令牌的个数,甚至可以用令牌桶思想来限制网关入口流量。

54_服务限流.png

漏斗限流

漏斗限流,意思是说在一个漏斗容器中,当请求来临时就从漏斗顶部放入,漏斗底部会以一定的频率流出,当放入的速度大于流出的速度时,漏斗的空间会逐渐减少为0,这时请求会被拒绝,其实就是上面开始时池塘流水的例子。流入速率是随机的,流出速率是固定的,当漏斗满了之后,其实到了一个平滑的阶段,因为流出是固定的,所以你流入也是固定的,相当于请求是匀速通过的

55_服务限流.png

首先定义漏斗限流的结构体,根据漏斗限流原理,需要字段流出速率,漏斗容量,定时器核心字段,这里容量不用具化的数据结构来表示了,采用双指针法,一个流入的指针,一个流出的指针,大家仔细看看设计。

//漏斗限流 type FunnelRateLimiter struct { interval time.Duration //时间间隔 cap int //漏斗容量 rate int //漏斗流出速率 每秒流多少 head int //放入水的指针 tail int //漏水的指针 ticker *time.Ticker //定时器 }

创建漏斗限流的对象,并且初始化各个字段,同时开启定时器,模拟漏斗流水操作。

//创建漏斗限流结构体 func NewFunnelRateLimiter(cap int, rate int) *FunnelRateLimiter { limiter := &FunnelRateLimiter{ interval: time.Second * 1, cap: cap, rate: rate, head: 0, tail: 0, } go leakRate(limiter) return limiter }

真实的漏斗流水,看流入的总容量减去流出的总容量是否大于流出速率,漏斗限流的核心是保证漏斗尽量空着,这样请求才能流入进来,所以大于的话就往出流走固定速率的请求,否则就把漏斗清空。

//模拟漏斗以一定的流速漏水 func leakRate(limiter *FunnelRateLimiter) { limiter.ticker = time.NewTicker(limiter.interval) for { <-limiter.ticker.C //根本没有流量,不需要漏(就是漏斗里没有请求,无法流出) if limiter.tail >= limiter.head { continue } //看漏斗里的剩余的请求是否大于流出的请求,如果大于,就流出这么多 //举个例子,每秒流出100,首先得保证漏斗里有100个 if (limiter.head - limiter.tail) > limiter.rate { limiter.tail = limiter.tail + limiter.rate } else { //否则流出所有(漏斗里只有70个,就把70个流完) limiter.tail = limiter.head } } }

最后必须有一个判断请求是否允许通过的方法,实则就是判断漏斗容量是否还有空位,也就判断流入总量减去流出总量是否大于总的容量,大于的话代表漏斗已经装不下了,必须限流,否则,请求通过

//是否允许请求通过(主要看漏斗是否满了) func (limiter *FunnelRateLimiter) IsAllow() bool { if limiter.head-limiter.tail >= limiter.cap { //说明漏斗满了 return false } limiter.head++ return true }

我们代码实现采用了双变量 head,tail,开始都是 0,每当有流量进入时,head 变量加 1,每过一定时间节点 tail 进行自加 rate,当 head 的值大于减去 tail 大于 cap,就代表漏斗满了,否则漏斗可以处理请求,通俗讲就相当于一个人(head)在前面跑,另一个人(tail)在后面追,当 head 跑的快时,他们之间的差距有可能达到 cap,但是记住,tail 不能追上 head,最多持平,都是 0。

分布式限流

当应用为单点应用时,只要应用进行了限流,那么应用所依赖的各种服务也都得到了保护。但线上业务出于各种原因考虑,多是分布式系统,单节点的限流仅能保护自身节点,但无法保护应用依赖的各种服务,并且在进行节点扩容、缩容时也无法准确控制整个服务的请求限制。

56_服务限流.png

如果实现了分布式限流,那么就可以方便地控制整个服务集群的请求限制,且由于整个集群的请求数量得到了限制,因此服务依赖的各种资源也得到了限流的保护。

57_服务限流.png

分布式限流方案

分布式限流的思想我列举下面三个方案:

  1. Redis令牌桶

    这种方案是最简单的一种集群限流思想。在本地限流中,我们使用 Long 的原子类作令牌桶,当实例数量超过 1,我们就考虑将 Redis 用作公共内存区域,进行读写。涉及到的并发控制,也可以使用 Redis 实现分布式锁。

    缺点:每取一次令牌都会进行一次网络开销,而网络开销起码是毫秒级,所以这种方案支持的并发量是非常有限的。

  2. QPS统一分配

    这种方案的思想是将集群限流最大程度的本地化。

    举个例子,我们有两台服务器实例,对应的是同一个应用程序(Application.name 相同),程序中设置的 QPS 为 100,将应用程序与同一个控制台程序进行连接,控制台端依据应用的实例数量将 QPS 进行均分,动态设置每个实例的 QPS 为 50,若是遇到两个服务器的配置并不相同,在负载均衡层的就已经根据服务器的优劣对流量进行分配,例如一台分配 70% 流量,另一台分配 30% 的流量。面对这种情况,控制台也可以对其实行加权分配 QPS 的策略。

    缺点:这也算一种集群限流的实现方案,但依旧存在不小的问题。该模式的分配比例是建立在大数据流量下的趋势进行分配,实际情况中可能并不是严格的五五分或三七分,误差不可控,极容易出现用户连续访问某一台服务器遇到请求驳回而另一台服务器此刻空闲流量充足的尴尬情况。

  3. 发票服务器

    这种方案的思想是建立在 Redis 令牌桶方案的基础之上的。如何解决每次取令牌都伴随一次网络开销,该方案的解决方法是建立一层控制端,利用该控制端与Redis令牌桶进行交互,只有当客户端的剩余令牌数不足时,客户端才向该控制层取令牌并且每次取一批。

    缺点:这种思想类似于 Java 集合框架的数组扩容,设置一个阈值,只有当超过该临界值时,才会触发异步调用。其余存取令牌的操作与本地限流无二。虽然该方案依旧存在误差,但误差最大也就一批次令牌数而已。