浅谈微服务中的熔断,限流,降级

简介

golang的熔断包hystrix
golang 提供了拓展库(golang.org/x/time/rate)提供了限流器组件,提供了 Token bucket (令牌桶算法)

在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。
缓存的目的是提升系统访问速度和增大系统能处理的容量,可谓是抗高并发流量的银弹;
降级是当服务出问题或者影响到核心流程的性能则需要暂时屏蔽掉,待高峰或者问题解决后再打开;
而有些场景并不能用缓存和降级来解决,比如稀缺资源(秒杀、抢购)、写服务(如评论、下单)、频繁的复杂查询(评论的最后几页),因此需有一种手段来限制这些场景的并发/请求量,即限流。

概念介绍

雪崩效应

一个应用可能会有多个微服务组成,微服务之间的数据交互通过远程过程调用完成。
这就带来一个问题,
假设微服务A调用微服务B和微服务C,微服务B和微服务C又调用其它的微服务,这就是所谓的“扇出”。
如果扇出的链路上某个微服务的调用响应时间过长或者不可用,对微服务A的调用就会占用越来越多的系统资源,进而引起系统崩溃,所谓的“雪崩效应”。

服务熔断

熔断机制是应对雪崩效应的一种微服务链路保护机制。
当扇出链路的某个微服务不可用或者响应时间太长时,会进行服务的降级,进而熔断该节点微服务的调用,快速返回错误的响应信息。当检测到该节点微服务调用响应正常后,恢复调用链路。

服务降级

降级是指自己的待遇下降了,从RPC调用环节来讲,就是去访问一个本地的伪装者而不是真实的服务。
当双11活动时,把无关交易的服务统统降级,如查看蚂蚁深林,查看历史订单,商品历史评论,只显示最后100条

服务熔断和服务降级的区别

服务熔断一般是某个服务(下游服务)故障引起,而服务降级一般是从整体负荷考虑;
熔断其实是一个框架级的处理,每个微服务都需要(无层级之分),而降级一般需要对业务有层级之分(比如降级一般是从最外围服务开始)
实现方式不太一样;服务降级具有代码侵入性(由控制器完成/或自动降级),熔断一般称为自我熔断。

服务限流

限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,
一旦达到限制速率则可以拒绝服务(定向到错误页或告知资源没有了)、排队或等待(比如秒杀、评论、下单)、降级(返回兜底数据或默认数据,如商品详情页库存默认有货)。

一般开发高并发系统常见的限流有:
限制总并发数(比如数据库连接池、线程池)、
限制瞬时并发数(如nginx的limit_conn模块,用来限制瞬时并发连接数)、
限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limit_req模块,限制每秒的平均速率);
其他还有如限制远程接口调用速率、限制MQ的消费速率。
另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。

限流算法
常见的限流算法有:令牌桶、漏桶。计数器也可以进行粗暴限流实现。

应用级限流
对于一个应用系统来说一定会有极限并发/请求数,即总有一个TPS/QPS阀值,如果超了阀值则系统就会不响应用户请求或响应的非常慢,因此我们最好进行过载保护,防止大量请求涌入击垮系统。
MySQL(如max_connections)、Redis(如tcp-backlog)都会有类似的限制连接数的配置。

池化技术
如果有的资源是稀缺资源(如数据库连接、线程),而且可能有多个系统都会去使用它,那么需要限制应用;
可以使用池化技术来限制总资源数:连接池、线程池。
比如分配给每个应用的数据库连接是100,那么本应用最多可以使用100个资源,超出了可以等待或者抛异常。

分布式限流
分布式限流最关键的是要将限流服务做成原子化,
而解决方案可以使使用redis+lua或者nginx+lua技术进行实现,通过这两种技术可以实现的高并发和高性能。

首先我们来使用redis+lua实现时间窗内某个接口的请求数限流,
实现了该功能后可以改造为限流总并发/请求数和限制总资源数。
Lua本身就是一种编程语言,也可以使用它实现复杂的令牌桶或漏桶算法。

有人会纠结如果应用并发量非常大那么redis或者nginx是不是能抗得住;
不过这个问题要从多方面考虑:
你的流量是不是真的有这么大,是不是可以通过一致性哈希将分布式限流进行分片,是不是可以当并发量太大降级为应用级限流;
对策非常多,可以根据实际情况调节;像在京东使用Redis+Lua来限流抢购流量,一般流量是没有问题的。

对于分布式限流目前遇到的场景是业务上的限流,而不是流量入口的限流;流量入口限流应该在接入层完成,而接入层笔者一般使用Nginx。

基于Redis功能的实现限流
基于令牌桶算法的实现

从微观角度思考

超时(timeout)

如果这种情况频度很高,那么就会整体降低consumer端服务的性能。
这种响应时间慢的症状,就会像一层一层波浪一样,从底层系统一直涌到最上层,造成整个链路的超时。
所以,consumer不可能无限制地等待provider接口的返回,会设置一个时间阈值,如果超过了这个时间阈值,就不继续等待。
这个超时时间选取,一般看provider正常响应时间是多少,再追加一个buffer即可。

重试(retry)

有可能provider只是偶尔抖动,对于这种偶尔抖动,可以在超时后重试一下,重试如果正常返回了,那么这次请求就被挽救了,能够正常给前端返回数据,只不过比原来响应慢一点。
重试时的一些细化策略:
重试可以考虑切换一台机器来进行调用,因为原来机器可能由于临时负载高而性能下降,重试会更加剧其性能问题,而换一台机器,得到更快返回的概率也更大一些。
如果允许consumer重试,那么provider就要能够做到幂等。
即,同一个请求被consumer多次调用,对provider产生的影响(这里的影响一般是指某些写入相关的操作) 是一致的。
而且这个幂等应该是服务级别的,而不是某台机器层面的,重试调用任何一台机器,都应该做到幂等。

熔断(circuit break)

如果provider持续的响应时间超长呢?

如果provider是核心路径的服务,down掉基本就没法提供服务了,那我们也没话说。 如果是一个不那么重要的服务,检查出来频繁超时,就把consumer调用provider的请求,直接短路掉,不实际调用,而是直接返回一个mock的值。
等provider服务恢复稳定之后,重新调用。
目前我们框架有通过注解使用的熔断器,大家可以参考应用在项目中。

限流(current limiting)

上面几个策略都是consumer针对provider出现各种情况而设计的。
而provider有时候也要防范来自consumer的流量突变问题。
这样一个场景,provider是一个核心服务,给N个consumer提供服务,突然某个consumer抽风,流量飙升,占用了provider大部分机器时间,导致其他可能更重要的consumer不能被正常服务。
所以,provider端,需要根据consumer的重要程度,以及平时的QPS大小,来给每个consumer设置一个流量上线,同一时间内只会给A consumer提供N个线程支持,超过限制则等待或者直接拒绝。

资源隔离
provider可以对consumer来的流量进行限流,防止provider被拖垮。
同样,consumer 也需要对调用provider的线程资源进行隔离。 这样可以确保调用某个provider逻辑不会耗光整个consumer的线程池资源。

服务降级
降级服务既可以代码自动判断,也可以人工根据突发情况切换。

consumer 端
consumer 如果发现某个provider出现异常情况,比如,经常超时(可能是熔断引起的降级),数据错误,这是,consumer可以采取一定的策略,降级provider的逻辑,基本的有直接返回固定的数据。

provider 端
当provider 发现流量激增的时候,为了保护自身的稳定性,也可能考虑降级服务。
比如,1,直接给consumer返回固定数据,2,需要实时写入数据库的,先缓存到队列里,异步写入数据库。

从宏观角度重新思考

宏观包括比A -> B 更复杂的长链路。
长链路就是 A -> B -> C -> D这样的调用环境。
而且一个服务也会多机部署,A 服务实际会存在 A1,A2,A3 …
微观合理的问题,宏观未必合理。
下面的一些讨论,主要想表达的观点是:如果系统复杂了,系统的容错配置要整体来看,整体把控,才能做到更有意义。

超时

如果A给B设置的超时时间,比B给C设置的超时时间短,那么肯定不合理把,A超时时间到了直接挂断,B对C支持太长超时时间没意义。
R表示服务consumer自身内部逻辑执行时间,TAB表示consumer A开始调用provider B到返回的时间 。
那么那么TAB > RB + TBC 才对。

重试

重试跟超时面临的问题差不多。
B服务一般100ms返回,所以A就给B设置了110ms的超时,而B设置了对C的一次重试,最终120ms正确返回了,但是A的超时时间比较紧,所以B对C的重试被白白浪费了。

A也可能对B进行重试,但是由于上一条我们讲到的,可能C确实性能不好,每次B重试一下就OK,但是A两次重试其实都无法正确的拿到结果。
N标示设置的重试次数
修正一下上面section的公式,TAB > RB+TBC * N。
虽然这个公式本身没什么问题,但是,如果站在长链路的视角来思考,我们需要整体规划每个服务的超时时间和重试次数,而不是仅仅公式成立即可。
比如下面情况:
A -> B -> C。
RB = 100ms,TBC=10ms
B是个核心服务,B的计算成本特别大,那么A就应该尽量给B长一点的超时时间,而尽量不要重试调用B,而B如果发现C超时了,B可以多调用几次C,因为重试C成本小,而重试B成本则很高。 so …

熔断

A -> B -> C,如果C出现问题了,那么B熔断了,则A就不用熔断了。

3.4 限流
B只允许A以QPS<=5的流量请求,而C却只允许B以QPS<=3的qps请求,那么B给A的设定就有点大,上游的设置依赖下游。
而且限流对QPS的配置,可能会随着服务加减机器而变化,最好是能在集群层面配置,自动根据集群大小调整。

服务降级

服务降级这个问题,如果从整体来操作,
1,一定是先降级优先级低的接口,两权相害取其轻
2,如果服务链路整体没有性能特别差的点,比如就是外部流量突然激增,那么就从外到内开始降级。
3如果某个服务能检测到自身负载上升,那么可以从这个服务自身做降级。

涟漪

A -> B -> C,如果C服务出现抖动,而B没有处理好这个抖动,造成B服务也出现了抖动,A调用B的时候,也会出现服务抖动的情况。
这个暂时的不可用状态就想波浪一样从底层传递到了上层。
所以,从整个体系的角度来看,每个服务一定要尽量控制住自己下游服务的抖动,不要让整个体系跟着某个服务抖动。

级联失败(cascading failure)

系统中有某个服务出现故障,不可用,传递性地导致整个系统服务不可用的问题。
跟上面涟漪(自造词)的区别也就是严重性的问题。
涟漪描述服务偶发的不稳定层层传递,而级联失败基本是导致系统不可用。 一般,前者可能会因为短时间内恢复而未引起重视,而后者一般会被高度重视。

关键路径

关键路径就是,你的服务想正常工作,必须要完整依赖的下游服务链,比如数据库一般就是关键路径里面的一个节点。
尽量减少关键路径依赖的数量,是提高服务稳定性的一个措施。
数据库一般在服务体系的最底层,如果你的服务可以会自己完整缓存使用的数据,解除数据库依赖,那么数据库挂掉,你的服务就暂时是安全的。

最长路径

想要优化你的服务的响应时间,需要看服务调用逻辑里面的最长路径,只有缩短最长时间路径的用时,才能提高你的服务的性能。

发布了264 篇原创文章 · 获赞 23 · 访问量 15万+

猜你喜欢

转载自blog.csdn.net/Edu_enth/article/details/103800426