简介

golang的熔断包hystrix  https://github.com/afex/hystrix-go

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。

从微观角度思考

超时(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 …

微观合理的问题,宏观未必合理。

下面的一些讨论,主要想表达的观点是:如果系统复杂了,系统的容错配置要整体来看,整体把控,才能做到更有意义。

超时

TAB > RB + TBC

重试

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

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就不用熔断了。

限流

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)

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

关键路径

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

最长路径

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