07 | 架构设计:设计一个灵活的RPC框架 #
RPC 架构
说起架构设计,我相信你一定不陌生。我理解的架构设计呢,就是从顶层角度出发,厘清各模块组件之间数据交互的流程,让我们对系统有一个整体的宏观认识。我们先看看 RPC 里面都有哪些功能模块。
我们讲过,RPC 本质上就是一个远程调用,那肯定就需要通过网络来传输数据。虽然传输协议可以有多种选择,但考虑到可靠性的话,我们一般默认采用 TCP 协议。为了屏蔽网络传输的复杂性,我们需要封装一个单独的数据传输模块用来收发二进制数据,这个单独模块我们可以叫做传输模块。
用户请求的时候是基于方法调用,方法出入参数都是对象数据,对象是肯定没法直接在网络中传输的,我们需要提前把它转成可传输的二进制,这就是我们说的序列化过程。但只是把方法调用参数的二进制数据传输到服务提供方是不够的,我们需要在方法调用参数的二进制数据后面增加“断句”符号来分隔出不同的请求,在两个“断句”符号中间放的内容就是我们请求的二进制数据,这个过程我们叫做协议封装。
虽然这是两个不同的过程,但其目的都是一样的,都是为了保证数据在网络中可以正确传输。这里我说的正确,可不仅指数据能够传输,还需要保证传输后能正确还原出传输前的语义。所以我们可以把这两个处理过程放在架构中的同一个模块,统称为协议模块。
除此之外,我们还可以在协议模块中加入压缩功能,这是因为压缩过程也是对传输的二进制数据进行操作。在实际的网络传输过程中,我们的请求数据包在数据链路层可能会因为太大而被拆分成多个数据包进行传输,为了减少被拆分的次数,从而导致整个传输过程时间太长的问题,我们可以在 RPC 调用的时候这样操作:在方法调用参数或者返回值的二进制数据大于某个阈值的情况下,我们可以通过压缩框架进行无损压缩,然后在另外一端也用同样的压缩算法进行解压,保证数据可还原。
传输和协议这两个模块是 RPC 里面最基础的功能,它们使对象可以正确地传输到服务提供方。但距离 RPC 的目标——实现像调用本地一样地调用远程,还缺少点东西。因为这两个模块所提供的都是一些基础能力,要让这两个模块同时工作的话,我们需要手写一些黏合的代码,但这些代码对我们使用 RPC 的研发人员来说是没有意义的,而且属于一个重复的工作,会导致使用过程的体验非常不友好。
这就需要我们在 RPC 里面把这些细节对研发人员进行屏蔽,让他们感觉不到本地调用和远程调用的区别。假设有用到 Spring 的话,我们希望 RPC 能让我们把一个 RPC 接口定义成一个 Spring Bean,并且这个 Bean 也会统一被 Spring Bean Factory 管理,可以在项目中通过 Spring 依赖注入到方式引用。这是 RPC 调用的入口,我们一般叫做 Bootstrap 模块。
学到这儿,一个点对点(Point to Point)版本的 RPC 框架就完成了。我一般称这种模式的 RPC 框架为单机版本,因为它没有集群能力。所谓集群能力,就是针对同一个接口有着多个服务提供者,但这多个服务提供者对于我们的调用方来说是透明的,所以在 RPC 里面我们还需要给调用方找到所有的服务提供方,并需要在 RPC 里面维护好接口跟服务提供者地址的关系,这样调用方在发起请求的时候才能快速地找到对应的接收地址,这就是我们常说的“服务发现”。
但服务发现只是解决了接口和服务提供方地址映射关系的查找问题,这更多是一种“静态数据”。说它是静态数据是因为,对于我们的 RPC 来说,我们每次发送请求的时候都是需要用 TCP 连接的,相对服务提供方 IP 地址,TCP 连接状态是瞬息万变的,所以我们的 RPC 框架里面要有连接管理器去维护 TCP 连接的状态。
有了集群之后,提供方可能就需要管理好这些服务了,那我们的 RPC 就需要内置一些服务治理的功能,比如服务提供方权重的设置、调用授权等一些常规治理手段。而服务调用方需要额外做哪些事情呢?每次调用前,我们都需要根据服务提供方设置的规则,从集群中选择可用的连接用于发送请求。
那到这儿,一个比较完善的 RPC 框架基本就完成了,功能也差不多就是这些了。按照分层设计的原则,我将这些功能模块分为了四层,具体内容见图示:
可扩展的架构
那 RPC 架构设计出来就完事了吗?当然不,技术迭代谁都躲不过。
不知道你有没有这样的经历,你设计的一个系统它看上去很完善,也能很好地运行,然后你成功地把它交付给了业务方。有一天业务方有了新的需求,要加入很多新的功能,这时候你就会发现当前架构面临的可就是大挑战了,要修改很多地方才能实现。
举个例子,假如你设计了一个商品发布系统,早些年我们只能在网上购买电脑、衣服等实物商品,但现在发展成可以在网上购买电话充值卡、游戏点卡等虚拟商品,实物商品的发布流程是需要选择购买区域的,但虚拟商品并没有这一限制。如果你想要在一套发布系统里面同时完成实物和虚拟商品发布的话,你就只能在代码里面加入很多的 if else 判断逻辑,这样是能行,可整个代码就臃肿、杂乱了,后期也极难维护。
其实,我们设计 RPC 框架也是一样的,我们不可能在开始时就面面俱到。那有没有更好的方式来解决这些问题呢?这就是我们接下来要讲的插件化架构。
在 RPC 框架里面,我们是怎么支持插件化架构的呢?我们可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。在 Java 里面,JDK 有自带的**SPI(Service Provider Interface)**服务发现机制,它可以动态地为某个接口寻找服务实现。使用 SPI 机制需要在 Classpath 下的 META-INF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。
但在实际项目中,我们其实很少使用到 JDK 自带的 SPI 机制,首先它不能按需加载,ServiceLoader 加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费。另外就是扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成,比如扩展里面依赖了一个 Spring Bean,原生的 Java SPI 就不支持。
加上了插件功能之后,我们的 RPC 框架就包含了两大核心体系——核心功能体系与插件体系,如下图所示:
这时,整个架构就变成了一个微内核架构,我们将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题。
08 | 服务发现:到底是要CP还是AP? #
为什么需要服务发现?
先举个例子,假如你要给一位以前从未合作过的同事发邮件请求帮助,但你却没有他的邮箱地址。这个时候你会怎么办呢?如果是我,我会选择去看公司的企业“通信录”。
同理,为了高可用,在生产环境中服务提供方都是以集群的方式对外提供服务,集群里面的这些 IP 随时可能变化,我们也需要用一本“通信录”及时获取到对应的服务节点,这个获取的过程我们一般叫作“服务发现”。
对于服务调用方和服务提供方来说,其契约就是接口,相当于“通信录”中的姓名,服务节点就是提供该契约的一个具体实例。服务 IP 集合作为“通信录”中的地址,从而可以通过接口获取服务 IP 的集合来完成服务的发现。这就是我要说的 RPC 框架的服务发现机制,如下图所示:
- 服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的 IP 和接口保存下来。
- 服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的 IP,然后缓存到本地,并用于后续的远程调用。
为什么不使用 DNS?
既然服务发现这么“厉害”,那是不是很难实现啊?其实类似机制一直在我们身边,我们回想下服务发现的本质,就是完成了接口跟服务提供者 IP 的映射。那我们能不能把服务提供者 IP 统一换成一个域名啊,利用已经成熟的 DNS 机制来实现?
好,先带着这个问题,简单地看下 DNS 的流程
如果我们用 DNS 来实现服务发现,所有的服务提供者节点都配置在了同一个域名下,调用方的确可以通过 DNS 拿到随机的一个服务提供者的 IP,并与之建立长连接,这看上去并没有太大问题,但在我们业界为什么很少用到这种方案呢?不知道你想过这个问题没有,如果没有,现在可以停下来想想这样两个问题:
- 如果这个 IP 端口下线了,服务调用者能否及时摘除服务节点呢?
- 如果在之前已经上线了一部分服务节点,这时我突然对这个服务进行扩容,那么新上线的服务节点能否及时接收到流量呢?
这两个问题的答案都是:“不能”。这是因为为了提升性能和减少 DNS 服务的压力,DNS 采取了多级缓存机制,一般配置的缓存时间较长,特别是 JVM 的默认缓存是永久有效的,所以说服务调用者不能及时感知到服务节点的变化。
这时你可能会想,我是不是可以加一个负载均衡设备呢?将域名绑定到这台负载均衡设备上,通过 DNS 拿到负载均衡的 IP。这样服务调用的时候,服务调用方就可以直接跟 VIP 建立连接,然后由 VIP 机器完成 TCP 转发,如下图所示:
这个方案确实能解决 DNS 遇到的一些问题,但在 RPC 场景里面也并不是很合适,原因有以下几点:
- 搭建负载均衡设备或 TCP/IP 四层代理,需求额外成本;
- 请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费些性能;
- 负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作和生效延迟;
- 我们在服务治理的时候,需要更灵活的负载均衡策略,目前的负载均衡设备的算法还满足不了灵活的需求。
由此可见,DNS 或者 VIP 方案虽然可以充当服务发现的角色,但在 RPC 场景里面直接用还是很难的。
基于 ZooKeeper 的服务发现
那么在 RPC 里面我们该如何实现呢?我们还是要回到服务发现的本质,就是完成接口跟服务提供者 IP 之间的映射。这个映射是不是就是一种命名服务?当然,我们还希望注册中心能完成实时变更推送,是不是像开源的 ZooKeeper、etcd 就可以实现?我很肯定地说“确实可以”。下面我就来介绍下一种基于 ZooKeeper 的服务发现方式。
整体的思路很简单,就是搭建一个 ZooKeeper 集群作为注册中心集群,服务注册的时候只需要服务节点向 ZooKeeper 节点写入注册信息即可,利用 ZooKeeper 的 Watcher 机制完成服务订阅与服务下发功能,整体流程如下图:
- 服务平台管理端先在 ZooKeeper 中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别用来存储服务提供方的节点信息和服务调用方的节点信息。
- 当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息。
- 当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调用方 watch 该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
- 当服务提供方目录下有节点数据发生变更时,ZooKeeper 就会通知给发起订阅的服务调用方。
我所在的技术团队早期使用的 RPC 框架服务发现就是基于 ZooKeeper 实现的,并且还平稳运行了一年多,但后续团队的微服务化程度越来越高之后,ZooKeeper 集群整体压力也越来越高,尤其在集中上线的时候越发明显。“集中爆发”是在一次大规模上线的时候,当时有超大批量的服务节点在同时发起注册操作,ZooKeeper 集群的 CPU 突然飙升,导致 ZooKeeper 集群不能工作了,而且我们当时也无法立马将 ZooKeeper 集群重新启动,一直到 ZooKeeper 集群恢复后业务才能继续上线。
zookeeper作为注册中心时,当服务节点数量达到一定规模时,会出现性能问题,主要是由于其保证强一致性。
经过我们的排查,引发这次问题的根本原因就是 ZooKeeper 本身的性能问题,当连接到 ZooKeeper 的节点数量特别多,对 ZooKeeper 读写特别频繁,且 ZooKeeper 存储的目录达到一定数量的时候,ZooKeeper 将不再稳定,CPU 持续升高,最终宕机。而宕机之后,由于各业务的节点还在持续发送读写请求,刚一启动,ZooKeeper 就因无法承受瞬间的读写压力,马上宕机。
这次“意外”让我们意识到,ZooKeeper 集群性能显然已经无法支撑我们现有规模的服务集群了,我们需要重新考虑服务发现方案。
基于消息总线的最终一致性的注册中心
我们知道,ZooKeeper 的一大特点就是强一致性,ZooKeeper 集群的每个节点的数据每次发生更新操作,都会通知其它 ZooKeeper 节点同时执行更新。它要求保证每个节点的数据能够实时的完全一致,这也就直接导致了 ZooKeeper 集群性能上的下降。这就好比几个人在玩传递东西的游戏,必须这一轮每个人都拿到东西之后,所有的人才能开始下一轮,而不是说我只要获得到东西之后,就可以直接进行下一轮了。
而 RPC 框架的服务发现,在服务节点刚上线时,服务调用方是可以容忍在一段时间之后(比如几秒钟之后)发现这个新上线的节点的。毕竟服务节点刚上线之后的几秒内,甚至更长的一段时间内没有接收到请求流量,对整个服务集群是没有什么影响的,所以我们可以牺牲掉 CP(强制一致性),而选择 AP(最终一致),来换取整个注册中心集群的性能和稳定性。
那么是否有一种简单、高效,并且最终一致的更新机制,能代替 ZooKeeper 那种数据强一致的数据更新机制呢?
因为要求最终一致性,我们可以考虑采用消息总线机制。注册数据可以全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性,具体流程如下图所示:
- 当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,推送给消息总线,每个消息都有整体递增的版本。
- 消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到消息的在消息回放模块里面回放,只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。
- 消费者订阅可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存里面。
- 采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进行合并。
为了性能,这里采用了两级缓存,注册中心和消费者的内存缓存,通过异步推拉模式来确保最终一致性。
另外,你也可能会想到,服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况,这个时候有没有问题?这个问题我们放到了 RPC 框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它节点。
通过消息总线的方式,我们就可以完成注册中心集群间数据变更的通知,保证数据的最终一致性,并能及时地触发注册中心的服务下发操作。在 RPC 领域精耕细作后,你会发现,服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略。
思考题:目前服务提供者上线后会自动注册到注册中心,服务调用方会自动感知到新增的实例,并且流量会很快打到该新增的实例。如果我想把某些服务提供者实例的流量切走,除了下线实例,你有没有想到其它更便捷的办法呢?
解决这个问题的方法还是有很多的,比如留言中提到的改变服务提供者实例的权重,将权重调整为 0,或者通过路由的方式也可以。
但解决这个问题最便捷的方式还是使用动态分组,通过业务分组来实现流量隔离。如果业务分组是动态的,我们就可以在管理平台动态地自由调整,那是不是就可以实现动态地流量切换了呢?
09 | 健康检测:这个节点都挂了,为啥还要疯狂发请求? #
服务发现的作用就是实时感知集群 IP 的变化,实现接口跟服务集群节点 IP 的映射。在超大规模集群实战中,我们更多需要考虑的是保证最终一致性。其实总结来说,就一关键词,你要记住“推拉结合,以拉为准”。接着昨天的内容,我们再来聊聊 RPC 中的健康检测。
因为有了集群,所以每次发请求前,RPC 框架会根据路由和负载均衡算法选择一个具体的 IP 地址。为了保证请求成功,我们就需要确保每次选择出来的 IP 对应的连接是健康的,这个逻辑你应该理解。
但你也知道,调用方跟服务集群节点之间的网络状况是瞬息万变的,两者之间可能会出现闪断或者网络设备损坏等情况,那怎么保证选择出来的连接一定是可用的呢?
从我的角度看,终极的解决方案是让调用方实时感知到节点的状态变化,这样他们才能做出正确的选择。这个道理像我们开车一样,车有各种各样的零件,我们不可能在开车之前先去挨个检查下他们的健康情况,转而是应该有一套反馈机制,比如今天我的大灯坏了,那中控台就可以给我提示;明天我的胎压不够了,中控台也能够收到提示。汽车中大部分关键零件的状态变化,我作为调用方,都能够第一时间了解。
那回到 RPC 框架里,我们应该怎么设计这套机制呢?你可以先停下来想想汽车的例子,看看他们是怎么做的。当然,回到我们 RPC 的框架里,这事用专业一点的词来说就是服务的健康检测。今天我们就来详细聊聊这个话题。
遇到的问题
在进一步讲解服务健康检测之前,我想先和你分享一个我曾经遇到过的线上问题。
有一天,我们公司某个业务研发团队的负责人急匆匆跑过来,让我帮他解决个问题。仔细听完他的描述后,我才明白,原来是他们发现线上业务的某个接口可用性并不高,基本上十次调用里总会有几次失败。
查看了具体的监控数据之后,我们发现只有请求具体打到某台机器的时候才会有这个问题,也就是说,集群中有某台机器出了问题。于是快刀斩乱麻,我建议他们先把这台“问题机器”下线,以快速解决目前的问题。
但对于我来说,问题并没有结束,我开始进一步琢磨:“接口调用某台机器的时候已经出现不能及时响应了,那为什么 RPC 框架还会继续把请求发到这台有问题的机器上呢?RPC 框架还会把请求发到这台机器上,也就是说从调用方的角度看,它没有觉得这台服务器有问题。”
就像警察破案一样,为了进一步了解事情的真相,我查看了问题时间点的监控和日志,在案发现场发现了这样几个线索:
- 通过日志发现请求确实会一直打到这台有问题的机器上,因为我看到日志里有很多超时的异常信息。
- 从监控上看,这台机器还是有一些成功的请求,这说明当时调用方跟服务之间的网络连接没有断开。因为如果连接断开之后,RPC 框架会把这个节点标识为“不健康”,不会被选出来用于发业务请求。
- 深入进去看异常日志,我发现调用方到目标机器的定时心跳会有间歇性失败。
- 从目标机器的监控上可以看到该机器的网络指标有异常,出问题时间点 TCP 重传数比正常高 10 倍以上。
有了对这四个线索的分析,我基本上可以得出这样的结论:那台问题服务器在某些时间段出现了网络故障,但也还能处理部分请求。换句话说,它处于半死不活的状态。但是(是转折,也是关键点),它还没彻底“死”,还有心跳,这样,调用方就觉得它还正常,所以就没有把它及时挪出健康状态列表。
到这里,你应该也明白了,一开始,我们为了快速解决问题,手动把那台问题机器下线了。刨根问底之后,我们发现,其实更大的问题是我们的服务检测机制有问题,有的服务本来都已经病危了,但我们还以为人家只是个感冒。
接下来,我们就来看看服务检测的核心逻辑。
健康检测的逻辑
刚刚我们提到了心跳机制,我估计你会想,搞什么心跳,是不是我们把问题复杂化了。当服务方下线,正常情况下我们肯定会收到连接断开的通知事件,在这个事件里面直接加处理逻辑不就可以了?是的,我们前面汽车的例子里检测都是这样做的。但咱们这里不行,因为应用健康状况不仅包括 TCP 连接状况,还包括应用本身是否存活,很多情况下 TCP 连接没有断开,但应用可能已经“僵死了”。
所以,业内常用的检测方法就是用心跳机制。心跳机制说起来也不复杂,其实就是服务调用方每隔一段时间就问一下服务提供方,“兄弟,你还好吧?”,然后服务提供方很诚实地告诉调用方它目前的状态。
结合前面的文章,你也不难想出来,服务方的状态一般会有三种情况,一个是我很好,一个是我生病了,一个是没回复。用专业的词来对应这三个状态就是:
- 健康状态:建立连接成功,并且心跳探活也一直成功;
- 亚健康状态:建立连接成功,但是心跳请求连续失败;
- 死亡状态:建立连接失败。
节点的状态并不是固定不变的,它会根据心跳或者重连的结果来动态变化,具体状态间转换图如下:
这里你可以关注下几个状态之间的转换箭头,我再给你解释下。首先,一开始初始化的时候,如果建立连接成功,那就是健康状态,否则就是死亡状态。这里没有亚健康这样的中间态。紧接着,如果健康状态的节点连续出现几次不能响应心跳请求的情况,那就会被标记为亚健康状态,也就是说,服务调用方会觉得它生病了。
生病之后(亚健康状态),如果连续几次都能正常响应心跳请求,那就可以转回健康状态,证明病好了。如果病一直好不了,那就会被断定为是死亡节点,死亡之后还需要善后,比如关闭连接。
当然,死亡并不是真正死亡,它还有复活的机会。如果某个时间点里,死亡的节点能够重连成功,那它就可以重新被标记为健康状态。
这就是整个节点的状态转换思路,你不用死记,它很简单,除了不能复活,其他都和我们人的状态一样。当服务调用方通过心跳机制了解了节点的状态之后,每次发请求的时候,就可以优先从健康列表里面选择一个节点。当然,如果健康列表为空,为了提高可用性,也可以尝试从亚健康列表里面选择一个,这就是具体的策略了。
具体的解决方案
理解了服务健康检测的逻辑,我们再回到开头我描述的场景里,看看怎么优化。现在你理解了,一个节点从健康状态过渡到亚健康状态的前提是“连续”心跳失败次数必须到达某一个阈值,比如 3 次(具体看你怎么配置了)。
而我们的场景里,节点的心跳日志只是间歇性失败,也就是时好时坏,这样,失败次数根本没到阈值,调用方会觉得它只是“生病”了,并且很快就好了。那怎么解决呢?我还是建议你先停下来想想。
你是不是会脱口而出,说改下配置,调低阈值呗。是的,这是最快的解决方法,但是我想说,它治标不治本。第一,像前面说的那样,调用方跟服务节点之间网络状况瞬息万变,出现网络波动的时候会导致误判。第二,在负载高情况,服务端来不及处理心跳请求,由于心跳时间很短,会导致调用方很快触发连续心跳失败而造成断开连接。
我们回到问题的本源,核心是服务节点网络有问题,心跳间歇性失败。我们现在判断节点状态只有一个维度,那就是心跳检测,那是不是可以再加上业务请求的维度呢?
起码我当时是顺着这个方向解决问题的。但紧接着,我又发现了新的麻烦:
- 调用方每个接口的调用频次不一样,有的接口可能 1 秒内调用上百次,有的接口可能半个小时才会调用一次,所以我们不能把简单的把总失败的次数当作判断条件。
- 服务的接口响应时间也是不一样的,有的接口可能 1ms,有的接口可能是 10s,所以我们也不能把 TPS 至来当作判断条件。
和同事讨论之后,我们找到了可用率这个突破口,应该相对完美了。可用率的计算方式是某一个时间窗口内接口调用成功次数的百分比(成功次数 / 总调用次数)。当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。
10 | 路由策略:怎么让请求按照设定的规则发到不同的节点上? #
为什么选择路由策略?
在前面我们提到过,在真实环境中我们的服务提供方是以一个集群的方式提供服务,这对于服务调用方来说,就是一个接口会有多个服务提供方同时提供服务,所以我们的 RPC 在每次发起请求的时候,都需要从多个服务提供方节点里面选择一个用于发请求的节点。
既然这些节点都可以用来完成这次请求,那么我们就可以简单地认为这些节点是同质的。这里的同质怎么理解呢?就是这次请求无论发送到集合中的哪个节点上,返回的结果都是一样的。
既然服务提供方是以集群的方式对外提供服务,那就要考虑一些实际问题。要知道我们每次上线应用的时候都不止一台服务器会运行实例,那上线就涉及到变更,只要变更就可能导致原本正常运行的程序出现异常,尤其是发生重大变动的时候,导致我们应用不稳定的因素就变得很多。
为了减少这种风险,我们一般会选择灰度发布我们的应用实例,比如我们可以先发布少量实例观察是否有异常,后续再根据观察的情况,选择发布更多实例还是回滚已经上线的实例。
但这种方式不好的一点就是,线上一旦出现问题,影响范围还是挺大的。因为对于我们的服务提供方来说,我们的服务会同时提供给很多调用方来调用,尤其是像一些基础服务的调用方会更复杂,比如商品、价格等等,一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损。
那对于我们的 RPC 框架来说,有什么的办法可以减少上线变更导致的风险吗?这就不得不提路由在 RPC 中的应用。具体好在哪里,怎么实现,我们接着往下看。
如何实现路由策略?
可能你会说,我们可以在上线前把所有的场景都重新测试一遍啊?这也是一种方法,而且测试肯定是上线前的一个重要环节。但以我个人的经验来看,由于线上环境太复杂了,单纯从测试角度出发只能降低风险出现的概率,想要彻底验证所有场景基本是不可能的。
那如果没法 100% 规避风险,我们还能怎么办?我认为只有一条路可以尝试了,就是尽量减小上线出问题导致业务受损的范围。基于这个思路,我们是不是可以在上线完成后,先让一小部分调用方请求过来进行逻辑验证,待没问题后再接入其他调用方,从而实现流量隔离的效果。那在 RPC 框架里面我们具体该怎么实现呢?
我们在服务发现那讲讲过,在 RPC 里面服务调用方是通过服务发现的方式拿到了所有服务提供方的 IP 地址,那我们是不是就可以利用这个特点?当我们选择要灰度验证功能的时候,是不是就可以让注册中心在推送的时候区别对待,而不是一股脑的把服务提供方的 IP 地址推送到所有调用方。换句话说就是,注册中心只会把刚上线的服务 IP 地址推送到选择指定的调用方,而其他调用方是不能通过服务发现拿到这个 IP 地址的。
使用路由策略进行灰度发布(实际中因为注册中心主要是存储配置,所以并不会将路由策略的功能放在注册中心上)
通过服务发现的方式来隔离调用方请求,从逻辑上来看确实可行,但注册中心在 RPC 里面的定位是用来存储数据并保证数据一致性的。如果把这种复杂的计算逻辑放到注册中心里面,当集群节点变多之后,就会导致注册中心压力很大,而且大部分情况下我们一般都是采用开源软件来搭建注册中心,要满足这种需求还需要进行二次开发。所以从实际的角度出发,通过影响服务发现来实现请求隔离并不划算。上面就是不在注册中心里面添加灰度逻辑的原因。
我们可以重新回到调用方发起 RPC 调用的流程。在 RPC 发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(就是我们常说的负载均衡),那我们是不是可以在选择节点前加上“筛选逻辑”,把符合我们要求的节点筛选出来。那这个筛选的规则是什么呢?就是我们前面说的灰度过程中要验证的规则。
举个具体例子你可能就明白了,比如我们要求新上线的节点只允许某个 IP 可以调用,那我们的注册中心会把这条规则下发到服务调用方。在调用方收到规则后,在选择具体要发请求的节点前,会先通过筛选规则过滤节点集合,按照这个例子的逻辑,最后会过滤出一个节点,这个节点就是我们刚才新上线的节点。通过这样的改造,RPC 调用流程就变成了这样:
这个筛选过程在我们的 RPC 里面有一个专业名词,就是“路由策略”,而上面例子里面的路由策略是我们常见的 IP 路由策略,用于限制可以调用服务提供方的 IP。使用了 IP 路由策略后,整个集群的调用拓扑如下图所示:
参数路由
有了 IP 路由之后,上线过程中我们就可以做到只让部分调用方请求调用到新上线的实例,相对传统的灰度发布功能来说,这样做我们可以把试错成本降到最低。
但在有些场景下,我们可能还需要更细粒度的路由方式。比如,在升级改造应用的时候,为了保证调用方能平滑地切调用我们的新应用逻辑,在升级过程中我们常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了 100% 且运行一段时间后才能去下线老应用。
在流量切换的过程中,为了保证整个流程的完整性,我们必须保证某个主题对象的所有请求都使用同一种应用来承接。假设我们改造的是商品应用,那主题对象肯定是商品 ID,在切流量的过程中,我们必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。
很显然,上面的 IP 路由并不能满足我们这个需求,因为 IP 路由只是限制调用方来源,并不会根据请求参数请求到我们预设的服务提供方节点上去。
那我们怎么利用路由策略实现这个需求呢?其实你只要明白路由策略的本质,就不难明白这种参数路由的实现。
我们可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,我们可以很容易地拿到请求参数,也就是我们例子中的商品 ID,我们可以根据注册中心下发的规则来判断当前商品 ID 的请求是过滤掉新应用还是老应用的节点。因为规则对所有的调用方都是一样的,从而保证对应同一个商品 ID 的请求要么是新应用的节点,要么是老应用的节点。使用了参数路由策略后,整个集群的调用拓扑如下图所示:
相比 IP 路由,参数路由支持的灰度粒度更小,他为服务提供方应用提供了另外一个服务治理的手段。灰度发布功能是 RPC 路由功能的一个典型应用场景,通过 RPC 路由策略的组合使用可以让服务提供方更加灵活地管理、调用自己的流量,进一步降低上线可能导致的风险。
11 | 负载均衡:节点负载差距这么大,为什么收到的流量还一样? #
一个需求
在进入主题之前,我想先和你分享一个需求,这是我们公司的业务部门给我们提的。他们反馈的问题是这样的:有一次碰上流量高峰,他们突然发现线上服务的可用率降低了,经过排查发现,是因为其中有几台机器比较旧了。当时最早申请的一批容器配置比较低,缩容的时候留下了几台,当流量达到高峰时,这几台容器由于负载太高,就扛不住压力了。业务问我们有没有好的服务治理策略?
这个问题其实挺好解决的,我们当时给出的方案是:在治理平台上调低这几台机器的权重,这样的话,访问的流量自然就减少了。
但业务接着反馈了,说:当他们发现服务可用率降低的时候,业务请求已经受到影响了,这时再如此解决,需要时间啊,那这段时间里业务可能已经有损失了。紧接着他们就提出了需求,问:RPC 框架有没有什么智能负载的机制?能否及时地自动控制服务节点接收到的访问量?
这个需求其实很合理,这也是一个比较普遍的问题。确实,虽说我们的服务治理平台能够动态地控制线上服务节点接收的访问量,但当业务方发现部分机器负载过高或者响应变慢的时候再去调整节点权重,真的很可能已经影响到线上服务的可用率了。
看到这儿,你有没有想到什么好的处理方案呢?接下来,我们就以这个问题为背景,一起看看 RPC 框架的负载均衡。
什么是负载均衡?
我先来简单地介绍下负载均衡。当我们的一个服务节点无法支撑现有的访问量时,我们会部署多个节点,组成一个集群,然后通过负载均衡,将请求分发给这个集群下的每个服务节点,从而达到多个服务节点共同分担请求压力的目的。
负载均衡主要分为软负载和硬负载,软负载就是在一台或多台服务器上安装负载均衡的软件,如 LVS、Nginx 等,硬负载就是通过硬件设备来实现的负载均衡,如 F5 服务器等。负载均衡的算法主要有随机法、轮询法、最小连接法等。
我刚才介绍的负载均衡主要还是应用在 Web 服务上,Web 服务的域名绑定负载均衡的地址,通过负载均衡将用户的请求分发到一个个后端服务上。
RPC 框架中的负载均衡
那 RPC 框架中的负载均衡是不是也是如此呢?和我上面讲的负载均衡,你觉得会有区别吗?
之前讲过为什么不通过 DNS 来实现“服务发现”,之后我又讲解了为什么不采用添加负载均衡设备或者 TCP/IP 四层代理,域名绑定负载均衡设备的 IP 或者四层代理 IP 的方式。
我的回答是这种方式会面临这样几个问题:
- 搭建负载均衡设备或 TCP/IP 四层代理,需要额外成本;
- 请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费一些性能;
- 负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作,“服务发现”在操作上是个问题;
- 我们在服务治理的时候,针对不同接口服务、服务的不同分组,我们的负载均衡策略是需要可配的,如果大家都经过这一个负载均衡设备,就不容易根据不同的场景来配置不同的负载均衡策略了。
我相信看到这儿,你应该已经知道了 RPC 实现的负载均衡所采用的策略与传统的 Web 服务实现负载均衡所采用策略的不同之处了。
RPC 的负载均衡完全由 RPC 框架自身实现,RPC 的服务调用者会与“注册中心”下发的所有服务节点建立长连接,在每次发起 RPC 调用时,服务调用者都会通过配置的负载均衡插件,自主选择一个服务节点,发起 RPC 调用请求。
RPC 负载均衡策略一般包括随机权重、Hash、轮询。当然,这还是主要看 RPC 框架自身的实现。其中的随机权重策略应该是我们最常用的一种了,通过随机算法,我们基本可以保证每个节点接收到的请求流量是均匀的;同时我们还可以通过控制节点权重的方式,来进行流量控制。比如我们默认每个节点的权重都是 100,但当我们把其中的一个节点的权重设置成 50 时,它接收到的流量就是其他节点的 1/2。
这几种负载均衡算法的实现还是很简单的,网上资料也非常多,在这我就不过多介绍了。有什么问题,咱们可以在留言区交流。
由于负载均衡机制完全是由 RPC 框架自身实现的,所以它不再需要依赖任何负载均衡设备,自然也不会发生负载均衡设备的单点问题,服务调用方的负载均衡策略也完全可配,同时我们可以通过控制权重的方式,对负载均衡进行治理。
了解完 RPC 框架的负载均衡,现在我们就可以回到这讲最开头业务提的那个需求:有没有什么办法可以动态地、智能地控制线上服务节点所接收到的请求流量?
现在答案是不是就显而易见了,解决问题的关键就在于 RPC 框架的负载均衡上。对于这个问题,我们当时的方案就是,设计一种自适应的负载均衡策略。
如何设计自适应的负载均衡?
我刚才讲过,RPC 的负载均衡完全由 RPC 框架自身实现,服务调用者发起请求时,会通过配置的负载均衡插件,自主地选择服务节点。那是不是只要调用者知道每个服务节点处理请求的能力,再根据服务处理节点处理请求的能力来判断要打给它多少流量就可以了?当一个服务节点负载过高或响应过慢时,就少给它发送请求,反之则多给它发送请求。
这就有点像日常工作中的分配任务,要多考虑实际情况。当一位下属身体欠佳,就少给他些工作;若刚好另一位下属状态很好,手头工作又不是很多,就多分给他一点。
那服务调用者节点又该如何判定一个服务节点的处理能力呢?
这里我们可以采用一种打分的策略,服务调用者收集与之建立长连接的每个服务节点的指标数据,如服务节点的负载指标、CPU 核数、内存大小、请求处理的耗时指标(如请求平均耗时、TP99、TP999)、服务节点的状态指标(如正常、亚健康)。通过这些指标,计算出一个分数,比如总分 10 分,如果 CPU 负载达到 70%,就减它 3 分,当然了,减 3 分只是个类比,需要减多少分是需要一个计算策略的。
我们又该如果根据这些指标来打分呢?
这就有点像公司对员工进行年终考核。假设我是老板,我要考核专业能力、沟通能力和工作态度,这三项的占比分别是 30%、30%、40%,我给一个员工的评分是 10、8、8,那他的综合分数就是这样计算的:$10\times30%+8\times30%+8\times40%=8.6$ 分。
给服务节点打分也一样,我们可以为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数。
服务调用者给每个服务节点都打完分之后,会发送请求,那这时候我们又该如何根据分数去控制给每个服务节点发送多少流量呢?
我们可以配合随机权重的负载均衡策略去控制,通过最终的指标分数修改服务节点最终的权重。例如给一个服务节点综合打分是 8 分(满分 10 分),服务节点的权重是 100,那么计算后最终权重就是 80(100*80%)。服务调用者发送请求时,会通过随机权重的策略来选择服务节点,那么这个节点接收到的流量就是其他正常节点的 80%(这里假设其他节点默认权重都是 100,且指标正常,打分为 10 分的情况)。
到这儿,一个自适应的负载均衡我们就完成了,整体的设计方案如下图所示:
关键步骤我来解释下:
- 添加服务指标收集器,并将其作为插件,默认有运行时状态指标收集器、请求耗时指标收集器。
- 运行时状态指标收集器收集服务节点 CPU 核数、CPU 负载以及内存等指标,在服务调用者与服务提供者的心跳数据中获取。
- 请求耗时指标收集器收集请求耗时数据,如平均耗时、TP99、TP999 等。
- 可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分。
- 通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略,来选择服务节点。
12 | 异常重试:在约定时间内安全可靠地重试 #
为什么需要异常重试?
我们可以考虑这样一个场景。我们发起一次 RPC 调用,去调用远程的一个服务,比如用户的登录操作,我们会先对用户的用户名以及密码进行验证,验证成功之后会获取用户的基本信息。当我们通过远程的用户服务来获取用户基本信息的时候,恰好网络出现了问题,比如网络突然抖了一下,导致我们的请求失败了,而这个请求我们希望它能够尽可能地执行成功,那这时我们要怎么做呢?
我们需要重新发起一次 RPC 调用,那我们在代码中该如何处理呢?是在代码逻辑里 catch 一下,失败了就再发起一次调用吗?这样做显然不够优雅吧。这时我们就可以考虑使用 RPC 框架的重试机制。
RPC 框架的重试机制
那什么是 RPC 框架的重试机制呢?
这其实很好理解,就是当调用端发起的请求失败时,RPC 框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。
那这个机制是如何实现的呢?
调用端在发起 RPC 调用时,会经过负载均衡,选择一个节点,之后它会向这个节点发送请求信息。当消息发送失败或收到异常消息时,我们就可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,当重试次数达到用户配置的重试次数的时候,就返回给调用端动态代理一个失败异常,否则就一直重试下去。
RPC 框架的重试机制就是调用端发现请求失败时捕获异常,之后触发重试,那是不是所有的异常都要触发重试呢?
当然不是了,因为这个异常可能是服务提供方抛回来的业务异常,它是应该正常返回给动态代理的,所以我们要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。
了解了 RPC 框架的重试机制,那用户在使用异常重试时需要注意哪些问题呢?
比如我刚才提的那个调用场景,当网络突然抖动了一下导致请求超时了,但这个时候调用方的请求信息可能已经发送到服务提供方的节点上,也可能已经发送到服务提供方的服务节点上,那如果请求信息成功地发送到了服务节点上,那这个节点是不是就要执行业务逻辑了呢?是的。
那如果这个时候发起了重试,业务逻辑是否会被执行呢?会的。
那如果这个服务业务逻辑不是幂等的,比如插入数据操作,那触发重试的话会不会引发问题呢?会的。
综上,我们可以总结出:在使用 RPC 框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启 RPC 框架的异常重试功能。这一点你要格外注意,这算是一个高频误区了。
通过上述讲解,我相信你已经非常清楚 RPC 框架的重试机制了,这也是现在大多数 RPC 框架所采用的重试机制。
那看到这儿,你觉得这个机制完善了吗?有没有想到连续重试对请求超时时间的影响?继续考虑这样一个场景:我把调用端的请求超时时间设置为 5s,结果连续重试 3 次,每次都耗时 2s,那最终这个请求的耗时是 6s,那这样的话,调用端设置的超时时间是不是就不准确了呢?
如何在约定时间内安全可靠地重试?
我刚才讲到,连续的异常重试可能会出现一种不可靠的情况,那就是连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间。
解决这个问题最直接的方式就是,在每次重试后都重置一下请求的超时时间。
当调用端发起 RPC 请求时,如果发送请求发生异常并触发了异常重试,我们可以先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试。
那么解决了因多次异常重试引发的超时时间失效的问题,这个重试机制是不是就完全可靠了呢?
我们接着考虑,当调用端设置了异常重试策略,发起了一次 RPC 调用,通过负载均衡选择了节点,将请求消息发送到这个节点,这时这个节点由于负载压力较大,导致这个请求处理失败了,调用端触发了重试,再次通过负载均衡选择了一个节点,结果恰好仍选择了这个节点,那么在这种情况下,重试的效果是否受影响了呢?
当然有影响。因此,我们需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。
那我们现在再完整地回顾一下,考虑了业务逻辑必须是幂等的、超时时间需要重置以及去掉有问题的服务节点后,这样的异常重试机制,还有没有可优化的地方呢?
我刚才讲过,RPC 框架的异常重试机制,是调用端发送请求之后,如果发送失败会捕获异常,触发重试,但并不是所有的异常都会触发重试的,只有 RPC 框架中特定的异常才会如此,比如连接异常、超时异常。
而像服务端业务逻辑中抛回给调用端的异常是不能重试的。那么请你想一下这种情况:服务端的业务逻辑抛给调用端一个异常信息,而服务端抛出这个异常是允许调用端重新发起一次调用的。
比如这个场景:服务端的业务逻辑是对数据库某个数据的更新操作,更新失败则抛出个更新失败的异常,调用端可以再次调用,来触发服务端重新执行更新操作。那这个时候对于调用端来说,它接收到了更新失败异常,虽然是服务端抛回来的业务异常,但也是可以进行重试的。
那么在这种情况下,RPC 框架的重试机制需要怎么优化呢?
RPC 框架是不会知道哪些业务异常能够去进行异常重试的,我们可以加个重试异常的白名单,用户可以将允许重试的异常加入到这个白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后,我们就可以采用这样的异常处理策略。如果这个异常是 RPC 框架允许重试的异常,或者这个异常类型存在于可重试异常的白名单中,我们就允许对这个请求进行重试。
RPC 框架是不会知道哪些业务异常能够去进行异常重试的,我们可以加个重试异常的白名单,用户可以将允许重试的异常加入到这个白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后,我们就可以采用这样的异常处理策略。如果这个异常是 RPC 框架允许重试的异常,或者这个异常类型存在于可重试异常的白名单中,我们就允许对这个请求进行重试。
所有可能出现的问题,我们排查了一圈下来之后,一个可靠的重试机制就诞生了,如下图所示:
思考题:在整个 RPC 调用的流程中,异常重试发生在哪个环节?
在回答这个问题之前,我们先回想下这一讲中讲过的内容。我在讲 RPC 为什么需要异常重试时我说过,如果在发出请求时恰好网络出现问题了,导致我们的请求失败,我们可能需要进行异常重试。从这一点我们可以看出,异常重试的操作是要在调用端进行的。因为如果在调用端发出请求时恰好网络出现问题导致请求失败,那么这个请求很可能还没到达服务端,服务端当然就没办法去处理重试了。
另外,我还讲过,我们需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。由此可见异常重试的操作应该发生在负载均衡之前,在发起重试的时候,会调用负载均衡插件来选择一个服务节点,在调用负载均衡插件时我们要告诉负载均衡需要刨除哪些有问题的服务节点。
在整个 RPC 调用的过程中,从动态代理到负载均衡之间还有一系列的操作,如果你研究过开源的 RPC 框架,你会发现在调用端发送请求消息之前还会经过过滤链,对请求消息进行层层的过滤处理,之后才会通过负载均衡选择服务节点,发送请求消息,而异常重试操作就发生在过滤链处理之后,调用负载均衡选择服务节点之前,这样的重试是可以减少很多重复操作的。
13 | 优雅关闭:如何避免服务停机带来的业务损失? #
关闭为什么有问题?
我们知道,在“单体应用”复杂到一定程度后,我们一般会进行系统拆分,也就是时下流行的微服务架构。服务拆分之后,自然就需要协同,于是 RPC 框架就出来了,它用来解决各个子系统之间的通信问题。
我再倒回来问你一个非常基础的问题?你觉得系统为啥非要拆分呢?从我的角度,如果只说一个原因,我觉得拆分之后我们可以更方便、更快速地迭代业务。那么问题来了,更快速地迭代业务,说人话不就是我会经常更新应用系统,时不时还老要重启服务器吗?
那具体到我们的 RPC 体系里,你就要考虑,在重启服务的过程中,RPC 怎么做到让调用方系统不出问题呢?
要想说明白这事,我们先要简述下上线的大概流程:当服务提供方要上线的时候,一般是通过部署系统完成实例重启。在这个过程中,服务提供方的团队并不会事先告诉调用方他们需要操作哪些机器,从而让调用方去事先切走流量。而对调用方来说,它也无法预测到服务提供方要对哪些机器重启上线,因此负载均衡就有可能把要正在重启的机器选出来,这样就会导致把请求发送到正在重启中的机器里面,从而导致调用方不能拿到正确的响应结果。
在服务重启的时候,对于调用方来说,这时候可能会存在以下几种情况:
- 调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中。
- 调用方发请求的时候,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中。
关闭流程
当然还存在目标服务正在启动的情况,如何优雅地启动我会在下一讲详细地讲,这也是重点。今天我们要聚焦讨论的就是当出现第二种情况的时候,在 RPC 里面怎么避免调用方业务受损。
这时候你可能会想到,我是不是在重启服务机器前,先通过“某种方式”把要下线的机器从调用方维护的“健康列表”里面删除就可以了,这样负载均衡就选不到这个节点了?你说得一点都没错,但这个具体的“某种方式”是怎么完成呢?
最没有效率的办法就是人工通知调用方,让他们手动摘除要下线的机器,这种方式很原始也很直接。但这样对于提供方上线的过程来说太繁琐了,每次上线都要通知到所有调用我接口的团队,整个过程既浪费时间又没有意义,显然不能被正常接受。
这时候,可能你还会想到,RPC 里面不是有服务发现吗?它的作用不就是用来“实时”感知服务提供方的状态吗?当服务提供方关闭前,是不是可以先通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除?关闭流程如下图所示:
这样不就可以实现不通过“人肉”的方式,从而达到一种自动化方式,但这么做就能完全保证实现无损上下线吗?
如上图所示,整个关闭过程中依赖了两次 RPC 调用,一次是服务提供方通知注册中心下线操作,一次是注册中心通知服务调用方下线节点操作。注册中心通知服务调用方都是异步的,我们在“服务发现”一讲中讲过在大规模集群里面,服务发现只保证最终一致性,并不保证实时性,所以注册中心在收到服务提供方下线的时候,并不能成功保证把这次要下线的节点推送到所有的调用方。所以这么来看,通过服务发现并不能做到应用无损关闭。
不能强依赖“服务发现”来通知调用方要下线的机器,那服务提供方自己来通知行不行?因为在 RPC 里面调用方跟服务提供方之间是长连接,我们可以在提供方应用内存里面维护一份调用方连接集合,当服务要关闭的时候,挨个去通知调用方去下线这台机器。这样整个调用链路就变短了,对于每个调用方来说就一次 RPC,可以确保调用的成功率很高。大部分场景下,这么做确实没有问题,我们之前也是这么实现的,但是我们发现线上还是会偶尔会出现,因为服务提供方上线而导致调用失败的问题。
那到底哪里出问题了呢?我后面分析了调用方请求日志跟收到关闭通知的日志,并且发现了一个线索如下:出问题请求的时间点跟收到服务提供方关闭通知的时间点很接近,只比关闭通知的时间早不到 1ms,如果再加上网络传输时间的话,那服务提供方收到请求的时候,它应该正在处理关闭逻辑。这就说明服务提供方关闭的时候,并没有正确处理关闭后接收到的新请求。
优雅关闭
知道了根本原因,问题就很好解决了。因为服务提供方已经开始进入关闭流程,那么很多对象就可能已经被销毁了,关闭后再收到的请求按照正常业务请求来处理,肯定是没法保证能处理的。所以我们可以在关闭的时候,设置一个请求“挡板”,挡板的作用就是告诉调用方,我已经开始进入关闭流程了,我不能再处理你这个请求了。
如果大家经常去银行办理业务,就会很熟悉这个流程。在交接班或者有其他要事情处理的时候,银行柜台工作人员会拿出一个纸板,放在窗口前,上面写到“该窗口已关闭”。在该窗口排队的人虽然有一万个不愿意,也只能换到其它窗口办理业务,因为柜台工作人员会把当前正在办理的业务处理完后正式关闭窗口。
基于这个思路,我们可以这么处理:当服务提供方正在关闭,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如 ShutdownException)。这个异常就是告诉调用方“我已经收到这个请求了,但是我正在关闭,并没有处理这个请求”,然后调用方收到这个异常响应后,RPC 框架把这个节点从健康列表挪出,并把请求自动重试到其他节点,因为这个请求是没有被服务提供方处理过,所以可以安全地重试到其他节点,这样就可以实现对业务无损。
但如果只是靠等待被动调用,就会让这个关闭过程整体有点漫长。因为有的调用方那个时刻没有业务请求,就不能及时地通知调用方了,所以我们可以加上主动通知流程,这样既可以保证实时性,也可以避免通知失败的情况。
说到这里,我知道你肯定会问,那要怎么捕获到关闭事件呢?
在我的经验里,可以通过捕获操作系统的进程信号来获取,在 Java 语言里面,对应的是 Runtime.addShutdownHook 方法,可以注册关闭的钩子。在 RPC 启动的时候,我们提前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时需要在我们调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。
看到这里,感觉问题已经比较好地被解决了。但细心的同学可能还会提出问题,关闭过程中已经在处理的请求会不会受到影响呢?
如果进程结束过快会造成这些请求还没有来得及应答,同时调用方会也会抛出异常。为了尽可能地完成正在处理的请求,首先我们要把这些请求识别出来。这就好比日常生活中,我们经常看见停车场指示牌上提示还有多少剩余车位,这个是如何做到的呢?如果仔细观察一下,你就会发现它是每进入一辆车,剩余车位就减一,每出来一辆车,剩余车位就加一。我们也可以利用这个原理在服务对象加上引用计数器,每开始处理请求之前加一,完成请求处理减一,通过该计数器我们就可以快速判断是否有正在处理的请求。
服务对象在关闭过程中,会拒绝新的请求,同时根据引用计数器等待正在处理的请求全部结束之后才会真正关闭。但考虑到有些业务请求可能处理时间长,或者存在被挂住的情况,为了避免一直等待造成应用无法正常退出,我们可以在整个 ShutdownHook 里面,加上超时时间控制,当超过了指定时间没有结束,则强制退出应用。超时时间我建议可以设定成 10s,基本可以确保请求都处理完了。整个流程如下图所示。
14 | 优雅启动:如何避免流量打到没有启动完成的节点? #
接着上一讲的内容,今天我们来聊聊优雅启动。
是不是很诧异?应用启动居然也要这么“讲究”吗?这就好比我们日常生活中的热车,行驶之前让发动机空跑一会,可以让汽车的各个部件都“热”起来,减小磨损。
换到应用上来看,原理也是一样的。运行了一段时间后的应用,执行速度会比刚启动的应用更快。这是因为在 Java 里面,在运行过程中,JVM 虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,这样就使得“热点”代码的执行不用每次都通过解释,从而提升执行速度。
但是这些“临时数据”,都在我们应用重启后就消失了。重启后的这些“红利”没有了之后,如果让我们刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。
在上一讲我们说过,在微服务架构里面,上线肯定是频繁发生的,那我们总不能因为上线,就让过来的请求出现大面积超时吧?所以我们得想点办法。既然问题的关键是在于“刚重启的服务提供方因为没有预跑就承担了大流量”,那我们是不是可以通过某些方法,让应用一开始只接少许流量呢?这样低功率运行一段时间后,再逐渐提升至最佳状态。
这其实就是我今天要和你分享的重点,RPC 里面的一个实用功能——启动预热。
启动预热
那什么叫启动预热呢?
简单来说,就是让刚启动的服务提供方应用不承担全部的流量,而是让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。
那在 RPC 里面,我们该怎么实现这个功能呢?
我们现在是要控制调用方发送到服务提供方的流量。我们可以先简单地回顾下调用方发起的 RPC 调用流程是怎样的,调用方应用通过服务发现能够获取到服务提供方的 IP 地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接。那这样的话,我们是不是就可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用?对于刚启动的应用,我们可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程。
现在方案有了,我们就可以考虑具体实现了。
首先对于调用方来说,我们要知道服务提供方启动的时间,这个怎么获取呢?我这里给出两种方法,一种是服务提供方在启动的时候,把自己启动的时间告诉注册中心;另外一种就是注册中心收到的服务提供方的请求注册时间。这两个时间我认为都可以,不过可能你会犹豫我们该怎么确保所有机器的日期时间是一样的?这其实不用太关心,因为整个预热过程的时间是一个粗略值,即使机器之间的日期时间存在 1 分钟的误差也不影响,并且在真实环境中机器都会默认开启 NTP 时间同步功能,来保证所有机器时间的一致性。
不管你是选择哪个时间,最终的结果就是,调用方通过服务发现,除了可以拿到 IP 列表,还可以拿到对应的启动时间。我们需要把这个时间作用在负载均衡上,上面介绍过一种基于权重的负载均衡,但是这个权重是由服务提供方设置的,属于一个固定状态。现在我们要让这个权重变成动态的,并且是随着时间的推移慢慢增加到服务提供方设定的固定值,整个过程如下图所示:
通过这个小逻辑的改动,我们就可以保证当服务提供方运行时长小于预热时间时,对服务提供方进行降权,减少被负载均衡选择的概率,避免让应用在启动之初就处于高负载状态,从而实现服务提供方在启动后有一个预热的过程。
看到这儿,你可能还会有另外一个疑问,就是当我在大批量重启服务提供方的时候,会不会导致没有重启的机器因为扛的流量太大而出现问题?
关于这个问题,我是这么考虑的。当你大批量重启服务提供方的时候,对于调用方来说,这些刚重启的机器权重基本是一样的,也就是说这些机器被选中的概率是一样的,大家都是一样得低,也就不存在权重区分的问题了。但是对于那些没有重启过的应用提供方来说,它们被负载均衡选中的概率是相对较高的,但是我们可以通过自适应负载的方法平缓地切换,所以也是没有问题的。
启动预热更多是从调用方的角度出发,去解决服务提供方应用冷启动的问题,让调用方的请求量通过一个时间窗口过渡,慢慢达到一个正常水平,从而实现平滑上线。但对于服务提供方本身来说,有没有相关方案可以实现这种效果呢?
当然有,这也是我今天要分享的另一个重点,和热启动息息相关,那就是延迟暴露。
延迟暴露
我们应用启动的时候都是通过 main 入口,然后顺序加载各种相关依赖的类。以 Spring 应用启动为例,在加载的过程中,Spring 容器会顺序加载 Spring Bean,如果某个 Bean 是 RPC 服务的话,我们不光要把它注册到 Spring-BeanFactory 里面去,还要把这个 Bean 对应的接口注册到注册中心。注册中心在收到新上线的服务提供方地址的时候,会把这个地址推送到调用方应用内存中;当调用方收到这个服务提供方地址的时候,就会去建立连接发请求。
但这时候是不是存在服务提供方可能并没有启动完成的情况?因为服务提供方应用可能还在加载其它的 Bean。对于调用方来说,只要获取到了服务提供方的 IP,就有可能发起 RPC 调用,但如果这时候服务提供方没有启动完成的话,就会导致调用失败,从而使业务受损。
那有什么办法可以避免这种情况吗?
在解决问题前,我们先看下出现上述问题的根本原因。这是因为服务提供方应用在没有启动完成的时候,调用方的请求就过来了,而调用方请求过来的原因是,服务提供方应用在启动过程中把解析到的 RPC 服务注册到了注册中心,这就导致在后续加载没有完成的情况下服务提供方的地址就被服务调用方感知到了。
这样的话,其实我们就可以把接口注册到注册中心的时间挪到应用启动完成后。具体的做法就是在应用启动加载、解析 Bean 的时候,如果遇到了 RPC 服务的 Bean,只先把这个 Bean 注册到 Spring-BeanFactory 里面去,而并不把这个 Bean 对应的接口注册到注册中心,只有等应用启动完成后,才把接口注册到注册中心用于服务发现,从而实现让服务调用方延迟获取到服务提供方地址(延迟注册的方式,使服务延迟暴露)
这样是可以保证应用在启动完后才开始接入流量的,但其实这样做,我们还是没有实现最开始的目标。因为这时候应用虽然启动完成了,但并没有执行相关的业务代码,所以 JVM 内存里面还是冷的。如果这时候大量请求过来,还是会导致整个应用在高负载模式下运行,从而导致不能及时地返回请求结果。而且在实际业务中,一个服务的内部业务逻辑一般会依赖其它资源的,比如缓存数据。如果我们能在服务正式提供服务前,先完成缓存的初始化操作,而不是等请求来了之后才去加载,我们就可以降低重启后第一次请求出错的概率。
那具体怎么实现呢?
我们还是需要利用服务提供方把接口注册到注册中心的那段时间。我们可以在服务提供方应用启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑。用户可以在 Hook 里面模拟调用逻辑,从而使 JVM 指令能够预热起来,并且用户也可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。整个应用启动过程如下图所示:
上面的 Hook 方案需要对不同的接口维护不通的case,维护成本较高。如果仅仅预热JVM,是不是可以在 Spring 容器 refresh 时调用初始化方法。这两个方案效果一样,一样需维护case。或者公司有流量回放工具,回放一部分流量。再或者JVM 可以预热一些代码,这个阿里的JVM可以实现了。
思考题:在启动预热那部分,我们特意提到过一个问题,就是“当大批量重启服务提供方的时候,会导致请求大概率发到没有重启的机器上,这时服务提供方有可能扛不住”,不知道你是怎么看待这个问题的,是否有好的解决方案呢?
我们可以考虑在非流量高峰的时候重启服务,将影响降到最低;也可以考虑分批次重启,控制好每批重启的服务节点的数量,当一批服务节点的权重与访问量都到正常水平时,再去重启下一批服务节点。
15 | 熔断限流:业务如何实现自我保护? #
为什么需要自我保护?
RPC 是解决分布式系统通信问题的一大利器,而分布式系统的一大特点就是高并发,所以说 RPC 也会面临高并发的场景。在这样的情况下,我们提供服务的每个服务节点就都可能由于访问量过大而引起一系列的问题,比如业务处理耗时过长、CPU 飘高、频繁 Full GC 以及服务进程直接宕机等等。但是在生产环境中,我们要保证服务的稳定性和高可用性,这时我们就需要业务进行自我保护,从而保证在高访问量、高并发的场景下,应用系统依然稳定,服务依然高可用。
那么在使用 RPC 时,业务又如何实现自我保护呢?
最常见的方式就是限流了,简单有效,但 RPC 框架的自我保护方式可不只有限流,并且 RPC 框架的限流方式可以是多种多样的。
我们可以将 RPC 框架拆开来分析,RPC 调用包括服务端和调用端,调用端向服务端发起调用。下面我就分享一下服务端与调用端分别是如何进行自我保护的。
服务端的自我保护
我们先看服务端,举个例子,假如我们要发布一个 RPC 服务,作为服务端接收调用端发送过来的请求,这时服务端的某个节点负载压力过高了,我们该如何保护这个节点?
这个问题还是很好解决的,既然负载压力高,那就不让它再接收太多的请求就好了,等接收和处理的请求数量下来后,这个节点的负载压力自然就下来了。
那么就是限流吧?是的,在 RPC 调用中服务端的自我保护策略就是限流,那你有没有想过我们是如何实现限流的呢?是在服务端的业务逻辑中做限流吗?有没有更优雅的方式?
限流是一个比较通用的功能,我们可以在 RPC 框架中集成限流的功能,让使用方自己去配置限流阈值;我们还可以在服务端添加限流逻辑,当调用端发送请求过来时,服务端在执行业务逻辑之前先执行限流逻辑,如果发现访问量过大并且超出了限流的阈值,就让服务端直接抛回给调用端一个限流异常,否则就执行正常的业务逻辑。
那服务端的限流逻辑又该如何实现呢?
方式有很多,比如最简单的计数器,还有可以做到平滑限流的滑动窗口、漏斗算法以及令牌桶算法等等。其中令牌桶算法最为常用。
- 计数器算法是最简单的一种限流算法。它将时间窗口固定,比如1秒或1分钟,并在这个时间窗口内计数请求。如果请求计数超过了设定的阈值,则拒绝后续的请求直到下一个时间窗口。这种方法实现简单,但可能会出现窗口临界点的请求突增问题,即在时间窗口切换的瞬间,请求量可能会暂时性地两倍于阈值。
- 滑动窗口算法是对计数器算法的改进。它通过记录每个请求的确切时间来更精确地控制请求的频率。滑动窗口可以视为是连续的多个固定窗口的叠加,每当新的请求到来时,它会计算当前窗口内的请求总数。这种方法比固定窗口算法更平滑,减少了临界点的请求量突增。
- 漏斗算法将请求想象为水滴,系统像一个漏斗。请求(水滴)以任意速率流入漏斗,而漏斗以固定的速率将水滴排出(处理请求)。如果水滴(请求)填满了漏斗,那么新进的水滴(请求)就会被丢弃。这种算法可以很好地平滑突发流量,确保数据的均匀处理,但在高流量下可能会导致请求的延迟。
- 令牌桶算法是一种灵活且常用的限流策略。系统会以固定的速率向令牌桶中添加令牌,每个请求到来时必须消耗一个令牌才能被处理。如果令牌桶中没有令牌,则请求要么等待直到获得令牌,要么直接被拒绝。这种方法既可以处理突发请求,也允许在流量较低时积累一定数量的请求处理能力。
我们可以假设下这样一个场景:我发布了一个服务,提供给多个应用的调用方去调用,这时有一个应用的调用方发送过来的请求流量要比其它的应用大很多,这时我们就应该对这个应用下的调用端发送过来的请求流量进行限流。所以说我们在做限流的时候要考虑应用级别的维度,甚至是 IP 级别的维度,这样做不仅可以让我们对一个应用下的调用端发送过来的请求流量做限流,还可以对一个 IP 发送过来的请求流量做限流。
这时你可能会想,使用方该如何配置应用维度以及 IP 维度的限流呢?在代码中配置是不是不大方便?我之前说过,RPC 框架真正强大的地方在于它的治理功能,而治理功能大多都需要依赖一个注册中心或者配置中心,我们可以通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置。
看到这儿,你有没有发现,在服务端实现限流,配置的限流阈值是作用在每个服务节点上的。比如说我配置的阈值是每秒 1000 次请求,那么就是指一台机器每秒处理 1000 次请求;如果我的服务集群拥有 10 个服务节点,那么我提供的服务限流阈值在最理想的情况下就是每秒 10000 次。
接着看这样一个场景:我提供了一个服务,而这个服务的业务逻辑依赖的是 MySQL 数据库,由于 MySQL 数据库的性能限制,我们是需要对其进行保护。假如在 MySQL 处理业务逻辑中,SQL 语句的能力是每秒 10000 次,那么我们提供的服务处理的访问量就不能超过每秒 10000 次,而我们的服务有 10 个节点,这时我们配置的限流阈值应该是每秒 1000 次。那如果之后因为某种需求我们对这个服务扩容了呢?扩容到 20 个节点,我们是不是就要把限流阈值调整到每秒 500 次呢?这样操作每次都要自己去计算,重新配置,显然太麻烦了。
我们可以让 RPC 框架自己去计算,当注册中心或配置中心将限流阈值配置下发的时候,我们可以将总服务节点数也下发给服务节点,之后由服务节点自己计算限流阈值,这样就解决问题了吧?
解决了一部分,还有一个问题存在,那就是在实际情况下,一个服务节点所接收到的访问量并不是绝对均匀的,比如有 20 个节点,而每个节点限流的阈值是 500,其中有的节点访问量已经达到阈值了,但有的节点可能在这一秒内的访问量是 450,这时调用端发送过来的总调用量还没有达到 10000 次,但可能也会被限流,这样是不是就不精确了?那有没有比较精确的限流方式呢?(单机限流存在不准确的问题)
我刚才讲解的限流方式之所以不精确,是因为限流逻辑是服务集群下的每个节点独立去执行的,是一种单机的限流方式,而且每个服务节点所接收到的流量并不是绝对均匀的。
我们可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量打过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值。我们甚至可以将限流逻辑放在调用端,调用端在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常即可。
这种限流方式可以让整个服务集群的限流变得更加精确,但也由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有很大劣势的。至于要选择哪种限流方式,就要结合具体的应用场景进行选择了。
调用端的自我保护
刚才我讲解了服务端如何进行自我保护,最简单有效的方式就是限流。那么调用端呢?调用端是否需要自我保护呢?
举个例子,假如我要发布一个服务 B,而服务 B 又依赖服务 C,当一个服务 A 来调用服务 B 时,服务 B 的业务逻辑调用服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务 C,C 超时直接导致 B 的业务逻辑一直等待,而这个时候服务 A 在频繁地调用服务 B,服务 B 就可能会因为堆积大量的请求而导致服务宕机。
由此可见,服务 B 调用服务 C,服务 C 执行业务逻辑出现异常时,会影响到服务 B,甚至可能会引起服务 B 宕机。这还只是 A->B->C 的情况,试想一下 A->B->C->D->……呢?在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机,这是非常恐怖的。
所以说,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。而最有效的自我保护方式就是熔断。
我们可以先了解下熔断机制。
熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。在正常情况下,熔断器是关闭的;当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。
熔断器机制:调用异常多则打开,过段时间半打开,尝试调用服务,一切正常就关闭
了解完熔断机制,你就会发现,在业务逻辑中加入熔断器其实是不够优雅的。那么在 RPC 框架中,我们该如何整合熔断器呢?
熔断机制主要是保护调用端,调用端在发出请求的时候会先经过熔断器。我们可以回想下 RPC 的调用流程:
熔断机制是保护调用端,限流是保护服务端。
你看图的话,有没有想到在哪个步骤整合熔断器会比较合适呢?
我的建议是动态代理,因为在 RPC 调用的流程中,动态代理是 RPC 调用的第一个关口。在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。
检查熔断的flag放在动态代理这一步,不是上边说的熔断的逻辑放在这一步,熔断检查的逻辑肯定是要经过调用服务提供方才知道是否调用成功。
思考题:在使用 RPC 的过程中业务要实现自我保护,针对这个问题你是否还有其他的解决方案?
在 RPC 调用中无论服务端还是调用端都需要自我保护,服务端自我保护的最简单有效的方式是“限流”,调用端则可以通过“熔断”机制来进行自我保护。
除了“熔断”和“限流”外,相信你一定听过“降级”这个词。简单来说就是当一个服务处理大量的请求达到一定压力的时候,我们可以让这个服务在处理请求时减少些非必要的功能,从而降低这个服务的压力。
还有就是我们可以通过服务治理,降低一个服务节点的权重来减轻某一方服务节点的请求压力,达到保护这个服务节点的目的。
16 | 业务分组:如何隔离流量? #
RPC 中常用的保护手段“熔断限流”,熔断是调用方为了避免在调用过程中,服务提供方出现问题的时候,自身资源被耗尽的一种保护行为;而限流则是服务提供方为防止自己被突发流量打垮的一种保护行为。虽然这两种手段作用的对象不同,但出发点都是为了实现自我保护,所以一旦发生这种行为,业务都是有损的。
那说起突发流量,限流固然是一种手段,但其实面对复杂的业务以及高并发场景时,我们还有别的手段,可以最大限度地保障业务无损,那就是隔离流量,业务分组。
为什么需要分组?
在我们的日常开发中,我们不都提倡让用户使用起来越简单越好吗?如果在接口上再加一个分组维度去管理,不就让事情变复杂了吗?
实则不然,举个例子。在没有汽车的年代,我们的道路很简单,就一条,行人、洋车都在上边走。那随着汽车的普及以及猛增,我们的道路越来越宽,慢慢地有了高速、辅路、人行道等等。很显然,交通网的建设与完善不仅提高了我们的出行效率,而且还更好地保障了我们行人的安全。
同样的道理,我们用在 RPC 治理上也是一样的。假设你是一个服务提供方应用的负责人,在早期业务量不大的情况下,应用之间的调用关系并不会复杂,请求量也不会很大,我们的应用有足够的能力扛住日常的所有流量。我们并不需要花太多的时间去治理调用请求过来的流量,我们通常会选择最简单的方法,就是把服务实例统一管理,把所有的请求都用一个共享的“大池子”来处理。这就类似于“简单道路时期”,服务调用方跟服务提供方之间的调用拓扑如下图所示:
后期因为业务发展丰富了,调用你接口的调用方就会越来越多,流量也会渐渐多起来。可能某一天,一个“爆炸式惊喜”就来了。其中一个调用方的流量突然激增,让你整个集群瞬间处于高负载运行,进而影响到其它调用方,导致它们的整体可用率下降。而这时候作为应用负责人的你,那就得变身“救火队长”了,要想尽各种办法来保证应用的稳定。
在经过一系列的救火操作后,我们肯定要去想更好的应对办法。那回到问题的根本去看,关键就在于,早期为了管理方便,我们把接口都放到了同一个分组下面,所有的服务实例是以一个整体对外提供能力的。
但后期因为业务发展,这种粗暴的管理模式已经不适用了,这就好比“汽车来了,我们的交通网也得抓紧建设”一样,让人车分流。此时,道路上的人和车就好比我们应用的调用方,我们可以尝试把应用提供方这个大池子划分出不同规格的小池子,再分配给不同的调用方,而不同小池子之间的隔离带,就是我们在 RPC 里面所说的分组,它可以实现流量隔离。
怎么实现分组?
现在分组是怎么回事我们搞清楚了,那放到 RPC 里我们该怎么实现呢?
既然是要求不同的调用方应用能拿到的池子内容不同,那我们就要回想下服务发现了,因为在 RPC 流程里,能影响到调用方获取服务节点的逻辑就是它了。
服务调用方是通过接口名去注册中心找到所有的服务节点来完成服务发现的,那换到这里的话,这样做其实并不合适,因为这样调用方会拿到所有的服务节点。因此为了实现分组隔离逻辑,我们需要重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数。
通过改造后的分组逻辑,我们可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用。那怎么分组好呢,有没有统一的标准?
坦白讲,这个分组并没有一个可衡量的标准,但我自己总结了一个规则可以供你参考,就是按照应用重要级别划分。
非核心应用不要跟核心应用分在同一个组,核心应用之间应该做好隔离,一个重要的原则就是保障核心应用不受影响。比如提供给电商下单过程中用的商品信息接口,我们肯定是需要独立出一个单独分组,避免受其它调用方污染的。有了分组之后,我们的服务调用方跟服务提供方之间的调用拓扑就如下图所示:
看上面这图相当于,从实例上就做好了分组的划分。 例如我有两个实例专供调用方 1,两个专供调用方 2。 1的应用压力不会不会影响到 2。 不过前文也提过,除了应用侧,数据库也有可能会互相影响,因此即使做了分组,还是要对每个应用做好限流。 另外除了应用隔离,部署这些实例的机器也要做好隔离。避免物理资源的竞争
通过分组的方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。对服务提供方来说,这种方式是我们日常治理服务过程中一个高频使用的手段,那通过这种分组进行流量隔离,对调用方应用会不会有影响呢?
如何实现高可用?
分组隔离后,单个调用方在发 RPC 请求的时候可选择的服务节点数相比没有分组前减少了,那对于单个调用方来说,出错的概率就升高了。比如一个集中交换机设备突然坏了,而这个调用方的所有服务节点都在这个交换机下面,在这种情况下对于服务调用方来说,它的请求无论如何也到达不了服务提供方,从而导致这个调用方业务受损。
那有没有更高可用一点的方案呢?回到我们前面说的那个马路例子上,正常情况下我们是必须让车在车道行驶,人在人行道上行走。但当人行道或者车道出现抢修的时候,在条件允许的情况下,我们一般都是允许对方借道行驶一段时间,直到道路完全恢复。
我们同样可以把这个特性用到我们的 RPC 中,要怎么实现呢?
在前面我们也说了,调用方应用服务发现的时候,除了带上对应的接口名,还需要带上一个特定分组名,所以对于调用方来说,它是拿不到其它分组的服务节点的,那这样的话调用方就没法建立起连接发请求了。
因此问题的核心就变成了调用方要拿到其它分组的服务节点,但是又不能拿到所有的服务节点,否则分组就没有意义了。一个最简单的办法就是,允许调用方可以配置多个分组。但这样的话,这些节点对于调用方来说就都是一样的了,调用方可以随意选择获取到的所有节点发送请求,这样就又失去了分组隔离的意义,并且还没有实现我们想要的“借道”的效果。
所以我们还需要把配置的分组区分下主次分组,只有在主分组上的节点都不可用的情况下才去选择次分组节点;只要主分组里面的节点恢复正常,我们就必须把流量都切换到主节点上,整个切换过程对于应用层完全透明,从而在一定程度上保障调用方应用的高可用。
思考题:在我们的实际工作中,测试人员和开发人员的工作一般都是并行的,这就导致一个问题经常出现:开发人员在开发过程中可能需要启动自身的应用,而测试人员为了能验证功能,会在测试环境中部署同样的应用。如果开发人员和测试人员用的接口分组名刚好一样,在这种情况下,就可能会干扰其它正在联调的调用方进行功能验证,进而影响整体的工作效率。不知道面对这种情况,你有什么好办法吗?
我们可以考虑配置不同的注册中心,开发人员将自己的服务注册到注册中心 A 上,而测试人员可以将自己的服务注册到测试专属的注册中心 B 上,这样测试人员在验证功能的时候,调用端会从注册中心 B 上拉取服务节点,开发人员重启自己的服务是影响不到测试人员的。
如果你使用过或者了解 k8s 的话,你一定知道“命名空间”的概念,RPC 框架如果支持“命名空间”,也是可以解决这一问题的。