一、微服务平台简介
1. 微服务平台
要搭建一套能稳定支持海量调用的微服务系统,需要先看看系统由哪些模块组成。如上图所示,从下往上看,不同的用户 VPC 代表多租户,中间是服务注册发现的模块,顶部是应用管理模块和数据化运营模块,应用管理模块用来进行 CICD,包括了分发、部署以及配置管理等应用生命周期相关的功能。
数据化运营这个模块主要用于帮助业务进行分析,包括但不限于调用链、日志、metrics 等。
从系统架构上来说,蓝框里的属于数据面,也就是常说的 data plane,是影响业务请求的核心链路;灰色区域内更多的偏向控制流,也就是 control plane ,帮助我们更简单的使用好微服务。
数据面和控制面属于两种不同的架构,面临的挑战也各不相同。数据面的挑战主要难点在于大流量、稳定性和高可用等,而控制面的更多则是业务复杂度。本文将围绕着整个数据面分享如何让微服务系统变得更稳定。
2. 海量调用的起始——微服务
2014 年云兴起之后,提出了云时代下的微服务概念:单一应用程序构成的小程序,自己拥有自己的逻辑与轻量化处理能力,服务以全自动方式部署,与其他的服务之间进行通信,服务可以使用不同编程语言和数据库实现。
这个概念和早期 Dubbo 这种基于 Netty 框架构成的系统本质上没区别,只不过云兴起之后增加了一些自动部署和 docker 的能力,也做了更多的集成。
通过上图可以看到,我们每个微服务都内聚了自己的业务逻辑,允许访问不同的数据库,以及通过 rest API 进行互相通信。从模型来看有点像是蜂巢,也很像一张网。这里就会引申出一个小问题,这么多的微服务,他们之间是如何进行调用的?
http client 本身我们知道是通过 IP 和 port 来进行互相调用,早期单体应用还能够简单的进行配置。但是在微服务时代,特别是用了 K8s 和 docker,每次启动的 IP 也可能会变,这里该怎么办呢?其实可以通过服务注册发现模块进行机器实例的互相发现和调用。
3. 微服务之间如何互相发现
我们来看图解释一下什么是服务注册发现。
图上能看到有两个微服务,ServiceA 和 ServiceB。在这里我们定义 ServiceA 为服务调用方,ServiceB 为服务提供者。
前面也说到了,在单体应用时代,我们都需要通过配置来指定需要访问的 IP,但是云时代下的微服务,IP 本身会变,所以需要有一个地方来记录这些 IP,那就是服务注册和发现的能力。
ServiceB 的每个实例在启动时,会将自身的 IP 和 port 写入到服务发现模块的某个路径,然后 ServiceA 会从这个路径来拉取到想要访问的 ServiceB 的服务提供者的列表。这里就会拉取到三个实例节点,从中选择一个节点进行访问。
目前市面上已经有很多成熟的注册发现组件,像是 zookeeper,nacos,Consul 等。Consul 本身作为一个开箱即用,并且支持 http 请求,同时拥有丰富的文档和简单的 API 的系统被很多的中小型公司青睐和使用。
当然,Consul 和 Spring 的对接也很成熟,很多中小型公司,特别是比较新的公司很多都会选择 Consul 来作为服务注册发现。所以我们选择基于 Consul 作为底层基础组件,在上面一点点的进行扩展,来搭建一套稳定的服务注册发现模块。
二、突破原生开源架构
1. 原生Consul的能力与限制
Consul 有开源版本和企业版本,对于开源版本来说,基本功能都齐全,但企业级能力却不提供。缺少这些企业级的能力,对想用 Consul 来实现支持海量调用的微服务系统会有不足。
原生的Consul的服务发现 API 参数只有一个服务名,想要多租户或者带上 namespace,只能拼接在服务名上。但是这里需要强行依赖 SDK,对于 Consul 这样一个暴露 http 的通用服务,不能限定用户的语言,也不可能每个语言去实现一套 SDK,我们需要用其他方式来实现。
2. 实现多租户
实现多租户的能力,需要在原有的 Consul-server 集群之前,加一层 Consul-access 层。对外暴露的 API 和原生的 Consul 完全一样,但背后针对一些 API 可以进行了一些改造。
先简单看看通过加入 access 层,怎么来实现多租户的能力:
服务注册发现一般来说分为三步
- 服务提供进行实例注册;
- 心跳上报;
- 服务消费者拉取服务提供者列表。
为了简化问题,我们在这里不考虑异常情况和顺序情况。
针对这三步,我们分别进行一些改造,用户侧使用原生的 Consul-SDK 进行服务注册,当 access 接收到注册请求后,会将该请求翻译成一个 KV 请求,然后存储到 Consul-server 上。
第一步,实现多租户的能力,服务发现第一步是服务提供者进行实例注册,接到注册请求时会将该请求翻译成key是/租户/service/serviceA/instance-id/data,value 是用户注册上来的节点信息的请求,每一个 instance-id 对应的目录就代表着一个服务提供者。
并没有采取原生的服务注册的 Consul 提供的服务注册,而是为了做治理能力分开成了 KV 进行存储。
第二步,心跳请求也会被 Consul-access 拦截,翻译成 KV 请求,存在 Consul 集群上。
第三步,是服务发现请求,该请求被 Consul-access 拦截后,首先会从前面所说的路径下拉取当前全部的实例列表,然后将对应实例的最后心跳上报时间取出,第二步存 KV 的地方将服务注册的实例信息和心跳数据进行合并。
合并是将最近 30 s 内没有心跳数据的节点状态置为 critical,30 s 内有心跳的节点状态置为 passing,然后返回给客户端,这样对于客户端来说,和直接请求 Consul-server 行为保持一致。
但这种实现方式有个问题:用户如果使用原生的 API 进行服务注册的话,本身其实并不包含租户的信息,那 access 如何知道写入的路径呢?
3. 透明地生成租户信息
这种实现有一个问题,用户使用的原生 API 进行注册,本来不带有租户信息,Access 第一步就无法实现了,这块如何处理?
原生 Consul 的有一个参数叫 token,是个 String 字段,为了即兼容原生的 Consul请求,又能够获取我们想要的信息,我们在这里做了一些文章。
首先需要实现 “token” 模块,该模块提供 token 授予和验证,同时也提供根据 token 返回相应信息的能力。
用户首先申请 token 密钥,填写信息,如租户名等等,然后 token-server 模块会根据这些信息生成一个 token 密钥并返回给客户端。用户在使用原生 API 的时候,需要把该 token 带上。Consul-access 收到 token 后,会从 token-server 来换取对应的信息,这样对于用户而言做到了完全透明。
Token-server 模块的存在,使得 access 层除了能够实现一些企业级的功能,比如多租户,也可以使得整个 Consul 集群可以更加稳定,比如防止用户恶意的进行调用,来对 Consul-server 造成 ddos ***。
也可以针对不同的业务模块或者用户可以进行不同的治理手段,比如我们可以根据用户的 token 等级来设定 Consul 的配额,或者可以在测试环境限制访问频率,或者说限制某些用户的某些操作权限。
引入了 token 模块和 access 层,还可以做非常多有意思的事情,这里是给大家发散思考的地方,如果你们来做的话,还会加入哪些能力呢?
我们已经发现加入了 access 层从功能层面上能够让整个 Consul 集群能够更加稳定,有些有经验的工程师可能会想到,会不会因为多了一层,而导致性能变差呢?
下面我们来看看,在加入 access 层后,我们做了哪些性能优化。
三、让服务发现更稳定
1. 性能优化Ⅰ 我们先来看一下没有 Consul-access 这一层的情况:
假设有 A,B,C 三个微服务,服务 A 需要调用服务 B 和服务 C。
服务 A 有 100 个实例,每个客户端实例都需要订阅服务 B 和服务 C,那么和 Consul 需要建立的长连接数为 2* 100=200 个连接。每个微服务需要建立 2 个长连接,一共 100 个,所以需要 200 个。
再来看一下加了 Consul-access 层之后,每个微服务需要建立两个长链接,100 个实例是 200 个,客户端到 access 的连接数还是 100* 2=200 没有变化,但是 access 到 Consul-server 的连接却变成了 3* 2=6 个!
这里的 3 指的是 Consul-access 的台数,而 2 指的是需要订阅的服务数目,这里就是为什么 B 和 C 两个可以做聚合的原因。
因为对于 access 而言,不同实例的相同订阅请求是可以合并的,比如实例 1-33 服务监听请求都发到了第一台 access,access 只需要发送一次 watch 请求到 Consul-server 上。当 Consul 集群的这个值出现变化后,会返回给 access,而 access 会从缓存中拉取出监听该服务的连接,然后依此将变化后的值推送回客户端。
这里可能有人会有疑问,就算 access 到 Consul-server 的连接数从 200 降为了 6,但是客户端到 access 层的连接还是 200 啊,而且总数还是 206,还多了 6 个,这里的区别在哪里呢?
Consul 本身是一个 CP 的系统,自身是基于 raft 来保证强一致的,即使请求连接发到 follower,也会同样的转发到 leader 上面。而 watch 类型的读请求(也就是上面说的订阅类型请求,需要长连接挂载的),在没有开启 stale-read 参数的情况下,也会被转发至 leader。
因此,leader 上挂载的长连接数会是整个集群的整体连接数,随着连接数的增多,每当数据有变化时,leader 需要一次遍历所有 watch 该路径的连接,将变更数据返回,会消耗大量的 CPU 和 IO。
比如说 100 个服务监听请求会 watch 在 Consul-server,当监听的微服务实例数变化时,Consul-server 就需要遍历 100 个连接。
虽然 Consul-access 层也需要做遍历连接这个操作,但 access 本身是无状态的,这是非常重要的一点。
一台 access 需要承载 100 个连接,3 台 access 的话,每台只需要负责 33 个连接,如果继续水平扩容,每台的负载可以更低。而每扩容一台 access,对 Consul-server 的集群压力很小,只会增加 watch 的服务数个请求。
相比之下 Consul-server 没有办法无限水平扩容,你扩的越多,反而对 leader 的压力越大,但是垂直扩容又是有上限的,不可能一直扩展下去,所以我们通过增加 access 层来解决这个难题。
这是一个非常通用的架构思想,当底层系统有瓶颈无法水平扩容时,可以想办法把压力上提到一个可以水平扩展的层级,将压力转移出去,从而使整个系统变得更加稳定。比如数据库中间件和背后的 mysql。
有些想的比较远的同学可能发现了,这样做只能大大缓解 Consul-server 的瓶颈,但是随着服务数的增多,还是会出现瓶颈。
2. 性能优化后依然到了瓶颈怎么办?
解决方案很简单,Consul-access 是无状态可以水平扩容的,但是 Consul-server 集群有瓶颈,那么我们可以以 Consul-server 集群为粒度进行水平扩容。
我们还是借助之前说的 token 模块,根据 token 返回的信息来进行判定,然后决定当前的用户请求应当转发到哪个 server 集群,然后通过这种思路,让整个服务发现系统做到完全水平扩容。
这种架构目前也被广泛采用,比如 shardingredis,水平分库中间件等。
3. 性能优化II
这里再讲一个小优化,在运行过程中,发现 Consul 的 CPU 占用还是比较高的,这里用 pprof 进行调用采样分析后,发现大量的 CPU 消耗都来自于一个请求。
通过排查,发现是 Spring-SDK 中会每 2s 定时发一个 watch 请求,在这里我们做个转换,将 watchtimeout 2s 的请求在 access 侧转换成了 55s 转发给 Consul-server。需要注意的是,这里的转换和原来没有区别,也是会在 55s 有变化的时候返回,但是大大降低了 CPU。上面两张 CPU 负载图是在运行了 3 天后的结果。
比起分享具体的某一次优化是怎么做的,更希望能给大家一些启发,某些看起来可能很难的事情,比如这里的 CPU 降低 60% 负载,但你去尝试优化了,可能发现还是比较容易的,但是成本收益很高,相比起某些架构上或者代码上的细节,一些点的修改可能会极大的影响稳定性。
4. CP系统如何做到高可用
讲了如何从功能纬度和性能纬度让整个服务发现系统更稳定,下面是如何让服务发现变得高可用。 Consul 是一个 CP 系统,根据 CAP 定理,CP 系统是没法做到高可用的,所以我们只能尽可能的在别的环节来加强,弥补一下 CP 系统的可用性。这里我会从客户端、SDK 和 access 层来分享如何尽量让整个系统更高可用。
为什么是在 SDK 和 access 层做增强呢,其实是因为 Consul-server 对我们来说是黑盒,我们不对他进行改造,因为不同于 access 层,修改 Consul 源码超出了大部分中小型公司的范围,在此我们不做定制化。
同样的,我们还是先分析没有 access 层的情况:
要搞清楚的一点是,服务注册发现三步骤中、注册、心跳和发现,出问题下分别会对系统造成什么样子的影响?
首先是注册,如果一个实例注册不上去,那么再有其他实例存在的情况下,对整体微服务是没有任何影响的。
然后是心跳,如果因为某个原因,比如网络闪断,或者丢包,丢失了心跳,那么会导致该节点从服务注册列表中下线。如果实例数少的情况下,部分实例下线会导致流量不均匀甚至整个系统垮掉。
最后发现,如果因为异常,比如 Consul 重启后丢失了数据,或者比如网络原因,服务提供者的实例没有注册上去,会导致拉取到的实例列表为 0,这里会直接造成服务不可用。
根据上面的分析,我们可以发现,首先要加强的是服务发现:
Spring 原生的服务发现是每 30s 左右去拉取新的实例列表,这里其实还有个 bug,这个参数无法通过配置来指定。
所以第一步,我们将定时拉取改为了 watch 机制。好处在于,某些实例如果已经反注册下线后,可以立刻通知客户端更新列表,否则在最多 30s 的时间内,可能请求还是会打到已经下线的机器上。
同时,增加了本地缓存,每次拉回来的服务列表,会存储在本地,这样如果机器 crash 或者因为某些原因,Consul 集群不可用时,不至于导致整个微服务系统全部不可用。
最后增加零实例保护,指的是,如果从 Consul 拉取的列表为空时,不替换内存中的数据,也不刷新缓存。因为如果 Consul 集群不可用,或者冷启动,或者其他不可预知的场景时,拉取回空列表会造成巨大的影响。
第二步我们要加强心跳上报的流程,心跳上报是 put 请求,所以这里需要设置 readtimeout,默认是 1min。
这里要注意的是,1min 可能是两个心跳周期,如果客户端和 Consul 之间的网络抖动或者丢包,会直接造成 1 分钟内不可用,所以这里首先要设置 readtimeout,一般推荐 5s 左右。
同时需要配套增加重试机制,否则一次失败就会导致一个生命周期掉线,这里推荐 3 次,配合上刚刚超时的 5s,一共 15s,小于一个周期的 30s。
说完 SDK 后,我们再来看看 access 层,在计算机系统里,每增加一个中间层,解决一些问题的同时,也会带来一些问题,特别是可用性这里,引入中间层,必须做的更多,才能保证和没有这一层一样的可用性。
和 SDK 不一样,access 对于客户端来说就是服务端,所以要尽可能的保证每个请求的成功,所以我们可以做一些通用性质的可用性增强建设:
第一,和 SDK 的一样,减少 timeout 和重试,但是由于 raft 的实现机制,我们只需要重试最多 1/2+1 次就行。
第二,当出现某个极端场景,比如整个 Consul-server 集群不可用,我们需要增加一个兜底集群。在某个集群整个不可用时,将流量转发到兜底集群,并做下记录,等服务发现等 get 类型请求时,需要知道从哪个集群拉取合并数据。
第三,我们还需要增加主动发现问题的能力,这里我们增加集群探测的 agent,定时发送请求给每台 access 和每个 Consul-server 集群,出了问题及时告警。
除了以上针对每个请求的通用能力建设外,我们还可以针对服务发现,做一些更多的增强。
首先如果真的出现内部错误,需要用 500 来代替空列表返回。这样不管是原生的 SDK 还是刚刚经过我们加强的 SDK,都不会替换内存里的列表,至少可以保证微服务系统继续运行。
然后,我们需要加入零实例保护的机制,这里和 SDK 有些区别,指的是如果发现所有实例都不可用,则以最近一个不可用的实例节点的最后心跳时间作为基线,往前一个心跳周期作为时间范围,将这个时间段的实例状态置为 passing 返回给客户端。
这里有点拗口,我们举个实际的例子,比如当前服务 B 有 10 个实例,其中 2 个在几个小时前就已经掉线了,还有 8 个在正常运行,此时,Consul 集群完全不可用十分钟,所以心跳信息无法上报,当 Consul 集群恢复时,access 会发现最近的心跳是 Consul 集群不可用那个时间点,也就是 10min 前,但是由于已经超过了一个心跳周期 30s,所以这里所有的实例都不可用,返回给客户端的话,会造成非常剧烈的影响。
但是如果增加了零实例保护,则会在返回实例列表时,发现最后一次上报心跳的节点在十分钟前,同时往前推 30s 发现一共有 8 个节点是在这个时间段丢失心跳,所以会将这 8 个实例返回给客户端。
当然,这里需要有个界限,一般选择 Consul-server 集群能够修复的时间,比如 1~3 小时,再长的话可能脏数据概率会比较大。
服务注册发现的话题先说到这,再来回顾我们通过增加 access 层和 token 模块,来实现了企业级能力,增强了功能性的稳定性。同时通过聚合连接以及多 Consul-server 集群模式,大大增强了整个 Consul 的性能稳定性,最后,通过一些高可用的增强,我们加强了整个服务发现系统的可用性。
在我们拥有一个稳定的服务发现系统之后,我们进入下一个话题,如何让服务之间的调用变得更加稳定。
5. 微服务之间如何互相调用
我们先来看看一个基于 Springcloud 开发的微服务之间的调用流程:
当用户发起 API 调用时,首先会来到 feign 模块。在 Springcloud 中,feign 起到了一个承上启下的作用,他封装了 http 的调用,让用户可以像使用接口一样的方式发起 rest 调用,同时也是在这里配置了需要访问的微服务名称。
feign 带着需要访问的服务名和拼接好的 http 请求来到了 ribbon 模块,
ribbon 简单来说就是一个负载均衡模块,如果给定的是 IP,则会直接像该IP发起调用,如果是服务名的话,会从服务注册发现模块中获取服务名对应的服务提供者列表,然后从中选择一台进行调用。
看起来整个调用过程非常简单,但是实际生产上,特别是流量较大的情况下,如果直接这么使用开源组件,并且使用默认配置的话,一旦某个环节出了一点问题,可能会直接导致一条线全部都崩溃。这也是很多研发遇到的问题。
那么如果在我不想改动业务代码的情况下,我们在这里又可以做哪些措施来让系统变得更稳定?
6. 基于开源我们还能多做些什么
整体的调用图和上一幅没有太大变化,这里我们增加了几个环节,下面来说一下。
首先,在 feign 和 ribbon 之间,增加了 hystrix 和 fallback。也是分别对应了熔断和容错这两个能力。
熔断这里要说的一点是,熔断本身不是万能药,一般来说,熔断只针对弱依赖,或者直白的说就是不那么重要的下游服务开启。
熔断这个能力是为了防止雪崩,所谓雪崩就是下游某个服务不可用时,调用该服务的服务调用方也会受到影响从而使得自身资源被吃光,也逐渐变得不可用。慢慢的整个微服务系统都开始变得瘫痪。
额外补充说明,在微服务体系或者说分布式系统里,服务半死不活远比服务整个宕掉难处理的多。
设想一下,假设某个下游服务完全挂了,进程也不存在,此时如果调用者调用该服务,得到的是 connection refuesd 异常,异常会非常快返回,所以就时间上来说和你调用成功没有太大区别,如果该服务是一个弱依赖服务,那影响更是微乎其微。
但如果下游服务不返回,上游调用者会一直阻塞在那里,随着请求的增多,会把线程池,连接池等资源都吃满,影响其他接口甚至导致整个都不可用。
那在这种场景下,我们需要一种办法,让我们达到和下游服务挂掉一样的快速失败,那就是熔断的作用。通过熔断模块,我们可以防止整个微服务被某一个半死不活的微服务拖死。同时也提醒大家,要重视快速识别出半死不活的那些机器。
说回容错,容错的使用的场景不是非常的频繁,更多是针对一些不那么重要的接口返回一些默认的数据,或者配和熔断等其他治理能力进行搭配使用。
当 ribbon 选定了实例之后,要正式发起调用的之前,可以添加一个重试和超时,加上重试和超时,如果真的能够配置好这几个参数,能将系统稳定性提升一大截的。
关于超时,很多人、甚至很多有一定经验的开发者都喜欢用默认的超时时间。一般来说,默认的超时时间都是分钟以上,有的框架甚至默认是 0,也就是没有超时。
这种行为在生产上十分危险,可能有一台服务提供者假死在那里,由于超时时间设置为 10 分钟,同时由于 loadbalance 的原因,使得线程慢慢积压起来,从而导致了自身系统的崩溃。
给出建议——绝对不要使用默认的超时,并且必须合理配置 timeout。超时一般来说主要是 connect timeout 和 read timeout,connect timeout 是指建立连接的超时时间,这个比较简单,一般同机房 5s 差不多了。
Read timeout 也就是 socketTimeout 需要用户根据自己下游接口的复杂度来配置,比如响应一般在毫秒级别的,我推荐设置 3s 到 5s 就行了,对一些秒级的 5s 也行,对于比较重要的大查询,耗时十几 s 到几十 s 的我建议设置为 1min,同时开启熔断,以及最好能够使用异步线程去将这种大请求和业务请求分离。
如果大家觉得这种需要根据每个请求来自行设定 read timeout 的方式太过于麻烦,其实还有一种更方便的方式,就是自适应超时。在网关如果传入的时候可以设置一个最大的超时时间,每个微服务都会将该时间传递下去,这样可以动态的设置当前请求的超时时间。
说完超时我们再来看一下重试,ribbon 其实自带了一些配置参数,常用的是 MaxAutoRetries,MaxAutoRetriesNextServer,OkToRetryOnAllOperations,这几个参数具体的含义和计算公式官网都有,这里不详细介绍了。
想提醒大家的是,对于一些幂等的请求,配合上较短的 timeout,合理的设置 1 次到 2 次重试,会让你整体的微服务系统更加稳定。
上面的所有增强其实不需要用户修改业务逻辑,基本上都是配置和依赖,但是正确的配置和使用这些开源组件,可以让你的整个系统变得更加稳定。下面看一些进阶场景。
四、稳定调用的学问
1. 更细粒度的降级
介绍了熔断,hystrix 是服务级别或者接口级别,但生产过程中,都是个别实例出现问题,特别是某一个实例假死或者不返回。
针对这种场景,hyxtrix 的熔断配置不是很灵活,因为它是通过整体的错误率来进行熔断的,一般一个实例异常是没有办法处罚熔断。
如果不改代码,我们有没有办法处理这种场景?我们其实可以通过减少 timeout 搭配上重试 1 次绕过该错误,如果是一个幂等的服务的话,一个实例坏掉,其实对整个系统不会有太大影响。
但如果是一个非幂等的,或者 put 请求,这里该怎么办?有没有办法不让失败率升高到 33% ?
TSF 用 resilience4j 重新自己实现了一整套的熔断,并加入了实例级别熔断的粒度,用来解决上述场景。
先说一下选型,为什么要用 resilience4j 来作为底座替换 hystrix?原因很简单,首先 resilience4j 是官方推荐的替代 hystrix 的框架,也更轻巧灵活。
它将容错、熔断、舱壁等治理能力独立,让用户可以自行组合,经过压测,发现单个 resilience4j 熔断器实例的 qps 可以高达百万,对应用不会造成额外的负担,所以这里选择基于 resilience4j 来进行二次开发。
熔断的原理是在发送请求前,根据当前想要访问的微服务或者接口,获取到对应的熔断器状态。如果是进入 open 状态,则直接拒绝调用,而在调用请求后,会将成功,失败的结果更新至对应的熔断器,如果失败比例超过了阈值,则打开熔断器,如果用户想扩展一些 metrics,比如慢调用等,只要记录时间,然后传给 resilience4j 熔断器即可, resilience4j 默认支持慢调用熔断。
再看一下实例级别的熔断,实例级别熔断和上面两个维度在实现上有些不同,它更多的是剔除有问题的节点。
具体的时机是在 ribbon从Consul 获取可用的服务列表后,会增加一步:判定当前访问的微服务有哪些节点是打开状态,然后需要将打开状态的节点从可用列表中剔除,然后再进行 loadbalance,这样就可以做到及时的将不可用节点剔除,大大降低失败率。
2. 如何升级应用
我们已经介绍了如何让服务发现和运行时调用变得更稳定,那么接下去,看看怎么让服务能够平滑的下线和上线。
可能有人会觉得奇怪,上下线能对系统稳定性造成什么影响?那我们就先来看一下简单的下线和上线会遇到什么问题:
首先是下线,一个考虑的比较周到的微服务框架在下线前会发送反注册请求给 Consul,然后再下线。看起来似乎没什么问题,但是从反注册发送到下线其实间隔很短。
这里如果用户用的是刚刚我们加强过的 SDK,也就是将 30s 定时轮训改为 watch 的机制后,影响大概在秒级。
具体影响的时间为从注册推送到 Consul,以及 Consul 到变更后返回给 watch 的客户端,然后客户端执行替换和缓存文件写入的时间,基本上在几百 ms 到 1s 左右。假设我们 qps 为 1000,服务实例列表为 10 台,那么大概会有 100 个请求失败。
如果用户用的是原生的 SpringSDK 的话,那么这里影响就比较大了,30 秒内会不停的有流量打过去,错误率会快速升高。
问题已经清楚,怎么解决?由于目前 k8S 是主流的容器编排的系统,所以介绍下如何利用 K8S 的一些特性,来做到优雅下线。
先明确这是因为反注册和 shutdown 间隔时间太短而导致的异常,如果我们能做到,先反注册,然后过 40s 再下线,那即使使用的是原生的 SDK,也不会有错误,因为最大 30s 后会更新到新的实例列表。
知道方法后,来看看在不改代码的情况下怎么做到优雅下线:
首先可以利用 k8s 自带的 pre-stophook 能力,这个 hook 是在需要 stop 容器之前进行的一个操作。当 pre-stop hook 执行完毕后才正式执行销毁容器。在 pre-stophook 里面进行反注册,成功后 sleep 35 秒,然后再执行 shutdown。
这里有个细节,虽然 pre-stophook 里面进行了反注册,但是应用还是会继续发心跳,所以需要在 Consul-access 层屏蔽掉发送了反注册请求实例节点的心跳数据。
我们再接着来看看优雅发布,正常发布会情况是,k8s 有两个状态,分别是 liveness 和 readiness。
默认情况下,容器启动了就算是 live了,启动完成后就变成了 ready 了。但大多数的 Spring 应用其实启动还是耗时间的,有的启动甚至需要好几分钟,如果 k8s 发布参数设置的不好,可能全部滚动更新后,所有的程序都还没初始化完毕,这样直接就导致整个服务全都不可用了。
我们如何避免以上情况?简单的办法是评估自己应用启动的时间,在 k8s 滚动发布的间隔参数配置的长一点,大于你预估的启动时间就行。那有没有更简单更自动的办法呢?
通过 k8s 的 readinessprobe,然后在 readinessprobe 中我们向 Consul 发送请求,查询该实例是否已经注册到 Consul 上,如果已经在 Consul 上了,则认为 ready。
因为一般来说,注册到 Consul 都已经是启动的最后一步了,通过这种方式,我们就可以不做任何干预的进行优雅发布。
从整个优雅发布和优雅下线可以看到,只是利用了原生 k8s 的 probe 能力,在正确的时间点,与 Consul 一起配合,就能没有任何异常的让系统稳定的更新,希望大家在自己的生产上也能用到这一点。
今天分享到此结束,感谢大家的观看!
五、Q&A
Q: hystrix 相对较重,技术选型的时候为何选择了hystrix?A: hystrix 确实比较重,特别像是线程池隔离,在线程较多的情况下,不管是上下文切换,还是线程本身带来的影响都是比较大的,所以后续我们在实现自己的熔断功能时使用了相对轻量级的 Resilience4j 来实现。如果大家有一定动手能力的话,我也推荐大家用 Resilience4j 来定制化开发。因为 hystrix 对于异步开发框架的新人来说,改造体验和断点体验都不是很友善。 Q: 熔断屏蔽掉出错节点后,需要把对应机器下掉或者告警出来吗?A:熔断其实是一个临时解决问题的方案,不能说有熔断就不去做异常的告警和发现,熔断可以在最短时间内帮助你快速降低错误异常率,但是真正要永久性的解决错误和异常率还是需要通过比较完整的告警机制以及监控机制快速定位节点并且把它剔除掉。
因为熔断从 close 状态到 open 状态把出错的服务器熔断掉,但是这里面的参数其实是有时间的,过了这段时间后,它会从 open 状态变为 halfopen 状态,而在 halfopen 状态再次回到 open 状态的时候,如果节点还是有问题,就会大量的抛错。
综上,如果有能力的话,请务必要把告警和监控机制完善好。 Q:Consul 本身有权限,为什么还开发 access 模块?A:如果想对 Consul 做进一步的开发,可以回看本视频,Access 的功能不仅仅是为了权限能力,它可以做非常多的事情,包括治理、限流、熔断和权限认证等,当然也包括需要增加性能优化,比如聚合连接。增加 Access 这一层从功能性到性能上都可以提升稳定性。
Q:java 除了 hystrix、sentinel 还有哪些熔断实现呢?A:熔断本身其实不是一个很难实现的系统,如果大家感兴趣的话可以自己去查一下。不管是 sentinel 还是 Resilience4j,真正核心的数据结构主要就是滑动窗口:sliding window,是用来统计单位时间内失败率的一个数据结构。当然不管是 sentinel 还是 Resilience4j,它们的性能都非常高。