RPC

作者: zxRay | 来源:发表于2018-07-29 19:51 被阅读131次

    故障语义

    一个系统通过RPC可以像本地调用一样调用一个远程接口。 当一个接口被本地调用时,其只有成功与失败两种结果。但是在分布式环境下,RPC以网络发起调用,其结果就有成功、失败、跟超时三种状态。其中,超时又是一个不可判定的状态。 所以,RPC下可以有三种调用语义:

    1. 或许语义: 调用一次后,不管是否超时
    2. at-most-one:至多调用一次。要做到这个效果,首先在超时的时候,客户端需要发起重试,同时服务端要记录调用结果,如果该请求已经被处理,则直接返回结果,不在重新执行。
    3. at-least-one:至少调用一次。这种是在超时的时候不断重试即可,知道收到调用结果

    三者取一的话,首先at-most-one成本太高。一般我们会在或许语义跟at-least-one两者之间做折中, 也即超时重试,但重试次数受限,不保证一定成功。

    系统架构

    RPC是一个点对点无中心架构,服务消费者直连服务提供者,没有任何第三方代理,只需要在使用系统引入SDK即可。 上面架构的缺陷是Consumer跟Provider紧耦合,Cousumer需要知道Provider的地址。同时,无法做到Provider实时上下线,所以又引入了服务注册中心。 上图就是现有RPC服务框架最基本的结构了:
    1. 服务提供者将服务地址推送到注册中心,消费者订阅服务地址
    2. 注册中心将服务地址做同类项合并,然后推送到消费者端
    3. 消费者收到服务地址后,经过路由规则得到一个地址池,然后通过负载均衡策略选出一个服务地址进行调用

    上面所有过程中,全部由SDK处理。其中Registry是一个中心节点,引入Registry需要保证其高可用,同时RPC服务框架需要保证弱依赖于Registry。

    上图是一个RPC服务框架基本架构图,但是作为一个完备的服务框架来说,它还缺少了以下部分:

    1. 配置中心:服务路由规则等信息需要动态可配,这部分信息可以写入Registry,但是写到配置中心是更适合的做法
    2. 元数据存储中心:Registry里只有服务提供者的IP,并没有接口方法的详细信息,比如接口的参数类型,参数名,返回值类型等等。这些元数据可以为接口测试系统,api网关系统等提供数据支持
    3. rpcops系统:需要有这么一个系统观察服务状态,并提供服务进行在线测试功能
    4. 调用链分析:这块后端需要一个强大的日志采集分析系统,RPC只需要打相应日志跟数据透传即可
    一个完整架构图如下

    框架设计

    好的框架应该是高度可定制化的,且这种定制不会去修改框架代码本身。要达到这个要求

    1. 对框架做分层抽象,抽象出各种组件。组件与组件之间松耦合,替换一个组件不会对其他组件造成影响。同时每层都可预留扩展点
    2. 提供合适的扩展点,根据业务或者系统的使用场景跟未来的发展做出抽象
    3. 扩展机制
    从服务调用跟被调用角度看,可以大致抽象出以下组件:
    1. Proxy:在Consumer方是一个代理对象,封装所有跟RPC相关的实现。在服务端可以认为就是接口的实现类
    2. Invoker:之所以有这层,是想在这层包装跟RPC相关的参数,上下文,返回值等信息。同时插入一个扩展点,能够在调用跟被调用时执行一个filter链
    3. Router and LoadBalance:流量路由
    4. Packet:实现IO层协议报文跟上层处理对象的互转。涉及到网络协议、序列化
    5. IO:网络传输层,这层处理IO事件。可先定义RPC框架IO事件,然后将底层网络IO事件映射到RPC层,这样方便以后更换网络框架
    6. ThreadPool:服务端业务处理线程池管理
    同样的,在服务发布跟订阅大致可以抽象以下三层
    1. Config:服务的一些配置信息,同时也希望这些配置用户也是可以定制的
    2. Registry:发布服务地址,订阅服务地址。
    3. Protocol:处理协议发布的一些流程,同时抽象出这层也希望能支持多种RPC协议

    扩展机制

    框架扩展在Java中有一套SPI机制,但是Java自带的SPI功能不够强大,我们想要的扩展机制需要具备以下功能:

    1. 筛选: 比如A接口,有三个实现类A1 A2 A3,只想实列化A3
    2. 作用域:单例或者多例
    3. 排序:一个filter链有很多filter,希望这些能够排序
    4. Aware:类似于spring的BeanFactoryAware,能够将框架本身的一些上下文信息注入到用户实现的扩展点中,而又不污染扩展点本身

    这些功能在Java的SPI中是不具备的,需要自行实现

    技术点

    NIO

    NIO即非阻塞IO跟IO多路复用。在这之前BIO方式下,一个连接需要一个线程处理,一个线程阻塞等待一个Scoket的IO事件。这种模式在连接数很多的情况下,系统需要很多的线程进行处理,问题在于线程上下文切换成本太高了。解决这个问题有两种方法:

    1. 减少线程数,即NIO方式
    2. 减少线程切换成本,如协程
    目前大部分RPC服务框架都采用NIO这种方式,使用系统提供的非阻塞IO,然后将多个IO挂接到一个Selector(IO复用器)上,最后用一个线程阻塞在此Selector上等待这批socket的IO事件。

    RequestId

    RequestId用于识别请求的发起方。在NIO方式下,不是一个请求处理完再发起另一请求的,它是并行的,并且请求的返回可能是乱序的。

    这个请求需要有一个RequetId去标识它,当收到服务响应报文后,能够通过RequetId找到调用线程。RequetId不需要分布式唯一,只要本地唯一即可。

    连接复用

    连接复用的目的是为了减少连接数。如果一个服务一个连接的话,假想下一个Consumer依赖了100个不同的Provider,每个Provider有10台机器,100个服务,那么极端情况下这个Consumer需要维护10010100=100000个长连接。这么多长连接不仅拖垮Consumer自身,同时定时的心跳包也就消耗大量的网络资源。
    所以,我们的连接是基于应用维度的,即上面最多只需要100*10=1000个长连接即可。这个能够被正确的使用,是因为我们在协议中设置了RequestId

    线程池

    采用NIO方式的IO框架,线程通常是以下结构

    1. 一个boss线程处理连接创建事件
    2. N(cpu)*2个线程处理IO读写事件
    3. 多个线程处理业务事件。

    一般IO线程只处理简单的IO事件,序列化这种耗CPU的操作都放到业务线程中处理

    异步调用

    RPC服务框架默认是同步调用。实际上在NIO下,所有都是异步调用。同步其实是用future模拟处理的。异步有两种

    1. future
    2. callback
    future模式根据future发起的时间,又可以有三种情况,其中情况2就是同步调用。可以看出future其实还是会有阻塞时间的

    callback也即发起请求后,注册了回调函数,调用线程继续执行。当收到请求后,开启另外一个线程调用回调函数。这个模式下要注意ThreadLocal等上下文的使用

    超时

    有很多情况可以造成超时,但是不管如何,客户端在规定时间内没有收到请求则一定超时。这种实现用future.get(timeout)即可。其实超时后,客户端还是会收到服务端的响应,只是此时该请求的requestIid已经被移除,所以响应会被抛弃。服务端超时也有些场景是需要考虑的:

    1. 服务端收到报文时已经超时。但是此时服务端无法判断请求是否超时,因为协议中没有请求开始时间,而对这种场景在协议中增加请求开始时间也不太明智。所以,这种场景不做考虑。
    2. 业务线程执行该请求时已经超时,这个情况其实没必要再执行请求了,实现起来也不麻烦

    异常与透传

    将这两者放在一起,是因为它们实现方式是一样的。

    1. 异常是指将服务端异常带回到客户端抛出
    2. 透传指的是给双方传递一些非参数跟结果的值。通常是traceId,用于实现调用链。透传主要用于日志监控方面,也见过一些业务方将session进行透传,这是一种bad smell。

    它们的实现非常简单,协议中的body字段是一个对象,该对象包含了异常对象跟透传信息。

    细节优化

    RPC服务框架是一个非常成熟的领域,上面描述的一切都是千篇一律的东西,一个合格的RPC服务框架都会具备上述所有功能。 在实际的使用过程,一般都会做一些细节优化,这些优化才真正反映了业务系统的规模跟复杂度。可以说,细节处做的如何是衡量一个PRC框架好坏非常重要的参考标准

    优雅上线

    当一个服务发布到注册中心时,容器可能还没启动完毕,那么服务依赖的组件就有可能还没初始化完毕。此时请求过来的话,要么服务报错,要么得到错误的结果。解决方案都是延迟将服务地址发布到注册中心

    1. 延迟固定时间,这种不能确保容器是否启动完毕
    2. 如果容器为spring并且确保spring启动完毕时所有服务可正常使用,则可以监听spring启动完毕事件
    3. RPC框架本身提供延迟发布地址接口(一个http请求),然后应用本身提供一个接口用于判断容器是否启动成功的接口(一个http请求),然后在部署脚本中进行处理

    优雅停机

    关闭服务的时候,如果不做优雅停机以下3中情况将得不到正确处理:

    1. 处理完毕还未写回的请求
    2. 正在接收缓存区,或者正在任务等待队列,或者正在执行的请求
    3. Consumer已经调用,服务方还未接受到的请求。即正在网络中传输的请求
    有时候应用被人为关闭或者异常关闭,我们需要捕获到这些情况。Java的Shutdownhook可以捕获常见的关闭情况,可以在这个地方进行优雅停机操作。还有一种是提供优雅停机接口,供人工或脚本调用。通常流程为:
    1. 移除服务地址: 防止新的请求调用到本机
    2. 处理待处理的请求:这点用于处理上述1、2两点,Netty跟线程池自带优雅停机功能,无需实现
    3. 关闭连接
    这种方案基本上已经满足需求,但是如果要满足情况3(还在网络中传输的请求),那么需要处理Tcp的"优雅关闭",即让Consumer先关闭写端,然后Provider处理完所有请求后关闭连接,Cousumer在收到FIN后关闭读端。

    启动预热

    应用在刚启动的时候,Java处于解释执行阶段,此时的处理速度较慢,大流量过来的时候可能会超时。同时,这个时候Java会启动JIT编译,会占用大量的CPU。所以,在刚启动的时候如果大流量过来的话,很多服务将可能超时。此类问题引起的线上故障举不胜举。解决方案也有几种:

    1. 业务方在优雅上线前循环调用热点服务,触发JIT编译后再对外提供服务
    2. RPC框架本身提供分批发布功能,比如一次先发10个服务上去,然后设置服务权重,引入小流量进行预热。并且定时监控系统Load,如果Load超过阈值,则下线部分接口。如此循环,直到启动完毕。

    地址合并更新

    当服务上下线或者路由规则修改时,我们需要重新路由算出地址池,当路由规则复杂的时候,需要进行大量的字符串匹配,占用大量CPU。

    考虑一个上千台机器的应用重启的时候,Consumer将会不停的收到地址刷新的请求,然后不停的计算路由,此时刷新地址会占用consumer大量的CPU,其本身提供的服务可能会超时。然而,其实我们并不需要对每次地址更新都重新计算,我们要的只是最后一个地址列表。比如现在有5个地址更新的请求过来,而我们正在处理请求1,那么处理完请求1后,可以直接处理请求5。
    可以使用如下的数据结构,实现这个功能

    收到地址更新请求后,offer一个信号到信号阻塞队列(这个队列大小为1),同时将最新的地址设置到地址副本中。 地址刷新线程从信号阻塞队列中poll一个信号,然后拿最新地址副本进行路由计算

    线程池选择

    Map优化

    锁使用场景优化

    相关文章

      网友评论

          本文标题:RPC

          本文链接:https://www.haomeiwen.com/subject/cczomftx.html