分享背景

RPC技术,作为互联网人必备的技术栈,不止于会用,还应该了解它的实现原理和核心模块。以便于我们更好的使用它,遇到问题有排查的思路。
不介绍具体的某一个RPC框架,了解核心模块、过程中会遇到的问题和解决方案。
大家可以自己试着造个轮子,挑战&收获。

问题:能否画张图描述一下RPC的整个流程?(越详细越好)

1. 协议——避免鸡同鸭讲

同一个进程:对象(参数) > 对象(结果)

RPC:对象 > 打包 > 序列化 > 网络传输 > 反序列化 > 解包 > 对象

远程服务调用 > 网络通信 > 协议(应用层)

RPC调用需要保证可靠性 > 基于TCP协议 > 自定义应用层协议

RPC 的都有哪些 rpc包含哪些模块_tcp/ip

1.1 协议设计的目标:

  • 轻量化
  • 紧凑、节省带宽
  • 可扩展

1.2 TCP粘/拆包

「粘包/拆包」在Socket编程中经常会出现,使用TCP协议传输数据时,如果对端连续发送多个小的数据包,TCP会将这些小的数据包打包,合并成一个TCP报文发送出去,这就是「粘包」。如果对端发送一个超大的数据包,TCP会根据缓冲区的情况,将这个超大数据包拆分成多个小的TCP报文发送出去,这就是「拆包」。

RPC 的都有哪些 rpc包含哪些模块_rpc_02


为什么会导致粘包/拆包?

TCP作为传输层(4层)协议,并不知道应用层(7层)数据报文的含义。优化性能,减少网络拥塞。

  • Nagle算法

Nagle是一种通过减少数据包的方式来提高TCP传输效率的算法,因为网络带宽有限,如果频繁的发送小的数据包,对带宽的压力会比较大。Nagle算法会在本地缓冲区先缓冲待发送的数据,待数据总量达到最大数据段(MSS)时,再一次性批量发送。这种方式虽然可能会使消息的发送存在延迟,但是对带宽的压力小,降低了网络拥堵的可能性并减少了额外的开销。

  • TCP_CORK

如果开启TCP_CORK选项,当发送的数据小于最大数据段(MSS)时,TCP会延迟20ms发送,或者等待发送缓冲区的数据达到最大数据段(MSS)才真正发送。

  • MTU

指通信协议的最大传输单元,普遍使用的网卡MTU为1500,即最大只能传输1500字节的数据帧。可以通过ifconfig命令查看各网卡的数据帧。

  • MSS

最大报文段长度(MSS)是TCP协议的一个选项,用于在TCP连接建立时,收发双方协商通信时每一个报文段所能承载的最大数据长度。如果应用层单次发送的报文长度超过了MSS,那么也会面临「拆包」。

粘/拆包的解决方案?

  1. 定长帧,报文固定长度,自动填充。这种方式实现简单,但会浪费一定的带宽。
  2. 使用特定的分隔符(如换行符)将报文进行分割。
  3. 在报文头写入报文的总长度。(推荐)

如果你使用Netty,它提供了开箱即用的解码器。

RPC 的都有哪些 rpc包含哪些模块_网络_03

1.3 Dubbo协议

16字节定长的Header + 变长的Body。

RPC 的都有哪些 rpc包含哪些模块_tcp/ip_04

Bit

字段

说明

0~15

Magic Number

魔数,固定0xdabb

16

Req/Res

0=Response,1=Request

17

2Way

仅在Request在有用。

是否期望服务器返回数据

18

Event

是否事件信息,如心跳

19~23

Serialization ID

序列化类型ID

24~31

Status

响应状态

32~95

Request ID

请求唯一标识

96~127

Data Length

Body的长度

变长部分

Body

对象序列化的byte[]

Dubbo粘/拆包解决方案是什么???

Body部分是变长的,长度DataLength已经写在Header里了。接收端如果读取的字节数不足16,说明连一个完整的Header都没有接收到,此时会等待对端发送更多的数据。读取到一个完整的Header,会解析出DataLength,然后判断Body是否完整,不完整同样会等待对端传输更多的数据,完整则解析Body进行后续的请求处理。

1.4 可扩展的协议

Dubbo协议头Header采用定长的16字节,且不支持扩展!!!如果要在协议里追加新的参数,怎么办???

  • 直接放在Body里,缺点:要想拿到该参数,必须对Body反序列化,成本较高。
  • 协议头Header也设计为变长的。(推荐)

RPC 的都有哪些 rpc包含哪些模块_提供方_05


🤔问题

  • 为什么不直接用现成的协议,比如HTTP,而是要设计私有协议???

2. 序列化——对象怎么通过网络传输?

网络传输的只能是二进制数据,请求参数和返回结果都是对象,对象无法通过网络传输。我们必须将对象转换成可以通过网络传输的字节序列:序列化。对端拿到字节序列再转换成对象:反序列化。

RPC 的都有哪些 rpc包含哪些模块_tcp/ip_06

2.1 👉可选方案

  • JDK序列化
  • JSON
  • XML
  • Hessian
  • Protobuf
  • Kryo

2.2 🤔如何选择?

需要考虑的因素:

  • 性能
  • 效率
  • 空间开销
  • 通用性
  • 兼容性 跨平台 跨语言
  • 安全性

JDK:效率低、占空间、无法跨语言。
JSON、XML:可读性高、兼容性好。效率低、占空间。
建议首选:Hessian、Protobuf。

2.3 ⚠️注意点

  • 出入参使用JDK自带的类 例如容器List,Map等,不要用三方框架容器!!!
  • 对象越小越好、越简单越好
  • 类避免复杂的继承关系、嵌套关系

3. 网络IO——哪种IO模型更高效?

3.1 IO模型

常见的网络IO模型:

  • 同步阻塞 BIO
  • 同步非阻塞 NIO
  • IO多路复用
  • 异步非阻塞 AIO

常用的网络IO模型:

  • 同步阻塞 BIO

开发简单,门槛低。对并发没有要求,连接数少的场景。

  • IO多路复用

单线程处理大量连接,开发复杂。对高并发有要求,连接数较多的场景。

3.2 Reactor线程模型

  • 单线程Reactor
  • 多线程Reactor
  • 主从Reactor(推荐)

1 * Accept + N * IO Thread + M * Worker Thread
Accept:监听本地端口 > 建立连接 > 注册到IO Thread
IO Thread:网络IO读写
Worker Thread:执行业务逻辑
反序列化在哪个线程?推荐Worker Accept IO线程 千万不能阻塞

Dubbo默认配置:
Accept = 1
IO Thread = CPU核心数+1 && <=32
Worker Thread = 200 最大并发200,不支持任务堆积

RPC 的都有哪些 rpc包含哪些模块_RPC 的都有哪些_07

3.3 🤔思考题?

  • 异步IO下,请求和响应如何关联?

![未命名文件 (3).jpg]([object Object]&name=未命名文件 (3).jpg&originHeight=687&originWidth=1038&originalType=binary&ratio=1&rotation=0&showTitle=false&size=69555&status=done&style=none&taskId=ud5407474-8028-4774-91d7-45735306059&title=&width=519)

Client:协议头 生成并写入requestId,关联一个Future丢到Map<Long,Future>,业务线程Wait。
Server:除了响应Response,还会原样写回requestId。
Client:解析响应头里的requestId,从Map<Long,Future>取出Future,写入结果。业务线程Wakeup。

4. 服务注册与发现——CP还是AP?

生产环境中,服务提供方一般是以「集群」的方式对外提供服务。集群可能会扩缩容,随时会有新的提供方上线下线,服务提供方的IP可能随时会变化。消费者不能把这些IP硬编码在程序里,它需要感知到服务提供方的变化,此时需要引入:服务注册中心!

RPC 的都有哪些 rpc包含哪些模块_tcp/ip_08

4.1 基于Zookeeper

RPC 的都有哪些 rpc包含哪些模块_提供方_09


特点:强一致性,牺牲可用性!

ZooKeeper 集群的每个节点的数据每次发生更新操作,都会通知其它 ZooKeeper 节点同时执行更新。
它要求保证每个节点的数据能够实时的完全一致,就直接导致了 ZooKeeper 集群性能上的下降。

4.2 CP还是AP?

Consistency:一致性
Availability:可用性
Partition tolerance:分区容错性

注册中心是否真的需要强一致性?

  • 新的提供方上线:消费方没有感知到 > 新机器短时间没有流量。
  • 现有提供方下线:消费方没有感知到 > 请求打过去报错,消费方重试其它节点即可。

不管是上线还是下线,注册中心就算没有强一致性,对整个服务集群的影响是很小的,可以有其它办法解决的。绝大多数场景下,可以牺牲CP,换取AP

4.3 最终一致性

利用「消息总线」机制,保证最终一致性。

RPC 的都有哪些 rpc包含哪些模块_提供方_10


新的服务注册 > 注册中心 推送消息 到总线 > 其它注册中心 推拉结合,消息回放 > 最终所有的注册中心数据是一致的。

5. 健康检测——你还活着吗?

生产环境中,服务提供方一般是以「集群」的方式对外提供服务。当某个节点宕机/无法正常工作的时候,注册中心要能及时感知到,并把它下线处理。

RPC 的都有哪些 rpc包含哪些模块_RPC 的都有哪些_11

5.1 心跳机制

健康检测的常用解决方案就是心跳机制。服务提供方需要定时发送心跳到注册中心,告诉对方“我还活着”。当注册中心超过一定时间(阈值)没有接收到心跳,就会将该节点从集群里剔除掉。

  • 真的需要心跳吗?监听Channel断开事件行不行?

不行!是否要剔除节点,关键在于节点能否正常对外提供服务。有的时候,TCP连接虽然没有断开,但是服务可能已经僵死。

一般30秒发一次心跳。心跳发送的太频繁,注册中心压力较大。发送频率太低,服务健康状态感知不及时。

5.2 心跳不代表健康

节点的状态除了健康和死亡,还应该引入中间状态——亚健康状态。

RPC 的都有哪些 rpc包含哪些模块_RPC 的都有哪些_12


节点可能会因为网络抖动、自身负载较重、依赖的下游服务异常等原因,虽然还可以间歇性的发送心跳,但是服务本身其实已经处于一个亚健康状态了。此时,处于亚健康状态的节点,权重应该要调的特别低,让消费方优先调用健康的节点,等待亚健康的节点恢复。

光靠是否有心跳去判断一个服务是否可用,太片面了。
服务可用率:一个时间窗口内,请求处理成功次数/总请求数。当服务可用率低于某个阈值的时候,修改节点的状态。节点发送心跳的时候,在心跳包里带上自身的服务可用率。

服务可用率过低:

  1. 注册中心剔除服务
  2. 消费方降低调用的权重

6. 负载均衡——流量怎么分配?

生产环境中,服务提供方一般是以「集群」的方式对外提供服务。消费方面对一堆可用的节点,具体该选择哪个节点进行服务调用呢?

RPC 的都有哪些 rpc包含哪些模块_tcp/ip_13


负载均衡分类:

  • 硬负载
  • F5服务器
  • 软负载
  • LVS
  • Nginx

考虑点:

  • 使用硬负载,需要额外的成本。
  • 搭建单独的软负载,所有请求流量都要经过负载均衡设备,多一次网络传输,影响效率。
  • 增删节点,需要大量的人工操作,服务发现比较困难。

RPC负载均衡,一般由RPC框架自身实现,且大多是消费方完成。消费方会与注册中心下发的服务提供方建立长连接,每次发起RPC调用,都要经过负载均衡算法,选择一个最终的节点进行调用。

RPC 的都有哪些 rpc包含哪些模块_tcp/ip_14

6.1 负载均衡算法

  • 随机
  • 加权随机
  • 轮询
  • 加权轮询
  • 一致性Hash
  • 最少连接
  • 最快响应
  • … …

Dubbo内置的负载均衡实现参考:

策略

说明

RandomLoadBalance

加权随机,默认算法

RoundRobinLoadBalance

加权轮询

LeastActiveLoadBalance

最少活跃调用数,慢的提供者会接收到更少的请求

ConsistentHashLoadBalance

一致性哈希,相同参数请求同一提供者

ShortestResponseLoadBalance

最快响应,选出响应时间最短的提供者

6.2 自适应负载均衡

如何实现一个自适应的负载均衡?根据节点运行时的情况,动态的调整流量。当节点负载较高,响应过慢时,就减少它的流量,反之就增加流量。

步骤:

  • 指标收集插件,节点收集自身数据。
  • CPU负载、内存
  • 请求处理平均耗时、TP99、TP999
  • 发送心跳时,带上节点自身指标数据。
  • 引入「打分器」,根据各项指标配置的权重,计算节点最终分数。
  • 消费方根据节点分数来调整权重,调节流量。

RPC 的都有哪些 rpc包含哪些模块_rpc_15

指标收集器设计成插件,可以动态配置需要收集哪些指标。
指标收集:全量 / 采样。

7. 优雅启停——平滑上下线

RPC服务,优雅启停也很重要。如何保证新节点接收流量时已经充分预热?如何保证服务下线对业务无损?

7.1 优雅停机

RPC 的都有哪些 rpc包含哪些模块_网络_16


服务提供方宣告自己关闭,至少需要2次网络调用,消费方才能感知到,它并不是实时的。此时,消费方仍然会把流量继续打到该节点,怎么办?

服务提供方需要维护一个服务状态,宣告自己要关闭后,就将服务状态设置为destroyed。服务状态一旦设置为destroyed,就不能再处理新的请求了,统一返回一个固定的异常ShutdownException。消费方只要接收到该异常,就知道提供方已经关闭服务了,此时应该重试其它节点。

destroyed只是不再处理新的请求。为了保证业务无损,已经接受到的请求,还是要处理完的。

如何保证上述这些动作,在服务关闭的时候会被执行到呢?

服务启动时,通过Runtime.addShutdownHook()方法注册钩子函数,处理上述逻辑。JVM程序正常退出时,会执行钩子函数。(Dubbo也是这么做的)

RPC 的都有哪些 rpc包含哪些模块_提供方_17

7.2 优雅启动

Java程序会「越跑越快」,运行时,JVM会把高频执行的代码编译成机器码、Class缓存、数据缓存、对象缓存、连接池等等,一旦JVM重启,这些“红利”都消失了。
如果让刚启动的机器,和运行很久的机器接收同样的流量,对刚启动的机器压力是很大的,可能会出现短时间大量请求超时,业务受损。

7.2.1 服务预热

  1. 服务提供方注册时,带上自己启动的时间戳。
  2. 注册中心下发服务时,顺带下发服务启动的时间戳。
  3. 消费方,根据提供方的启动时间和当前时间的差值,来计算权重。

RPC 的都有哪些 rpc包含哪些模块_RPC 的都有哪些_18

例如:刚启动,权重10%,过了30秒,权重再加10%,以此类推。

7.2.2 延迟暴露

确保服务依赖的相关资源都准备好了,预热完,才暴露到注册中心。

理论上我们认为,只要暴露到注册中心,就会开始有流量打进来。在这之前,需要保证相关资源都准备好了,例如:数据库连接池、Redis等,否则流量一进来,就会猝不及防。

可以在服务注册前,预留一个Hook钩子,可以自定义Hook逻辑,比如准备资源、完成预热。

RPC 的都有哪些 rpc包含哪些模块_rpc_19

8. 服务保护——活着最重要

RPC 的都有哪些 rpc包含哪些模块_RPC 的都有哪些_20

8.1 限流:提供方

限流算法:

  • 计数器
  • 漏桶算法
  • 令牌桶
  • … …

限流纬度:

  • 应用级别
  • IP级别
  • 方法级别
  • … …

限流策略可以保存在注册中心/配置中心,实现动态配置。

8.2 熔断:消费方

服务C异常,服务B如果继续调用服务C,会导致自己也会大量线程阻塞,最终带崩自己和服务A。结果就是仅仅因为服务C异常,最终整个链路全部雪崩。

RPC 的都有哪些 rpc包含哪些模块_RPC 的都有哪些_21


消费方保护自己的方式:熔断!!!

RPC 的都有哪些 rpc包含哪些模块_提供方_22

熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。
在正常情况下,熔断器是关闭的;
当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,
这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;
当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,
如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。

8.3 流量隔离

限流和熔断,对业务都是有损的!!!要尽量避免。
还有一种相对无损的方式:流量隔离。

流量没有隔离的情况下,调用拓扑图:

RPC 的都有哪些 rpc包含哪些模块_rpc_23

  • 如何实现流量隔离?

将服务按照应用的重要级别进行分组,优先保证核心业务!!!

RPC 的都有哪些 rpc包含哪些模块_RPC 的都有哪些_24


高可用保证:设置多分组,主次分组,优先主分组。消费方实在没有节点可调用了,再选择次分组。服务提供方负载较高时,优先拒绝次分组流量,保障核心业务正常运行。

9. 全异步RPC——压榨单机吞吐量

场景:压测TPS始终上不去,CPU负载也不高,如何提升单机吞吐量?

RPC默认同步调用,服务提供方处理比较耗时,消费方大量线程阻塞,CPU负载不高,但TPS就是上不去。

RPC 的都有哪些 rpc包含哪些模块_提供方_25


解决方案:异步!!!

9.1 客户端异步

Future:表示未来异步计算结果的占位符。

RPC调用的本质:客户端向服务端发送一个请求报文,服务端向客户端发送一个响应报文。
绝大多数RPC框架内部实现上,这两个过程本身就是异步的。只不过,同步调用的场景下,框架自动帮我们阻塞住当前线程,等待服务端响应结果。

服务端不响应,客户端也不能一直阻塞,要有超时机制。

RPC 的都有哪些 rpc包含哪些模块_提供方_26


客户端实现异步非常简单,框架底层不要主动阻塞Worker线程,而是直接返回一个Future,由Worker线程自己决定什么时候调用get()方法获取结果。同步调用:T = T1 + T2 + T3 + T4…

异步调用:T = Max(T1 , T2 , T3 , T4 …)

RPC 的都有哪些 rpc包含哪些模块_提供方_27

9.2 服务端异步

场景:服务端业务执行缓慢(慢SQL、下游服务慢),线程池很快被打满,新的请求被拒绝,TPS压不上去,CPU负载较低。
解决方案:异步!!!服务端将阻塞业务从Worker线程池切换到自定义线程池,避免过度占用Worker线程池,避免不同服务之间相互影响,降低了服务的可用性。(线程池隔离)

Tips:服务端异步执行,不会更节约资源,也不会提高响应性能(实际上更慢,线程切换)。你的业务是阻塞的,它就是阻塞的,总要有线程去执行。
你要考虑的如何提高业务执行的效率!!!

问题:服务端业务异步执行后,结果怎么返回?

channel.read > biz > result > Response > channel.write

让服务端支持CompletableFuture,注册一个回调函数:将result响应给客户端。自定义线程池执行完业务逻辑,自动触发回调,响应数据。

  1. channel.read > submit task > add callback > return
  2. biz > exec callback > channel.write

CompletableFuture是Future的子类,因此我们可以让客户端和服务端同时支持CompletableFuture,来实现全流程异步化。客户端想阻塞获取结果,就调用get()方法;客户端要注册异步回调,就调用thenApplyAsync()

对于客户端:不关心服务端是同步/异步执行,只要服务端channel.write()响应了结果,客户端就可以继续执行。
对于服务端:不关心客户端是同步/异步调用,只需要保证最终执行了业务逻辑,响应了结果即可。

客户端同步/异步调用、服务端同步/异步执行。两者相互独立,可以任意组合使用!!!