RPC原理

RPC协议

为什么需要协议

  • 传输过程
  • 只有二进制才能在网络中传输,在 RPC 请求发送前,需要将请求参数信息转换成二进制,写入本地 socket,然后通过网卡发送到网络设备中
  • 发送时,并不一定会一次性把所有请求参数的二进制数据发送到对端机器,有可能会拆分成几个数据包或者合并其他请求的数据包(合并前提是同一个 TCP 连接的数据),至于怎么拆分涉及到系统参数配置和 TCP 窗口大小。对于服务提供方来说,会从 TCP 通道中收到很多二进制数据,这时候需要区分哪些数据是属于第一个请求的
  • 所以需要在发送请求的时候设定一个边界,然后在收到请求时按照这个边界进行数据分割。这个边界语义的表达,就是协议
  • 为何不用 HTTP 协议,而是自定义私有 RPC 协议
  • RPC 负责应用间的通信,性能要求相对较高,HTTP 协议数据包大小相对请求数据本身会大很多
  • HTTP 属于无状态协议,客户端无法对请求和响应进行关联,每次请求需要重新建立连接,响应完成后再关闭连接。
  • 因此对于高性能的 RPC 来说,HTTP 很难满足性能需求,需要设计更加紧凑的私有协议

如何设计 RPC 协议

  • 定长协议
  • 整个协议会拆分成两部分:协议头和协议体
  • 协议头中会存放,协议长度、序列化方式、协议标示、消息ID、消息类型等参数
  • 协议体中会存放,请求接口方法、接口参数和一些扩展属性
  • 可扩展协议
  • 定长协议,也就意味着后续无法在协议头中加入新的参数,否则会导致兼容性问题
  • 为了能够支持升级改造,需要设计一种可扩展协议。关键在于协议头可扩展,也就无法定长,也就需要一个固定的写入头的长度
  • 整个协议变成三部分:固定内容,协议头,协议体,前两部分还可以统称为协议头

如何选择序列化

  • 在序列化的选择上,与序列化协议的效率、性能、序列化协议后的体积相比,其通用性和兼容性优先级更高,因为他是直接关系到服务调用的稳定性和可用率的。对于服务性能来说,服务可用性又更加重要
  • 综合考虑,总结一下这几个序列化协议
  • 首选的还是 Hessian 和 Protobuf,因为他们在性能,时间开销,序列化后的体积,通用性,兼容性,安全性都可以满足要求。其中 Hessian 在使用上更加方便,在对象的兼容性更好;Protobuf 则更加高效,通用性上更有优势

RPC 框架注意事项

  • 对象构造过于复杂
  • 属性很多,又存在多层嵌套。序列化框架在序列化对象时,对象越复杂就越影响性能,消耗 CPU,这会严重影响 RPC 框架的整体性能
  • 对象过于庞大
  • 序列化之后的字节长度达到上兆字节。序列化庞大的对象是很耗费时间的,直接影响到请求耗时。
  • 使用框架不支持的类作为入参类
  • 如 Hessian 天然不支持 LinkedHashMap,LinkedHashSet,大多数情况下最好不要使用第三方集合类,如 Guava 中的集合类
  • 对象有复杂的继承关系
  • 在做序列化时,会对对象的属性逐个序列化,当有继承关系时,会不停寻找父类,遍历属性,对象越复杂就会越浪费性能

序列化

  • JSON
  • Json进行序列化的额外空间开销比较大,对于大数据量的应用意味着更大的内存和磁盘开销
  • Json没有类型,像 Java 这种强类型语言,需要通过反射统一解决,所以性能不会太好
  • 如果 RPC 框架选用 Json 序列化,服务之前传递的数据量要相对较小,否则会严重影响性能
  • Hessian
  • Hessian 是动态类型、二进制、紧凑的,性能要比 Json 序列化高很多,并且生成的字节数也更小
  • 相对于 Json 有非常好的兼容性和稳定性,所以 Hessian 更适合做 RPC 序列化协议
  • 不支持 Linked 系列,LinkedHashMap,LinkedHashSet
  • Byte/Short 反序列化时会变成 Integer
  • Protobuf
  • 序列化后的体积会比 Json、Hessian 小很多
  • IDL 能清晰描述语义,保证应用程序之间的类型不会丢失
  • 序列化/反序列化速度很快,无需通过反射获取类型
  • 消息格式升级和兼容不错,可以做到向后兼容
  • Protostuff 无需依赖 IDL 文件,可以直接对 Java 对象进行序列化操作,效率上和 Protobuf 差不多,生成的二进制格式和 Protobuf 完全相同,是一个 Java 版本的 Protobuf 序列化框架,但是也有不支持的情况
  • 不支持 null
  • 不支持单纯的 Map,List 集合对象,需要包在对象里面

如何实现请求和响应关联

  • 为何需要将请求和响应管理
  • 调用端会向服务端发送请求消息,之后他还会接收到服务端发送回来的响应消息,但这两个操作并不是同步进行的
  • 在高并发情况下调用端可能会在某一时刻向服务端连续发送很多条消息之后,才会陆续收到服务端发送回来的各个响应消息。这时调用端需要区分这些响应消息分别对应的是之前的哪条请求消息
  • 解决方案
  • 其实设计的私有协议都会有消息 ID,这个 ID 的作用就是起到请求跟响应关联的作用。调用端为每一个消息生成一个唯一的消息 ID,他收到服务端发送回来的响应消息如果是同一消息 ID,那么调用端就可以认为,这条响应消息是之前那条请求消息的响应消息

服务发现

为什么不使用DNS

  • 为了提升 DNS 性能,DNS 采用了多级缓存,一般配置的缓存时间较长,所以无法及时感知到服务变化
  • 所以会导致服务下线后,服务调用者无法及时摘除下线节点;服务扩容时,新上线的服务也无法及时接收到流量

基于Zookeeper的服务发现

  • 流程
  • 服务平台管理端先在 ZK 中创建一个根路径,在这个路径再创建服务提供方目录和服务调用方目录,例如:/consumer,/provider,分别用来存储服务提供方节点信息和服务调用方节点信息。
  • 服务提供方发起注册时,在服务提供方目录中创建一个临时节点,节点中存储服务提供方节点信息。
  • 服务调用方发起注册时,在服务调用方目录下创建一个临时节点,节点中存储服务调用方节点信息。并且服务注册节点需要 watch 服务提供方目录(/provider)中所有节点数据
  • 当服务提供方有节点发生变更时,ZK 就会通知发起订阅的服务调用方
  • 问题
  • ZK 的特点是强一致性,需要保证每个节点数据实时完全一致,因此导致 ZK 集群性能上的下降。
  • 当连接 ZK 的节点特别多,集中上下线时, ZK 的读写就会特别频繁,且 ZK 目录数量打到一定数量后,ZK 本身会变得不稳定,CPU 持续飙高直至宕机。宕机后,由于各业务节点还在持续发送读写请求,刚启动就因为无法承受瞬间读写压力马上宕机。

基于消息总线的最终一致性注册中心

  • RPC 框架依赖的注册中心的服务数据的一致性其实并不需要满足 CP,只要满足 AP 即可。
  • 因为要求最终一致性,我们可以考虑采用消息总线机制。注册数据可以全量缓存在每个注册中心内存中,通过消息总线来同步数据。
  • 当有一个注册中心节点接受服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其他注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性,具体流程如下:
  • 服务上线时,注册中心节点收到注册请求,服务列表数据发生变化生成一个消息,推送给消息总线,每个消息都有整体递增版本。
  • 消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉去消息。对于获取到消息的在消息回放模块中回放,直接收大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。
  • 消费者订阅可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存中。
  • 采用推拉模式,消费者可以及时拿到服务实例增量变化情况,并和内存中的缓存数据进行合并。
  • 因为服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况。这个问题可以放到 RPC 框架中处理,在服务调用方发送请求到目标节点后,目标节点进行合法性验证,如果指定接口服务不存在或者正在下线,会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其他节点。

健康监测

  • 服务除了直接宕机,还会存在"僵死"的情况。所以如何判断服务是健康,亚健康,还是宕机需要有一定策略
  • 调用方每个接口的调用频次不一样,有的接口可能1秒内调用上百次,有的接口可能半小时才会调用一次。所以我们不能简单把总失败次数当作判断条件。
  • 服务的接口响应时间也不一样,有的接口可能1ms,有的可能10s,所以我们也不能把 TPS 当作判断条件。
  • 我们可以使用可用率这一判断条件。可用率的计算方式是某一个时间窗口内接口调用成功次数的百分比(成功次数 / 总调用次数)。当可用率低于某个比例就认为这个节点存在问题,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。

灰度发布

  • 结合nacos,重写ribbon路由规则自研开发(动态调整灰度开关、灰度策略)
  • 首先需要找到分支接口(接收请求的第一个被改动接口)
  • 全部都是新增接口
  • 分支接口的服务只上线一台,其余服务全量上线。调整分支接口服务权重或者在分支接口处设置灰度策略
  • 改动旧接口
  • 分支接口下游链路所有涉及改动服务全部需要灰度上线,并增加对应版本号。调整分支接口服务权重或者在分支接口处设置灰度策略,严格按照版本号进行请求分发(请求只会在相同版本号之间传递),灰度服务请求下游服务只会打到灰度服务
  • 请求头增加灰度标识,下游服务可以借此标识自定义灰度逻辑
  • 灰度策略采用工厂模式,增加策略时只需要新增对应GrayHandler。灰度策略类路径存储在配置中心,通过反射加载(只在初始化的时候加载一次,放在服务缓存中,避免每次反射带来的性能开销),如需更换别的策略可以修改配置动态调整
  • 字段:服务名称,接口URI,灰度策略类路径,逻辑参数字段,灰度版本号
  • 服务部署:分成3台,两台正常节点一台灰度节点

自适应负载均衡

  • 根据线上服务 CPU,load,内存等变化情况,自动调整各服务负载权重
  • 可以配合随机权重的负载均衡策略控制,通过最终的指标分数修改服务节点最终权重。
  • 例如给一个服务节点综合打分是8分,服务节点的权重是100,那么计算后最终权重就是80(100 * 0.8)
  • 服务调用者发送请求时,会通过随机权重的策略来选择服务节点,那么这个节点收到的流量就是其他正常节点的80%
  • 整体设计方案如下
  • 添加服务指标收集器,并将其作为路由插件,默认有运行时状态指标收集器、请求耗时指标收集器
  • 运行时状态指标收集器收集服务节点 CPU 核数、CPU 负载、内存等指标,在服务调用者与服务提供者的心跳数据中获取
  • 请求耗时指标收集器收集请求耗时数据,如平均耗时、TP999
  • 可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分
  • 通过服务节点的综合打分与节点权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略选择服务节点。

异常重试机制

  • 异常重试是为了尽最大可能保证接口可用率的一种手段,但这种策略只能用在幂等接口上
  • 实现方案
  • 当调用方发起的请求失败时,如果配置了异常重试策略,RPC 框架会捕捉异常,对异常判定,符合条件则进行重试,重新发送请求
  • 在使用 RPC 框架的重试机制时,我们要确保被调用的服务业务逻辑是幂等的,这样才能考虑是否重试,这点至关重要
  • 重试过程中,为了能够在约定时间内进行安全可靠的重试,在每次触发重试之前,我们需要现判定这个请求是否已经超时,如果超时直接返回超时异常,防止因多次重试导致这个请求的处理时间超过用户配置的超时时间,从而影响到业务处理的耗时
  • 当发起重试,在负载均衡选择节点的时候,应该去掉重试之前出现问题的那个节点,这样可以提高重试的成功率,并且允许用户配置可重试异常的白名单,这样可以让 RPC 框架的异常重试功能变得更加友好

启动预热

  • 为何需要启动预热
  • Java 中,JVM 虚拟机会把高频代码编译成机器码,被加载过的累也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,这样就使得"热点"代码的执行不用每次都通过解释,从而提升执行速度
  • 微服务架构中上线时频繁发生的,服务重启后以上的“临时数据”都会消失,如果刚启动的应用就承担停机前一样的流量,启动之初就处于高负载状态,就可能导致调用方过来的请求出现大面积超时
  • 什么是启动预热
  • 刚启动的服务提供方应用不承担全部流量,而是让它被调用的次数虽则时间推移慢慢增加,最终让流量缓和递增到跟已经运行一段时间后的水平一样
  • 方案实现
  • 服务在启动后,向服务中心注册时,记录下服务的启动时间
  • 服务间负载均衡调用时,权重策略中加入启动时间的判断,初始调用时,例如权重只有10%,随着时间推移,不断增加其权重,直到达到预热时间,权重恢复正常
  • 让服务延迟注册,并且在服务内部预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑,模拟调用过程,使得JVM指令能够预热起来,用户也可以在 Hook 中实现加载一些资源

流量隔离

  • 问题
  • 因为一个调用发出现流量激增,有可能影响所有调用方的可用率,导致集群崩溃
  • 解决方案
  • 集群分组
  • 集群调用间合理分组,只能同组间相互调用
  • 一个组的崩溃,不会影响其他组可用率
  • 并且如果业务分组是动态的,可以在管理平台动态自由调整分组,就可以实现动态的流量切换
  • 高可用
  • 分组隔离后,单个调用方在发 RPC 请求的时候可选择的服务节点数相比没有分组前减少了,对于单个调用方来说出错的概率提升了。
  • 可以支持调用方配置多个分组,当己方分组出现问题(例如集中交换机设备突然坏了),可以暂时调用别的分组
  • 需要将配置的多个分组区分主次,只有主分组的节点都不可用才去选择次分组节点;只要主分组里面的节点恢复正常,就必须把流量都切换到主节点上,整个切换过程对于应用层完全透明。

异步 RPC

  • RPC 框架的同步与异步
  • 对于调用端来说,向服务端发送请求消息和接受服务端发送过来的响应消息,这两个处理过程是完全独立的,所以对于 RPC 框架,无论同步调用还是异步调用,调用端内部实现都是异步的
  • 调用端发送的每条消息都有一个唯一的消息标识,实际上调用端向服务端发送请求消息之前会创建一个 Futrue,并会存储这个消息标识与这个 Future 的映射,动态代理所获得的返回值最终就是从这个 Future 中获取的;当收到服务端响应的消息时,调用端会根据响应消息的唯一标识,通过之前存储的映射找到对应的 Future,将响应结果注入到这个 Future 中,最后动态代理从 Future 中获得正确的返回值
  • 所谓同步调用,不过时 RPC 框架在调用段的处理逻辑中主动执行了这个 Future 的 get 方法,让动态代理等待返回值;而异步调用则是 RPC 框架没有主动执行这个 Future 的 get 方法,用户可以从请求上下文中得到这个 Future,自己决定什么时候执行 get 方法
  • 如何做到 RPC 调用全异步
  • 一个 RPC 请求,对二进制消息数据包拆解包的处理是在处理网络 IO 的线程中,比如网络通信框架使用的是 Netty 框架,那么对二进制包的处理是在 IO 线程中,解码与反序列化的过程也在 IO 线程中处理,而服务端的业务逻辑则应该交给专门的业务线程池处理,以防止业务逻辑处理过慢影响到网络 IO 的处理
  • 实现方式
  • 服务调用方发起 RPC 调用,直接拿到返回值 CompletableFuture 对象,之后就不需要任何额外与 RPC 框架相关的操作了,直接就异步处理
  • 在服务端的业务逻辑中创建一个返回值 CompletableFuture 对象,之后服务端真正的业务逻辑完全可以在一个线程池中异步处理,业务逻辑完成之后再调用这个 CompletableFuture 对象的 complete 方法,完成异步通知
  • 调用端在收到服务端发送过来的响应之后,RPC 框架再自动调用调用端拿到的 CompletableFuture 的 complete 方法,这样一次异步调用就完成了

流量回放

  • 在改动原有功能后,尤其改动量很大,我们无法保证测试能够覆盖所有影响范围,此时我们就可以使用流量回放功能,回放历史线上流量,调用新的改动方法,比对新旧结果是否符合预期
  • 可以在 RPC 中记录历史流量,异步存储到 mysql 或者 mongo 中

多版本 RPC 兼容

  • 如果面临新旧 RPC 协议需要做切换,又无法同时将所有服务全部更新到新的 RPC 协议,就需要新的 RPC 协议兼容旧的 RPC 协议
  • 处理方案
  • 协议的作用是用来分割二进制数据流
  • 每种协议开头都有一个协议编码,称之为 magic number
  • 当 RPC 收到数据包后,可以先解析出 magic number,找到对应协议的数据格式,进而解析收到的二进制数据
  • 协议解析过程就是把一串二进制数据变成一个 RPC 对象,但这个对象一般是跟协议相关的,为了让 RPC 内部处理起来更加方便,一般会把这个协议相关对象转成一个跟协议无关的 RPC 对象。因为在 RPC 流程中,当服务提供方受到反序列化后的请求时,需要根据当前请求参数找到对应接口的实现类去完成真正的方法调用。如果这个请求参数跟协议相关,后续 RPC 的整个处理逻辑就会变得很复杂
  • 当完成真正 RPC 调用后,RPC 返回的也是一个跟协议无关的通用对象,所以在真正往调用方回写数据的时候,同样需要完成一个对象转换的逻辑,把通用对象转成协议对象
  • 在收发数据包的时候,通过两次转换实现 RPC 内部处理逻辑跟协议无关,同时保证调用方收到的数据格式跟调用请求过来的数据格式是一样的,流程如下
  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/9aaac01f45748cd2f7797fe2a
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券