微服务1:微服务及其演进史
微服务2:微服务全景架构
微服务3:微服务拆分策略
微服务4:服务注册与发现
微服务5:服务注册与发现(实践篇)
微服务6:通信之网关
微服务7:通信之RPC
微服务8:通信之RPC实践篇(附源码)
微服务9:服务治理来保证高可用
在第2篇《微服务2:微服务全景架构 》中,我们曾经分析过微服务化后所面临的挑战,有过如下的结论:
1.1 分布式固有复杂性微服务架构是基于分布式的系统,而构建分布式系统必然会带来额外的开销。
性能: 分布式系统是跨进程、跨网络的调用,受网络延迟和带宽的影响。
可靠性: 由于高度依赖于网络状况,任何一次的远程调用都有可能失败,随着服务的增多还会出现更多的潜在故障点。因此,如何提高系统的可靠性、降低因网络引起的故障率,是系统构建的一大挑战。
分布式通信: 分布式通信大大增加了功能实现的复杂度,并且伴随着定位难、调试难等问题。
数据一致性: 需要保证分布式系统的数据强一致性,即在 C(一致性)A(可用性)P(分区容错性) 三者之间做出权衡。这块可以参考我的这篇《分布式事务》。
在单体应用中,通常使用集成测试来验证依赖是否正常。而在微服务架构中,服务数量众多,每个服务都是独立的业务单元,服务主要通过接口进行交互,如何保证它的正常,是测试面临的主要挑战。所以单元测试和单个服务链路的可用性非常重要。
1.3 有效的配置版本管理在单体系统中,配置可以写在yaml文件,分布式系统中需要统一进行配置管理,同一个服务在不同的场景下对配置的值要求还可能不一样,所以需要引入配置的版本管理、环境管理。
1.4 自动化的部署流程在微服务架构中,每个服务都独立部署,交付周期短且频率高,人工部署已经无法适应业务的快速变化。有效地构建自动化部署体系,配合服务网格、容器技术,是微服务面临的另一个挑战。
1.5 对于DevOps更高的要求在微服务架构的实施过程中,开发人员和运维人员的角色发生了变化,开发者也将承担起整个服务的生命周期的责任,包括部署、链路追踪、监控;因此,按需调整组织架构、构建全功能的团队,也是一个不小的挑战。
1.6 更高运维成本运维主要包括配置、部署、监控与告警和日志收集四大方面。微服务架构中,每个服务都需要独立地配置、部署、监控和收集日志,成本呈指数级增长。服务化粒度越细,运维成本越高。
2 迫切的治理需求正是因为有这些弊端,所以对微服务来说,有了更迫切的服务治理需求,以弥补弊端产生的问题。
可以看看下面的这张图,这是一个典型的微服架构,他包含4层的Load Balance,7层的GateWay,计算服务,存储服务,及其他的一些中间件系统。
实际上,但凡有需要微服务化的系统,都是具备一定规模了。一般会有很多模块构成,相应的部署节点也会非常多,这样故障的概率就会大幅增加,比如磁盘故障、网络故障,机器宕机,触发一些内核bug或者是运行环境漂移等。
本质上也是微服务细粒度拆分后提升了出问题的概率,正如上面说的,分布式系统有它固有的复杂性,相比于单体服务错误会显著的增多,需要高可用方案来保证复杂通信链路的健壮性。
我们有很多种方法对服务进行治理来保障服务的高可用。但总的来说有4类:
- 流量调控:方法主要是金丝雀发布(灰度发布)、ABTesting、流量染色。
- 请求高可用:方法主要有超时重试、快速重试以及负载均衡。
- 服务的自我保护:主要包括限流、熔断和降级。
- 应对故障实例:主要分为异常点驱逐和主动健康检查。
流量调度中典型的金丝雀场景,你可以先放行一部分流量到一个新的服务实例中,这个新的服务实例只有你的研发和测试团队可以接入。可以在上面试用或者测试,直到你确认你的服务是健康的,没有bug的,再把流量逐渐的迁移过去。
这个的好处是减少发布新功能存在的风险,而且全程是无停服发布,对用户是透明无感知的,大大提高了可用性。
流量染色也是一种典型的场景。如果你想让不同的用户群体(比如这边的Group A、Group B、Group C)使用的功能也是不同的,那流量染色是一个不可缺少的功能。
它可以把符合某些特征的用户流量调控到对应的服务版本中。比如GroupA是学生群体,对应到V1版本,GroupB是老人群体,对应到V2版本。需要注意的是,如果是一条完整的链路,那链路上的各个服务包括数据存储层都应该有不同的版本,这样才能一一对应。
假设你有两个服务,服务A和服务B,服务A向服务B请求数据。但是B服务由于非常繁忙,在给定的时间周期内(红色时间线)都无法响应。而这个红色时间线是A服务固定的超时时间,如果这个时间之后还没有等到B服务的响应,A服务就不等了,去执行其他的任务。
这个其实就是一个超时的基本概念。它的意义在于可以避免一些长时间的无意义的等待,因为这个时候下游可能是处于故障或者有请求堆积,短时间内可能是无法返回正常的结果的。
因此,服务A在超时之后,可以及时释放自己的一些资源,比如线程或者是请求相关的其他资源。
在实践中,超时时间的设置通常要比正常的请求时间稍微大一些(正常的返回时间可以根据平响进行分析),这样可以避免请求还没有来得及返回就触发超时。当然这个超时时间也不能设置太大。如果太大的话,在服务B出现异常的时候,服务A不能够及时的释放资源,会导致请求堆积,降低自身服务的吞吐能力。
跟上面一样,A服务向B服务获取数据,由于B服务非常繁忙,在给定的超时时间内无法获得响应数据。于是A设置了重试机制,在超时时间结束之后,重新获取一下B服务的数据,这时候B服务已经不忙碌了,很快就把正常数据响应给A服务。
可以看到,重试的意义在于可以提升一次请求的成功率。通常重试不仅可以配合超时,也可以配合一些其他种类的失败。
比如B服务5xx错误了,但可能是有概率的错误,所以重试一次就可能获取到想要的结果。当然重试也有一些注意事项,避免重试带来其他灾难。
- 重试尽量避开之前已经选择过的失败实例,因为这个时候再重试,大概率还是错误的,意义不是特别大。
- 其次重试的次数也不能太多,否则很容易对服务B造成数倍的压力,导致服务B发生一些雪崩。
- 对于多次重试,我们通常可以配合一些类似于退避重试的策略来减缓对服务B的压力。退避策略说明,算法
同上面一样,服务A向服务B发起一个正常的请求,服务B工作繁忙,A服务在给定的超时时间之前都无法获得响应。按照之前的做法就是等超时时间达到的时候,再发起一次请求。
但是可以在超时时间到达之前做一次更智能的处理,比如超时时间线的中间点,再请求一次服务B。可以看到,重试其实就是一次backup request。正是由于它在正常的超时之前就触发了,所以我们叫它快速重试。
这次快速重试,刚好服务B可能已经缓过来。他就收到了这个请求,给服务A返回了这个结果。
服务A在正常的超时触发之后,就是红色这条线,他也会发起一次正常的标准重试,这个时候服务B也有可能会再给他返回一次信息,快速重试的返回和正常返回,他们的时序是有可能不一定的。通常我们的处理方法是服务A先收到哪一个回复,就以哪个为准。后面收到了就会被抛弃掉。
这边需要注意的是,多一次重试会有一定的额外资源开销,所以在使用的时候,需要注意快速重试和正常重试合理设置,避免总的重试次数过多导致服务可用性反受影响。
假设服务B有多个实例,对于服务A的请求,我们希望它是比较均匀的流向B服务的各个实例,这样才能真正做到负载均衡,提供更稳定的服务。比较常用的一些策略比如随机轮询、RR顺序轮询等。
我们在实际的生产实践中会有一些比较高级的负载均衡策略,比如说Least Conn、Least Request,他会观测你后端集群中连接数、请求数最少的实例进行分配。
还有如LA负载均衡策略,它可以动态计算后端的压力,比如说,可以根据qps和延迟来算一个权重。那些qps很高,延迟又很低的后端实力,我们可以认为它是一个比较优秀的后端实例,处理能力比较强,我们会给它比较高的权重,反之就给一个稍微低一些的权重。
另外一个比较常用的负载均衡策略就是一致性哈希。一致性哈希指的是说保证相同来源的请求能够落入相同的后端实例。这在一些后端实例有缓存,或者是有一些类似的场景的话,可以大幅提升请求的性能。
正常一个长期稳定运行的服务,他们的请求是正常波形状且符合预期的,你可以观察他的流量峰值、平均值来判断服务真正的吞吐。
如果你的服务突然遇到持续性的、高频率的、不符合预期的突发流量。你需要检查一下服务是否有被错误调用、恶意攻击,或者下游程序逻辑问题。参考我的这一篇,就是典型的下游疯狂调用。
这种超出预期的调用经常会造成你的服务响应延迟,请求堆积,甚至服务雪崩。而雪崩会随着调用链向上传递,导致整个服务链的崩溃,对我们的系统造成很大的隐患。
限流通常指的是上游服务(服务B)这一侧,任何服务处理能力总是有限的,所以在超过他的处理能力之后,我们需要一些保护行为来避免服务过载。通常的做法就是让服务B快速返回失败来进行自我保护,否则大量请求在服务B堆积,在请求队列里阻塞,会造成大量资源损耗,也导致正常的请求无法被有效处理。
一些常见的限流方法,比如QPS限流、连接数限流,并发请求数限流等,都可以有效的对服务进行保护,下面列举几种常见的限流算法。
-
时间窗:简单易用
时间窗实现非常简单,大概原理是我们有一个统计的时间窗,比1分钟之内,我们只允许通过1000个请求。但是这种方法有一个比较大的缺陷,就是不太稳定。比如刚好在前10秒,就来了1000个请求,而后面50秒就不能够再接受任何请求了,非常的不均匀。所以在要求严谨的生产环境中比较少使用。 -
漏桶算法:定速流出
对于漏桶来说,由于它的出水口的速度是恒定的,也就是消化处理请求的速度是恒定的,所以它可以保证组件以恒定的速率来处理请求,这对一些对处理速度或者资源有严格要求的系统是非常实用的。 -
令牌桶:定速流入
令牌桶配了一个蓄水池,这个蓄水池通常如果请求比较少的话,那么它一直往蓄水池里面放水,就会导致这个蓄水池的容量会稍微多一些。那这样的话,在接下来的一个瞬时的流量高峰,它可以允许系统经过比这个平均速率更高一些的请求高峰。所以它有一定的弹性,在实践中也得到了非常广泛的使用。
假设服务B非常繁忙,对于服务A正常的请求未能及时的返回结果,一直在Pending。而服务A在触发超时时间之后,按照重试策略又发了一次请求,服务B依然没有给返回。服务A经过多次重试,觉察到服务B有一些异常,他就自己做决策,认为服务B可能已经无法提供服务了,这个时候继续发出重试请求,可能意义不太大。所以它主动发起熔断(注意,是服务A发起的,因为B服务可能已经死了),就是一段时间内不再请求服务B。
A既然发起了熔断,那他总得返回对应的信息给用户,不能让用户或者更下游的服务一直等待。
这时候的处理方式就是fall back 到默认的处理信息,比如跳到一个预设的函数,指定返回默认设置的静态信息。或者对返回值进行降级,比如说是之前请求成功的一些信息,或者一些缓存的旧值,这样比什么都不返回好很多。
笔者这边的做法是制定一个特定的返回结构(包含状态码、错误信息、flag),带有特定信息标志,让前端可以识别出是正常的返回还是熔断自动返回。
当集群中的某个服务实例发生故障的时候,其实我们最优先的做法是先进行驱逐,然后再检查问题,处理问题并恢复故障。所以,能否快速的对异常实例进行驱逐,对提高系统的可用性很重要。
下面的是ServiceMesh中Istio的异常驱逐配置,表示每秒钟扫描一次上游主机,连续失败 2 次返回 5xx 错误码的所有主机会被移出负载均衡连接池 3 分钟,并且上游被离群的主机在集群中占比不应该超过10%。
outlierDetection:
consecutiveErrors: 2
interval: 1s
baseEjectionTime: 3m
maxEjectionPercent: 10
说明:
驱逐: 一段时间内出现多次失败,屏蔽该实例一段时间
恢复: 屏蔽时间之后,再尝试请求该实例