分享背景
RPC技术,作为互联网人必备的技术栈,不止于会用,还应该了解它的实现原理和核心模块。以便于我们更好的使用它,遇到问题有排查的思路。
不介绍具体的某一个RPC框架,了解核心模块、过程中会遇到的问题和解决方案。
大家可以自己试着造个轮子,挑战&收获。
问题:能否画张图描述一下RPC的整个流程?(越详细越好)
1. 协议——避免鸡同鸭讲
同一个进程:对象(参数) > 对象(结果)
RPC:对象 > 打包 > 序列化 > 网络传输 > 反序列化 > 解包 > 对象
远程服务调用 > 网络通信 > 协议(应用层)
RPC调用需要保证可靠性 > 基于TCP协议 > 自定义应用层协议
1.1 协议设计的目标:
- 轻量化
- 紧凑、节省带宽
- 可扩展
1.2 TCP粘/拆包
「粘包/拆包」在Socket编程中经常会出现,使用TCP协议传输数据时,如果对端连续发送多个小的数据包,TCP会将这些小的数据包打包,合并成一个TCP报文发送出去,这就是「粘包」。如果对端发送一个超大的数据包,TCP会根据缓冲区的情况,将这个超大数据包拆分成多个小的TCP报文发送出去,这就是「拆包」。
为什么会导致粘包/拆包?
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,那么也会面临「拆包」。
粘/拆包的解决方案?
- 定长帧,报文固定长度,自动填充。这种方式实现简单,但会浪费一定的带宽。
- 使用特定的分隔符(如换行符)将报文进行分割。
- 在报文头写入报文的总长度。(推荐)
如果你使用Netty,它提供了开箱即用的解码器。
1.3 Dubbo协议
16字节定长的Header + 变长的Body。
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也设计为变长的。(推荐)
🤔问题
- 为什么不直接用现成的协议,比如HTTP,而是要设计私有协议???
2. 序列化——对象怎么通过网络传输?
网络传输的只能是二进制数据,请求参数和返回结果都是对象,对象无法通过网络传输。我们必须将对象转换成可以通过网络传输的字节序列:序列化。对端拿到字节序列再转换成对象:反序列化。
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,不支持任务堆积
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硬编码在程序里,它需要感知到服务提供方的变化,此时需要引入:服务注册中心!
4.1 基于Zookeeper
特点:强一致性,牺牲可用性!
ZooKeeper 集群的每个节点的数据每次发生更新操作,都会通知其它 ZooKeeper 节点同时执行更新。
它要求保证每个节点的数据能够实时的完全一致,就直接导致了 ZooKeeper 集群性能上的下降。
4.2 CP还是AP?
Consistency:一致性
Availability:可用性
Partition tolerance:分区容错性
注册中心是否真的需要强一致性?
- 新的提供方上线:消费方没有感知到 > 新机器短时间没有流量。
- 现有提供方下线:消费方没有感知到 > 请求打过去报错,消费方重试其它节点即可。
不管是上线还是下线,注册中心就算没有强一致性,对整个服务集群的影响是很小的,可以有其它办法解决的。绝大多数场景下,可以牺牲CP,换取AP。
4.3 最终一致性
利用「消息总线」机制,保证最终一致性。
新的服务注册 > 注册中心 推送消息 到总线 > 其它注册中心 推拉结合,消息回放 > 最终所有的注册中心数据是一致的。
5. 健康检测——你还活着吗?
生产环境中,服务提供方一般是以「集群」的方式对外提供服务。当某个节点宕机/无法正常工作的时候,注册中心要能及时感知到,并把它下线处理。
5.1 心跳机制
健康检测的常用解决方案就是心跳机制。服务提供方需要定时发送心跳到注册中心,告诉对方“我还活着”。当注册中心超过一定时间(阈值)没有接收到心跳,就会将该节点从集群里剔除掉。
- 真的需要心跳吗?监听Channel断开事件行不行?
不行!是否要剔除节点,关键在于节点能否正常对外提供服务。有的时候,TCP连接虽然没有断开,但是服务可能已经僵死。
一般30秒发一次心跳。心跳发送的太频繁,注册中心压力较大。发送频率太低,服务健康状态感知不及时。
5.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 服务预热
- 服务提供方注册时,带上自己启动的时间戳。
- 注册中心下发服务时,顺带下发服务启动的时间戳。
- 消费方,根据提供方的启动时间和当前时间的差值,来计算权重。
例如:刚启动,权重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线程池,避免不同服务之间相互影响,降低了服务的可用性。(线程池隔离)
Tips:服务端异步执行,不会更节约资源,也不会提高响应性能(实际上更慢,线程切换)。你的业务是阻塞的,它就是阻塞的,总要有线程去执行。
你要考虑的如何提高业务执行的效率!!!
问题:服务端业务异步执行后,结果怎么返回?
channel.read > biz > result > Response > channel.write
让服务端支持CompletableFuture,注册一个回调函数:将result响应给客户端。自定义线程池执行完业务逻辑,自动触发回调,响应数据。
- channel.read > submit task > add callback > return
- biz > exec callback > channel.write
CompletableFuture是Future的子类,因此我们可以让客户端和服务端同时支持CompletableFuture,来实现全流程异步化。客户端想阻塞获取结果,就调用get()
方法;客户端要注册异步回调,就调用thenApplyAsync()
。
对于客户端:不关心服务端是同步/异步执行,只要服务端channel.write()
响应了结果,客户端就可以继续执行。
对于服务端:不关心客户端是同步/异步调用,只需要保证最终执行了业务逻辑,响应了结果即可。
客户端同步/异步调用、服务端同步/异步执行。两者相互独立,可以任意组合使用!!!