RPC
故障语义
一个系统通过RPC可以像本地调用一样调用一个远程接口。 当一个接口被本地调用时,其只有成功与失败两种结果。但是在分布式环境下,RPC以网络发起调用,其结果就有成功、失败、跟超时三种状态。其中,超时又是一个不可判定的状态。 所以,RPC下可以有三种调用语义:
- 或许语义: 调用一次后,不管是否超时
- at-most-one:至多调用一次。要做到这个效果,首先在超时的时候,客户端需要发起重试,同时服务端要记录调用结果,如果该请求已经被处理,则直接返回结果,不在重新执行。
- at-least-one:至少调用一次。这种是在超时的时候不断重试即可,知道收到调用结果
三者取一的话,首先at-most-one成本太高。一般我们会在或许语义跟at-least-one两者之间做折中, 也即超时重试,但重试次数受限,不保证一定成功。
系统架构
RPC是一个点对点无中心架构,服务消费者直连服务提供者,没有任何第三方代理,只需要在使用系统引入SDK即可。 上面架构的缺陷是Consumer跟Provider紧耦合,Cousumer需要知道Provider的地址。同时,无法做到Provider实时上下线,所以又引入了服务注册中心。 上图就是现有RPC服务框架最基本的结构了:- 服务提供者将服务地址推送到注册中心,消费者订阅服务地址
- 注册中心将服务地址做同类项合并,然后推送到消费者端
- 消费者收到服务地址后,经过路由规则得到一个地址池,然后通过负载均衡策略选出一个服务地址进行调用
上面所有过程中,全部由SDK处理。其中Registry是一个中心节点,引入Registry需要保证其高可用,同时RPC服务框架需要保证弱依赖于Registry。
上图是一个RPC服务框架基本架构图,但是作为一个完备的服务框架来说,它还缺少了以下部分:
- 配置中心:服务路由规则等信息需要动态可配,这部分信息可以写入Registry,但是写到配置中心是更适合的做法
- 元数据存储中心:Registry里只有服务提供者的IP,并没有接口方法的详细信息,比如接口的参数类型,参数名,返回值类型等等。这些元数据可以为接口测试系统,api网关系统等提供数据支持
- rpcops系统:需要有这么一个系统观察服务状态,并提供服务进行在线测试功能
- 调用链分析:这块后端需要一个强大的日志采集分析系统,RPC只需要打相应日志跟数据透传即可
框架设计
好的框架应该是高度可定制化的,且这种定制不会去修改框架代码本身。要达到这个要求
- 对框架做分层抽象,抽象出各种组件。组件与组件之间松耦合,替换一个组件不会对其他组件造成影响。同时每层都可预留扩展点
- 提供合适的扩展点,根据业务或者系统的使用场景跟未来的发展做出抽象
- 扩展机制
- Proxy:在Consumer方是一个代理对象,封装所有跟RPC相关的实现。在服务端可以认为就是接口的实现类
- Invoker:之所以有这层,是想在这层包装跟RPC相关的参数,上下文,返回值等信息。同时插入一个扩展点,能够在调用跟被调用时执行一个filter链
- Router and LoadBalance:流量路由
- Packet:实现IO层协议报文跟上层处理对象的互转。涉及到网络协议、序列化
- IO:网络传输层,这层处理IO事件。可先定义RPC框架IO事件,然后将底层网络IO事件映射到RPC层,这样方便以后更换网络框架
- ThreadPool:服务端业务处理线程池管理
- Config:服务的一些配置信息,同时也希望这些配置用户也是可以定制的
- Registry:发布服务地址,订阅服务地址。
- Protocol:处理协议发布的一些流程,同时抽象出这层也希望能支持多种RPC协议
扩展机制
框架扩展在Java中有一套SPI机制,但是Java自带的SPI功能不够强大,我们想要的扩展机制需要具备以下功能:
- 筛选: 比如A接口,有三个实现类A1 A2 A3,只想实列化A3
- 作用域:单例或者多例
- 排序:一个filter链有很多filter,希望这些能够排序
- Aware:类似于spring的BeanFactoryAware,能够将框架本身的一些上下文信息注入到用户实现的扩展点中,而又不污染扩展点本身
这些功能在Java的SPI中是不具备的,需要自行实现
技术点
NIO
NIO即非阻塞IO跟IO多路复用。在这之前BIO方式下,一个连接需要一个线程处理,一个线程阻塞等待一个Scoket的IO事件。这种模式在连接数很多的情况下,系统需要很多的线程进行处理,问题在于线程上下文切换成本太高了。解决这个问题有两种方法:
- 减少线程数,即NIO方式
- 减少线程切换成本,如协程
RequestId
RequestId用于识别请求的发起方。在NIO方式下,不是一个请求处理完再发起另一请求的,它是并行的,并且请求的返回可能是乱序的。这个请求需要有一个RequetId去标识它,当收到服务响应报文后,能够通过RequetId找到调用线程。RequetId不需要分布式唯一,只要本地唯一即可。
连接复用
连接复用的目的是为了减少连接数。如果一个服务一个连接的话,假想下一个Consumer依赖了100个不同的Provider,每个Provider有10台机器,100个服务,那么极端情况下这个Consumer需要维护10010100=100000个长连接。这么多长连接不仅拖垮Consumer自身,同时定时的心跳包也就消耗大量的网络资源。
所以,我们的连接是基于应用维度的,即上面最多只需要100*10=1000个长连接即可。这个能够被正确的使用,是因为我们在协议中设置了RequestId
线程池
采用NIO方式的IO框架,线程通常是以下结构
- 一个boss线程处理连接创建事件
- N(cpu)*2个线程处理IO读写事件
- 多个线程处理业务事件。
一般IO线程只处理简单的IO事件,序列化这种耗CPU的操作都放到业务线程中处理
异步调用
RPC服务框架默认是同步调用。实际上在NIO下,所有都是异步调用。同步其实是用future模拟处理的。异步有两种
- future
- callback
callback也即发起请求后,注册了回调函数,调用线程继续执行。当收到请求后,开启另外一个线程调用回调函数。这个模式下要注意ThreadLocal等上下文的使用
超时
有很多情况可以造成超时,但是不管如何,客户端在规定时间内没有收到请求则一定超时。这种实现用future.get(timeout)即可。其实超时后,客户端还是会收到服务端的响应,只是此时该请求的requestIid已经被移除,所以响应会被抛弃。服务端超时也有些场景是需要考虑的:
- 服务端收到报文时已经超时。但是此时服务端无法判断请求是否超时,因为协议中没有请求开始时间,而对这种场景在协议中增加请求开始时间也不太明智。所以,这种场景不做考虑。
- 业务线程执行该请求时已经超时,这个情况其实没必要再执行请求了,实现起来也不麻烦
异常与透传
将这两者放在一起,是因为它们实现方式是一样的。
- 异常是指将服务端异常带回到客户端抛出
- 透传指的是给双方传递一些非参数跟结果的值。通常是traceId,用于实现调用链。透传主要用于日志监控方面,也见过一些业务方将session进行透传,这是一种bad smell。
它们的实现非常简单,协议中的body字段是一个对象,该对象包含了异常对象跟透传信息。
细节优化
RPC服务框架是一个非常成熟的领域,上面描述的一切都是千篇一律的东西,一个合格的RPC服务框架都会具备上述所有功能。 在实际的使用过程,一般都会做一些细节优化,这些优化才真正反映了业务系统的规模跟复杂度。可以说,细节处做的如何是衡量一个PRC框架好坏非常重要的参考标准
优雅上线
当一个服务发布到注册中心时,容器可能还没启动完毕,那么服务依赖的组件就有可能还没初始化完毕。此时请求过来的话,要么服务报错,要么得到错误的结果。解决方案都是延迟将服务地址发布到注册中心
- 延迟固定时间,这种不能确保容器是否启动完毕
- 如果容器为spring并且确保spring启动完毕时所有服务可正常使用,则可以监听spring启动完毕事件
- RPC框架本身提供延迟发布地址接口(一个http请求),然后应用本身提供一个接口用于判断容器是否启动成功的接口(一个http请求),然后在部署脚本中进行处理
优雅停机
关闭服务的时候,如果不做优雅停机以下3中情况将得不到正确处理:
- 处理完毕还未写回的请求
- 正在接收缓存区,或者正在任务等待队列,或者正在执行的请求
- Consumer已经调用,服务方还未接受到的请求。即正在网络中传输的请求
- 移除服务地址: 防止新的请求调用到本机
- 处理待处理的请求:这点用于处理上述1、2两点,Netty跟线程池自带优雅停机功能,无需实现
- 关闭连接
启动预热
应用在刚启动的时候,Java处于解释执行阶段,此时的处理速度较慢,大流量过来的时候可能会超时。同时,这个时候Java会启动JIT编译,会占用大量的CPU。所以,在刚启动的时候如果大流量过来的话,很多服务将可能超时。此类问题引起的线上故障举不胜举。解决方案也有几种:
- 业务方在优雅上线前循环调用热点服务,触发JIT编译后再对外提供服务
- RPC框架本身提供分批发布功能,比如一次先发10个服务上去,然后设置服务权重,引入小流量进行预热。并且定时监控系统Load,如果Load超过阈值,则下线部分接口。如此循环,直到启动完毕。
地址合并更新
当服务上下线或者路由规则修改时,我们需要重新路由算出地址池,当路由规则复杂的时候,需要进行大量的字符串匹配,占用大量CPU。
可以使用如下的数据结构,实现这个功能
收到地址更新请求后,offer一个信号到信号阻塞队列(这个队列大小为1),同时将最新的地址设置到地址副本中。 地址刷新线程从信号阻塞队列中poll一个信号,然后拿最新地址副本进行路由计算