资讯详情

RPC核心模块汇总

分享背景

RPC技术,作为互联网人必备的技术栈,不仅要使用,还要了解其实现原理和核心模块。这样我们就可以更好地使用它,并调查问题的想法。 不介绍具体的一个RPC了解核心模块、过程中遇到的问题和解决方案。 你可以试着自己做一个轮子来挑战&收获。

1. 协议-避免鸡和鸭谈话

同一过程:对象(参数) > 对象(结果) RPC:对象 > 打包 > 序列化 > 网络传输 > 反序列化 > 解包 > 对象 远程服务调用 > 网络通信 > 协议(应用层) RPC调用需要保证可靠性 > 基于TCP协议 > 自定义应用层协议 image.png

1.1 协议设计目标:

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

1.2 TCP粘/拆包

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

  • Nagle算法

Nagle通过减少数据包来改进它TCP由于网络带宽有限,传输效率算法如果频繁发送小数据包,对带宽的压力会更大。Nagle当数据总量达到最大数据段时,算法将首先在本地缓冲区缓冲待发送的数据(MSS)一次性批量发送。虽然这种方式可能会延迟消息的发送,但对带宽的压力很小,降低了网络拥堵的可能性,并减少了额外的费用。

  • TCP_CORK

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

  • MTU

网卡是指通信协议中常用的最大传输单元MTU1500,即最大只能传输1500字节的数据帧。ifconfig命令查看每个网卡的数据帧。

  • MSS

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

  1. 固定长帧,报纸固定长度,自动填充。这种方法很简单,但会浪费一定的带宽。
  2. 使用特定的分隔符(如换行符)来分割报文。

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

1.3 Dubbo协议

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

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 响应状态
变长部分 Body 对象序列化byte[]

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

1.4 可扩展协议

Dubbo协议头Header采用固定长度的16字节,不支持扩展!!!在协议中添加新的参数怎么办?

  • 直接放在Body缺点:如果你想得到这个参数,你必须是对的Body反序列化,成本高。

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

2. 如何通过网络传输序列化-对象?

网络只能传输二进制数据,要求参数和返回结果为对象,对象不能通过网络传输。我们必须将对象转换为可以通过网络传输的字节序列:序列化。将字节序列转换为对象:反序列化。

2.1 ??可选方案

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

2.2 ??如何选择?

需要考虑的因素:

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

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

2.3 ??注意点

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

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

3.1 IO模型

常见的网络IO模型:

  • 同步非阻塞 NIO
  • 异步非阻塞 AIO

常用的网络IO模型:

  • 同步阻塞 BIO

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

  • IO多路复用

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

3.2 Reactor线程模型

  • 单线程Reactor
  • 多线程Reactor

3.3 🤔思考题?

![未命名文件 (3).jpg](https://img-blog.csdnimg.cn/img_convert/c544570038a73859882110c1f4ed7820.jpeg#clientId=ua7694ce8-189f-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=344&id=uf9133f9c&margin=[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硬编码在程序里,它需要感知到服务提供方的变化,此时需要引入:服务注册中心!

4.1 基于Zookeeper

特点:,牺牲可用性!

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

4.2 CP还是AP?

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

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

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

4.3 最终一致性

利用「消息总线」机制,保证最终一致性。 新的服务注册 > 注册中心 推送消息 到总线 > 其它注册中心 推拉结合,消息回放 > 最终所有的注册中心数据是一致的。

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

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

5.1 心跳机制

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

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

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

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

5.2 心跳不代表健康

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

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

服务可用率过低:

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

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

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

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

考虑点:

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

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

6.1 负载均衡算法

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

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

RandomLoadBalance 加权随机,默认算法
RoundRobinLoadBalance 加权轮询
LeastActiveLoadBalance 最少活跃调用数,慢的提供者会接收到更少的请求
ConsistentHashLoadBalance 一致性哈希,相同参数请求同一提供者
ShortestResponseLoadBalance 最快响应,选出响应时间最短的提供者

6.2 自适应负载均衡

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

步骤:

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

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

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

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

7.1 优雅停机

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

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

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

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

7.2 优雅启动

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

7.2.1 服务预热

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

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

7.2.2 延迟暴露

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

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

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

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

8.1 限流:提供方

限流算法:

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

限流纬度:

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

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

8.2 熔断:消费方

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

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

8.3 流量隔离

还有一种相对无损的方式:流量隔离。

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

  • 如何实现流量隔离?

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

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

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

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

解决方案:

9.1 客户端异步

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

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

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

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

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

9.2 服务端异步

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

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

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

标签: cp114差压变送器

锐单商城拥有海量元器件数据手册IC替代型号,打造 电子元器件IC百科大全!

锐单商城 - 一站式电子元器件采购平台