赞
踩
上次Version06说到了咱们手写迷你版RPC的大体流程,
在搭建一个复杂的分布式系统过程中,如果开发人员在编码时要对每个涉及到网络通信的逻辑都进行一系列的复杂编码
,这将是件多么恐怖的事儿。所以说,网络通信是搭建分布式系统的一个大难题,是一点不为过的,我们必须给予足够的重视。而 RPC 对网络通信的整个过程做了完整包装,在搭建分布式系统时,它会使网络通信逻辑的开发变得更加简单,同时也会让网络通信变得更加安全可靠
。可以认为RPC 是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机的子程序,远程机器返回结果的过程,而开发人员无需额外地为这个交互编程或者说不需要了解底层网络技术的协议
。概括来讲RPC 主要解决了:让分布式或者微服务系统中不同服务之间的调用像本地调用一样简单
。】,RPC 关注的是远程调用而非本地调用。【【RPC(Remote Procedure Call Protocol,远程过程调用协议
,RPC是一种进程间通信方式
,分布式常见的通讯方式,从跨进程到跨物理机已有几十年历史)框架:】RPC跨越了传输层和应用层
。RPC使得开发包括网络分布式多程序在内的应用程序更加容易。RPC采用客户机/服务器模式。请求程序就是一个客户机,而服务提供程序就是一个服务器。首先,客户机调用进程发送一个有进程参数的调用信息到服务进程,然后等待应答信息。在服务器端,进程保持睡眠状态直到调用信息到达为止。当一个调用信息到达,服务器获得进程参数,计算结果,发送答复信息,然后等待下一个调用信息,最后,客户>端调用进程接收答复信息,获得进程结果,然后调用执行继续进行
。有多种 RPC模式和执行。PC的方式一般是同步的,可以马上得到结果
**。在实际中,大多数应用都要求立刻得到结果,这时同步方式更有优势,代码也更简单。两个不同的服务器上的服务提供的方法不在一个内存空间,所以,需要通过网络编程才能传递方法调用所需要的参数。并且,方法调用的结果也需要通过网络编程来接收
。如果我们自己手动网络编程来实现这个调用过程的话工作量是非常大的,因为,我们需要考虑底层传输方式(TCP还是UDP)以及网络传输过程中需要考虑的其他问题比如:采用序列化方式?需要需要其他的编解码呀之类的等等方面
。RPC已经帮咱们封装好了,RPC帮咱们屏蔽了网络编程细节
,直接用就行了【实现调用远程方法就跟调用本地(同一个项目中的方法)一样
】通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单。并且!我们不需要了解底层网络编程的具体细节等基础上进行的
。【两个不同的服务 A、B 部署在两台不同的机器上,服务 A 如果想要调用服务 B 中的某个方法的话就可以通过 RPC 来做。】。总结下来就是两件事:
屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法
使用动态代理可以屏蔽远程方法调用的细节比如网络传输
。也就是说 当你调用远程方法的时候,实际会通过代理对象来传输网络请求,不然的话,怎么可能直接就调用到远程方法呢
?RPC真正强大的地方在于它的治理功能,比如连接管理、健康检测、负载均衡、优雅启停机、异常重试、业务分组以及熔断限流
等等,使得我们可以更加方便地构建分布式系统RPC 框架指的是可以让客户端直接调用服务端方法,就像调用本地方法一样简单的框架,比如 Dubbo、Motan、gRPC这些。 如果需要和 HTTP 协议打交道,解析和封装 HTTP 请求和响应。这类框架并不能算是“RPC 框架”,比如Feign
】Dubbo 提供的是基于接口的远程方法调用【即客户端只需要知道接口的定义即可调用远程服务,但是在 Java 中接口并不能直接调用实例方法,必须通过其实现类对象来完成此调用操作,这意味着客户端必须为这些接口生成代理对象,对此 Java 提供了 Proxy、InvocationHandler 生成动态代理的支持;生成了代理对象,jdk 动态代理生成的代理对象调用指定方法时实际会执行 InvocationHandler 中定义的 #invoke 方法,在该方法中完成远程方法调用并获取结果】,不光可以帮助我们调用远程服务,还提供了一些其他开箱即用的功能比如智能负载均衡
。】【Apache Dubbo 是一款微服务框架,为大规模微服务实践提供高性能 RPC 通信、流量治理、可观测性等解决方案, 涵盖 Java、Golang 等多种语言 SDK 实现。Dubbo 提供了从服务定义、服务发现、服务通信到流量管控等几乎所有的服务治理能力,支持 Triple 协议(基于 HTTP/2 之上定义的下一代 RPC 通信协议)、应用级服务发现、Dubbo Mesh (Dubbo3 赋予了很多云原生友好的新特性)等特性。】
服务越来越多,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动
,架构师都不能完整的描述应用的架构关系。Dubbo 可以为我们解决服务之间互相是如何调用的
。由于 Spring Cloud 在微服务中应用更加广泛,所以,我觉得一般我们提 Dubbo 的话,大部分是分布式系统的情况
。缺乏一系列配套的服务化组件和服务治理功能的支撑
。Dubbo 不论是从功能完善程度、生态系统还是社区活跃度来说都是最优秀的。而且,Dubbo在国内有很多成功的案例比如当当网、滴滴等等,是一款经得起生产考验的成熟稳定的 RPC 框架。Dubbo 也是 Spring Cloud Alibaba 里面的一个组件
。最重要的是你还能找到非常多的 Dubbo 参考资料,学习成本相对也较低。如果需要跨多种语言调用的话,可以考虑使用 gRPC
如果你要是公司实际使用的话,还是推荐 Dubbo
,其社区活跃度以及生态都要好很多。gRPC是可以在任何环境中运行的现代开源高性能RPC框架。它可以通过可插拔的支持来有效地连接数据中心内和跨数据中心的服务,以实现负载平衡,跟踪,运行状况检查和身份验证。它也适用于分布式计算的最后一英里,以将设备,移动应用程序和浏览器连接到后端服务
。gRPC 的设计导致其几乎没有服务治理能力。如果你想要解决这个问题的话,就需要依赖其他组件比如腾讯的 PolarisMesh(北极星)了。
由于Thrift其跨语言特性和出色的性能,在很多互联网公司得到应用,有能力的公司甚至会基于thrift研发一套分布式服务框架,增加诸如服务注册、服务发现等功能
由于Apache Thrift 其跨语言特性和出色的性能【Thrift支持多种不同的编程语言,包括C++、Java、Python、PHP、Ruby等(相比于 gRPC 支持的语言更多 )】
,在很多互联网公司得到应用,有能力的公司甚至会基于 thrift 研发一套分布式服务框架,增加诸如服务注册、服务发现等功能。RMI(JDK自带): JDK自带的RPC,有很多局限性,不推荐使用
。轻量级二进制RPC框架
,Hessian通过Servlet提供远程服务,可以将某个请求映射到Hessian服务
。Spring MVC的DispatcherServlet支持该功能,DispatcherServlet可将匹配模式的请求转发到Hessian服务。Hessian的Server端提供一个Servlet基类,用来处理发送的请求,而Hessian的远程过程调用则使用动态代理来实现,采用面向接口编程。因此,Hessian 服务通常通过Java接口对外暴露
。
所以dubbo RPC默认采用hessian2序列化
。以本地调用的方式
调用远程服务;调用方应用通过服务发现能够获取到服务提供方的 IP 地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接
Client Stub负责将方法、参数等组装成能够进行网络传输的消息体(序列化)
,也就是RpcRequest;或者说客户端会通过本地代理模块 Proxy 调用服务端,Proxy 模块收到负责将方法、参数等数据转化成网络字节流
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”
。我们把数据格式的约定内容叫做“协议”。大多数的协议会分成两部分,分别是数据头和消息体
。数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等。】格式,服务提供方就可以正确地从二进制数据中分割出不同的请求来,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象。这个过程叫作“反序列化”。RPC 常用于业务系统之间的数据交互,需要保证其可靠性,所以 RPC 一般默认采用 TCP 来传输
】给了服务提供方server stub(桩)收到RpcRequest类型的消息将RpcRequest类型的消息反序列化为Java对象
,然后紧接着服务端 server stub(桩)根据从RpcRequest中反序列化出来的对象中的类、方法、方法参数等信息调用本地的方法【服务调用者调用的远程服务实际是远程服务的本地代理,本质上是通过动态代理的拦截机制,将本地调用封装成远程服务调用】
;
将执行结果组装成能够进行网络传输的消息体RpcResponse(序列化)并发送至消费方
;(client stub)接收到RpcResponse类型的消息后并将RpcResponse类型的消息反序列化为Java对象,这样也就得到了最终远程调用的结果
。over!
整个 RPC的 核心功能可以分为如下五个部分或者五个角色
:
客户端的代理类
。代理类
主要做的事情很简单,就是 把你客户端要调用方法、类、方法参数等信息传递到服务端
。
服务调用者调用的服务实际是远程服务的本地代理
,对于Java语言,它的实现就是JDK的动态代理,通过动态代理的拦截机制,将本地调用封装成远程服务调用
。
既然要调用远程的方法就要发请求,请求中至少要包含你调用的类名、方法名以及相关参数吧。网络传输就是你要把你调用的方法的信息比如说参数啊这些东西通过网络传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来
。【Client在不知道调用细节的情况下调用存在于远程计算机上的某个对象(就像调用本地应用程序中的对象一样)、调了跨进程的方法
】
封装更加优秀的 Netty(推荐)
。这个桩就不是代理类了
。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去指定对应的方法然后返回结果给客户端的类
。
服务端最重要的任务便是提供服务接口的真正实现并在某个端口上监听网络请求,监听到请求后从网络请求中获取到对应的参数(比如服务接口、方法、请求参数等),再根据这些参数通过反射的方式调用接口的真正实现获取结果并将其写入对应的响应流中
。】服务端最重要的任务便是提供服务接口的真正实现并在某个端口上监听网络请求,监听到请求后从网络请求中获取到对应的参数(比如服务接口、方法、请求参数等),再根据这些参数通过反射的方式调用接口的真正实现获取结果并将其写入对应的响应流中
。】
可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现
。【在 Java 里面,JDK 有自带的 SPI(Service Provider Interface)服务发现机制, SPI它可以动态地为某个接口寻找服务实现
。使用 SPI 机制需要在 Classpath 下的 META-INF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。】会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费
。另外就是 扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成
,比如扩展里面依赖了一个 Spring Bean,原生的 Java SPI 就不支持。你像人家Dubbo中有自己的SPI,没用原生的核心功能体系与插件体系
整个架构就变成了一个微内核架构,其实关键点就是“插件化”,我们将每个功能点抽象成一个接口
,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现
。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题
。完成了接口跟服务提供者 IP 的映射,这个映射就是一种命名服务
】咱们Dubbo中的Zookeeper不就是干这个的嘛服务节点就是提供该契约或者说该接口的一个具体实现
】,这个 获取对应的服务提供者端的服务节点的过程我们一般叫作“服务发现
”。】
服务端节点上线后自行将服务名称及其对应的地址(ip+port)向注册中心注册服务列表,节点下线时需要从注册中心将节点元数据信息移除
。客户端向服务端发起调用时,服务调用方自己负责根据服务名称从注册中心获取服务端的服务列表,然后在通过负载均衡算法选择其中一个服务节点进行调用
。这是最简单直接的服务端和客户端的发布和订阅模式,不需要再借助任何中间服务器,性能损耗也是最小的。
但是如果节点异常退出,例如断网、进程崩溃等,那么注册中心将会一直残留异常节点的元数据,从而可能造成服务调用出现问题
。为了避免上述问题,实现服务优雅下线比较好的方式是采用主动通知 + 心跳检测的方案
。除了
主动通知注册中心下线外,还需要增加节点与注册中心的心跳检测功能,这个过程也叫作探活。心跳检测可以由节点或者注册中心负责,例如注册中心可以向服务节点每 60s 发送一次心跳包,如果 3 次心跳包都没有收到请求结果,可以任务该服务节点已经下线
zookeeper作为注册中心时,当服务节点数量达到一定规模时,会出现性能问题
,主要是由于其保证强一致性】,ZooKeeper 集群整体压力也越来越高,尤其在集中上线的时候越发明显【比如”集中爆发”,在一次大规模上线的时候,当有超大批量的服务节点在同时发起注册操作
,ZooKeeper 集群的 CPU 突然飙升,导致 ZooKeeper 集群不能工作了,而且也无法立马将 ZooKeeper 集群重新启动,一直到 ZooKeeper 集群恢复后业务才能继续上线。当连接到 ZooKeeper 的节点数量特别多,对 ZooKeeper 读写特别频繁,且 ZooKeeper 存储的目录达到一定数量的时候,ZooKeeper 将不再稳定,CPU 持续升高,最终宕机。而宕机之后,由于各业务的节点还在持续发送读写请求,刚一启动,ZooKeeper 就因无法承受瞬间的读写压力,马上宕机。转自RPC实战与核心原理】。这也说明了ZooKeeper 集群性能显然已经无法支撑现有规模的服务集群
了,需要重新考虑服务发现方案
ZooKeeper 的一大特点就是强一致性,ZooKeeper 集群的每个节点的数据每次发生更新操作,都会通知其它 ZooKeeper 节点同时执行更新
。它要求**保证每个节点的数据能够实时的完全一致,这也就直接导致了 ZooKeeper 集群性能上的下降
**。这不跟IO模型中的有些想法很类似嘛可以牺牲掉 CP(强制一致性),而选择 AP(最终一致),来换取整个注册中心集群的性能和稳定性【大部分高并发或者、高性能场景中一般不使用强一致性 而采用最终一致性】
**。注册数据可以全量缓存在集群中的每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性
在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它节点
。服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略
RPC 框架中设计自适应的负载均衡,其关键点就是调用端收集服务端每个节点的指标数据,再根据各方面的指标数据进行计算打分,最后根据每个节点的分数,将更多的流量打到分数较高的节点上
。
当时最早申请的一批容器配置比较低,缩容的时候留下了几台,当流量达到高峰时,这几台容器由于负载太高,就扛不住压力了
。业务问我们有没有好的服务治理策略?治理平台上调低这几台老旧机器的权重,这样的话,老旧机器上来的访问的流量自然就减少了
。
虽说我们的服务治理平台能够动态地控制线上服务节点接收的访问量,但当业务方发现部分机器负载过高或者响应变慢的时候再去调整节点权重,真的很可能已经影响到线上服务的可用率了
。这时再如此解决,需要时间啊,那这段时间里业务可能已经有损失了
。咱们强大的RPC 框架有没有什么智能负载的机制?能否及时地自动控制服务节点接收到的访问量---->不就说的是咱们负载均衡策略嘛?设计一种自适应的负载均衡策略
其中的随机权重策略应该是我们最常用的一种了,通过随机算法,我们基本可以保证每个节点接收到的请求流量是均匀的;同时我们还可以通过控制节点权重的方式,来进行流量控制
。比如我们默认每个节点的权重都是 100,但当我们把其中的一个节点的权重设置成 50 时,它接收到的流量就是其他节点的 1/2。自然也不会发生负载均衡设备的单点问题,服务调用方的负载均衡策略也完全可配,同时我们可以通过控制权重的方式,对负载均衡进行治理
。【服务调用者发起请求时,会通过配置的负载均衡插件,自主地选择服务节点。按需分配】每个服务节点处理请求的能力
服务处理节点处理请求的能力
来判断要打给它多少流量就可以了
服务调用者收集与之建立长连接的每个服务节点的指标数据,如服务节点的负载指标、CPU 核数、内存大小、请求处理的耗时指标(如请求平均耗时、TP99、TP999)、服务节点的状态指标(如正常、亚健康)。通过这些指标,计算出一个分数,比如总分 10 分,如果 CPU 负载达到 70%,就减它 3 分,当然了,减 3 分只是个类比,需要减多少分是需要一个计算策略的
。给服务节点打分时我们可以为每个指标都设置一个 指标权重占比,
然后再根据这些指标数据,计算分数。配合随机权重的负载均衡策略
去控制,通过最终的指标分数修改服务节点最终的权重
。例如给一个服务节点综合打分是 8 分(满分 10 分),服务节点的权重是 100,那么计算后最终权重就是 80(100*80%)。服务调用者发送请求时,会通过随机权重的策略来选择服务节点,那么这个节点接收到的流量就是其他正常节点的 80%(这里假设其他节点默认权重都是 100,且指标正常,打分为 10 分的情况)
。在每次发起请求的时候都可以拿到一个可用的连接
。解决方案是让调用方实时感知到节点的状态变化
所以每次发请求前,RPC 框架会根据路由和负载均衡算法选择一个具体的 IP 地址。为了保证请求成功,我们就需要确保每次选择出来的 IP 对应的连接是健康的【因为调用方跟服务集群节点之间的网络状况是瞬息万变的,两者之间可能会出现闪断或者网络设备损坏等情况,所以咱们得保证选择出来的连接一定是可用的】
。但是呢,咱们不可能每次提前加悲观锁,挨个检查一遍,而应该是 有一套反馈机制,大部分关键部位的状态变化,我作为调用方,都能够第一时间了解
。所以 解决方案是让调用方实时感知到节点的状态变化
那为什么 RPC 框架还会继续把请求发到这台有问题的机器上呢?
。 RPC 框架还会把请求发到这台机器上,也就是说从服务调用方的角度看,它没有觉得这台服务器有问题。这是为啥呢”。一步一步查看问题时间点的监控和日志,发现了...
在某些时间段出现了网络故障,但也还能处理部分请求
**。换句话说,它处于半死不活的状态【它还没彻底“死”,还有心跳,这样,调用方就觉得它还正常,所以就没有把它及时挪出健康状态列表。】。说明我们的 服务检测机制有问题,有的服务本来都已经病危了,但我们还以为人家只是个感冒
。因为应用健康状况不仅包括 TCP 连接状况,还包括应用本身是否存活,很多情况下 TCP 连接没有断开,但应用可能已经“僵死了”
。所以 业内常用的检测方法就是用心跳机制
。【心跳机制其实就是 服务调用方每隔一段时间就问一下服务提供方,“兄弟,你还好吧?”,然后服务提供方很诚实地告诉调用方它目前的状态。服务提供方的状态一般会有三种情况,一个是我很好【健康状态:建立连接成功,并且心跳探活也一直成功】,一个是我生病了【亚健康状态:建立连接成功,但是心跳请求连续失败】,一个是没回复【死亡状态:建立连接失败】。用专业的词来对应这三个状态就是:
】。
一个节点从健康状态过渡到亚健康状态的前提是“连续”心跳失败次数必须到达某一个阈值
,比如 3 次(具体看你怎么配置了)
改下配置,调低阈值
服务节点网络有问题,心跳间歇性失败
。我们现在判断节点状态只有一个维度,那就是心跳检测,那是不是可以再加上业务请求的维度呢?用 可用率
这个参数解决服务提供方是以一个集群的方式
提供服务,这对于服务调用方来说,就是一个接口会有多个服务提供方同时提供服务,所以我们的 RPC 在每次发起请求的时候,都需要 从多个服务提供方节点里面选择一个用于发请求的节点
。既然这些节点都可以用来完成这次请求,那么我们就可以简单地认为这些节点是同质的【这次请求无论发送到集合中的哪个节点上,返回的结果都是一样的】
那上线就涉及到变更,只要变更就可能导致原本正常运行的程序出现异常,尤其是发生重大变动的时候,导致我们应用不稳定的因素就变得很多
。灰度发布
我们的应用实例,比如我们可以 先发布少量实例观察是否有异常,后续再根据观察的情况,选择发布更多实例还是回滚已经上线的实例
。
一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损,人家业务已经办完的人该咋办
。可以在上线完成后,先让一小部分调用方请求过来进行逻辑验证,待没问题后再接入其他调用方,从而实现流量隔离的效果
注册中心只会把刚上线的服务 IP 地址推送到选择指定的调用方
,而其他调用方是不能通过服务发现拿到这个 IP 地址的。
所以从实际的角度出发,通过影响服务发现来实现请求隔离并不划算
。那我们是不是可以在选择节点前加上“筛选逻辑”,把符合我们要求的节点筛选出来。那这个筛选的规则是什么呢?就是我们前面说的灰度过程中要验证的规则
。上线过程中我们就可以做到只让部分调用方请求调用到新上线的实例
,相对传统的灰度发布功能来说,这样做我们可以把试错成本降到最低。在升级改造应用的时候,为了保证调用方能平滑地切调用我们的新应用逻辑,在升级过程中我们常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了 100% 且运行一段时间后才能去下线老应用
。在流量切换的过程中,为了保证整个流程的完整性,我们必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。因为 IP 路由只是限制调用方来源,并不会根据请求参数请求到我们预设的服务提供方节点上去。所以我们可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,我们可以很容易地拿到请求参数当调用端发起的请求失败时,RPC 框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数
。所以我们要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常
等等。那如果请求信息成功地发送到了服务节点上,那这个节点就要执行业务逻辑了,如果这个时候发起了重试,业务逻辑还是会被执行,但是如果这个服务业务逻辑不是幂等的,比如插入数据操作,那么触发重试机制就会引发一致性等问题
在使用 RPC 框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启 RPC 框架的异常重试功能。这一点你要格外注意,这算是一个高频误区了
。在每次重试后都重置一下请求的超时时间【当调用端发起 RPC 请求时,如果发送请求发生异常并触发了异常重试,我们可以先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试。】
再次通过负载均衡选择了一个节点,结果恰好仍选择了这个节点
,就会影响重试的效果。因此,我们需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率
。我们可以加个重试异常的白名单,用户可以将允许重试的异常加入到这个白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后,我们就可以采用这样的异常处理策略。如果这个异常是 RPC 框架允许重试的异常,或者这个异常类型存在于可重试异常的白名单中,我们就允许对这个请求进行重试
。停机,就是为了让服务提供方在停机应用的时候,保证所有调用方都能“安全”地切走流量,不再调用自己,从而做到对业务无损
。其中实现的关键点就在于,让正在停机的服务提供方应用有状态,让调用方感知到服务提供方正在停机。】
调用方发请求前,目标服务已经下线
。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中RPC 里面不是有服务发现吗?它的作用不就是用来“实时”感知服务提供方的状态吗?当服务提供方关闭前,是不是可以先通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除
】”把要下线的机器从调用方维护的“健康列表”里面删除,这样负载均衡就选不到这个节点了。整个关闭过程中依赖了两次 RPC 调用:
所以注册中心在收到服务提供方下线的时候,并不能成功保证把这次要下线的节点推送到所有的调用方
。所以通过服务发现并不能做到应用无损关闭】因为服务提供方已经开始进入关闭流程,那么很多对象就可能已经被销毁了,关闭后再收到的请求按照正常业务请求来处理,肯定是没法保证能处理的
。所以我们可以在关闭的时候,设置一个请求“挡板”,挡板的作用就是告诉调用方,我已经开始进入关闭流程了,我不能再处理你这个请求了。就相当于人家窗口交接工作时会在窗口放一个该窗口已打烊。
服务提供方正在关闭
,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如 ShutdownException)
。这个异常就是告诉调用方“我已经收到这个请求了,但是我正在关闭,并没有处理这个请求”,然后调用方收到这个异常响应后,RPC 框架把这个节点从健康列表挪出,并把请求自动重试到其他节点,因为这个请求是没有被服务提供方处理过,所以可以安全地重试到其他节点【不然你处理了一半交给半天,别人万一处理不了半截业务怎么办???】,这样就可以实现对业务无损
因为有的调用方那个时刻没有业务请求,就不能及时地通知调用方了,所以我们可以加上主动通知流程
,这样既可以保证实时性,也可以避免通知失败的情况。人家服务消费者没法调用的时候你如何去捕获到关闭事件呢,你咋知道谁发生上面的异常事件了呢?
在 Java 语言里面,对应的是 Runtime.addShutdownHook 方法,可以注册关闭的钩子。在 RPC 启动的时候,我们提前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时需要在我们调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常
JVM 虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载
,这样就使得“热点”代码的执行不用每次都通过解释,从而提升执行速度】,but这些临时的加载好的数据在我们应用重启后就消失了,如果让我们刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。所以 我们是不是可以通过某些方法,让应用一开始只接少许流量呢?这样低功率运行一段时间后,再逐渐提升至最佳状态
。—>RPC中的启动预热调用方应用通过服务发现能够获取到服务提供方的 IP 地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接
。那这样的话,我们是不是就可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用?对于刚启动的应用,我们可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程。在Spring中应用启动加载的过程中,Spring 容器会顺序加载 Spring Bean,如果某个 Bean 是 RPC 服务的话,我们不光要把它注册到 Spring-BeanFactory 里面去,还要把这个 Bean 对应的接口注册到注册中心。注册中心在收到新上线的服务提供方地址的时候,会把这个地址推送到调用方应用内存中;当调用方收到这个服务提供方地址的时候,就会去建立连接发请求
。
把接口注册到注册中心的时间挪到应用启动完成后
**。具体做法是:在应用启动加载、解析 Bean 的时候,如果遇到了 RPC 服务的 Bean,只先把这个 Bean 注册到 Spring-BeanFactory 里面去,而并不把这个 Bean 对应的接口注册到注册中心
,只有等应用启动完成后,才把接口注册到注册中心用于服务发现,从而实现让服务调用方延迟获取到服务提供方地址
。这时服务端的某个节点负载压力过高,你就得保护一下这个节点,不让它再接收太多的请求,等接收和处理的请求数量下来后,这个节点的负载压力自然就下来了,不然这老小子就宕机了,这就叫做限流
在做限流的时候要考虑应用级别的维度,甚至是 IP 级别的维度
,这样做不仅可以让我们对一个应用下的调用端发送过来的请求流量做限流,还可以对一个 IP 发送过来的请求流量做限流。
我们可以通过 RPC 治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置
。那如果之后因为某种需求我们对这个服务扩容了呢?扩容到 20 个节点,我们是不是就要把限流阈值调整到每秒 500 次呢?这样操作每次都要自己去计算,重新配置,显然太麻烦了
。
由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有很大劣势的。至于要选择哪种限流方式,就要结合具体的应用场景进行选择了
。】这还只是 A->B->C 的情况,试想一下 A->B->C->D->……呢?在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机,这是非常恐怖的
。所以说,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。而最有效的自我保护方式就是熔断
。让人车分流
。很显然,交通网的建设与完善不仅提高了我们的出行效率,而且还更好地保障了我们行人的安全。同样的道理,我们用在 RPC 治理上也是一样的
,调用接口的调用方就会越来越多,流量也会渐渐多起来
**。可能某一天,一个“爆炸式惊喜”就来了。其中一个调用方的流量突然激增,让你整个集群瞬间处于高负载运行,进而影响到其它调用方,导致它们的整体可用率下降把应用提供方这个大池子划分出不同规格的小池子,再分配给不同的调用方,而不同小池子之间的隔离带,就是我们在 RPC 里面所说的分组,它可以实现流量隔离
。Docker中的镜像与容器隔离、JVM中的线程私有空间、内存分段分页、ThreadLocal等。虽然不一定作用完全相同,但确实有相似的地方重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数【就像协议头中好多东西...】,通过改造后的分组逻辑,我们可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用
。个调用方在发 RPC 请求的时候可选择的服务节点数相比没有分组前减少了
,那对于单个调用方来说,出错的概率就升高了。比如一个集中交换机设备突然坏了,而这个调用方的所有服务节点都在这个交换机下面,在这种情况下对于服务调用方来说,它的请求无论如何也到达不了服务提供方
,从而导致这个调用方业务受损。正常情况下我们是必须让车在车道行驶,人在人行道上行走。但当人行道或者车道出现抢修的时候,在条件允许的情况下,我们一般都是允许对方借道行驶一段时间,直到道路完全恢复
。
允许调用方可以配置多个分组
。但这样的话,这些节点对于调用方来说就都是一样的了,调用方可以随意选择获取到的所有节点发送请求,这样就又失去了分组隔离的意义,并且还没有实现我们想要的“借道”的效果。所以我们还需要把配置的分组区分下主次分组,只有在主分组上的节点都不可用的情况下才去选择次分组节点;只要主分组里面的节点恢复正常,我们就必须把流量都切换到主节点上,整个切换过程对于应用层完全透明,从而在一定程度上保障调用方应用的高可用
那如果当咱们设计一个RPC框架时,应该如何思考设计步骤,或者说一个成熟的RPC框架可以完成哪些功能
:【RPC,个人感觉,说到底 RPC就是把拦截到的服务消费者端发出的调用服务所需的方法参数,通过序列化压缩编解码等转成可以在网络中传输的二进制,并保证通信过程正常,然后在服务提供方能正确地还原出语义,也就是反序列化出请求参数或者请求对象,最终找到对应的具体实现,然后实现像调用本地一样地调用远程的目的
】首先我们得需要一个注册中心
,去管理消费者和提供者的节点信息
,这样才会有消费者和提供才可以去订阅服务,注册服务。当然这个不是必要,因为有长连接缓存通信双方信息,所以这个注册中心不要也行。有了方便,可以在集群中随意玩耍
可能会有很多个provider节点
,那么我们肯定会有一个负载均衡模块来负责节点的调用
,至于用户指定路由规则可以使一个额外的优化点。具体的调用肯定会需要牵扯到通信协议
,所以需要一个模块来对通信协议进行封装,网络传输还要考虑序列化
。
当调用失败后怎么去处理?所以我们还需要一个容错模块,来负责失败情况的处理
。有更多的优化点
,比如一些请求数据的监控,配置信息的处理,日志信息的处理等等。服务注册与发现
)服务消费者调用的目标方法或者说服务(调用方法映射)
,那是不是得通信呀。快递点帮咱们往过传
,至于是坐火车、飞机、汽车还是人家自己送,咱不管(肯定是网络协议帮咱们传呀
,因为 RPC 用于业务系统之间的数据交互,要保证数据传输的可靠性,所以它一般默认采用 TCP 来实现网络数据传输
)----->网络协议
服务消费者如何获取可用的远程服务器以及服务端如何确定并调用目标方法
处于对于数据的安全性或者完整性考虑(网络传输的数据必须是二进制数据,可是在 RPC 框架中,调用方请求的出入参数都是对象,对象不能直接在网络中传输,所以需要提前把对象转成可传输的二进制数据,转换算法还要可逆,这个过程就叫“序列化”和“反序列化”
。),要对咱们要送的东西进行包装,也就是序列化。----->序列化(其实序列化、反序列化以及编解码也可以算作是网络通信过程中的事情)
在网络传输中,RPC 不会把请求参数的所有二进制数据一起发送到服务提供方机器上,而是拆分成好几个数据包(或者把好几个数据包封装成一个数据包),所以服务提供方可能一次获取多个或半个数据包,这也就是网络传输中的粘包和半包问题
需要提前约定传输数据的格式,即“RPC 协议”。 大多数的协议会分成数据头和消息体
:
大多系统都是集群部署的
,多台主机/容器对外提供相同的服务,如果 集群的节点数量很大的话,那么管理服务地址
也将是一件十分繁琐的事情。
各个服务节点将自己的地址和提供的服务列表注册到一个 注册中心,由 注册中心 来统一管理服务列表
;这样的做法解决了一些问题同时 为客户端增加了一项新的工作——那就是服务发现【因为客户端要从注册中心中找到远程方法对应的服务列表并通过某种策略从中选取一个服务地址来完成网络通信。我客户端怎么知道要访问哪一个呢,是不是需要借助负载均衡咯】
如果不能知道调用方用的序列化方式,即使服务提供方还原出了正确的语义,也并不能把二进制还原成对象,那服务提供方收到这个数据后也就不能完成调用了
。。因此我们需要把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数我们可以统称为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。除了序列化方式,还有协议标示、消息 ID、消息类型这样的参数,这才是头部的。协议体呢,人家一般只放请求接口方法、请求的业务参数值和一些扩展属性新加的 2 个 bit 会当作协议体前 2 个 bit 数据读出来,但原本的协议体最后 2 个 bit 会被丢弃了,这样就会导致协议体的数据是错的。
。所以说明设计耦合的太死,得留一定的扩展性一般用于公司内部集群里,各个微服务之间的通讯
。那么为什么各个公司内部集群里各个微服务之间为什么不直接用HTTP呢。经过下面几个点之后,在服务发现、底层连接形式以及传输内容上HTTP和RPC大差不差,但是在传输内容这个过程中因为RPC 定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,基于这一点RPC成为天选之子
客户端找到要调用的服务对应的服务端的 IP 端口的过程,其实就是 服务发现
。这块俩不分高低
你客户端得知道服务器 IP 地址和端口,因为服务器那么多呀,集群?
但在我们业界为什么很少用到这种方案呢
?就是因为下面两个问题
这是因为为了提升性能和减少 DNS 服务的压力,DNS 采取了多级缓存机制,一般配置的缓存时间较长,特别是 JVM 的默认缓存是永久有效的,所以说服务调用者不能及时感知到服务节点的变化
】,有一种想法是 加一个负载均衡设备,将域名绑定到这台负载均衡设备上,通过 DNS 拿到负载均衡的 IP,这样服务调用的时候,服务调用方就可以直接跟 VIP 建立连接,然后由 VIP 机器完成 TCP 转发【在kubernetes中其实就是这样做的:DNS + service】
。但是在RPC中依旧不常用,缺点在图中:HTTP1.1 协议,会支持长连接,也就是默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive)
,之后的请求和响应都会复用这条连接。
也是通过建立 TCP 长链接进行数据交互
,但不同的地方在于,RPC 协议一般还会再建个 连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保
。消息头 Header 【Header 是用于标记一些特殊信息,其中最重要的是 消息体长度】和消息体 Body【Body 则是放我们真正需要传输的内容,而这些内容只能是二进制 01 串,毕竟计算机只认识这玩意。所以 TCP 传字符串和数字都问题不大,因为字符串可以转成编码再变成 01 串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制 01 串,这样的方案现在也有很多现成的,比如 JSON,Protocol Buffers (Protobuf) 。这个将结构体转为二进制数组的过程就叫 序列化 ,反过来将二进制数组复原成结构体的过程叫 反序列化。】
。在 Body 这块,它使用 JSON 来 序列化 结构体数据
。定制化程度更高
,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因
。【but,上面说的 HTTP,其实 特指的是现在主流使用的 HTTP1.1,HTTP2在前者的基础上做了很多改进,所以 性能可能比很多 RPC 协议还要好
,甚至连gRPC底层都直接用的HTTP2。】这个是由于 HTTP2 是 2015 年出来的。那时候很多公司内部的 RPC 协议都已经跑了好些年了,基于历史原因,一般也没必要去换了
。基于 TCP 造出来的 HTTP 和各类 RPC 协议它们都只是定义了不同消息格式的 应用层协议 而已【RPC(Remote Procedure Call)又叫做 远程过程调用,它和HTTP还不一样,HTTP人家实打实就是一个协议【平时上网在浏览器上敲个网址就能访问网页,这里用到的就是 HTTP 协议】,RPC本身并不是一个具体的应用层协议,而是一种 调用方式 。】
。
RPC 改用 UDP 或者 HTTP,其实也可以做到类似的功能
。PART2-1:网络协议:我们的通信框架基于Netty进行设计和开发
。
服务调用者通过网络 IO 发送一条请求消息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次 RPC 调用便结束了
。可以说,网络通信是整个 RPC 调用流程的基础//通过ip和port,建立Socket连接,然后通过反射得到要传给服务端的方法名、参数类型以及参数,并通过网络将这些参数传递过去
Socket socket = new Socket("127.0.0.1", 2221);
ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());
...
//下来就是,服务端这边通过BIO的方式监听Socket,等待客户端或者代理类的连接,然后开启一个线程去处理
ServerSocket serverSocket = new ServerSocket(2221);
while(flag){
Socket socket = serverSocket.accept();
/**
* 对客户端的连接进行处理
*/
process(socket);
socket.close();
}
绝大多数的分布式服务框架(RPC框架)都推荐使用长连接进行内部通信
,选择长连接而不是短连接的原因如下:
相比于短连接,长连接更节省资源
。如果每发送一条消息就要创建链路、发起握手认证、关闭链路释放资源,会损耗大量的系统资源。长连接只在首次创建时或者链路断连重连才创建链路,链路创建成功之后服务提供者和消费者会通过业务消息和心跳维系链路,实现多消息复用同一个链路节省资源。服务化之后,本地API调用变成了远程服务调用,大量本地方法演化成了跨进程通信,网络时延成为关键指标之一。相比于一次简单的服务调用,链路的重建通常耗时更多,这就会导致链路层的时延消耗远远大于服务调用本身的损耗
,这对于大型的业务系统而言是无法接受的。一般来说,低负载、低并发的应用程序可以选择同步阻塞I/O以降低编程复杂度,但是对于高负载、高并发的网络应用,需要使用NIO的非阻塞模式进行开发
。一个多路复用器Selector可以同时轮询多个Channel,由于JDK使用了epoll()代替传统的select实现,所以它并没有最大连接句柄1024/2048的限制。这也就意味着只需要一个线程负责Selector的轮询,就可以接入成千上万的客户端,所以咱们在通信框架中应该使用更高效的NIO非阻塞IO模型
。
IO 多路复用更适合高并发的场景,可以用较少的进程(线程)处理较多的 socket 的 IO 请求
,咱们Java中对咱们原生API做了封装,也就是Netty框架。还有类似Go语言,本身对 IO 多路复用的封装就已经很简洁了。阻塞 IO 每处理一个 socket 的 IO 请求都会阻塞进程(线程),但使用难度较低。在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞 IO 已经满足了需求,并且不需要发起 select 调用,开销上还要比 IO 多路复用低
就是把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使系统在单线程的情况下可以同时处理多个客户端请求
。这种方式的优势是开销小,不用为每个请求创建一个线程,可以节省系统开销
。
直接集成开源NIO框架
的方式代替之前的自研方案。以最成熟的NIO框架Netty为例,它已经得到成百上千的商用项目验证。例如Hadoop的RPC框架avro使用Netty作为底层通信框架、实时流式计算框架Storm底层通信框架也采用的是Netty,还有Twitter内部使用的RPC框架Finagle,其底层通信框架也基于Netty构建。
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.51.Final</version>
</dependency>
RPC 框架一般会提供四种调用方式【RPC 框架的性能和吞吐量与合理使用调用方式是息息相关的】
,分别为同步 Sync、异步 Future、回调 Callback和单向 Oneway
PART2-2:序列化:自己实现encode与decode
【为啥要序列化,再叨叨一遍:网络传输的数据必须是二进制数据【在网络传输的时候,我们需要经过 IO,而 IO 传输支持的就是字节数组这种格式,所以序列化过后可以更好的传输。】
,但调用方请求的出入参数都是对象
。对象是不能直接在网络中传输的,所以我们需要提前把它转成可传输的二进制,并且要求 转换算法是可逆的
,这个过程我们一般叫做“序列化”】
压缩数据,加快网络传输
。【 网络传输耗时一方面取决于网络带宽大小,另一方面取决于数据传输量。想加快网络传输,要么提高带宽,要么减小数据传输量,而对数据进行编码的主要目的就是减小数据传输量。比如一部高清电影原始大小为 30GB,如果经过特殊编码格式处理,可以减小到 3GB,同样是 100MB/s 的网速,下载时间可以从 300s 减小到 30s。】对于不想进行序列化的变量,使用 transient 关键字修饰
。
static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化
import java.io.*; public class Student implements Serializable { //学号 private int studentId; //姓名 private String name; public int getStudentId() { return studentId; } public void setNo(int studentId) { this.studentId = studentId; } public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public String toString() { return "Student{" + "studentId =" + studentId + ", name='" + name + '\'' + '}'; } public static void main(String[] args) throws IOException, ClassNotFoundException { String home = System.getProperty("user.home"); String basePath = home + "/Desktop"; FileOutputStream fos = new FileOutputStream(basePath + "student.dat"); Student student = new Student(); student.setStudentId(100); student.setName("TEST_STUDENT"); //JDK 自带的序列化机制具体的实现是由 ObjectOutputStream 完成的 ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(student); oos.flush(); oos.close(); FileInputStream fis = new FileInputStream(basePath + "student.dat"); //反序列化的具体实现是由 ObjectInputStream 完成的 ObjectInputStream ois = new ObjectInputStream(fis); Student deStudent = (Student) ois.readObject(); ois.close(); System.out.println(deStudent); } }
这个Serializable接口是一个空接口,只是用来标记的
。如果 RPC 框架选用 JSON 序列化,服务提供者与服务调用者之间传输的数据量要相对较小
,否则将严重影响性能】
对于大数据量服务这意味着需要巨大的内存和磁盘开销
。因为很存在很多冗余内容,比如双引号,花括号Hessian可以很好地跟 Spring 进行集成,我们可以直接把 Java 类对外进行暴露,用作 RPC 接口的定义
//代码示例 Student student = new Student(); student.setStudentId(101); student.setName("HESSIAN"); //把student对象转化为byte数组 ByteArrayOutputStream bos = new ByteArrayOutputStream(); Hessian2Output output = new Hessian2Output(bos); output.writeObject(student); output.flushBuffer(); byte[] data = bos.toByteArray(); bos.close(); //把刚才序列化出来的byte数组转化为student对象 ByteArrayInputStream bis = new ByteArrayInputStream(data); Hessian2Input input = new Hessian2Input(bis); Student deStudent = (Student) input.readObject(); input.close(); System.out.println(deStudent);
支持 Java、Python、C++、Go 等多种语言
。 但是Protobuf 使用的时候需要定义 IDL(Interface description language)文件,然后使用不同语言的 IDL 编译器,生成序列化工具类以及序列化代码
】
对于Protobuf 协议,我们需要创建一个后缀为 .proto 的文件,在文件里面我们需要定义出我们的协议内容
正常情况下使用者需要定义 proto 文件,然后使用 IDL 编译器编译成你需要的语言
。下面的两个例子:/** * * // IDl 文件格式 * synax = "proto3"; * option java_package = "com.test"; * option java_outer_classname = "StudentProtobuf"; * * message StudentMsg { * //序号 * int32 studentId = 1; * //姓名 * string name = 2; * } * */ StudentProtobuf.StudentMsg.Builder builder = StudentProtobuf.StudentMsg.newBuilder(); builder.setStudentId(103); builder.setName("protobuf"); //把student对象转化为byte数组 StudentProtobuf.StudentMsg msg = builder.build(); byte[] data = msg.toByteArray(); //把刚才序列化出来的byte数组转化为student对象 StudentProtobuf.StudentMsg deStudent = StudentProtobuf.StudentMsg.parseFrom(data); System.out.println(deStudent);
性能高,体积小
;IDL 能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器;序列化反序列化速度很快,不需要通过反射获取类型【Protostuff 不需要依赖 IDL 文件,可以直接对 Java 领域对象进行反 / 序列化操作,在效率上跟 Protobuf 差不多,生成的二进制格式和 Protobuf 是完全相同的,可以说是一个 Java 版本的 Protobuf 序列化框架】;消息格式升级和兼容性不错,可以做到向后兼容
相比于传统的HTTP协议,Thrift效率更高,Thrift传输占用带宽更小
。另外,Thrift是跨语言的。Thrift的接口描述文件,通过其编译器可以生成不同开发语言的通讯框架。Kryo 其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积
。另外,Kryo 已经是一种非常成熟的序列化实现了,已经在 Twitter、Groupon、Yahoo 以及多个著名开源项目(如 Hive、Storm)中广泛的使用推荐使用 Kryo 作为生产环境的序列化方式
。Kryo不支持没有无参构造函数的对象进行反序列化,因此如果某个对象希望使用Kryo来进行序列化操作的话,需要有相应的无参构造函数才可以
。/** * Kryo serialization class, Kryo serialization efficiency is very high, but only compatible with Java language * * @author shuang.kou * @createTime 2020年05月13日 19:29:00 */ @Slf4j public class KryoSerializer implements Serializer { /** * Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects */ private final ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(() -> { Kryo kryo = new Kryo(); kryo.register(RpcResponse.class); kryo.register(RpcRequest.class); return kryo; }); @Override public byte[] serialize(Object obj) { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); Output output = new Output(byteArrayOutputStream)) { Kryo kryo = kryoThreadLocal.get(); // Object->byte:将对象序列化为byte数组 kryo.writeObject(output, obj); kryoThreadLocal.remove(); return output.toBytes(); } catch (Exception e) { throw new SerializeException("Serialization failed"); } } @Override public <T> T deserialize(byte[] bytes, Class<T> clazz) { try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); Input input = new Input(byteArrayInputStream)) { Kryo kryo = kryoThreadLocal.get(); // byte->Object:从byte数组中反序列化出对对象 Object o = kryo.readObject(input, clazz); kryoThreadLocal.remove(); return clazz.cast(o); } catch (Exception e) { throw new SerializeException("Deserialization failed"); } } }
体积大,容易内容多。可以看到为了记录一个字段的值,每个标签都需要成对存在,过于冗余了
。将对象序列化为字节数组
,再用于网络传输、数据持久化等序列化框架的选择
:在序列化的选择上,与序列化协议的效率、性能、序列化协议后的体积相比,其通用性和兼容性的优先级会更高,因为他是会直接关系到服务调用的稳定性和可用率的,对于服务的性能来说,服务的可靠性显然更加重要。我们更加看重这种序列化协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,是否有很多人已经用过并且踩过了很多的坑,其次我们才会去考虑性能、效率和空间开销
【序列化协议的安全性也是非常重要的一个参考因素,甚至应该放在第一位去考虑。以 JDK 原生序列化为例,它就存在漏洞。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵】
RPC【或者说如果是微服务之间的数据传输,因为RPC用来干啥的你忘了,那我们就可以选择 Protobuf 或者 Thrift 这种更高效的协议来进行传输,因为这种场景我们对于协议序列化的体积和速度都有很高的要求。】首选的还是 Hessian 与 Protobuf,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足了我们的要求
。其中 Hessian 在使用上更加方便,在对象的兼容性上更好;Protobuf 则更加高效,通用性上更有优势前后端对接,那么自然是 JSON 最合适
,因为网页的交互要求不需要太高,秒级别是可以接受的,所以我们可以更加关注可读性
。考虑时间与空间开销
,切勿忽略通用性兼容性
在RPC 框架使用过程中需要注意哪些序列化上的问题
【在 RPC 框架的使用过程中,我们要尽量构建简单的对象作为入参和返回值对象】
对象构造得过于复杂
:属性很多,并且存在多层的嵌套,比如 A 对象关联 B 对象,B 对象又聚合 C 对象,C 对象又关联聚合很多其他对象,对象依赖关系过于复杂
。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗 CPU,这会严重影响 RPC 框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高RPC 请求经常超时,排查后发现入参对象非常得大,比如为一个大 List 或者大 Map,序列化之后字节长度达到了上兆字节
。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。
使用序列化框架不支持的类作为入参类
:比如 Hessian 框架,他天然是不支持 LinkedHashMap、LinkedHashSet 等,而且大多数情况下最好不要使用第三方集合类,如 Guava 中的集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如 HashMap、ArrayList
PART3:RPC:To the Future,面向未来编程
知道了这些有关RPC的八股文后,再理一遍或者说总结一遍咱们写一个RPC到了Version06之后,接下来要干什么,然后和人家著名的RPC框架Dubbo源码比较一下,见Version08,
巨人的肩膀
Spring源码深度解析
网上很多好的RPC课程
JavaGuide
Github上面好多RPC源码
程序员田螺~手写一个RPC框架
RPC实战与核心原理(何小锋)
极客时间
SpringForAll老师公众号中的关于微服务的几种调用方式
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。