架构设计、服务发现、健康监测
# 1. 架构设计:设计一个灵活的 RPC 框架
前面我们了解了 RPC 的通信原理和各个组件的作用,简单回顾下就是:“其实RPC就是把拦截到的方法参数,转成可以在网络中传输的二进制,并保证在服务提供方能正确地还原出语义,最终实现像调用本地一样地调用远程的目的。”
接下来,我们讲一下各个组件之间是如何完成数据交互的,这也是这一章的主题,一起搞清楚 RPC 的架构设计。
# 1.1 RPC 架构
架构设计就是从顶层角度触发,厘清各个模块组件之间数据交互的流程,让我们对系统有一个整体的宏观认识。我们先看看RPC里面都有哪些功能模块。
# 1.1.1 传输模块
RPC 的本质是远程调用,这个过程需要网络传输,为了屏蔽网络传输的复杂性,我们需要封装一个单独的数据传输模块用来收发二进制数据,这个单独模块我们可以叫做传输模块。
# 1.1.2 协议模块
RPC 出入参数均为对象数据,在网络传输中需要序列化为二进制,同时还需要增加“断句”符号来分隔不同的请求,这个过程叫做协议封装。
这两个过程都是为了保证数据传输后能还原出正确的语义,所以我们可以把这两个处理过程放在架构中的同一个模块,统称为协议模块。
除此之外,我们还可以在协议模块中加入压缩功能,比如传输前对请求数据包进行拆分,在接收方可以正确还原。
# 1.1.3 Bootstrap 模块
传输和协议是 RPC 里最基本的功能,但为了调用远程,并让两边的这些模块同时工作,我们需要手动粘合很多重复代码。为了把 RPC 里面的细节对研究人员进行屏蔽,让他们感觉不到本地调用和远程调用的区别,我们需要把 RPC 接口定义成一个 Spring Bean,然后可以在项目中通过 Spring 依赖注入到方式引用,这就是 RPC 调用的入口,我们一般叫做 Bootstrap 模块。
# 1.1.4 集群模块
学到这里,一个点对点(Point to Point)版本的 RPC 框架就完成了,我们一般称这种模式的 RPC 框架为单机版本,因为它没有集群能力。
所谓集群能力,就是针对同一个接口有着多个服务提供者,但这多个服务提供者对于我们的调用方来说是透明的,所以在RPC里面我们还需要给调用方找到所有的服务提供方,并需要在RPC里面维护好接口跟服务提供者地址的关系,这样调用方在发起请求的时候才能快速地找到对应的接收地址,这就是我们常说的“服务发现”。
但服务发现只是解决了接口和服务提供方地址映射关系的查找问题,这更多是一种“静态数据”。说它是静态数据是因为,对于我们的RPC来说,我们每次发送请求的时候都是需要用TCP连接的,相对服务提供方IP地址,TCP连接状态是瞬息万变的,所以我们的RPC框架里面要有连接管理器去维护TCP连接的状态。
有了集群之后,提供方可能就需要管理好这些服务了,那我们的RPC就需要内置一些服务治理的功能,比如服务提供方权重的设置、调用授权等一些常规治理手段。而服务调用方需要额外做哪些事情呢?每次调用前,我们都需要根据服务提供方设置的规则,从集群中选择可用的连接用于发送请求。
那到这儿,一个比较完善的RPC框架基本就完成了,功能也差不多就是这些了。按照分层设计的原则,我将这些功能模块分为了四层,具体内容见图示:
# 1.2 可扩展的架构
那 RPC 架构设计出来就完事了吗?当然不,技术迭代谁都躲不过。为了应对新需求,我们需要给架构留出可扩展性。我们设计 RPC 架构也是一样,我们不可能在开始时就面面俱到。那有没有更好的方式来解决这些问题呢?这就是我们接下来要讲的插件化架构。
在 RPC 框架里面,我们是怎么支持插件化架构的呢?我们可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。
在Java里面,JDK有自带的SPI(Service Provider Interface)服务发现机制,它可以动态地为某个接口寻找服务实现。使用SPI机制需要在Classpath下的META-INF/services目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。
但在实际项目中,我们其实很少使用到JDK自带的SPI机制,首先它不能按需加载,ServiceLoader加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费。另外就是扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成,比如扩展里面依赖了一个Spring Bean,原生的Java SPI就不支持。
加上了插件功能之后,我们的RPC框架就包含了两大核心体系——核心功能体系与插件体系,如下图所示:
这时,整个架构就变成了一个微内核架构,我们将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入RPC导致的包版本冲突问题。
# 1.3 总结
我们都知道软件开发的过程很复杂,不仅是因为业务需求经常变化,更难的是在开发过程中要保证团队成员的目标统一。我们需要用一种可沟通的话语、可“触摸”的愿景达成目标,我认为这就是软件架构设计的意义。
但仅从功能角度设计出的软件架构并不够健壮,系统不仅要能正确地运行,还要以最低的成本进行可持续的维护,因此我们十分有必要关注系统的可扩展性。只有这样,才能满足业务变化的需求,让系统的生命力不断延伸。
# 2. 服务发现:到底是要 CP 还是 AP?
今天聊一下 RPC 里面的“服务发现”在超大规模集群的场景下面临的挑战。
# 2.1 为什么需要服务发现
由于集群中 IP 随时变化,为了获取到对应的服务节点,我们需要“服务发现”,实现从调用接口 -> 服务 IP 的映射。如下图所示:
- 服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的IP和接口保存下来。
- 服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的IP,然后缓存到本地,并用于后续的远程调用。
# 2.2 为什么不使用 DNS?
服务发现的本质就是接口到服务提供者 IP 的映射,而如果我们把服务提供者 IP 统一换成域名,那利用成熟的 DNS 机制不就可以实现了吗?
我们简单看一下 DNS 的流程:
如果我们用DNS来实现服务发现,所有的服务提供者节点都配置在了同一个域名下,调用方的确可以通过DNS拿到随机的一个服务提供者的IP,并与之建立长连接,这看上去并没有太大问题,但在我们业界为什么很少用到这种方案呢?这主要有两个问题:
- 如果这个IP端口下线了,服务调用者能否及时摘除服务节点呢?
- 如果在之前已经上线了一部分服务节点,这时我突然对这个服务进行扩容,那么新上线的服务节点能否及时接收到流量呢?
这两个问题的答案都是:“不能”。这是因为为了提升性能和减少DNS服务的压力,DNS采取了多级缓存机制,一般配置的缓存时间较长,特别是JVM的默认缓存是永久有效的,所以说服务调用者不能及时感知到服务节点的变化。
这时你可能会想,我是不是可以加一个负载均衡设备(VIP)呢?将域名绑定到这台负载均衡设备上,通过DNS拿到负载均衡的IP。这样服务调用的时候,服务调用方就可以直接跟VIP建立连接,然后由VIP机器完成TCP转发,如下图所示:
这个方案确实能解决DNS遇到的一些问题,但在RPC场景里面也并不是很合适,原因有以下几点:
- 搭建负载均衡设备或TCP/IP四层代理,需求额外成本;
- 请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费些性能;
- 负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作和生效延迟;
- 我们在服务治理的时候,需要更灵活的负载均衡策略,目前的负载均衡设备的算法还满足不了灵活的需求。
由此可见,DNS 或者 VIP 方案虽然可以充当服务发现的角色,但在 RPC 场景里面直接用还是很难的。
# 2.3 基于 ZooKeeper 的服务发现
除了完成接口到服务 IP 的映射,我们还希望注册中心能完成实时变更推送,那像开源的 ZooKeeper、etcd 是不是就可以实现?我很肯定地说“确实可以”。下面我就来介绍下一种基于ZooKeeper的服务发现方式。
# 2.3.1 使用 ZooKeeper 进行服务发现
整体的思路很简单,就是搭建一个 ZooKeeper 集群作为注册中心集群,服务注册的时候只需要服务节点向 ZooKeeper 节点写入注册信息即可,利用 ZooKeeper 的 Watcher 机制完成服务订阅与服务下发功能,整体流程如下图:
- 服务平台管理端先在ZooKeeper中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别用来存储服务提供方的节点信息和服务调用方的节点信息。
- 当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息。
- 当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调用方watch该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
- 当服务提供方目录下有节点数据发生变更时,ZooKeeper就会通知给发起订阅的服务调用方。
# 2.3.2 ZooKeeper 出现的问题
我所在的技术团队早期使用的RPC框架服务发现就是基于ZooKeeper实现的,并且还平稳运行了一年多,但后续团队的微服务化程度越来越高之后,ZooKeeper集群整体压力也越来越高,尤其在集中上线的时候越发明显。“集中爆发”是在一次大规模上线的时候,当时有超大批量的服务节点在同时发起注册操作,ZooKeeper集群的CPU突然飙升,导致ZooKeeper集群不能工作了,而且我们当时也无法立马将ZooKeeper集群重新启动,一直到ZooKeeper集群恢复后业务才能继续上线。
经过我们的排查,引发这次问题的根本原因就是ZooKeeper本身的性能问题,当连接到ZooKeeper的节点数量特别多,对ZooKeeper读写特别频繁,且ZooKeeper存储的目录达到一定数量的时候,ZooKeeper将不再稳定,CPU持续升高,最终宕机。而宕机之后,由于各业务的节点还在持续发送读写请求,刚一启动,ZooKeeper就因无法承受瞬间的读写压力,马上宕机。
这次“意外”让我们意识到,ZooKeeper集群性能显然已经无法支撑我们现有规模的服务集群了,我们需要重新考虑服务发现方案。
# 2.3.3 基于消息总线的最终一致性的注册中心
我们知道,ZooKeeper的一大特点就是强一致性,ZooKeeper集群的每个节点的数据每次发生更新操作,都会通知其它ZooKeeper节点同时执行更新。它要求保证每个节点的数据能够实时的完全一致,这也就直接导致了ZooKeeper集群性能上的下降。这就好比几个人在玩传递东西的游戏,必须这一轮每个人都拿到东西之后,所有的人才能开始下一轮,而不是说我只要获得到东西之后,就可以直接进行下一轮了。
而RPC框架的服务发现,在服务节点刚上线时,服务调用方是可以容忍在一段时间之后(比如几秒钟之后)发现这个新上线的节点的。毕竟服务节点刚上线之后的几秒内,甚至更长的一段时间内没有接收到请求流量,对整个服务集群是没有什么影响的,所以我们可以牺牲掉CP(强制一致性),而选择AP(最终一致),来换取整个注册中心集群的性能和稳定性。
那么是否有一种简单、高效,并且最终一致的更新机制,能代替ZooKeeper那种数据强一致的数据更新机制呢?
因为要求最终一致性,我们可以考虑采用消息总线机制。注册数据可以全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性,具体流程如下图所示:
- 当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,推送给消息总线,每个消息都有整体递增的版本。
- 消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到消息的在消息回放模块里面回放,只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。
- 消费者订阅可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存里面。
- 采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进行合并。
为了性能,这里采用了两级缓存,注册中心和消费者的内存缓存,通过异步推拉模式来确保最终一致性。
另外,你也可能会想到,服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况,这个时候有没有问题?这个问题我们放到了RPC框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它节点。
通过消息总线的方式,我们就可以完成注册中心集群间数据变更的通知,保证数据的最终一致性,并能及时地触发注册中心的服务下发操作。在RPC领域精耕细作后,你会发现,服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略。
# 2.3.4 总结
今天我分享了RPC框架服务发现机制,以及如何用ZooKeeper完成“服务发现”,还有ZooKeeper在超大规模集群下作为注册中心所存在的问题。
通常我们可以使用ZooKeeper、etcd或者分布式缓存(如Hazelcast)来解决事件通知问题,但当集群达到一定规模之后,依赖的ZooKeeper集群、etcd集群可能就不稳定了,无法满足我们的需求。
在超大规模的服务集群下,注册中心所面临的挑战就是超大批量服务节点同时上下线,注册中心集群接受到大量服务变更请求,集群间各节点间需要同步大量服务节点数据,最终导致如下问题:
- 注册中心负载过高;
- 各节点数据不一致;
- 服务下发不及时或下发错误的服务节点列表。
RPC 框架依赖的注册中心的服务数据的一致性其实并不需要满足 CP,只要满足 AP 即可。我们就是采用“消息总线”的通知机制,来保证注册中心数据的最终一致性,来解决这些问题的。
另外,在今天的内容中,很多知识点不只可以应用到RPC框架的“服务发现”中。例如服务节点数据的推送采用增量更新的方式,这种方式提高了注册中心“服务下发”的效率,而这种方式,你还可以利用在其它地方,比如统一配置中心,用此方式可以提升统一配置中心下发配置的效率。
# 3. 健康检测:这个节点都挂了,为啥还要疯狂发请求?
接着,我们聊一下 RPC 中的健康检测。
因为有了集群,所以每次发请求前,RPC框架会根据路由和负载均衡算法选择一个具体的IP地址。为了保证请求成功,我们就需要确保每次选择出来的IP对应的连接是健康的,这个逻辑你应该理解。但你也知道,调用方跟服务集群节点之间的网络状况是瞬息万变的,两者之间可能会出现闪断或者网络设备损坏等情况,那怎么保证选择出来的连接一定是可用的呢?
从我的角度看,终极的解决方案是让调用方实时感知到节点的状态变化,在 RPC 框架里,我们应该怎么设计这套机制呢?
# 3.1 背景引入:实际遇到的问题
在进一步讲解服务健康检测之前,我想先和你分享一个我曾经遇到过的线上问题。
有一天,我们公司某个业务研发团队的负责人急匆匆跑过来,让我帮他解决个问题。仔细听完他的描述后,我才明白,原来是他们发现线上业务的某个接口可用性并不高,基本上十次调用里总会有几次失败。
查看了具体的监控数据之后,我们发现只有请求具体打到某台机器的时候才会有这个问题,也就是说,集群中有某台机器出了问题。于是快刀斩乱麻,我建议他们先把这台“问题机器”下线,以快速解决目前的问题。
但对于我来说,问题并没有结束,我开始进一步琢磨:“接口调用某台机器的时候已经出现不能及时响应了,那为什么RPC框架还会继续把请求发到这台有问题的机器上呢?RPC框架还会把请求发到这台机器上,也就是说从调用方的角度看,它没有觉得这台服务器有问题。”
就像警察破案一样,为了进一步了解事情的真相,我查看了问题时间点的监控和日志,在案发现场发现了这样几个线索:
- 通过日志发现请求确实会一直打到这台有问题的机器上,因为我看到日志里有很多超时的异常信息。
- 从监控上看,这台机器还是有一些成功的请求,这说明当时调用方跟服务之间的网络连接没有断开。因为如果连接断开之后,RPC框架会把这个节点标识为“不健康”,不会被选出来用于发业务请求。
- 深入进去看异常日志,我发现调用方到目标机器的定时心跳会有间歇性失败。
- 从目标机器的监控上可以看到该机器的网络指标有异常,出问题时间点TCP重传数比正常高10倍以上。
有了对这四个线索的分析,我基本上可以得出这样的结论:那台问题服务器在某些时间段出现了网络故障,但也还能处理部分请求。换句话说,它处于半死不活的状态。但是(是转折,也是关键点),它还没彻底“死”,还有心跳,这样,调用方就觉得它还正常,所以就没有把它及时挪出健康状态列表。
到这里,你应该也明白了,一开始,我们为了快速解决问题,手动把那台问题机器下线了。刨根问底之后,我们发现,其实更大的问题是我们的服务检测机制有问题,有的服务本来都已经病危了,但我们还以为人家只是个感冒。
接下来,我们就来看看服务健康检测的核心逻辑。
# 3.2 健康检测的逻辑
能不能通过“连接断开”这个事件来判断节点的健康状况并进行处理呢?其实在这里是不行的,因为应用健康状况不仅包括TCP连接状况,还包括应用本身是否存活,很多情况下TCP连接没有断开,但应用可能已经“僵死了”。
所以,业内常用的检测方法就是用心跳机制。心跳机制说起来也不复杂,其实就是服务调用方每隔一段时间就问一下服务提供方,“兄弟,你还好吧?”,然后服务提供方很诚实地告诉调用方它目前的状态。
结合前面的文章,你也不难想出来,服务方的状态一般会有三种情况,一个是我很好,一个是我生病了,一个是没回复。用专业的词来对应这三个状态就是:
- 健康状态:建立连接成功,并且心跳探活也一直成功
- 亚健康状态:建立连接成功,但是心跳请求连续失败
- 死亡状态:建立连接失败
节点的状态并不是固定不变的,它会根据心跳或者重连的结果来动态变化,具体状态间转换图如下:
这里你可以关注下几个状态之间的转换箭头,我再给你解释下。
- 首先,一开始初始化的时候,如果建立连接成功,那就是健康状态,否则就是死亡状态。这里没有亚健康这样的中间态。紧接着,如果健康状态的节点连续出现几次不能响应心跳请求的情况,那就会被标记为亚健康状态,也就是说,服务调用方会觉得它生病了。
- 生病之后(亚健康状态),如果连续几次都能正常响应心跳请求,那就可以转回健康状态,证明病好了。如果病一直好不了,那就会被断定为是死亡节点,死亡之后还需要善后,比如关闭连接。
- 当然,死亡并不是真正死亡,它还有复活的机会。如果某个时间点里,死亡的节点能够重连成功,那它就可以重新被标记为健康状态。
这就是整个节点的状态转换思路,你不用死记,它很简单,除了不能复活,其他都和我们人的状态一样。当服务调用方通过心跳机制了解了节点的状态之后,每次发请求的时候,就可以优先从健康列表里面选择一个节点。当然,如果健康列表为空,为了提高可用性,也可以尝试从亚健康列表里面选择一个,这就是具体的策略了。
# 3.3 具体的解决方案
理解了服务健康检测的逻辑,我们再回到开头我描述的场景里,看看怎么优化。现在你理解了,一个节点从健康状态过渡到亚健康状态的前提是“连续”心跳失败次数必须到达某一个阈值,比如3次(具体看你怎么配置了)。
而我们的场景里,节点的心跳日志只是间歇性失败,也就是时好时坏,这样,失败次数根本没到阈值,调用方会觉得它只是“生病”了,并且很快就好了。那怎么解决呢?我还是建议你先停下来想想。
你是不是会脱口而出,说改下配置,调低阈值呗。是的,这是最快的解决方法,但是我想说,它治标不治本。第一,像前面说的那样,调用方跟服务节点之间网络状况瞬息万变,出现网络波动的时候会导致误判。第二,在负载高情况,服务端来不及处理心跳请求,由于心跳时间很短,会导致调用方很快触发连续心跳失败而造成断开连接。
我们回到问题的本源,核心是服务节点网络有问题,心跳间歇性失败。我们现在判断节点状态只有一个维度,那就是心跳检测,那是不是可以再加上业务请求的维度呢?
起码我当时是顺着这个方向解决问题的。但紧接着,我又发现了新的麻烦:
- 调用方每个接口的调用频次不一样,有的接口可能1秒内调用上百次,有的接口可能半个小时才会调用一次,所以我们不能把简单的把总失败的次数当作判断条件。
- 服务的接口响应时间也是不一样的,有的接口可能1ms,有的接口可能是10s,所以我们也不能把TPS至来当作判断条件。
和同事讨论之后,我们找到了可用率这个突破口,应该相对完美了。可用率的计算方式是某一个时间窗口内接口调用成功次数的百分比(成功次数/总调用次数)。当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。
# 3.4 总结
这一讲我给你分享了RPC框架里面的一个核心的功能——健康检测,它能帮助我们从连接列表里面过滤掉一些存在问题的节点,避免在发请求的时候选择出有问题的节点而影响业务。但是在设计健康检测方案的时候,我们不能简单地从TCP连接是否健康、心跳是否正常等简单维度考虑,因为健康检测的目的就是要保证“业务无损”,所以在设计方案的时候,我们可以加入业务请求可用率因素,这样能最大化地提升RPC接口可用率。
正常情况下,我们大概30S会发一次心跳请求,这个间隔一般不会太短,如果太短会给服务节点造成很大的压力。但是如果太长的话,又不能及时摘除有问题的节点。
除了在RPC框架里面我们会有采用定时“健康检测”,其实在其它分布式系统设计的时候也会用到“心跳探活”机制。
比如在应用监控系统设计的时候,需要对不健康的应用实例进行报警,好让运维人员及时处理。和咱们RPC的例子一样,在这个场景里,你也不能简单地依赖端口的连通性来判断应用是否存活,因为在端口连通正常的情况下,应用也可能僵死了。
那有啥其他办法能处理应用僵死的情况吗?我们可以让每个应用实例提供一个“健康检测”的URL,检测程序定时通过构造HTTP请求访问该URL,然后根据响应结果来进行存活判断,这样就可以防止僵死状态的误判。你想想,这不就是咱们前面讲到的心跳机制吗?
不过,这个案例里,我还要卖个关子。加完心跳机制,是不是就没有问题了呢? 当然不是,因为检测程序所在的机器和目标机器之间的网络可能还会出现故障,如果真出现了故障,不就会误判吗?你以为人家已经生病或者挂了,其实是心跳仪器坏了…
根据我的经验,有一个办法可以减少误判的几率,那就是把检测程序部署在多个机器里面,分布在不同的机架,甚至不同的机房。因为网络同时故障的概率非常低,所以只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常。