RPC架构设计
RPC终极含义回顾
其实RPC就是把拦截到的方法参数,转成可以在网络中传输的二进制,并保证在服务提供方能正确地还原出语义,最终实现像调用本地一样地调用远程的目的。
RPC基础架构
传输模块
RPC本质上就是一个远程调用,那肯定就需要通过网络来传输数据。虽然传输协议 可以有多种选择,但考虑到可靠性的话,我们一**般默认采用TCP协议**。为了屏蔽网络传输的复
杂性,我们需要封装一个单独的数据传输模块用来收发二进制数据,这个单独模块我们可以叫做传输模块
协议模块
序列化,然后在方法调用参数的二进制数据后面增加“断句”符号来分隔出不同的请求,还可以在协议模块中加入压缩功能。
集群模块
服务发现、服务治理功能
Bootstrap模块
可扩展的架构(添加插件体系)
在RPC框架里面,我们是怎么支持插件化架构的呢?我们可以将每个功能点抽象成一个接口, 将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认 实现。在Java里面,JDK有自带的SPI(Service Provider Interface)服务发现机制,它可以 动态地为某个接口寻找服务实现。使用SPI机制需要在Classpath下的META-INF/services目 录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。
但在实际项目中,我们其实很少使用到JDK自带的SPI机制,首先它不能按需加载ServiceLoader加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载 入并实例化一遍,会造成不必要的浪费。另外就是扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成,比如扩展里面依赖了一个Spring Bean,原生的 Java SPI就不支持。
这时,整个架构就变成了一个微内核架构,我们将每个功能点抽象成一个接口,将这个接口 作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的 架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非 常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了 核心包的精简,依赖外部包少,这样可以有效减少开发人员引入RPC导致的包版本冲突问题。
超大规模RPC服务发现:CP VS AP
服务发现的必要性
服务发现的本质: 完成了接口跟服务提供者IP的映射
为了高可用,在生产环境中服务提供方都是以集群的方式对外提供服务,集群里面的这些IP随时可能变化,我们也需要用一本“通信录”及时获取到对应的服务节点,这个获取的 过程我们一般叫作“服务发现”。
服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的IP和接口保存下来。
服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的IP,然后缓存
到本地,并用于后续的远程调用。
为什么不使用DNS
基于ZooKeeper的服务发现
搭建一个ZooKeeper集群作为注册中心集群,服务注册的时候只需要服务节点向ZooKeeper节点写入注册信息即可,利用ZooKeeper的**Watcher机制**完成服务订阅与服务下发功能,整体流程如下图:
使用步骤:
- 服务平台管理端先在ZooKeeper中创建一个服务根路径,可以根据接口名命名(例
如:/service/com.demo.xxService),在这个路径再创建服务提供方目录与服务调用方
目录(例如:provider、consumer),分别用来存储服务提供方的节点信息和服务调用方
的节点信息。
- 当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务
提供方的注册信息。
- 当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务
调用方的信息,同时服务调用方**watch**该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
- 当服务提供方目录下有节点数据发生变更时,ZooKeeper就会通知给发起订阅的服务调用
方。
基于消息总线的最终一致性的注册中心
ZooKeeper的一大特点就是强一致性,ZooKeeper集群的每个节点的数据每次发生更新操作,都会通知其它ZooKeeper节点同时执行更新。它要求保证每个节点的数据能够实时的完全一致,这也就直接导致了ZooKeeper集群性能上的下降。这就好比几个人在玩传递东西的游戏,必须这一轮每个人都拿到东西之后,所有的人才能开始下一轮,而不是说我只要获得到东西之后,就可以直接进行下一轮了。
当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,
推送给消息总线,每个消息都有整体递增的版本。 消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到消息的在消息回放模块里面回放,只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。
消费者订阅可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存里
面。采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进
行合并。 为了性能,这里采用了两级缓存,注册中心和消费者的内存缓存,通过异步推拉模式来确保最终一致性。
另外,你也可能会想到,服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况,这个时候有没有问题?这个问题我们放到了RPC框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服
务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它点。
通过消息总线的方式,我们就可以完成注册中心集群间数据变更的通知,保证数据的最终一 致性,并能及时地触发注册中心的服务下发操作。 服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,**舍弃强一致性,更多地考虑 系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略。**
如果想要切换流量,想把某些服务提供者实例的流量切走,除了下线实例,还可以让服务端配置有权重负载均衡策略,这样服务器端可以通过调整权重来安排流量。
服务节点健康检测:避免节点挂了,还能接受到请求
重点:让调用方实时感知到节点的状态变化:健康、不建康、亚健康
健康状态:建立连接成功,并且心跳探活也成功
亚健康状态:建立连接成功,但是心跳请求连续失败
死亡状态:建立连接失败
健康检测的逻辑
一开始初始化的时候,如果建立连接成功,那就是健康状态,否则就是死亡状态。这里没有亚健康这样的中间态。紧接着,如果健康状态的节点连续出现几次不能响应心跳请求的情况,那就会被标记为亚健康状态,
生病之后(亚健康状态),如果连续几次都能正常响应心跳请求,那就可以转回健康状态,
证明病好了。如果病一直好不了,那就会被断定为是死亡节点,死亡之后还需要善后,比如
关闭连接。
死亡并不是真正死亡,它还有复活的机会。如果某个时间点里,死亡的节点能够重连成功,那它就可以重新被标记为健康状态。
具体的解决方案
早先版本的解决方案
一个节点从健康状态过渡到亚健康状态的前提是“连续”心跳失败次数必须到达某一个 阈值,比如3次。阈值的设置是考验检测机制的关键。
如果阈值过低:
- 调用方跟服务节点之间网络状况瞬息万变,出现网络波动的时候会导致误判。
- 在负载高情况,服务端来不及处理心跳请求,由于心跳时间很短,会导致调用方很快触发连续心跳失败而造成断开连接。
如果阈值过高:
- 可能服务节点已经出现了问题,但是请求还是不停的打在上面,不能及时的摘除有问题的节点,造成了服务调用者多次调用失败的问题。
阈值的设置还要受到复杂的系统影响:
- 调用方每个接口的调用频次不一样,有的接口可能1秒内调用上百次,有的接口可能半个小时才会调用一次,所以我们不能把简单的把总失败的次数当作判断条件。
- 服务的接口响应时间也是不一样的,有的接口可能1ms,有的接口可能是10s,所以我们也不能把TPS至来当作判断条件。
新版本:可用率—加入了业务请求可用率的考量
可用率的计算方式是某一个时间窗口内接口调用成功次数的百分比(成功次数/总调用次数)。当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。
对于心跳检测本身如何避免出现问题:
把检测程序部署在多个机器里面, 分布在不同的机架,甚至不同的机房。因为网络同时故障的概率非常低,所以只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常。
路由:让请求按照设定的规则发到不同的节点上
路由策略最好要抽象成配置信息,可以动态下发,根据不同的场景控制选择合适的目标机器
路由选择的必要性:
一个接口会有多个服务提供方同时提供服务,所以我们的RPC在每次发起请求的时候,都需要从多个服务提供方节点里面选择一个用于发请求的节点。既然这些节点都可以用来完成这次请求,那么我们就可以简单地认为这些节点是同质的(就是这次请求无论发送到集合中的哪个节点上,返回的结果都是一样的)。
路由策略对于应用实例发布的重要性
灰度发布应用实例,比如先发布少量实例观察是否有异常,后续再根据观察的情况,选择发布更多实例还是回滚已经上线的实例。但这种方式不好的一点就是,线上一旦出现问题,影响范围还是挺大的。因为对于我们的服 务提供方来说,我们的服务会同时提供给很多调用方来调用,尤其是像一些基础服务的调用 方会更复杂,比如商品、价格等等,一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损。
可以在上线前把所有的场景都重新测试一遍,但是线上环境太过于复杂,,单纯从测试角度出发只能降低风险出现的概率,想要彻底验证所有场景基本是不可能的。
如何实现路由策略
可以在上线完成后,先让一小 部分调用方请求过来进行逻辑验证【路由策略】,待没问题后再接入其他调用方,从而实现流量隔离的效果。
当选择要灰度验证功能的时候,是不是就可以让**注册中心**在推送的时候区别对待,而不是一股脑的把服务提供方的IP地址推送到所有调用方。换句话说就是,注册中心只会把刚上线的服务IP地址推送到选择指定的调用方,而其他调用方是不能通过服务发现拿到这个IP地址的。
但最好不要使用注册中心,原因如下:
- 注册中心在RPC里面的定位是用来存储数据并保证数据一致性的。如果把这种复杂的计算逻辑放到注册中心里面,当集群节点变多之后,就会导致注册中心压力很大。
- 大部分情况下我们一般都是采用开源软件来搭建注册中心,要满足这种需求还需要进行二次开发。
解决方案
当从服务提供方节点集合里面选择一个合适的节点(就是我们常说的负载均衡),可以在选择节点前加上“筛选逻辑”【灰度过程中要验证的规则】,把符合我们要求的节点筛选出来。
比如我们要求新上线的节点只允许某个IP可以调用,那我们的注册中心会把这条规则下发到服务调用方。在调用方收到规则后,在选择具体要发请求的节点前,会先通过筛选规则过滤节点集合。**筛选过程就叫做路由策略**
参数路由(更加细粒度的路由方式)
在IP路由【只是限制调用方来源,并不会根据请求参数请求到预设的服务提供方节点上去】的基础上,添加参数,使路由更加细粒度。
负载均衡:
一个服务节点无法支撑现有的访问量时,我们会部署多个节点,组成一个集群,然后通过负载均衡,将请求分发给这个集群下的每个服务节点,从而达到多个服务节点共同分担请求压力的目的。
负载均衡的分类
- 软负载就是在一台或多台服务器上安装负载均衡的软件,如LVS、Nginx等。
- 硬负载就是通过硬件设备来实现的负载均衡,如F5服务器等。
负载均衡的算法主要有随机法、轮询法、最小连接法等
RPC框架中负载均衡的实现
与web服务的负载均衡的不同之处
RPC框架并不是依赖一个负载均衡设备或者负载均衡服务器来实现负载均衡的,而是由RPC框架本身实现的,服务调用者可以自主选择服务节点,发起服务调用。
RPC的负载均衡完全由RPC框架自身实现,RPC的服务调用者会与“注册中心”下发的所有服
务节点建立长连接,在每次发起RPC调用时,**服务调用者**都会通过配置的负载均衡插件,自主选择一个服务节点,发起RPC调用请求。
RPC负载均衡策略一般包括随机权重、Hash、轮询。当然,这还是主要看RPC框架自身的实 现。其中的随机权重策略应该是我们最常用的一种了,通过随机算法,我们基本可以保证每 个节点接收到的请求流量是均匀的;同时我们还可以通过控制节点权重的方式,来进行流量控制。比如我们默认每个节点的权重都是100,但当我们把其中的一个节点的权重设置成50时,它接收到的流量就是其他节点的1/2。
自适应负载均衡【动态、智能的控制服务节点所接受流量】
不需要人工观察节点情况来手动调节
1. 对服务节点的处理能力进行量化衡量
这里我们可以采用一种打分的策略,**服务调用者收集与之建立长连接的每个服务节点的指标数据**,如服务节点的负载指标、CPU核数、内存大小、请求处理的耗时指标(如请求平均耗时、TP99、TP999)、服务节点的状态指标(如正常、亚健康)。通过这些指标,计算出一 个分数,比如总分10分,如果CPU负载达到70%,就减它3分。
可以为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数。
2. 根据打分情况下发流量
可以配合随机权重的负载均衡策略去控制,通过最终的指标分数修改服务节点最终的权
重。例如给一个服务节点综合打分是8分(满分10分),服务节点的权重是100,那么计算后 最终权重就是80(100*80%)。服务调用者发送请求时,会通过随机权重的策略来选择服务节点,那么这个节点接收到的流量就是其他正常节点的80%(这里假设其他节点默认权重都是100,且指标正常,打分为10分的情况)。
异常重试机制:在约定时间内安全可靠的重试
异常重试就是为了尽最大可能保证接口可用率的一种手段,但这种策略只能用在幂等接口上,否则就会因为重试导致应用系统数据“写花”。
当调用端发起的请求失败时,RPC框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。
当消息发送失败或收到异常消息时,我们就可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,当重试次数达到用户配置的重试次数的时候,就返回给调用端动态代理一个失败异常,否则就一直重试下去
并不是所有的异常都要触发重试机制
因为这个异常**可能是服务提供方抛回来的业务异常**,它是应该正常返回给动态代理的,所以我们要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。
在使用RPC框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启RPC框架的异常重试功能。
在约定时间内安全可靠的重试
连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间。解决这个问题最直接的方式就是,在每次重试后都重置一下请求的超时时间。
重试时负载均衡选取节点时要剔除前一次访问的节点