只要涉及到网络通信,我们就可能用到 RPC,RPC 是解决分布式系统通信问题的一大利器。
RPC 的作用就是体现在这样两个方面:
- 屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法;
- 隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。
RPC 在架构中的位置
应用架构最终都会从“单体”演进成“微服务化”,整个应用系统会被拆分为多个不同功能的应用,并将它们部署在不同的服务器中,而应用之间会通过 RPC 进行通信,可以说 RPC 对应的是整个分布式应用系统,就像是“经络”一样的存在。
RPC 框架能够帮助我们解决系统拆分后的通信问题,并且能让我们像调用本地一样去调用
远程方法。
1、协议
RPC 协议就是围绕应用层协议展开的
协议的作用
为了避免语义不一致的事情发生,我们就需要在发送请求的时候设定一个边界,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,就是我们所说的协议。
为了保证能平滑地升级改造前后的协议,我们有必要设计一种支持可扩展的协议。
2、序列化
序列化就是将对象转换成二进制数据的过程,而反序列就是反过来将二进制转换为对象的过程。
1、JDK 原生序列化:实现是由 ObjectOutputStream 、ObjectInputStream。序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。
2、JSON:JSON 进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存
和磁盘开销;
3、Hessian:相对于 JDK、JSON,由于 Hessian 更加高效,生成的字节数更小,有非常好的兼容性和稳
定性,所以 Hessian 更加适合作为 RPC 框架远程通信的序列化协议。
4、Protobuf:序列化后体积相比 JSON、Hessian 小很多,消息格式升级和兼容性不错,可以做到向后兼容。
RPC 框架中如何选择序列化?
我们首选的还是 Hessian 与 Protobuf,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足了我们的要求。其中 Hessian 在使用上更加方便,在对象的兼容性上更好;Protobuf 则更加高效,通用性上更有优势。
3、网络通信
为什么说阻塞 IO 和 IO 多路复用最为常用?
现在大多数系统内核都会支持阻塞 IO、非阻塞 IO 和 IO 多路复用,但像信号驱动 IO、异步 IO,只有高版本的 Linux 系统内核才会支持。
在高性能的网络编程框架的编写上,大多数都是基于 Reactor 模式,其中最为典型的便是 Java 的 Netty 框架,而 Reactor 模式是基于 IO多路复用的。当然,在非高并发场景下,同步阻塞 IO 是最为常见的。
- 高并发场景: IO 多路复用
- 非高并发场景: 同步阻塞 IO
在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞IO 已经满足了需求,并且不需要发起 select 调用,开销上还要比 IO 多路复用低。
RPC 框架在网络通信的处理上,我们更倾向选择 IO 多路复用的方式。
零拷贝带来的好处就是避免没必要的 CPU 拷贝,让 CPU 解脱出来去做其他的事,同时也减少了 CPU 在用户空间与内核空间之间的上下文切换,从而提升了网络通信效率与应用程序的整体性能。
4、动态代理
RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。
这样可以帮用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验。
5、gRPC
gRPC 就是采用 HTTP/2 协议,并且默认采用 PB 序列化方式的一种 RPC,它充分利用了 HTTP/2 的多路复用特性,使得我们可以在同一条链路上双向发送不同的 Stream 数据,以解决 HTTP/1.X 存在的性能问题。
6、RPC架构设计
RPC 架构
可扩展的架构
在 Java 里面,JDK 有自带的 SPI(Service Provider Interface)服务发现机制,它可以动态地为某个接口寻找服务实现。使用 SPI 机制需要在 Classpath 下的 METAINF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。
这时,整个架构就变成了一个微内核架构,我们将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。
用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身。
7、服务发现
基于消息总线的最终一致性的注册中心
通常我们可以使用 ZooKeeper、etcd 或者分布式缓存(如 Hazelcast)来解决事件通知问题,但在超大规模的服务集群下,注册中心所面临的挑战就是超大批量服务节点同时上下线,注册中心集群接受到大量服务变更请求,集群间各节点间需要同步大量服务节点数据,最终导致如下问题:
- 注册中心负载过高;
- 各节点数据不一致;
- 服务下发不及时或下发错误的服务节点列表。
我们可以采用“消息总线”的通知机制,来保证注册中心数据的最终一致性,来解决这些问题的。
注册数据可以全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性。
8、健康检测
- 健康状态:建立连接成功,并且心跳探活也一直成功;
- 亚健康状态:建立连接成功,但是心跳请求连续失败;
- 死亡状态:建立连接失败。
可用率的计算方式是某 一个时间窗口内接口调用成功次数的百分比(成功次数 / 总调用次数)。当可用率低于某 个比例就认为这个节点存在问题,把它挪到亚健康列表
9、 路由策略
路由策略:请求按照设定的规则发到不同的节点上。
我们可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生 请求的时候,我们可以很容易地拿到请求参数,也就是我们例子中的商品 ID,我们可以根 据注册中心下发的规则来判断当前商品 ID 的请求是过滤掉新应用还是老应用的节点。因为 规则对所有的调用方都是一样的,从而保证对应同一个商品 ID 的请求要么是新应用的节 点,要么是老应用的节点。使用了参数路由策略后,整个集群的调用拓扑如下图所示:
灰度发布功能作为 RPC 路由功能的一个典型应用场景,我们可以通过路由功能完成像定点 调用、黑白名单等一些高级服务治理功能。在 RPC 里面,不管是哪种路由策略,其核心思 想都是一样的,就是让请求按照我们设定的规则发送到目标节点上,从而实现流量隔离的效 果。
9、 负载均衡
RPC 负载均衡策略一般包括随机权重、Hash、轮询。
如何设计自适应的负载均衡?
自适应的负载均衡:当一个 服务节点负载过高或响应过慢时,就少给它发送请求,反之则多给它发送请求。
如何判定一个服务节点的处理能力
可以采用一种打分的策略,服务调用者收集与之建立长连接的每个服务节点的指标 数据,如服务节点的负载指标、CPU 核数、内存大小、请求处理的耗时指标(如请求平均 耗时、TP99、TP999)、服务节点的状态指标(如正常、亚健康)。通过这些指标,计算 出一个分数,比如总分 10 分,如果 CPU 负载达到 70%,就减它 3 分,当然了,减 3 分 只是个类比,需要减多少分是需要一个计算策略的。
打完分之后,我们又该如何根据分数去控制给每个服务节点发送多少流量呢?
可以配合随机权重的负载均衡策略去控制,通过最终的指标分数修改服务节点最终的权 重。
10、异常重试
重试机制就是调用端发现请求失败时捕获异常,之后触发重试,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。
如何在约定时间内安全可靠地重试?
在每次触发重试之前,我们需要先判定下这个请求是否已经超时,如果超时了会直接返回超时异常,否则我们需要重置下这个请求的超时时间,防止因多次重试导致这个请求的处理时间超过用户配置的超时时间,从而影响到业务处理的耗时。
在发起重试、负载均衡选择节点的时候,我们应该去掉重试之前出现过问题的那个节点,这 样可以提高重试的成功率,并且我们允许用户配置可重试异常的白名单,这样可以让 RPC 框架的异常重试功能变得更加友好。
在使用 RPC 框架的重试机制时,我们要确保被调用的服务的业务逻辑是幂等的。
11、优雅关闭
对调用方来说,它也无法预测到服务提供方要对哪些机器重启上线,因此负载均衡就有可能把要正在重启的机器选出来。
问题原因:
请求的时间点跟收到服务提供方关闭通知的时间点很接近,只比关闭 通知的时间早不到 1ms,如果再加上网络传输时间的话,那服务提供方收到请求的时候, 它应该正在处理关闭逻辑。这就说明服务提供方关闭的时候,并没有正确处理关闭后接收到 的新请求。
处理方法:
设置一个请求“挡板”,挡板的作用就是告诉调用方,我已经开始进入关闭流程了,我不能再处理你这个请求了。
在 Java 语言里面,对应的是 Runtime.addShutdownHook 方法,可以注册关闭的钩子。在 RPC 启动的时候,我们提 前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关 闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时需要在我们调用链里面加 上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。
12、优雅启动
如何避免流量打到没有启动完成的节点?
启动预热
让刚启动的服务提供方应用不承担全部的流量,而是让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。
调用方通过服务发现,除了可以拿到 IP 列表, 还可以拿到对应的启动时间。我们需要把这个时间作用在负载均衡上,我们要让这个权重变成动态的,并且是随着时间的推移慢慢增加到服务提供方设定 的固定值
延迟暴露
我们还是需要利用服务提供方把接口注册到注册中心的那段时间。我们可以在服务提供方应 用启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑。用户可以在 Hook 里面模拟调用逻辑,从而使 JVM 指令能够预热起来,并且 用户也可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才 把接口注册到注册中心。整个应用启动过程如下图所示:
13、熔断限流
调用端的自我保护:熔断
服务端的自我保护:限流
有没有想到在哪个步骤整合熔断器会比较合适呢?
RPC 框架可以在动态代理的逻辑中去整合熔断器,在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。
14、业务分组
实现:调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数。
作用:通过分组的方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。
15、异步RPC
在大多数情况下,影响到 RPC 调用的吞吐量的原因也就是业务逻辑处理慢了,CPU大部分时间 都在等待资源。
为了能够提升业务处理的吞吐量。其实关键就两个字:“异步”。
调用端异步
RPC 框架的异步策略主要是调用端异步与服务端异步。调用端的异步就是通过 Future 方式 实现异步,调用端发起一次异步请求并且从请求上下文中拿到一个 Future,之后通过Future 的 get 方法获取结果,如果业务逻辑中同时调用多个其它的服务,则可以通过 Future 的方式减少业务逻辑的耗时,提升吞吐量。服务端异步则需要一种回调方式,让业 务逻辑可以异步处理,之后调用 RPC 框架提供的回调接口,将最终结果异步通知给调用 端。
RPC 调用全异步
我们可以让 RPC 框架支持 CompletableFuture,实现 RPC 调用在 调用端与服务端之间完全异步。
CompletableFuture 是 Java8 原生支持的。假如 RPC 框架能够支持 CompletableFuture,整个调用过程会分为这样几步:
- 服务调用方发起 RPC 调用,直接拿到返回值 CompletableFuture 对象,之后就不需要 任何额外的与 RPC 框架相关的操作了(如我刚才讲解 Future 方式时需要通过请求上下 文获取 Future 的操作),直接就可以进行异步处理;
- 在服务端的业务逻辑中创建一个返回值 CompletableFuture 对象,之后服务端真正的业 务逻辑完全可以在一个线程池中异步处理,业务逻辑完成之后再调用这个 CompletableFuture 对象的 complete 方法,完成异步通知;
- 调用端在收到服务端发送过来的响应之后,RPC 框架再自动地调用调用端拿到的那个返 回值 CompletableFuture 对象的 complete 方法,这样一次异步调用就完成了。
通过对 CompletableFuture 的支持,RPC 框架可以真正地做到在调用端与服务端之间完 全异步,同时提升了调用端与服务端的两端的单机吞吐量,并且 CompletableFuture 是 Java8 原生支持,业务逻辑中没有任何代码入侵性。
16、安全体系
RPC的安全一般指什么?
RPC 是解决应用间互相通信的框架,而应用之间的远程调用过程一般不会暴露在 公网,换句话讲就是说 RPC 一般用于解决内部应用之间的通信,而这个“内部”是指应用 都部署在同一个大局域网内。相对于公网环境,局域网的隔离性更好,也就相对更安全,所 以在 RPC 里面我们很少考虑像数据包篡改、请求伪造等恶意行为。
这里面其实存在一个安全隐患问题,因为私服上所有的 Jar 坐标我们所有人都可以看到,只 要拿到了 Jar 的坐标,我们就可以把发布到私服的 Jar 引入到项目中完成 RPC 调用了吗?
这种行为对于服务提供方来说就很危险了,因为接入了新的调用方就意味着承担的调用量会变大,有时候很有可能新
增加的调用量会成为压倒服务提供方的“最后一根稻草”,从而导致服务提供方无法正常提供服务,关键是服务提供方还不知道是被谁给压倒的。
调用方之间的安全保证
我们只需要给每个调用方设定一个唯一的身份,每个调用方在调用之前都先来服务提供方这登记下身份,只有登记过的调用方才能继续放行,没有登记过的调用方一律拒绝。
首先我们要有一个可以供调用方进行调用接口登记的地方,我们姑且称这个地方为“授权平台”,调用方可以在授权平台上申请自己应用里面要调用的接口,而服务提供方则可以在授权平台上进行审批,只有服务提供方审批后调用方才能调用。
17、分布式链路跟踪
RPC 在整合分布式链路跟踪需要做的最核心的两件事就是“埋点”和“传递”。
所谓“埋点”就是说,分布式链路跟踪系统要想获得一次分布式调用的完整的链路信息,就 必须对这次分布式调用进行数据采集,而采集这些数据的方法就是通过 RPC 框架对分布式 链路跟踪进行埋点。
RPC 调用端在访问服务端时,在发送请求消息前会触发分布式跟踪埋点,在接收到服务端 响应时,也会触发分布式跟踪埋点,并且在服务端也会有类似的埋点。这些埋点最终可以记 录一个完整的 Span,而这个链路的源头会记录一个完整的 Trace,最终 Trace 信息会被上 报给分布式链路跟踪系统。
那所谓“传递”就是指,上游调用端将 Trace 信息与父 Span 信息传递给下游服务的服务 端,由下游触发埋点,对这些信息进行处理,在分布式链路跟踪系统中,每个子 Span 都存 有父 Span 的相关信息以及 Trace 的相关信息。
18、时钟轮
时钟轮就是用来执行定时任务的,可以说在 RPC框架中只要涉及到定时相关的操作,我们就可以使用时钟轮。
刚才我举例讲到的调用端请求超时处理,这里我们就可以应用到时钟轮,我们每发一次请 求,都创建一个处理请求超时的定时任务放到时钟轮里,在高并发、高访问量的情况下,时 钟轮每次只轮询一个时间槽位中的任务,这样会节省大量的 CPU。
19、流量回放
流量回放:先把线上一段时间内的请求参数和响应结果保存下来,然后把这些请求参数在新改造的应用里重新请求一遍,最后比对一下改造前后的响应结果是否一致,这就间接达到了使用线上流量测试的效果。有了线上的请求参数和响应结果后,我们再结合持续集成过程,就可以让我们改动后的代码随时用线上流量进行验证,这就跟我录制球赛视频一样,只要我想看,我随时都可以拿出来重新看一遍。