分享背景
RPC技术,作为互联网人必备的技术栈,不仅要使用,还要了解其实现原理和核心模块。这样我们就可以更好地使用它,并调查问题的想法。 不介绍具体的一个RPC了解核心模块、过程中遇到的问题和解决方案。 你可以试着自己做一个轮子来挑战&收获。
1. 协议-避免鸡和鸭谈话
同一过程:对象(参数) > 对象(结果) RPC:对象 > 打包 > 序列化 > 网络传输 > 反序列化 > 解包 > 对象 远程服务调用 > 网络通信 > 协议(应用层) RPC调用需要保证可靠性 > 基于TCP协议 > 自定义应用层协议
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,所以也会面临「拆包」。
- 固定长帧,报纸固定长度,自动填充。这种方法很简单,但会浪费一定的带宽。
- 使用特定的分隔符(如换行符)来分割报文。
如果你使用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 🤔思考题?
.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 心跳不代表健康
节点的状态除了健康和死亡,还应该引入中间状态——亚健康状态。 节点可能会因为网络抖动、自身负载较重、依赖的下游服务异常等原因,虽然还可以间歇性的发送心跳,但是服务本身其实已经处于一个亚健康状态了。此时,处于亚健康状态的节点,权重应该要调的特别低,让消费方优先调用健康的节点,等待亚健康的节点恢复。
光靠是否有心跳去判断一个服务是否可用,太片面了。 :一个时间窗口内,请求处理成功次数/总请求数。当服务可用率低于某个阈值的时候,修改节点的状态。节点发送心跳的时候,在心跳包里带上自身的服务可用率。
服务可用率过低:
- 注册中心剔除服务
- 消费方降低调用的权重
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线程池,避免不同服务之间相互影响,降低了服务的可用性。(线程池隔离)
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()
响应了结果,客户端就可以继续执行。 对于服务端:不关心客户端是同步/异步调用,只需要保证最终执行了业务逻辑,响应了结果即可。
客户端同步/异步调用、服务端同步/异步执行。两者相互独立,可以任意组合使用!!!