RPC高频问题与底层原理剖析
1. 能否解释下RPC的通信流程?
RPC能实现调用远程方法就跟调用本地(同一个项目中的方法)一样,发起调用请求的那一方叫做调用方,被调用的一方叫做服务提供方。既然 RPC 存在的核心目的是为了实现远程调用,那肯定就需要通过网络来传输数据,并且RPC 常用于业务系统之间的数据交互,需要保证其可靠性,所以 RPC 一般默认采用 TCP 来传输。事实上。我们常用的 HTTP 协议也是建立在 TCP 之上的。选择tcp的核心原因还是因为他的效率要比很多应用层协议高很多。
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。
调用方持续地把请求参数序列化成二进制后,经过 TCP 传输给了服务提供方。服务提供方从 TCP 通道里面收到二进制数据,那如何知道一个请求的数据到哪里结束,是一个什么类型的请求呢?所以需要建立一个协议。大多数的协议会分成两部分,分别是数据头和消息体。数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等。
根据协议格式,服务提供方就可以正确地从二进制数据中分割出不同的请求来,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象。这个过程叫作“反序列化”。
服务提供方再根据反序列化出来的请求对象找到对应的实现类,完成真正的方法调用,然后把执行结果序列化后,回写到对应的 TCP 通道里面。调用方获取到应答的数据包后,再反序列化成应答对象,这样调用方就完成了一次 RPC 调用。
如果我们觉得序列化后的字节数组体积比较大,我们还可以对他进行压缩,压缩后的字节数组体积更小,能在传输的过程中更加节省带宽和内存。
到这里,一个简单版本的 RPC 框架就实现了,但对于研发人员来说,这样做要掌握太多的 RPC 底层细节,需要手动写代码去构造请求、调用序列化,并进行网络调用,整个 API 非常不友好。那就需要用AOP动态代理的技术屏蔽RPC处理流程,对方法进行拦截增强,以便于增加需要的额外处理逻辑。
由服务提供者给出业务接口声明,在调用方的程序里面,RPC 框架根据调用的服务接口提前生成动态代理实现类,并通过依赖注入等技术注入到声明了该接口的相关业务逻辑里面。该代理实现类会拦截所有的方法调用,在提供的方法处理逻辑里面完成一整套的远程调用,并把远程调用结果返回给调用方,这样调用方在调用远程方法的时候就获得了像调用本地接口一样的体验。
2. 怎么设计可扩展且向后兼容的协议?
粘包问题
粘包:指通信双方中的一端发送了多个数据包,但在另一端则被读取成了一个数据包,比如客户端发送123、ABC
两个数据包,但服务端却收成的却是123ABC
这一个数据包。造成这个问题的主要是因为TPC
为了优化传输效率,将多个小包合并成一个大包发送,同时多个小包之间没有界限分割造成的。
沾包问题解决方案
①当使用TCP
短连接时,不必考虑沾包问题。
②当发送无结构数据,如文件传输时,也不需要考虑沾包问题,因为这类数据只管发送和接收保存即可。
③如果使用长连接,那么则需要考虑沾包问题:
- 固定长度的消息;
- 特殊字符作为边界;
- 自定义消息结构,一部分是头部,一部分是内容体;其中头部结构大小固定,且有一个字段声明内容体的大小。
半包问题
半包:指通信双方中的一端发送一个大的数据包,但在另一端被读取成了多个数据包,例如客户端向服务端发送了一个数据包:ABCDEFGXYZ
,而服务端则读取成了ABCEFG、XYZ
两个包,这两个包实际上都是一个数据包中的一部分,这个现象则被称之为半包问题(产生这种现象的原因在于:接收方的数据接收缓冲区过小导致的)。
粘包、半包问题的产生原因
粘包:发送12345、ABCDE
两个数据包,被接收成12345ABCDE
一个数据包,多个包粘在一起。
- 应用层:接收方的接收缓冲区太大,导致读取多个数据包一起输出。
TCP
滑动窗口:接收方窗口较大,导致发送方发出多个数据包,接收方处理不及时造成粘包。Nagle
算法:由于发送方的数据包体积过小,导致多个数据包合并成一个包发送。
半包:发送12345ABCDE
一个数据包,被接收成12345、ABCDE
两个数据包,一个包拆成多个。
- 应用层:接收方缓冲区太小,无法存方发送方的单个数据包,因此拆开读取。
- 滑动窗口:接收方的窗口太小,无法一次性放下完整数据包,只能读取其中一部分。
MSS
限制:发送方的数据包超过MSS
限制,被拆分为多个数据包发送。
netty中有哪几种解码器解决粘包的,你的项目怎么处理的?
有定长帧解码器、行帧解码器、分隔符帧解码器、LTC帧解码器。
定长帧解码器的确能够有效避免粘包、半包问题的出现,因为每个数据包之间,会以八个字节的长度作为界限,然后分割数据。但这种方式也存在三个致命缺陷:
- ①只适用于传输固定长度范围内的数据场景,而且客户端在发送数据前,还需自己根据长度补齐数据。
- ②如果发送的数据超出固定长度,服务端依旧会按固定长度分包,所以仍然会存在半包问题。
- ③对于未达到固定长度的数据,还需要额外传输补齐的
*
号字符,会占用不必要的网络资源。
相较于原本的定长解码器,行解码器、自定义分隔符解码器显然更加灵活,因为支持可变长度的数据,但这两种解码器,依旧存在些许缺点:
- ①对于每一个读取到的字节都需要判断一下:是否为结尾的分隔符,这会影响整体性能。
- ②依旧存在最大长度限制,当数据超出最大长度后,会自动将其分包,在数据传输量较大的情况下,依旧会导致半包现象出现。
项目用的LTC帧解码器
public class LengthFieldBasedFrameDecoder extends ByteToMessageDecoder {public LengthFieldBasedFrameDecoder(int maxFrameLength, int lengthFieldOffset, int lengthFieldLength, int lengthAdjustment, int initialBytesToStrip) {this(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip, true);}// 暂时省略其他参数的构造方法......
}
从上述构造器中可明显看出,LTC
中存在五个参数,看起来都比较长,接着简单解释一下:
maxFrameLength
:数据最大长度,允许单个数据包的最大长度,超出长度后会自动分包。lengthFieldOffset
:长度字段偏移量,表示描述数据长度的信息从第几个字段开始。lengthFieldLength
:长度字段的占位大小,表示数据中的使用了几个字节描述正文长度。lengthAdjustment
:长度调整数,表示在长度字段的N
个字节后才是正文数据的开始。initialBytesToStrip
:头部剥离字节数,表示先将数据去掉N
个字节后,再开始读取数据。
自定义 RPC 协议设计解析
这个 MessageFormatConstant
类定义了一个 RPC 通信协议的消息格式,核心是 固定大小的消息头 + 可变大小的消息体,方便解析与扩展。下面逐步解析其协议设计。
1. 协议格式解析
协议头的结构示例如下:RequestID后面有八个字节的时间戳
- 固定长度的消息头
magic
(4B) - 魔数,用于协议识别,防止数据混乱(类似于Magic Number
)。version
(1B) - 版本号,便于协议升级扩展。header length
(2B) - 头部长度,便于动态扩展头部字段。full length
(4B) - 报文总长度,便于解析数据流。qt
(1B) - 请求类型(如请求/响应/心跳等)。ser
(1B) - 序列化方式(如 JSON / Protobuf / Hessian)。comp
(1B) - 压缩方式(如 GZIP / Snappy)。requestId
(8B) - 唯一 ID,便于请求追踪。
- 可变长度的消息体
body
(N B) - 具体的业务数据,通常是序列化后的 RPC 请求/响应数据。
2. 关键字段解析
// 魔数,固定4字节,标识协议,避免混用其他协议
public final static byte[] MAGIC = "yRPC".getBytes();
MAGIC
用于识别是否是该 RPC 框架的消息,避免解析错误。
// 版本号,1字节
public final static byte VERSION = 1;
VERSION
用于协议升级时兼容不同版本。
// 头部信息的长度
public final static short HEADER_LENGTH = (byte)(MAGIC.length + 1 + 2 + 4 + 1 + 1 + 1 + 8 + 8);
- 计算公式:
这说明 最小头部长度是 30 字节,但由于 header length
是动态的,可能会变长。
- `MAGIC.length`(4B)
- `VERSION`(1B)
- `header length`(2B)
- `full length`(4B)
- `qt`(1B)
- `ser`(1B)
- `comp`(1B)
- `requestId`(8B)
// 头部信息长度占用的字节数
public static final int HEADER_FIELD_LENGTH = 2;
- 头部长度字段的长度,表示头部总共有多少字节,以便解析时读取正确字节数。
// 最大帧长度
public final static int MAX_FRAME_LENGTH = 1024 * 1024;
- 限制 单个请求最大不超过 1MB,防止大请求影响系统稳定性。
// 版本号的长度(1 字节)
public static final int VERSION_LENGTH = 1;
- 这里再次强调
VERSION
的字节数,保证解析时可以准确读取。
// 报文总长度字段的长度
public static final int FULL_FIELD_LENGTH = 4;
- full length 用 4 字节存储,表示完整数据包大小(包括 header + body)。
3. 为什么这么设计?
- 使用魔数(MAGIC)进行协议识别
- 避免服务器误解析非 RPC 消息,比如 HTTP、Redis 数据包。
- 版本号(VERSION)可扩展
- 未来升级协议时,仍可兼容旧版本。
- 头部长度(header length)动态可变
- 允许后续扩展更多字段,而不影响现有解析逻辑。
- 总长度(full length)方便流式解析
- 读取到 full length 字段后,可以一次性拿到完整的数据包,而不会拆包/粘包。
- 支持不同的请求类型(qt)
- 可以区分普通 RPC 请求、心跳包、管理请求等。
- 支持多种序列化(ser)和压缩方式(comp)
- 适应不同的业务场景(如 JSON 方便调试,Protobuf 高效传输)。
- RequestId 便于 RPC 调用追踪
- 在客户端、服务端可以用 RequestId 来做异步回调匹配,提升性能。
3. 对象怎么在网络中传输?(序列化)
- jdk的序列化方式就是使用IO进行序列化,只支持不同的jvm之间的传输,并不能跨语言
- Hessian 是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian 协议要比 JDK、JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小。
- JSON 进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销;JSON 没有类型,但像 Java 这种强类型语言,需要通过反射统一解决,所以性能不会太好。
为了让框架可以支持更多的序列化方式,设计了序列化器工厂,并对不同的序列化器进行了缓存,我们可以根据序列化的类型和编号轻松的获取一个序列化器。支持SPI配置、XML配置、代码配置
4.RPC框架在网络通信上更倾向于哪种网络IO模型?
RPC 是解决进程间通信的一种方式。一次 RPC 调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。服务调用者通过网络 IO 发送一条请求消息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次 RPC 调用便结束了。可以说,网络通信是整个 RPC 调用流程的基础。
说到网络通信,就不得不提一下网络 IO 模型。为什么要讲网络 IO 模型呢?因为所谓的两台 PC 机之间的网络通信,实际上就是两台 PC 机对网络 IO 的操作。常见的网络 IO 模型分为四种:同步阻塞 IO(BIO)、同步非阻塞 IO(NIO)、IO 多路复用和异步非阻塞 IO(AIO)。在这四种 IO 模型中,只有 AIO 为异步 IO,其他都是同步IO。
同步阻塞 IO
BIO
(Blocking-IO)即同步阻塞模型,这也是最初的IO模型,也就是当调用内核的read()函数后,内核在执行数据准备、复制阶段的IO操作时,应用线程都是阻塞的,所以本次IO操作则被称为同步阻塞式IO。
同步非阻塞 IO(NIO)
NIO(Non-Blocking-IO)
同步非阻塞模型,从字面意思上来说就是:调用read()
函数的线程并不会阻塞,而是可以正常运行。当应用程序中发起IO
调用后,内核并不阻塞当前线程,而是立马返回一个“数据未就绪”的信息给应用程序,而应用程序这边则一直反复轮询去问内核:数据有没有准备好?直到最终数据准备好了之后,内核返回“数据已就绪”状态,紧接着再由进程去处理数据…
IO 多路复用
在多路复用模型中,内核仅有一条线程负责处理所有连接,所有网络请求/连接(Socket
)都会利用通道Channel
注册到选择器上,然后监听器负责监听所有的连接,过程如下:
当出现一个IO
操作时,会通过调用内核提供的多路复用函数,将当前连接注册到监听器上,当监听器发现该连接的数据准备就绪后,会返回一个可读条件给用户进程,然后用户进程拷贝内核准备好的数据进行处理(这里实际是读取Socket
缓冲区中的数据)。
这里我们可以看到,当用户进程发起了 select 调用,进程会被阻塞,当发现该 select 负责的 socket 有准备好的数据时才返回,之后才发起一次 read,整个流程要比阻塞 IO 要复杂,似乎也更浪费性能。但它最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。在高性能的网络编程框架的编写上,大多数都是基于 Reactor 模式,其中最为典型的便是 Java 的 Netty 框架,而 Reactor 模式是基于 IO 多路复用的。
Reactor 模式
Reactor
模式一般翻译成反应器模式,也有人称为分发者模式。是基于事件驱动的设计模式,拥有一个或多个并发输入源,有一个服务处理器和多个请求处理器,服务处理器会同步的将输入的请求事件以多路复用的方式分发给相应的请求处理器。简单来说就是 由一个线程来接收所有的请求,然后派发这些请求到相关的工作线程中。
在 Reactor
模式中有三个重要的角色:
Reactor
:负责响应事件,将事件分发到绑定了对应事件的Handler
,如果是连接事件,则分发到Acceptor
;Handler
:事件处理器。负责执行对应事件对应的业务逻辑;Acceptor
:绑定了connect
事件,当客户端发起connect
请求时,Reactor
会将accept
事件分发给Acceptor
处理;
单 Reactor 单线程版本
只有一个 Selector
循环接受请求,客户端注册进来由 Reactor
接收注册事件,然后再由 Reactor
分发出去,由对应的 Handler
进行业务逻辑处理。
单线程的问题实际上是很明显的。只要其中一个 Handler
方法阻塞了,那就会导致所有的 client
的 Handler
都被阻塞了,也会导致注册事件也无法处理,无法接收新的请求。所以这种模式用的比较少,因为不能充分利用到多核的资源。因此,这种模式仅仅只能处理 Handler
比较快速完成的场景。
单 Reactor 多线程版本
在多线程 Reactor
中,注册接收事件都是由 Reactor
来做,其它的计算,编解码由一个线程池来做。从图中可以看出工作线程是多线程的,监听注册事件的 Reactor
还是单线程。
对于 Reactor
部分,代码不需要调整,因为也是单 Reactor
,Handler
部分增加了线程池的支持。
对比单 Reactor
单线程模型,多线程 Reactor
模式在 Handler
读写处理时,交给工作线程池处理,可以充分利用多核cpu的处理能力,因为 Reactor
分发和 Handler
处理是分开的,不会导致 Reactor
无法执行。从而提升应用的性能。缺点是 Reactor
只在主线程中运行,承担所有事件的监听和响应,如果短时间的高并发场景下,依然会造成性能瓶颈。
多 Reactor 多线程版本
也称为主从 Reactor
模式,在这种模式下,一般会有两个 Reactor
:mainReactor
和 subReactor
。mainReactor
负责监听客户端请求,专门处理新连接的建立,再将建立好的连接注册到 subReactor
。subReactor
将分配的连接加入到队列进行监听,当有新的事件发生时,会调用连接相对应的 Handler
进行业务处理。
这样的模型使得每个模块更加专一,耦合度更低,能支持更高的并发量。netty就使用这种模式。
异步非阻塞 IO(AIO)
AIO(Asynchronous-Non-Blocking-IO)
异步非阻塞模型,该模型是真正意义上的异步非阻塞式IO
,代表数据准备与复制阶段都是异步非阻塞的:
RPC框架在网络通信上倾向选择哪种网络IO 模型?
IO 多路复用更适合高并发的场景,可以用较少的进程(线程)处理较多的 socket 的 IO 请求。RPC 调用在大多数的情况下,是一个高并发调用的场景,在网络通信的处理上,选择 IO 多路复用的方式。开发语言的网络通信框架的选型上,我们最优的选择是基于Reactor 模式实现的框架,如 Java 语言,首选的框架便是 Netty 框架。
5. RPC项目中动态代理做了什么?(面向接口编程,屏蔽RPC处理流程)
在项目中,当要使用 RPC 的时候,我们一般的做法是先找服务提供方要接口,通过Maven 或者其他的工具把接口依赖到我们项目中。我们在编写业务逻辑的时候,如果要调用提供方的接口,我们就只需要通过依赖注入的方式把接口注入到项目中就行了,然后在代码里面直接调用接口的方法 。而接口里并不会包含真实的业务逻辑,业务逻辑都在服务提供方应用里,但我们通过调用接口方法,确实拿到了想要的结果。**这里面用到的核心技术就是前面说的动态代理。**RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。
1. 代理对象创建
HelloYRPC helloYRPC = reference.get();
reference.get()
返回的是一个 动态代理对象(通过Proxy.newProxyInstance
创建)。- 该代理对象实现了
HelloYRPC
接口,但所有方法调用会被转发到RPCConsumerInvocationHandler
中。
2. 方法调用触发远程通信
helloYRPC.sayHi("你好yRPC");
- 表面看是本地方法调用,但实际会进入代理逻辑。
- 底层逻辑:
- 代理对象拦截
sayHi
方法调用,转发到InvocationHandler.invoke()
方法。 RPCConsumerInvocationHandler
的invoke()
方法中会:- 封装请求:将方法名(
sayHi
)、参数("你好yRPC"
)序列化为协议数据。 - 网络通信:通过 RPC 客户端将请求发送到服务端,并等待响应。
- 返回结果:将响应反序列化为
String
类型,最终赋值给sayHi
。
- 封装请求:将方法名(
- 代理对象拦截
核心机制解释
动态代理的核心作用
- 隐藏网络细节:通过代理对象,开发者无需手动处理序列化、网络传输、响应解码等底层操作。
- 伪装本地调用:让远程调用在代码层面看起来和本地方法调用一致。
关键代码段
public T get() {// 创建动态代理对象InvocationHandler handler = new RPCConsumerInvocationHandler(...);Object proxy = Proxy.newProxyInstance(...);return (T) proxy;
}
RPCConsumerInvocationHandler
:负责在invoke()
方法中实现 RPC 通信逻辑。Proxy.newProxyInstance
:生成代理对象,拦截接口方法调用。get()
方法只是 创建代理对象,此时尚未发生网络通信。- 真正的远程调用发生在 通过代理对象调用接口方法时(即
sayHi()
被调用时)。 - RPC 远程调用触发点:
helloYRPC.sayHi("你好yRPC")
。 - 核心原理:动态代理将方法调用拦截,转换为网络请求。
- 代码定位:具体网络通信逻辑隐藏在
RPCConsumerInvocationHandler.invoke()
中(需结合未展示的 Handler 代码分析)。
6. 如果这个节点都挂了,疯狂发请求,怎么处理?(健康检测)
让调用方实时感知到节点的状态变化,一般进行心跳检测,帮助我们从连接列表里面过滤掉一些存在问题的节点,避免在发请求的时候选择出有问题的节点而影响业务。
- 健康状态:建立连接成功,并且心跳探活也一直成功;
- 亚健康状态:建立连接成功,但是心跳请求连续失败;
- 死亡状态:建立连接失败。
比如一个节点“连续”心跳失败次数到达某一个阈值,比如 3 次(具体看你怎么配置了)就把它移出健康服务列表。也可以设置可用率,一个时间窗口内接口调用成功次数的百分比(成功次数 / 总调用次数),当可用率低于某个比例就认为这个节点存在问题。
定期向所有的channel发送一个简单的请求即可,如果能得到回应说明连接是正常的。其中我们要在心跳探测的过程中完成以下几项工作:
1、如果可以正常访问,记录响应时间,以备后用。
2、如果不能正常访问,则进行重试,重试三次依旧不能访问,则从健康服务列表中剔除,以后的访问不会使用该连接。
注意:重试的等待时间我们选取一个合适范围内的随机时间,这样可以避免局域网络问题导致的大面积同时重试,产生重试风暴。
7. 节点负载差距这么大,为什么收到的流量还一样?(负载均衡)
当我们的一个服务节点无法支撑现有的访问量时,我们会部署多个节点,组成一个集群,然后通过负载均衡,将请求分发给这个集群下的每个服务节点,从而达到多个服务节点共同分担请求压力的目的。
RPC 负载均衡策略一般包括随机权重、一致性Hash、轮询、最短响应时间等。
一致性哈希
传统的hash算法思路,我们需要构建一张hash表,将服务器挂载在hash表中
但是,这样的方式会存在很多问题,如动态扩容的问题。比如,随着业务量增长,将原有的六个服务扩容至八个,此时,我们不仅要修改路由表,还要修改hash的路由策略。
一致性hash借鉴了hash算法的部分能力做了如下的设计:
- 将hash值均匀的分布在一个区间,一般将区间设置为整形的取值范围(-231 ~ 231-1)当然这个范围也可以是(0 ~ 2^32-1),只要是一个合理的容易计算的足够大的范围即可。
- 将这个区间构建成一个环,构建成环不一定必须要链表,其实很多的有序的数据结构都可以,比如数组,比如红黑树,只要加上一点点逻辑,就是数完最后一个回到第一个节点就可以了。
- 将服务器按照自身的特点,计算hash值,并将其挂载在hash表中。
当请求进来以后,根据请求的部分特征,如url、请求id,请求来源等信息进行hash运算,看请求落在哪个范围,然后顺时针找到第一个服务器即可,这样最大的好处就是当有新的服务加入集群只需要将服务挂载在hash环即可,但是后自然会有流量进入该服务器,而不需要修改任何的逻辑,因为我们的hash环足够大,所以可以容纳的机器也很多。
但是此时会出现一个问题,如果节点过少,hash分布不均匀会产生严重的流量倾斜:
为了解决这个问题,我们就需要引入虚拟节点的概念,我们可以将一个真实节点化身为n个(比如128)虚拟节点,每个虚拟节点都指向同一个服务,分别对虚拟节点进行hash,可以让一个服务的虚拟节点大致均匀的分布在hash环上,不要觉得他很神奇,代码实现其实很简单。当然虚拟节点数也可以乘以每个服务单位权重。
还是只有两个节点:
8. 如何避免服务停机带来的业务损失?(优雅关闭)
当我们快速关闭服务提供方时,注册中心感知、以及通过watcher机制通知调用方一定不能做到实时,一定会有延时,同时我们的心跳检测也会有一定的时间间隔。也就意味着当一个提供方实际上已经下线了,但是他依然在调用方的健康列表中,调用方依然认为他健康依然会给他发送消息,最后的结果就是超时等待,不断重试。所以有必要在服务下线时快速的让调用方感知。
大概可以有以下几种解决方案:
1、通过控制台人工通知调用方,让他们手动摘除要下线的机器,这种方式很原始也很直接。但这样对于提供方上线的过程来说太繁琐了,每次上线都要通知到所有调用我接口的团队,整个过程既浪费时间又没有意义,显然不能被正常接受。
2、通过服务发现机制感知,这种方式我们探讨过,因为存在一定的时间差,所以会出现一定的问题。
3、不强依赖“服务发现”来通知调用方要下线的机器,由服务提供方自己来通知行不行。在yRPC里面调用方跟服务提供方之间是长连接,我们可以在提供方应用内存里面维护一份调用方连接集合,当服务要关闭的时候,挨个去通知调用方去下线这台机器。
第三种方式已经很好了,但是依旧会出现一些问题 ,如请求的时间点跟收到服务提供方关闭通知的时间点很接近,只比关闭通知的时间早不到1ms,如果再加上网络传输时间的话,那服务提供方收到请求的时候,它应该正在处理关闭逻辑。这就说明服务提供方关闭的时候,并没有正确处理关闭后接收到的新请求。因为服务提供方已经开始进入关闭流程,那么很多对象就可能已经被销毁了,关闭后再收到的请求按照正常业务请求来处理,肯定没法保证能处理的。所以我们可以在关闭的时候,设置一个请求”挡板”,挡板的作用就是告诉调用方,我已经开始进入关闭流程了,我不能再处理你这个请求了。
基于这个思路,可以这么处理:
- 调用方发起请求,给调用方一个特殊的响应,使用响应码标记即可,就是告诉调用方我已经收到这个请求了,但是我正在关闭,并没有处理这个请求。
- 调用方收到这个异常响应后,yRPC框架把这个节点从健康列表挪出,并把请求自动重试到其他节点,因为这个请求是没有被服务提供方处理过,所以可以安全地重试至其他节点,这样就可以实现对业务无损。
**问题一:**那要怎么捕获到关闭事件呢?
可以通过捕获操作系统的进程信号来获取,在java 语言里面,可以使用Runtime的addShutdownHook方法,可以注册关闭的钩子。在yRPC启动的时候,我们提前注册关闭钩子,并在里面添加处理程序,负责开启关闭标识和安全关闭服务,服务在关闭的时候会通知调用方下线节点。同时需要在我们调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常,返回特定结果。
那关闭过程中已经在处理的请求会不会受到影响呢?
如果进程结束过快会造成这些请求还没有来得及应答, 此时调用方会也会抛出异常。为了尽可能地完成正在处理的请求,首先我们要把这些请求识别出来,需要有一个标识来判断是否还有正在处理的请求,如果没有了再关闭服务。可以引入一个全局计数器,每开始处理请求之前加一完成请求处理减一,通过该计数器我们就可以快速判断是否有正在理的请求。
服务对象在关闭过程中,会拒绝新的请求,同时根据引用计数器等待正在处理的请求全部结束之后才会真正关闭。但考虑到有些业务请求可能处理时间长,或者存在被挂住的情况,为了避免一直等待造成应用无法正常退出,我们可以在整个 ShutdowrHook里面,加上超时时间控制, 当超过了指定时间没有结束,则强制退出应用。超时时间我建议可以设定成 10s,基本可以确保请求都处理完了。整个流程如下图所示。
9. 如何避免流量打到没有启动完成的节点?(优雅启动)
运行了一段时间后的应用,执行速度会比刚启动的应用更快。这是因为在 Java 里面,在运行过程中,JVM 虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到 JVM 缓存中,再次使用的时候不会触发临时加载,这样就使得“热点”代码的执行不用每次都通过解释,从而提升执行速度。
但是这些“临时数据”,都在我们应用重启后就消失了。重启后的这些“红利”没有了之后,如果让我们刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。
在上一讲我们说过,在微服务架构里面,上线肯定是频繁发生的,那我们总不能因为上线,就让过来的请求出现大面积超时吧?所以我们得想点办法。既然问题的关键是在于“刚重启的服务提供方因为没有预跑就承担了大流量”,那我们是不是可以通过某些方法,让应用一开始只接少许流量呢?这样低功率运行一段时间后,再逐渐提升至最佳状态。
1、启动预热
那什么叫启动预热呢?
简单来说,就是让刚启动的服务提供方应用不承担全部的流量,而是让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。
那在RPC 里面,我们该怎么实现这个功能呢?
我们现在是要控制调用方发送到服务提供方的流量。我们可以先简单地回顾下调用方发起的RPC 调用流程是怎样的,调用方应用通过服务发现能够获取到服务提供方的 IP 地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接。那这样的话,我们是不是就可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用?对于刚启动的应用,我们可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程。
2、延迟暴露
我们应用启动的时候都是通过 main 入口,然后顺序加载各种相关依赖的类。以 Spring 应用启动为例,在加载的过程中,Spring 容器会顺序加载 Spring Bean,如果某个 Bean 是RPC 服务的话,我们不光要把它注册到 Spring-BeanFactory 里面去,还要把这个 Bean 对应的接口注册到注册中心。注册中心在收到新上线的服务提供方地址的时候,会把这个地址推送到调用方应用内存中;当调用方收到这个服务提供方地址的时候,就会去建立连接发请求。
但这时候是不是存在服务提供方可能并没有启动完成的情况?因为服务提供方应用可能还在加载其它的 Bean。对于调用方来说,只要获取到了服务提供方的 IP,就有可能发起RPC 调用,但如果这时候服务提供方没有启动完成的话,就会导致调用失败,从而使业务受损。
那有什么办法可以避免这种情况吗?
在解决问题前,我们先看下出现上述问题的根本原因。这是因为服务提供方应用在没有启动完成的时候,调用方的请求就过来了,而调用方请求过来的原因是,服务提供方应用在启动过程中把解析到的RPC 服务注册到了注册中心,这就导致在后续加载没有完成的情况下服务提供方的地址就被服务调用方感知到了。
这样的话,其实我们就可以**把接口注册到注册中心的时间挪到应用启动完成后。**具体的做法就是在应用启动加载、解析 Bean 的时候,如果遇到了RPC 服务的 Bean,只先把这个 Bean 注册到 Spring-BeanFactory 里面去,而并不把这个 Bean 对应的接口注册到注册中心,只有等应用启动完成后,才把接口注册到注册中心用于服务发现,从而实现让服务调用方延迟获取到服务提供方地址。
这样是可以保证应用在启动完后才开始接入流量的,但其实这样做,我们还是没有实现最开始的目标。因为这时候应用虽然启动完成了,但并没有执行相关的业务代码,所以 JVM 内存里面还是冷的。如果这时候大量请求过来,还是会导致整个应用在高负载模式下运行,从而导致不能及时地返回请求结果。而且在实际业务中,一个服务的内部业务逻辑一般会依赖其它资源的,比如缓存数据。如果我们能在服务正式提供服务前,先完成缓存的初始化操作,而不是等请求来了之后才去加载,我们就可以降低重启后第一次请求出错的概率。
那具体怎么实现呢?
我们还是需要利用服务提供方把接口注册到注册中心的那段时间。我们可以在服务提供方应用启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑。用户可以在 Hook 里面模拟调用逻辑,从而使 JVM 指令能够预热起来,并且用户也可以在 Hook 里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。整个应用启动过程如下图所示:
10. 业务如何实现自我保护?(熔断限流)
服务端的自我保护
限流是为了防止系统因突然的流量激增而导致的崩溃,同时保证服务的高可用性和稳定性。
方式有很多,比如最简单的计数器,还有可以做到平滑限流的滑动窗口、漏斗算法以及令牌桶算法等等。其中令牌桶算法最为常用。
public class TokenBuketRateLimiter implements RateLimiter {// 思考,令牌是个啥?令牌桶是个啥?// String,Object? list? ,map?// 代表令牌的数量,>0 说明有令牌,能放行,放行就减一,==0,无令牌 阻拦private int tokens;// 限流的本质就是,令牌数private final int capacity;// 令牌桶的令牌,如果没了要怎么办? 按照一定的速率给令牌桶加令牌,如每秒加500个,不能超过总数// 可以用定时任务去加--> 启动一个定时任务,每秒执行一次 tokens+500 不能超过 capacity (不好)// 对于单机版的限流器可以有更简单的操作,每一个有请求要发送的时候给他加一下就好了private final int rate;// 上一次放令牌的时间private Long lastTokenTime;public TokenBuketRateLimiter(int capacity, int rate) {this.capacity = capacity;this.rate = rate;lastTokenTime = System.currentTimeMillis();tokens = capacity;}/*** 判断请求是否可以放行* @return true 放行 false 拦截*/public synchronized boolean allowRequest() {// 1、给令牌桶添加令牌// 计算从现在到上一次的时间间隔需要添加的令牌数Long currentTime = System.currentTimeMillis();long timeInterval = currentTime - lastTokenTime;// 如果间隔时间超过一秒,放令牌if(timeInterval >= 1000/rate){int needAddTokens = (int)(timeInterval * rate / 1000);System.out.println("needAddTokens = " + needAddTokens);// 给令牌桶添加令牌tokens = Math.min(capacity, tokens + needAddTokens);System.out.println("tokens = " + tokens);// 标记最后一个放入令牌的时间this.lastTokenTime = System.currentTimeMillis();}// 2、自己获取令牌,如果令牌桶中有令牌则放行,否则拦截if(tokens > 0){tokens --;System.out.println("请求被放行---------------");return true;} else {System.out.println("请求被拦截---------------");return false;}}
}
根据令牌桶算法,桶中的令牌是持续生成存放的,有请求时需要先从桶中拿到令牌才能开始执行,谁来持续生成令牌存放呢?
一种解法是,开启一个定时任务,由定时任务持续生成令牌。这样的问题在于会极大的消耗系统资源,如,某接口需要分别对每个用户做访问频率限制,假设系统中存在6W用户,则至多需要开启6W个定时任务来维持每个桶中的令牌数,这样的开销是巨大的。
另一种解法则是延迟计算,如上resync函数。该函数会在每次获取令牌之前调用,其实现思路为,若当前时间晚于nextFreeTicketMicros,则计算该段时间内可以生成多少令牌,将生成的令牌加入令牌桶中并更新数据。这样一来,只需要在获取令牌时计算一次即可。Guava提供的
<font style="color:rgb(255, 80, 44);background-color:rgb(255, 245, 245);">RateLimiter</font>
类就是以延迟计算的方式实现限流。
限流逻辑是服务集群下的每个节点独立去执行的,是一种单机的限流方式,而且每个服务节点所接收到的流量并不是绝对均匀的。可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量打过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值。我们甚至可以将限流逻辑放在调用端,调用端在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常即可。
这种限流方式可以让整个服务集群的限流变得更加精确,但也由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有很大劣势的。至于要选择哪种限流方式,就要结合具体的应用场景进行选择了。
调用端的自我保护
服务熔断,也就是 Circuit Breaker,用于在分布式系统中处理服务调用失败的情况。
假设有个 <font style="color:rgb(221, 17, 68);">A 服务</font>
调用 <font style="color:rgb(221, 17, 68);">B 服务</font>
的场景,如果 <font style="color:rgb(221, 17, 68);">B 服务</font>
已经出现频繁失败的情况,<font style="color:rgb(221, 17, 68);">A</font>
继续调用只会加剧 <font style="color:rgb(221, 17, 68);">B 服务</font>
的负担,严重的时候,有可能导致 <font style="color:rgb(221, 17, 68);">B 服务</font>
崩溃,甚至出现 <font style="color:rgb(221, 17, 68);">B 服务</font>
重启后立马被打崩的情况。因此,最好的做法是,在一段时间内先不要再频繁调用 <font style="color:rgb(221, 17, 68);">B 服务</font>
。
为了实现这个保护效果,我们可以在 A 和 B 之间加一个熔断器。当 <font style="color:rgb(221, 17, 68);">B 服务</font>
频繁失败时,熔断器可以防止 <font style="color:rgb(221, 17, 68);">A</font>
继续频繁调用 <font style="color:rgb(221, 17, 68);">B 服务</font>
,相当于阻断服务间的请求,并且还能在 <font style="color:rgb(221, 17, 68);">B 服务</font>
恢复正常之后,恢复 <font style="color:rgb(221, 17, 68);">A</font>
对 <font style="color:rgb(221, 17, 68);">B</font>
的调用。
当服务调用失败的次数超过某个阈值时,熔断器会自动“打开”(Open),阻止进一步的服务调用,防止不断报错重试导致压垮被调用服务。
然后在在一段时间之后,熔断器开始尝试允许少量的请求通过,以检查服务是否已经恢复,也就是所谓的“半打开”(HalfOpen)。
如果这些请求成功,熔断器会“关闭”(Close),系统恢复正常的服务调用;但如果调用还是失败,那熔断器会继续再次回到“打开”(Open)状态。
上面提到的三个状态<font style="color:rgb(221, 17, 68);">Open</font>
,<font style="color:rgb(221, 17, 68);">HalfOpen</font>
和<font style="color:rgb(221, 17, 68);">Close</font>
是服务熔断中非常重要的三个状态。
• Closed(关闭):这是熔断器的初始状态。在这种状态下,可以进行服务间调用,熔断器会跟踪服务调用的成功和失败情况。如果失败调用次数,到了某个配置的阈值,熔断器就会切换到 Open(打开)状态。
熔断器关闭
• HalfOpen(半开):保持 Open 状态一段时间后,熔断器会尝试进入 HalfOpen 状态。这个状态下,熔断器会尝试放几个请求通过,看下被调用服务是否已经恢复。如果这些请求成功,熔断器就会回到 Closed 状态;如果失败,那它会退回到 Open 状态。
熔断器半打开
• Open(打开):当熔断器检测到服务调用连续失败时,它会切换到 Open 状态。在这种状态下,熔断器会阻止所有对服务的调用,直到超时时间过后,或者在 HalfOpen 状态下的探测请求成功。
熔断器打开
它们的状态流转关系就像下图这样。
在业务逻辑中加入熔断器其实是不够优雅的,**所以在该 RPC 框架中,在动态代理中加入熔断逻辑。**熔断机制主要是保护调用端,调用端在发出请求的时候会先经过熔断器。因为在RPC 调用的流程中,动态代理是RPC 调用的第一个关口。在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。
我们自己手写的断路器代码如下,我们并没有添加半打开的状态
package com.ydlclass.protection;import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;/*** 熔断器的实现*/
public class CircuitBreaker {// 理论上:标准的断路器应该有三种状态 open close half_open,我们为了简单只选取两种private volatile boolean isOpen = false;// 需要搜集指标 异常的数量 比例// 总的请求数private AtomicInteger requestCount = new AtomicInteger(0);// 异常的请求数private AtomicInteger errorRequest = new AtomicInteger(0);// 异常的阈值private int maxErrorRequest;private float maxErrorRate;public CircuitBreaker(int maxErrorRequest, float maxErrorRate) {this.maxErrorRequest = maxErrorRequest;this.maxErrorRate = maxErrorRate;}// 断路器的核心方法,判断是否开启public boolean isBreak(){// 优先返回,如果已经打开了,就直接返回trueif(isOpen){return true;}// 需要判断数据指标,是否满足当前的阈值if( errorRequest.get() > maxErrorRequest ){this.isOpen = true;return true;}if( errorRequest.get() > 0 && requestCount.get() > 0 &&errorRequest.get()/(float)requestCount.get() > maxErrorRate) {this.isOpen = true;return true;}return false;}// 每次发生请求,获取发生异常应该进行记录public void recordRequest(){this.requestCount.getAndIncrement();}public void recordErrorRequest(){this.errorRequest.getAndIncrement();}/*** 重置熔断器*/public void reset(){this.isOpen = false;this.requestCount.set(0);this.errorRequest.set(0);}public static void main(String[] args) {CircuitBreaker circuitBreaker = new CircuitBreaker(3,1.1F);new Thread(() ->{for (int i = 0; i < 1000; i++) {try {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}circuitBreaker.recordRequest();int num = new Random().nextInt(100);if(num > 70){circuitBreaker.recordErrorRequest();}boolean aBreak = circuitBreaker.isBreak();String result = aBreak ? "断路器阻塞了请求":"断路器放行了请求";System.out.println(result);}}).start();new Thread(() -> {for (;;) {try {Thread.sleep(2000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("-----------------------------------------");circuitBreaker.reset();}}).start();try {Thread.sleep(1000000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
说说常见的限流算法?
固定窗口
实现原理:在指定周期内累加访问次数,当访问次数达到设定的阈值时,触发限流策略,当进入下一个时间周期时进行访问次数的清零。如图所示,我们要求3秒内的请求不要超过150次:
优点:实现简单,容易理解
缺点:
1.限流不够平滑。例如:限流是每秒3个,在第一毫秒发送了3个请求,达到限流,窗口剩余时间的请求都将会被拒绝,体验不好。
2.无法处理窗口边界问题。因为是在某个时间窗口内进行流量控制,所以可能会出现窗口边界效应,即在时间窗口的边界处可能会有大量的请求被允许通过,从而导致突发流量。即:如果第2到3秒内产生了150次请求,而第3到4秒内产生了150次请求,那么其实在第2秒到第4秒这两秒内,就已经发生了300次请求了,远远大于我们要求的3秒内的请求不要超过150次这个限制,如下图所示:
滑动窗口
实现原理:滑动窗口将一个窗口分为若干个等份的小窗口,每次仅滑动一小块的时间。每个小窗口对应不同的时间点,拥有独立的计数器,当请求的时间点大于当前窗口的最大时间点时,则将窗口向前平移一个小窗口(将第一个小窗口的数据舍弃,第二个小窗口变成第一个小窗口,当前请求放在最后一个小窗口),整个窗口的所有请求数相加不能大于阈值。其中,Sentinel就是采用滑动窗口算法来实现限流的。如图所示:
核心步骤:
1.把3秒钟划分为3个小窗,每个小窗限制请求不能超过50秒。
2.比如我们设置,3秒内不能超过150个请求,那么这个窗口就可以容纳3个小窗,并且随着时间推移,往前滑动。每次请求过来后,都要统计滑动窗口内所有小窗的请求总量。
优点:解决了固定窗口算法的窗口边界问题,避免突发流量压垮服务器。
缺点:还是存在限流不够平滑的问题。例如:限流是每秒3个,在第一毫秒发送了3个请求,达到限流,剩余窗口时间的请求都将会被拒绝,体验不好。
漏桶算法
核心步骤:
a.一个固定容量的漏桶,按照固定速率出水(处理请求);
b.当流入水(请求数量)的速度过大会直接溢出(请求数量超过限制则直接拒绝)。
c.桶里的水(请求)不够则无法出水(桶内没有请求则不处理)。
优点:
1.平滑流量。由于漏桶算法以固定的速率处理请求,可以有效地平滑和整形流量,避免流量的突发和波动(类似于消息队列的削峰填谷的作用)。
2.防止过载。当流入的请求超过桶的容量时,可以直接丢弃请求,防止系统过载。
缺点:
1.无法处理突发流量:由于漏桶的出口速度是固定的,无法处理突发流量。例如,即使在流量较小的时候,也无法以更快的速度处理请求。
2.可能会丢失数据:如果入口流量过大,超过了桶的容量,那么就需要丢弃部分请求。在一些不能接受丢失请求的场景中,这可能是一个问题。
3.不适合速率变化大的场景:如果速率变化大,或者需要动态调整速率,那么漏桶算法就无法满足需求。
4.资源利用率:不管当前系统的负载压力如何,所有请求都得进行排队,即使此时服务器的负载处于相对空闲的状态,这样会造成系统资源的浪费。
由于漏桶的缺陷比较明显,所以在实际业务场景中,使用的比较少。
令牌算法
令牌桶算法是基于漏桶算法的一种改进,主要在于令牌桶算法能够在限制服务调用的平均速率的同时,还能够允许一定程度内的突发调用。
实现原理:
1.系统以固定的速率向桶中添加令牌;
2.当有请求到来时,会尝试从桶中移除一个令牌,如果桶中有足够的令牌,则请求可以被处理或数据包可以被发送;
3.如果桶中没有令牌,那么请求将被拒绝;
4.桶中的令牌数不能超过桶的容量,如果新生成的令牌超过了桶的容量,那么新的令牌会被丢弃。
5.令牌桶算法的一个重要特性是,它能够应对突发流量。当桶中有足够的令牌时,可以一次性处理多个请求,这对于需要处理突发流量的应用场景非常有用。但是又不会无限制的增加处理速率导致压垮服务器,因为桶内令牌数量是有限制的。
如图所示:
优点:
1.可以处理突发流量:令牌桶算法可以处理突发流量。当桶满时,能够以最大速度处理请求。这对于需要处理突发流量的应用场景非常有用。
2.限制平均速率:在长期运行中,数据的传输率会被限制在预定义的平均速率(即生成令牌的速率)。
3.灵活性:与漏桶算法相比,令牌桶算法提供了更大的灵活性。例如,可以动态地调整生成令牌的速率。
缺点:
1.可能导致过载:如果令牌产生的速度过快,可能会导致大量的突发流量,这可能会使网络或服务过载。
2.需要存储空间:令牌桶需要一定的存储空间来保存令牌,可能会导致内存资源的浪费。
3.实现稍复杂:相比于计数器算法,令牌桶算法的实现稍微复杂一些。
11. 如何隔离流量?(业务分组)
假设你是一个服务提供方应用的负责人,在早期业务量不大的情况下,应用之间的调用关系并不会复杂,请求量也不会很大,我们的应用有足够的能力扛住日常的所有流量。我们并不需要花太多的时间去治理调用请求过来的流量,我们通常会选择最简单的方法,就是把服务实例统一管理,把所有的请求都用一个共享的“大池子”来处理。
后期业务发展了,调用你接口的调用方就会越来越多,流量也会渐渐多起来。可能某一天,一个“爆炸式惊喜”就来了。其中一个调用方的流量突然激增,让你整个集群瞬间处于高负载运行,进而影响到其它调用方,导致整体的业务可用性降低。
怎么样杜绝这样的事情发生呢?最好的办法就是隔离流量,将多个yRPC服务进行分组,一个调用方只能访问一个分组的服务,就是一个调用方流量爆炸也只会影响一个分组的服务,整体还是可用的。
原本服务调用方是通过接口名去注册中心找到所有的服务节点来完成服务发现的,那换到这里的话,这样做其实并不合适,因为这样调用方会拿到所有的服务节点。因此为了实现分组隔离逻辑,我们需要重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数。
通过改造后的分组逻辑,我们可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用。那怎么分组好呢,有没有统一的标准?
非核心应用不要跟核心应用分在同一个组,核心应用之间应该做好隔离,一个重要的原则就是保障核心应用不受影响。
通过分组的方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。对服务提供方来说,这种方式是我们日常治理服务过程中一个高频使用的手段,那通过这种分组进行流量隔离,对调用方应用会不会有影响呢?
回到我们的项目中,我们的实现方案就是,给服务加上一个@YRPCApi(group = "primary")
通过group属性对服务进行编组:
以后在服务注册和发现时,将组名作为一个参数携带上即可。
12. 为什么要用RPC?
一、为什么要用 RPC 框架?
RPC能让远程服务调用像本地方法调用一样简单,而且RPC封装了底层的网络细节(如序列化、传输协议),开发者就不用手动处理参数拼接和解析,大幅提升开发效率;如果直接用HTTP。。。此外,RPC框架(如gRPC、Dubbo)内置服务发现、负载均衡、熔断等治理能力,保障分布式系统的稳定性。
二、为什么不直接用 HTTP 或 TCP?
RPC(Remote Procedure Call)叫做远程过程调用。它本身并不是一个具体的协议,而是一种调用方式。而且RPC协议可以基于TCP协议,也可以基于HTTP,比如那个SpringCloud的OpenFeign基于Http/1.1,而gRPC基于升级后的HTTP/2。如果直接使用主流的HTTP/1.1,它的头部有大量的冗余内容,序列化效率低,而RPC定制化的程度比较高,可以自定义消息结构,也可以采用不同的序列化协议,性能更好一些。如果直接使用TCP协议,又太底层了,使用难度比较大,需要处理粘包、序列化等问题。同时RPC框架一般会有一些服务治理功能,比如服务发现、负载均衡、熔断等。尽管HTTP/2.0 在 HTTP/1.1 的基础上做了优化,性能可能比很多 RPC 协议都要好,但由于是这几年才出来的,所以也不太可能取代掉 RPC。
13. 短连接和长连接
一、短连接(Short-Lived Connection)
定义
每次通信时建立连接 → 传输数据 → 关闭连接,完成一次完整流程后立即断开。
核心特点
- 按需建立连接
- 每次请求前需通过 TCP 三次握手 建立连接,传输完成后通过 四次挥手 断开连接。
- 例如:HTTP/1.1 默认使用短连接(可通过
Connection: keep-alive
开启长连接优化)。
- 资源消耗大
- 频繁握手/挥手带来额外延迟和 CPU 开销。
- 高并发场景下,大量连接频繁创建/销毁可能导致端口耗尽或服务器负载过高。
- 适用场景
- 低频请求:如用户浏览网页(每个页面加载后连接关闭)。
- 简单交互:无需保持状态的短期通信(如一次性 API 调用)。
缺点
- 性能差:频繁建立连接增加延迟(尤其在高延迟网络中)。
- 资源浪费:TCP 慢启动阶段(Slow Start)未充分利用带宽。
二、长连接(Long-Lived Connection)
定义
建立连接后保持打开状态,供多次通信复用,直到超时或主动关闭。
核心特点
- 连接复用
- 一次 TCP 连接可传输多个请求/响应(如 HTTP/2 多路复用、gRPC 的 Streaming)。
- 例如:WebSocket、数据库连接池、RPC 框架(如 gRPC 基于 HTTP/2 长连接)。
- 资源高效
- 避免频繁握手/挥手,减少延迟和 CPU 开销。
- 充分复用 TCP 连接,利用慢启动后的高带宽。
- 适用场景
- 高频交互:如微服务间通信、实时消息推送(WebSocket)。
- 高并发场景:连接池管理减少资源消耗(如数据库连接池)。
缺点
- 连接管理复杂:需处理心跳保活、空闲超时、异常断连等问题。
- 服务器资源占用:大量空闲长连接可能占用内存和文件描述符。
三、对比总结
特性 | 短连接 | 长连接 |
---|---|---|
连接生命周期 | 单次请求后立即关闭 | 保持打开,供多次请求复用 |
性能开销 | 高(频繁握手/挥手) | 低(连接复用) |
资源占用 | 临时占用,释放快 | 长期占用,需管理空闲连接 |
适用场景 | 低频、简单交互(如 HTTP/1.1) | 高频、实时交互(如 RPC、WebSocket) |
复杂度 | 简单 | 高(需心跳、超时、容错机制) |
四、技术细节补充
1. TCP 连接建立与关闭的成本
- 三次握手:客户端与服务器交换 3 个报文(SYN → SYN-ACK → ACK),耗时约 1.5 RTT(Round-Trip Time)。
- 四次挥手:双方各发送 FIN 和 ACK,耗时约 2 RTT。
- 高延迟网络下(如跨国通信),短连接的额外延迟非常明显。
2. 长连接的保活机制
- 心跳包(Heartbeat):定期发送空数据包,检测连接是否存活。
- 超时关闭:设置空闲超时时间(如 60 秒),自动断开无活动连接。
- 例如:TCP 的
Keep-Alive
选项、gRPC 的 HTTP/2 Ping 帧。
3. HTTP/1.1 的优化:Keep-Alive
- 通过
Connection: keep-alive
头部,允许复用 TCP 连接传输多个 HTTP 请求。 - 本质仍是短连接:复用有限(串行请求),性能不如 HTTP/2 多路复用。
五、实际应用中的选择
- 用短连接
- 对外提供 RESTful API,客户端请求频率低。
- 简单的一次性操作(如发送短信验证码)。
- 用长连接
- 微服务间高频调用(如订单服务调用库存服务)。
- 实时通信场景(如在线聊天、股票行情推送)。
- 数据库/缓存中间件(通过连接池管理长连接)。
六、为什么 RPC 框架偏好长连接?
- 性能优先:微服务场景下高频调用需低延迟、高吞吐。
- 连接复用:通过连接池管理长连接,减少握手开销。
- 协议优化:如 gRPC 基于 HTTP/2,支持多路复用和头部压缩。
总结
- 短连接:简单但低效,适合低频、一次性交互。
- 长连接:复杂但高效,适合高频、实时场景。
- 现代分布式系统(如微服务)普遍使用长连接,而 RPC 框架通过封装长连接管理和服务治理能力,进一步降低了开发复杂度。
14. 怎么用的SPI服务发现机制
SPI(Service Provider Interface),是一种基于ClassLoader来发现并加载服务的机制,接口与实现解耦,能在运行时灵活加载不同的实现类。一个SPI由三部分组成:
- Service:是一个公开的接口或抽象类,定义了一个抽象的功能模块。
- Service Provider:Service接口的一个实现类。
- ServiceLoader:SPI机制的核心组件,负责在运行时发现并加载Service Provider。
例如:JDBC中的数据库连接驱动使用SPI机制,只定义了数据库连接接口的规范,而具体实现由各大数据库厂商实现,不同数据库的实现不同,我们常用的mysql的驱动也实现了其接口规范,通过这种方式,JDBC数据库连接可以适配不同的数据库。
SPI机制在各种框架中也有应用,例如:springboot的自动装配中查找spring.factories文件的步骤就是应用了SPI机制;dubbo也对Java的SPI机制进行扩展,实现了自己的SPI机制。
一般来说,位于rt.jar
包中的SPI接口,是由Bootstrap类加载器完成加载的,而classpath
路径下的SPI实现类,则是App
类加载器进行加载的。但往往在SPI接口中,会经常调用实现者的代码,所以一般会需要先去加载自己的实现类,但实现类并不在Bootstrap类加载器的加载范围内,而因为双亲委派机制,父类加载器是不能将类加载请求委派给自己的子类加载器进行加载的,所以此时就出现了这个问题:如何加载SPI接口的实现类?答案是打破双亲委派模型。
线程上下文类加载器就是双亲委派模型的破坏者,可以在执行线程中打破双亲委派机制的加载链关系,从而使得程序可以逆向使用类加载器。
简单来说,Java提供了很多核心接口的定义,这些接口被称为SPI接口,同时为了方便加载第三方的实现类,SPI提供了一种动态的服务发现机制(约定),只要第三方在编写实现类时,在工程内新建一个META-INF/services/
目录并在该目录下创建一个与服务接口名称同名的文件,那么在程序启动的时候,就会根据约定去找到所有符合规范的实现类,然后交给线程上下文类加载器进行加载处理。
但在实际项目中,其实很少使用到 JDK 自带的 SPI 机制,首先它不能按需加载,ServiceLoader 加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费。可以根据自己项目的需求自定义SPI处理器。
- SPI 是扩展点的“注册中心”:负责管理实现类的发现与实例化。
- XML 是扩展点的“调度中心”:通过声明式配置决定运行时行为。
15. RPC服务发现
- 服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的 IP 和接口保存下来。
- 服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的 IP,然后缓存到本地,并用于后续的远程调用。
服务注册的时候只需要服务节点向 ZooKeeper 节点写入注册信息即可,利用 ZooKeeper 的 Watcher 机制完成服务订阅与服务下发功能。
- 服务平台管理端先在 ZooKeeper 中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别用来存储服务提供方的节点信息和服务调用方的节点信息。
- 当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息。
- 当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调用方 watch 该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
- 当服务提供方目录下有节点数据发生变更时,ZooKeeper 就会通知给发起订阅的服务调用方。
16. 如何进行异常重试
发起一次 RPC 调用,去调用远程的一个服务,比如用户的登录操作,我们会先对用户的用户名以及密码进行验证,验证成功之后会获取用户的基本信息。当我们通过远程的用户服务来获取用户基本信息的时候,恰好网络出现了问题,比如网络突然抖了一下,导致我们的请求失败了,而这个请求我们希望它能够尽可能地执行成功,那这时我们要怎么做呢?
我们需要重新发起一次RPC 调用,那我们在代码中该如何处理呢?是在代码逻辑里 catch 一下,失败了就再发起一次调用吗?这样做显然不够优雅吧。这时我们就可以考虑使用 RPC 框架的重试机制。
当调用端发起的请求失败时,yRPC 框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。
q:那如果这个时候发起了重试,业务逻辑是否会被执行呢?会的。
那如果这个服务业务逻辑不是幂等的,比如插入数据操作,那触发重试的话会不会引发问题呢?会的。
综上,我们可以总结出:在使用RPC 框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启RPC 框架的异常重试功能。这一点你要格外注意,这算是一个高频误区了。
为了解决以上的问题提供了以下方案:
1、手动指定可重试的接口,可以通过注解的形式进行标记,有特定注解的接口才能重试。
2、设置重试白名单。
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {// 从接口中获取判断是否需要重试TryTimes tryTimesAnnotation = method.getAnnotation(TryTimes.class);// 默认值0,代表不重试int tryTimes = 0;int intervalTime = 0;if (tryTimesAnnotation != null) {tryTimes = tryTimesAnnotation.tryTimes();intervalTime = tryTimesAnnotation.intervalTime();}while (true) {try {// 执行逻辑break;} catch (Exception e) {// 次数减一,并且等待固定时间,固定时间有一定的问题,重试风暴tryTimes--;try {Thread.sleep(intervalTime);} catch (InterruptedException ex) {log.error("在进行重试时发生异常.", ex);}if (tryTimes < 0) {log.error("对方法【{}】进行远程调用时,重试{}次,依然不可调用",method.getName(), tryTimes, e);break;}log.error("在进行第{}次重试时发生异常.", 3 - tryTimes, e);}}throw new RuntimeException("执行远程方法" + method.getName() + "调用失败。");
}
17.RPC的请求ID怎么生成的?
在当前项目中,我们需要给请求一个唯一标识,用来标识一个请求和响应的关联关系,我们要求请求的id必须唯一,且不能占用过大的空间,可用的方案如下:
1、自增id,单机的自增id不能解决不重复的问题,微服务情况下我们需要一个稳定的发号服务才能保证,但是这样做性能偏低。
2、uuid,将uuid作为唯一标识占用空间太大,128位字符串
3、雪花算法,能满足高并发分布式系统环境下ID不重复;基于时间戳,可以保证基本有序递增;不依赖第三方的库或者中间件;生成效率高。**缺点:**依赖服务器时间,服务器时钟回拨时可能会生成重复 id。
雪花算法的原理:就是生成一个的 64 位的 long 类型的唯一 id,主要分为如下4个部分组成:
- 1位标识:由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0,所以这第一位都是0。
- 41位时间戳 :这个时间戳不是存储当前时间的时间戳,而是存储时间戳的差值(当前时间戳 - 开始时间戳**) 得到的值),这样我们可以存储一个相对更长的时间。
- 10位存储机器码:一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID(实际项目中可以根据实际情况调整)。这样就可以区分不同集群/机房的节点。最多支持1024台机器,当并发量非常高,同时有多个请求在同一毫秒到达,可以根据机器码进行第二次生成。
- 12位存储序列号:一共 12 位,用来表示序列号。 序列号为自增值,代表单台机器每毫秒能够产生的最大 ID 数(2^12 = 4096),也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。当同一毫秒有多个请求访问到了同一台机器后,此时序列号可以为这些请求进行第三次创建。
public class IdGenerator {// 这是单机版本的线程安全的id发号器,一旦变成集群状态,就不行了
// private static LongAdder longAdder = new LongAdder();
//
// public static long getId(){
// longAdder.increment();
// return longAdder.sum();
// }// 雪花算法 -- 世界上没有一个片雪花是一样的// 机房号(数据中心) 5bit 32// 机器号 5bit 32// 时间戳(long 1970-1-1) 原本64位表示的时间,必须减少(64),自由选择一个比较近的时间// 比如我们公司成立的日期// 同一个机房的同一个机器号的同一个时间可以因为并发量很大需要多个id// 序列号 12bit 5+5+42+12 = 64// 起始时间戳public static final long START_STAMP = DateUtil.get("2022-1-1").getTime();//public static final long DATA_CENTER_BIT = 5L;public static final long MACHINE_BIT = 5L;public static final long SEQUENCE_BIT = 12L;// 最大值 Math.pow(2,5) -1public static final long DATA_CENTER_MAX = ~(-1L << DATA_CENTER_BIT);public static final long MACHINE_MAX = ~(-1L << MACHINE_BIT);public static final long SEQUENCE_MAX = ~(-1L << SEQUENCE_BIT);// 时间戳 (42) 机房好 (5) 机器号 (5) 序列号 (12)// 101010101010101010101010101010101010101011 10101 10101 101011010101public static final long TIMESTAMP_LEFT = DATA_CENTER_BIT + MACHINE_BIT + SEQUENCE_BIT;public static final long DATA_CENTER_LEFT = MACHINE_BIT + SEQUENCE_BIT;public static final long MACHINE_LEFT = SEQUENCE_BIT;private long dataCenterId;private long machineId;private LongAdder sequenceId = new LongAdder();// 时钟回拨的问题,我们需要去处理private long lastTimeStamp = -1L;public IdGenerator(long dataCenterId, long machineId) {// 判断传世的参数是否合法if(dataCenterId > DATA_CENTER_MAX || machineId > MACHINE_MAX){throw new IllegalArgumentException("你传入的数据中心编号或机器号不合法.");}this.dataCenterId = dataCenterId;this.machineId = machineId;}public long getId(){// 第一步:处理时间戳的问题long currentTime = System.currentTimeMillis();long timeStamp = currentTime - START_STAMP;// 判断时钟回拨if(timeStamp < lastTimeStamp){throw new RuntimeException("您的服务器进行了时钟回调.");}// sequenceId需要做一些处理,如果是同一个时间节点,必须自增if (timeStamp == lastTimeStamp){sequenceId.increment();if(sequenceId.sum() >= SEQUENCE_MAX){timeStamp = getNextTimeStamp();sequenceId.reset();}} else {sequenceId.reset();}// 执行结束将时间戳赋值给lastTimeStamplastTimeStamp = timeStamp;long sequence = sequenceId.sum();return timeStamp << TIMESTAMP_LEFT | dataCenterId << DATA_CENTER_LEFT| machineId << MACHINE_LEFT | sequence;}private long getNextTimeStamp() {// 获取当前的时间戳long current = System.currentTimeMillis() - START_STAMP;// 如果一样就一直循环,直到下一个时间戳while (current == lastTimeStamp){current = System.currentTimeMillis() - START_STAMP;}return current;}public static void main(String[] args) {IdGenerator idGenerator = new IdGenerator(1,2);for (int i = 0; i < 1000; i++) {new Thread(() -> System.out.println(idGenerator.getId())).start();}}}
怎么处理时间回拨?
// 判断时钟回拨if(timeStamp < lastTimeStamp){throw new RuntimeException("您的服务器进行了时钟回调.");}
- 直接抛出异常:在雪花算法原本的实现中,针对这种问题,算法本身只是返回错误,由应用另行决定处理逻辑,如果是在一个并发不高或者请求量不大的业务系统中,错误等待或者重试的策略问题不大,但是如果是在一个高并发的系统中,这种策略显得过于粗暴
- 延迟等待:将当前线程阻塞3ms,之后再获取时间,看时间是否比上一次请求的时间大,如果大了,说明恢复正常了,则不用管如果还小,说明真出问题了,则抛出异常
18.介绍一下BIO和NIO
- Java IO 是面向流的,而 Java NIO 是面向缓冲的。面向流的 Java IO 每次只能从流中读取一个字节,直到读取到所有的字节,同时接收到的数据没有缓冲的地方。而 Java NIO 首先提供管道 Channel,同时自身提供了一个缓冲区 Buffer。比如读数据时,数据先进缓冲区,然后再用 Channel 从缓冲区读出数据,数据处理后放入任意的介质。这样我们就可以在缓冲中寻找和处理接收到的数据。
- Java IO 是阻塞的,而 Java NIO 是非阻塞的。比如:在 Java IO 中,当一个线程调用 read() 或 write() 时,该线程被阻塞。而 Java NIO 是非阻塞的,当响应来的时候再去处理,没有响应的时候线程不会阻塞,这样就可以充分地利用 CPU 资源了。
- Java NIO 为了实现非阻塞设计了组件 Selector。Selector 的具体工作是负责网络连接、网络读和网络写事件的注册和监测。网络连接、网络读写这三类网络事件事先要注册到 Selector 上,然后由 Selector 监控这三类网络事件的发生。当网络事件发生时线程再处理,如果没发生,那么线程也不会阻塞,线程会去做别的事情。
19.介绍一下直接内存?
在 Java 中对象都是在堆内分配的,通常我们说的JVM 内存也就指的堆内内存,堆内内存完全被JVM 虚拟机所管理,JVM 有自己的垃圾回收算法,对于使用者来说不必关心对象的内存如何回收。堆外内存与堆内内存相对应,对于整个机器内存而言,除堆内内存以外部分即为堆外内存。堆外内存不受 JVM 虚拟机管理,直接由操作系统管理。直接内存申请空间调用 allocateDirect()
方法就可以了。Java 中堆外内存的分配方式有两种:ByteBuffer#allocateDirect和Unsafe#allocateMemory。
首先我们介绍下 Java NIO 包中的 ByteBuffer 类的分配方式,使用方式如下:
// 分配 10M 堆外内存ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);
下图展示了 Buffer 分配在直接内存的功能和作用:
直接内存的好处是:Java 程序可以直接在内存上为 ByteBuffer 申请空间,而不是在 JVM 的堆空间上申请。如果我们在 JVM 申请空间,想保存到磁盘中,数据的拷贝路径是这样的:JVM 空间–>操作系统控制的内存–>磁盘。但如果我们在直接内存给 ByteBuffer 分配空间,那么数据的拷贝路径是:操作系统控制的直接内存–>磁盘。这样就少了一次数据拷贝次数,提高了效率。
当然,直接内存也是有劣势的,比如申请和释放直接内存的开销比 JVM 内存要大。
堆外内存和堆内内存各有利弊,这里我针对其中重要的几点进行说明。
- 堆内内存由 JVM GC 自动回收内存,但是 GC 是需要时间开销成本的,堆外内存由于不受 JVM 管理,所以在一定程度上可以降低 GC 对应用运行时带来的影响。
- 堆外内存需要手动释放,这一点跟 C/C++ 很像,稍有不慎就会造成应用程序内存泄漏,当出现内存泄漏问题时排查起来会相对困难。
- 当进行网络 I/O 操作、文件读写时,堆内内存都需要转换为堆外内存,然后再与底层设备进行交互,所以直接使用堆外内存可以减少一次内存拷贝。
- 堆外内存可以实现进程之间、JVM 多实例之间的数据共享。
由此可以看出,如果你想实现高效的 I/O 操作、缓存常用的对象、降低 JVM GC 压力,堆外内存是一个非常不错的选择。
20.堆外内存怎么回收?
堆内存放的 DirectByteBuffer 对象并不大,仅仅包含堆外内存的地址、大小等属性,同时还会创建对应的 Cleaner 对象,通过 ByteBuffer 分配的堆外内存不需要手动回收,它可以被 JVM 自动回收。当堆内的 DirectByteBuffer 对象被 GC 回收时,Cleaner 就会用于回收对应的堆外内存。
试想这么一种场景,因为 DirectByteBuffer 对象有可能长时间存在于堆内内存,所以它很可能晋升到 JVM 的老年代,所以这时候 DirectByteBuffer 对象的回收需要依赖 Old GC 或者 Full GC 才能触发清理。如果长时间没有 Old GC 或者 Full GC 执行,那么堆外内存即使不再使用,也会一直在占用内存不释放,很容易将机器的物理内存耗尽,这是相当危险的。
那么在使用 DirectByteBuffer 时我们如何避免物理内存被耗尽呢?因为 JVM 并不知道堆外内存是不是已经不足了,所以我们最好通过 JVM 参数 -XX:MaxDirectMemorySize 指定堆外内存的上限大小,当堆外内存的大小超过该阈值时,就会触发一次 Full GC 进行清理回收,如果在 Full GC 之后还是无法满足堆外内存的分配,那么程序将会抛出 OOM 异常。
此外在 ByteBuffer.allocateDirect 分配的过程中,如果没有足够的空间分配堆外内存,在 Bits.reserveMemory 方法中也会主动调用 System.gc() 强制执行 Full GC,但是在生产环境一般都是设置了 -XX:+DisableExplicitGC,System.gc() 是不起作用的,所以依赖 System.gc() 并不是一个好办法。
通过前面堆外内存分配方式的介绍,我们知道 DirectByteBuffer 在初始化时会创建一个 Cleaner 对象,它会负责堆外内存的回收工作。Cleaner 就属于虚引用PhantomReference 的子类,虚引用唯一能做的是在对象被GC时,收到通知,并执行一些后续工作。如以下源码所示,PhantomReference 不能被单独使用,需要与引用队列 ReferenceQueue 联合使用。
public class Cleaner extends java.lang.ref.PhantomReference<java.lang.Object> {private static final java.lang.ref.ReferenceQueue<java.lang.Object> dummyQueue;private static sun.misc.Cleaner first;private sun.misc.Cleaner next;private sun.misc.Cleaner prev;private final java.lang.Runnable thunk;public void clean() {}}
首先我们看下,当初始化堆外内存时,内存中的对象引用情况如下图所示,first 是 Cleaner 类中的静态变量,Cleaner 对象在初始化时会加入 Cleaner 链表中。DirectByteBuffer 对象包含堆外内存的地址、大小以及 Cleaner 对象的引用,ReferenceQueue 用于保存需要回收的 Cleaner 对象。
当发生 GC 时,DirectByteBuffer 对象被回收,内存中的对象引用情况发生了如下变化:
此时 Cleaner 对象不再有任何引用关系,在下一次 GC 时,该 Cleaner 对象将被添加到 ReferenceQueue 中,有一个后台任务会从这个队列中拿出对象并执行 clean() 方法。clean() 方法主要做两件事情:
- 将 Cleaner 对象从 Cleaner 链表中移除;
- 调用 unsafe.freeMemory 方法清理堆外内存。
至此,堆外内存的回收已经介绍完了,下次再排查内存泄漏问题的时候先回顾下这些最基本的知识。
21.讲讲四大引用?
在Java层面,一共有四种引用:强引用、软引用、弱引用、虚引用,从名字也可以发现,这几种引用的生命周期由强到弱。
22.强引用
强引用(Strong Reference)是使用最普遍的引用,99%的代码可能都是强引用,很多人平时接触的也都是强引用相关的代码,比如下面这种:
Object o=new Object()
这种情况是普遍存在的,在写中间件框架代码时,可能才需要其它引用。
如果一个对象,和GC Root有强引用的关系,当内存不足发生GC时,宁可抛出OOM异常,终止程序,也不会回收这些对象。相反,当一个对象,和GC Root没有强引用关系时,可能会被回收(因为可能还有其它引用),如果没有任何引用关系,GC之后,该对象就被回收了。
软引用
如果一个对象只具有软引用(Soft Reference),则内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。这种特别适合用来实现缓存。
Object reference = new MyObject();
System.out.println(reference);
Reference root = new SoftReference(reference);
reference = null; // MyObject对象只有软引用
System.gc();
System.out.println(root.get());输出:
cn.itcast.nio.c10.MyObject@511d50c0
cn.itcast.nio.c10.MyObject@511d50c0
弱引用
弱引用(Weak Reference),相对于软引用,它的生命周期更短,当发生GC时,如果扫描到一个对象只有弱引用,不管当前内存是否足够,都会对它进行回收。
感觉说的有点干,来段代码解解渴。
Object reference = new MyObject();
System.out.println(reference);
Reference root = new WeakReference(reference);
reference = null; // MyObject对象只有弱引用
System.gc();
System.out.println(root.get());
输出:
null
ThreadLocal
scala 代码解读复制代码static class ThreadLocalMap {static class Entry extends WeakReference<ThreadLocal<?>> {Object value;Entry(ThreadLocal<?> k, Object v) {super(k);value = v;}}//......}
ThreadLocal.ThreadLocalMap.Entry 继承了弱引用,key为当前线程实例,和WeakHashMap基本相同。
虚引用
虚引用(Phantom Reference),和之前两种引用的最大不同是:它的get方法一直返回null。
很奇怪,一个返回null的引用有什么用?
虚引用的使用场景很窄,在JDK中,目前只知道在申请堆外内存时有它的身影。 申请堆外内存时,在JVM堆中会创建一个对应的Cleaner对象,这个Cleaner类继承了PhantomReference,当DirectByteBuffer对象被回收时,可以执行对应的Cleaner对象的clean方法,做一些后续工作,这里是释放之前申请的堆外内存。
由于虚引用的get方法无法拿到真实对象,所以当你不想让真实对象被访问时,可以选择使用虚引用,它唯一能做的是在对象被GC时,收到通知,并执行一些后续工作。
实现原理
上述引用中,除了强引用,其它几种都有对应的实现类,都继承了Reference,所有的精华也都在这个类中。
Reference有几个重要的参数,有些和GC密切相关:
- referent: 就是所引用的对象,会被GC特别对待。
- queue:RererenceQueue,看名字也知道它是一个Reference队列,用来保存Reference对象,当新建一个Reference时,可以选择性的传入第二个参数。
- discovered:该对象被JVM使用,表示下一个要被处理的Reference对象(1.8的实现)
- next:当Reference对象被放入RererenceQueue时,使用next变量形成链表结构。
- pending:该对象会被JVM使用,当前被处理的Reference对象。
Reference中有一个重要的线程 Reference Handler,运行优先级极高,启动之后负责轮询pending变量是否有数据,如果pending被JVM设置了一个值,就把它拿出来放到queue中,这里有个例外,就是之前说的堆外内存申请时的Cleaner对象,只会执行它的clean方法,并不会放到queue中。
当Reference对象被放进queue之后,就可以使用一个线程,依次拿出来进行处理。
@Testpublic void test2() throws IOException {final ReferenceQueue queue = new ReferenceQueue();new Thread(new Runnable() {@Overridepublic void run() {while (true) {try {Reference reference = queue.remove();System.out.println(reference + "回收了");} catch (InterruptedException e) {}}}}).start();Object o = new Object();Reference root = new WeakReference(o, queue);System.out.println(root);o = null;System.gc();System.in.read();}java.lang.ref.WeakReference@4c98385c
java.lang.ref.WeakReference@4c98385c回收了
上述代码中,先初始化了一个ReferenceQueue,随后又初始化了一个线程,循环的从queue中捞数据,因为当一个软引用、弱引用或虚引用的对象被GC回收时,这个引用会被放到对应的ReferenceQueue中,这里会被拿出来进行打印,更多的是做一些清理工作。
23.为什么要用Netty?
完美弥补 Java NIO 的缺陷
在 JDK 1.4 投入使用之前,只有 BIO 一种模式。开发过程相对简单。新来一个连接就会创建一个新的线程处理。随着请求并发度的提升,BIO 很快遇到了性能瓶颈。JDK 1.4 以后开始引入了 NIO 技术,支持 select 和 poll;JDK 1.5 支持了 epoll;JDK 1.7 发布了 NIO2,支持 AIO 模型。Java 在网络领域取得了长足的进步。
既然 JDK NIO 性能已经非常优秀,为什么还要选择 Netty?这是因为 Netty 做了 JDK 该做的事,但是做得更加完备。我们一起看下 Netty 相比 JDK NIO 有哪些突出的优势。
- 易用性。 我们使用 JDK NIO 编程需要了解很多复杂的概念,比如 Channels、Selectors、Sockets、Buffers 等,编码复杂程度令人发指。相反,Netty 在 NIO 基础上进行了更高层次的封装,屏蔽了 NIO 的复杂性;Netty 封装了更加人性化的 API,统一的 API(阻塞/非阻塞) 大大降低了开发者的上手难度;与此同时,Netty 提供了很多开箱即用的工具,例如常用的行解码器、长度域解码器等,而这些在 JDK NIO 中都需要你自己实现。
- 稳定性。 Netty 更加可靠稳定,修复和完善了 JDK NIO 较多已知问题,例如臭名昭著的 select 空转导致 CPU 消耗 100%,TCP 断线重连,keep-alive 检测等问题。
- 可扩展性。 Netty 的可扩展性在很多地方都有体现,这里我主要列举其中的两点:一个是可定制化的线程模型,用户可以通过启动的配置参数选择 Reactor 线程模型;另一个是可扩展的事件驱动模型,将框架层和业务层的关注点分离。大部分情况下,开发者只需要关注 ChannelHandler 的业务逻辑实现。
更低的资源消耗
作为网络通信框架,需要处理海量的网络数据,那么必然面临有大量的网络对象需要创建和销毁的问题,对于 JVM GC 并不友好。为了降低 JVM 垃圾回收的压力,Netty 主要采用了两种优化手段:
- 对象池复用技术。 Netty 通过复用对象,避免频繁创建和销毁带来的开销。
- 零拷贝技术。 除了操作系统级别的零拷贝技术外,Netty 提供了更多面向用户态的零拷贝技术,例如 Netty 在 I/O 读写时直接使用 DirectBuffer,从而避免了数据在堆内存和堆外内存之间的拷贝。
因为 Netty 不仅做到了高性能、低延迟以及更低的资源消耗,还完美弥补了 Java NIO 的缺陷
24.介绍一下netty的零拷贝,和操作系统的零拷贝有什么区别
零拷贝:所谓的零拷贝,并不是不需要经过数据拷贝,而是减少内存拷贝的次数
无零拷贝时,数据的发送流程
DMA(Direct Memory Access,直接内存存取)是现代大部分硬盘都支持的特性,DMA 接管了数据读写的工作,不需要 CPU 再参与 I/O 中断的处理,从而减轻了 CPU 的负担。
应用程序把磁盘数据发送到网络的过程中会发生4 次用户态和内核态之间的切换
,同时会有4 次数据拷贝
。过程如下:
- 应用进程向系统申请读磁盘的数据,这时候程序从用户态切换成内核态。
- 系统也就是 linux 系统得知要读数据会通知 DMA 模块要读数据,这时 DMA 从磁盘拉取数据写到系统内存中。
- 系统收到 DMA 拷贝的数据后把数据拷贝到应用内存中,同时把程序从内核态变为用户态。
- 应用内存拿到数据后,会把数据拷贝到系统的 Socket 缓存,然后程序从用户态切换为内核态。
- 系统再次调用 DMA 模块,DMA 模块把 Socket 缓存的数据拷贝到网卡,从而完成数据的发送,最后程序从内核态切换为用户态。
如何提升文件传输的效率?
我们程序的目的是把磁盘数据发送到网络中,所以数据在用户内存和系统内存直接的拷贝根本没有意义,与数据拷贝同时进行的用户态和内核态之间的切换也没有意义。而上述常规方法出现了 4 次用户态和内核态之间的切换,以及 4 次数据拷贝。我们优化的方向无非就是减少用户态和内核态之间的切换次数
,以及减少数据拷贝的次数
。
为什么要在用户态和内核态之间做切换?
因为用户态的进程没有访问磁盘上数据的权限,也没有把数据从网卡发送到网络的权限。只有内核态也就是操作系统才有操作硬件的权限,所以需要系统向用户进程提供相应的接口函数来实现数据的读写。
这里涉及了两个系统接口调用分别是:
- read(file, tmp_buf, len);
- write(socket, tmp_buf, len);
于是,零拷贝技术应运而生,系统为我们上层应用提供的零拷贝方法有下列两种:
- mmap + write
- sendfile
MMAP + write
这个方法主要是用 MMAP 替换了 read。
对应的系统方法为:
- buf = mmap(file,length)
- write(socket,buf,length)
所谓的 MMAP,其实就是系统内存某段空间和用户内存某段空间保持一致,也就是说应用程序能通过访问用户内存访问系统内存。所以,读取数据的时候,不用通过把系统内存的数据拷贝到用户内存中再读取,而是直接从用户内存读出,这样就减少了一次拷贝。
步骤:
- 应用进程通过接口调用系统接口 MMAP,并且进程从用户态切换为内核态。
- 系统收到 MMAP 的调用后用 DMA 把数据从磁盘拷贝到系统内存,这时是第 1 次数据拷贝。由于这段数据在系统内存和应用内存是共享的,数据自然就到了应用内存中,这时程序从内核态切换为用户态。
- 程序从应用内存得到数据后,会调用 write 系统接口,这时第 2 次拷贝开始,具体是把数据拷贝到 Socket 缓存,而且用户态切换为内核态。
- 系统通过 DMA 把数据从 Socket 缓存拷贝到网卡。
- 最后,进程从内核态切换为用户态。
这样做到收益是减少了一次拷贝
,但是用户态和内核态仍然是 4 次切换
。
sendfile
这个系统方法可以实现系统内部不同设备之间的拷贝。具体逻辑我们还是先上图:
使用 sendfile 主要的收益是避免了数据在应用内存和系统内存或 socket 缓存直接的拷贝,同时这样会避免用户态和内核态之间的切换。
基本原理分为下面几步:
- 应用进程调用系统接口 sendfile,进程从用户态切换完内核态。
- 系统接收到 sendfile 指令后,通过 DMA 从磁盘把数据拷贝到系统内存。
- 数据到了系统内存后,CPU 会把数据从系统内存拷贝到 socket 缓存中。
- 通过 DMA 拷贝到网卡中。
- 最后,进程从内核态切换为用户态。
但是,这还不是零拷贝,所谓的零拷贝不会在内存层面去拷贝数据,也就是系统内存拷贝到 socket 缓存,下面给大家介绍一下真正的零拷贝。
真正的零拷贝
真正的零拷贝是基于 sendfile,当网卡支持 SG-DMA 时,系统内存的数据可以直接拷贝到网卡。如果这样实现的话,执行流程就会更简单,如下图所示:
基本原理分为下面几步:
- 应用进程调用系统接口 sendfile,进程从用户态切换完内核态。
- 系统接收到 sendfile 指令后,通过 DMA 从磁盘把数据拷贝到系统内存。
- 数据到了系统内存后,CPU 会把文件描述符和数据长度返回到 socket 缓存中(注意这里没有拷贝数据)。
- 通过 SG-DMA 把数据从系统内存拷贝到网卡中。
- 最后,进程从内核态切换为用户态。
零拷贝在用户态和内核态之间的切换是 2 次,拷贝是 2 次
,大大减少了切换次数和拷贝次数,而且全程没有 CPU 参与数据的拷贝。
Netty中的零拷贝
Netty 中的零拷贝和传统 Linux 的零拷贝不太一样。Netty 中的零拷贝技术除了操作系统级别的功能封装,更多的是面向用户态的数据操作优化,主要体现在以下 5 个方面:
- 堆外内存,避免 JVM 堆内存到堆外内存的数据拷贝。
- CompositeByteBuf 类,可以组合多个 Buffer 对象合并成一个逻辑上的对象,避免通过传统内存拷贝的方式将几个 Buffer 合并成一个大的 Buffer。
- 通过 Unpooled.wrappedBuffer 可以将 byte 数组包装成 ByteBuf 对象,包装过程中不会产生内存拷贝。
- ByteBuf.slice 操作与 Unpooled.wrappedBuffer 相反,slice 操作可以将一个 ByteBuf 对象切分成多个 ByteBuf 对象,切分过程中不会产生内存拷贝,底层共享一个 byte 数组的存储空间。新的 ByteBuf 对象进行数据操作也会对原始 ByteBuf 对象生效。
- Netty 使用 FileRegion 实现文件传输,FileRegion 底层封装了 FileChannel#transferTo() 方法,可以将文件缓冲区的数据直接传输到目标 Channel,避免内核缓冲区和用户态缓冲区之间的数据拷贝,这属于操作系统级别的零拷贝。
堆外内存
如果在 JVM 内部执行 I/O 操作时,必须将数据拷贝到堆外内存,才能执行系统调用。这是所有 VM 语言都会存在的问题。那么为什么操作系统不能直接使用 JVM 堆内存进行 I/O 的读写呢?主要有两点原因:第一,操作系统并不感知 JVM 的堆内存,而且 JVM 的内存布局与操作系统所分配的是不一样的,操作系统并不会按照 JVM 的行为来读写数据。第二,同一个对象的内存地址随着 JVM GC 的执行可能会随时发生变化,例如 JVM GC 的过程中会通过压缩来减少内存碎片,这就涉及对象移动的问题了。
Netty 在进行 I/O 操作时都是使用的堆外内存,可以避免数据从 JVM 堆内存到堆外内存的拷贝。
25.介绍一下Netty的网络通信模型
BIO 的线程设计模式最大的问题是一个连接就要对应一个线程
,造成大量线程资源的浪费。 Reactor 单线程模型解决了线程过多的问题,但是一个线程负责所有事情会造成响应不及时
,进而拖慢网络事件的响应速度。而单 Reactor + 多工作线程通过把非 IO 任务分给工作多线程,让 Reactor 线程只关心网络 IO 事件的处理,但是会造成高并发场景下一个 Reactor 处理大量网络事件会延迟,进而无法及时响应网络连接
。最终,主从 Reactor + 工作线程池模型解决了这个问题,主 Reactor 只负责与客户端建立连接,从 Reactor 负责响应已经建立好的连接上的网络读写事件,这样就不会产生对客户端连接请求的不及时的问题。
NioEventLoop 的事件处理机制采用的是无锁串行化的设计思路。
- BossEventLoopGroup 和 WorkerEventLoopGroup 包含一个或者多个 NioEventLoop。BossEventLoopGroup 负责监听客户端的 Accept 事件,当事件触发时,将事件注册至 WorkerEventLoopGroup 中的一个 NioEventLoop 上。每新建一个 Channel, 只选择一个 NioEventLoop 与其绑定。所以说 Channel 生命周期的所有事件处理都是线程独立的,不同的 NioEventLoop 线程之间不会发生任何交集。
- NioEventLoop 完成数据读取后,会调用绑定的 ChannelPipeline 进行事件传播,ChannelPipeline 也是线程安全的,数据会被传递到 ChannelPipeline 的第一个 ChannelHandler 中。数据处理完成后,将加工完成的数据再传递给下一个 ChannelHandler,整个过程是串行化执行,不会发生线程上下文切换的问题。
26.netty怎么解决JDK epoll 空轮询的 Bug的?
在 JDK 中, Epoll 的实现是存在漏洞的,即使 Selector 轮询的事件列表为空,NIO 线程一样可以被唤醒,导致 CPU 100% 占用。这就是臭名昭著的 JDK epoll 空轮询的 Bug。实际上 Netty 并没有从根源上解决该问题,而是巧妙地规避了这个问题。
long time = System.nanoTime();if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {selectCnt = 1;} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {selector = selectRebuildSelector(selectCnt);selectCnt = 1;break;}
Netty 提供了一种检测机制判断线程是否可能陷入空轮询,具体的实现方式如下:
- 每次执行 Select 操作之前记录当前时间 currentTimeNanos。
- time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos,如果事件轮询的持续时间大于等于 timeoutMillis,那么说明是正常的,否则表明阻塞时间并未达到预期,可能触发了空轮询的 Bug。
- Netty 引入了计数变量 selectCnt。在正常情况下,selectCnt 会重置,否则会对 selectCnt 自增计数。当 selectCnt 达到 SELECTOR_AUTO_REBUILD_THRESHOLD(默认512) 阈值时,会触发重建 Selector 对象。
Netty 采用这种方法巧妙地规避了 JDK Bug。异常的 Selector 中所有的 SelectionKey 会重新注册到新建的 Selector 上,重建完成之后异常的 Selector 就可以废弃了。
27.介绍一下netty的ByteBuf
首先介绍下 JDK NIO 的 ByteBuffer,才能知道 ByteBuffer 有哪些缺陷和痛点。下图展示了 ByteBuffer 的内部结构:
从图中可知,ByteBuffer 包含以下四个基本属性:
- mark:为某个读取过的关键位置做标记,方便回退到该位置;
- position:当前读取的位置;
- limit:buffer 中有效的数据长度大小;
- capacity:初始化时的空间容量。
ByteBuffer存在的缺陷在于:
- ByteBuffer 分配的长度是固定的,无法动态扩缩容,所以很难控制需要分配多大的容量。如果分配太大容量,容易造成内存浪费;如果分配太小,存放太大的数据会抛出 BufferOverflowException 异常。在使用 ByteBuffer 时,为了避免容量不足问题,你必须每次在存放数据的时候对容量大小做校验,如果超出 ByteBuffer 最大容量,那么需要重新开辟一个更大容量的 ByteBuffer,将已有的数据迁移过去。整个过程相对烦琐,对开发者而言是非常不友好的。
- ByteBuffer 只能通过 position 获取当前可操作的位置,因为读写共用的 position 指针,所以需要频繁调用 flip、rewind 方法切换读写状态,向 ByteBuffer 写完数据后要读数据了,那么我们必须调用 flip() 方法来改变 index 到写的起始位置才能读取数据,当读转换为写时也是一样的,需要调用 rewind() 方法。
pasition
是变化的,每次都会记录着下一个要操作的索引下标,当发生模式切换时,操作位会置零,因为模式切换代表新的开始。
而ByteBuf特点如下:
- 容量可以按需动态扩展,类似于 StringBuffer;
- 读写采用了不同的指针,读写模式可以随意切换,不需要调用 flip 方法;
- 通过内置的复合缓冲类型可以实现零拷贝;
- 支持引用计数;
- 支持缓存池。
ByteBuf 包含三个指针:读指针 readerIndex、写指针 writeIndex、最大容量 maxCapacity,根据指针的位置又可以将 ByteBuf 内部结构可以分为四个部分:
第一部分是废弃字节,表示已经丢弃的无效字节数据。
第二部分是可读字节,表示 ByteBuf 中可以被读取的字节内容,可以通过 writeIndex - readerIndex 计算得出。从 ByteBuf 读取 N 个字节,readerIndex 就会自增 N,readerIndex 不会大于 writeIndex,当 readerIndex == writeIndex 时,表示 ByteBuf 已经不可读。
第三部分是可写字节,向 ByteBuf 中写入数据都会存储到可写字节区域。向 ByteBuf 写入 N 字节数据,writeIndex 就会自增 N,当 writeIndex 超过 capacity,表示 ByteBuf 容量不足,需要扩容。
第四部分是可扩容字节,表示 ByteBuf 最多还可以扩容多少字节,当 writeIndex 超过 capacity 时,会触发 ByteBuf 扩容,最多扩容到 maxCapacity 为止,超过 maxCapacity 再写入就会出错。
由此可见,Netty 重新设计的 ByteBuf 有效地区分了可读、可写以及可扩容数据,解决了 ByteBuffer 无法扩容以及读写模式切换烦琐的缺陷。
引用计数
ByteBuf 字节容器会把缓冲分配到 JVM 堆上,也会分配到直接内存上。分配在 JVM 堆上的缓冲可以通过 JVM GC 回收,但是直接内存不受 JVM GC 的管理,如何回收呢?
为了解决这个问题,Netty 模仿了 JVM GC 的引用计数的原理,实现了用引用计数器来标记缓冲是否可达。这样做有两个好处:
- 实现对直接内存上的不再使用的缓冲进行回收,防止内存泄露;
- Netty ByteBuf 有池化模式,为了提升池化的重用效率也需要引用计数器来支持。
ByteBuf 的池化(Pooled ByteBuf)
ByteBuf 的池化就是初始化多个 ByteBuf 对象,并把这些 ByteBuf 对象保存在一个容器中,这个容器就是 ByteBuf 池。当有 ByteBuf 需要的时候就释放一个 ByteBuf 对象出来,使用完了再放回 ByteBuf 池中,这样能够避免 ByteBuf 对象被频繁地创建和销毁,而 ByteBuf 对象的创建和销毁是很消耗系统资源的,ByteBuf 池提升了资源的利用率。
ByteBuf 中引用计数的规则
大体如下:
当 ByteBuf 初始化的时候,它的引用计数器就为 1,当调用方法 retain() 时,引用计数器加 1,当调用方法 release() 的时候,引用计数器减 1。当引用计数器为 0 的时候,这个 ByteBuf 对象就要被回收了。
池化技术
Netty
默认会采用本地内存创建ByteBuf
对象,而本地内存因为不是操作系统分配给Java
程序使用的,所以基于本地内存创建对象时,则需要额外单独向OS
申请,这个过程自然开销较大,在高并发情况下,频繁的创建、销毁ByteBuf
对象,一方面会导致性能降低,同时还有可能造成OOM
的风险(使用完没及时释放,内存未归还给OS
的情况下会出现内存溢出)。而使用池化技术后,一方面能有效避免OOM
问题产生,同时还可以省略等待创建缓冲区的时间。
28.CAP原则
CAP 原则又称 CAP 定理,指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性)这 3 个基本需求,最多只能同时满足其中的 2 个。
- 一致性(Consistency) : 数据在多个副本之间能够保持一致的特性(严格的一致性)
- 可用性(Availability): 指系统提供的服务必须一直处于可用的状态,每次请求都能获取到合理的响应(不保证获取的数据为最新数据)
- 分区容错性(Partition Tolerance) : 分布式系统在遇到任何网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务,除非整个网络环境都发生了故障。
当发生网络分区的时候,如果我们要继续服务,那么强一致性和可用性只能 2 选 1。也就是说当网络分区之后 P 是前提,决定了 P 之后才有 C 和 A 的选择。也就是说分区容错性(Partition tolerance)我们是必须要实现的。
简而言之就是:CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C。
常见的可以作为注册中心的组件有:ZooKeeper、Eureka、Nacos…。
- ZooKeeper 保证的是 CP。 任何时刻对 ZooKeeper 的读请求都能得到一致性的结果,但是, ZooKeeper 不保证每次请求的可用性比如在 Leader 选举过程中或者半数以上的机器不可用的时候服务就是不可用的。
- Eureka 保证的则是 AP。 Eureka 在设计的时候就是优先保证 A (可用性)。在 Eureka 中不存在什么 Leader 节点,每个节点都是一样的、平等的。因此 Eureka 不会像 ZooKeeper 那样出现选举过程中或者半数以上的机器不可用的时候服务就是不可用的情况。 Eureka 保证即使大部分节点挂掉也不会影响正常提供服务,只要有一个节点是可用的就行了。只不过这个节点上的数据可能并不是最新的。
- Nacos 不仅支持 CP 也支持 AP
29.为什么用Zookeeper当注册中心?
Zookeeper提供了“心跳检测”功能,它会定时向各个服务提供者发送一个请求(实际上建立的是一个 socket 长连接),如果长期没有响应,服务中心就认为该服务提供者已经“挂了”,并将其剔除。
消费者订阅服务节点路径(如 /dubbo/service/providers),当节点增减时,ZooKeeper 通过 Watcher 主动推送变更事件,触发消费者本地缓存刷新
但Zookeeper是CP模型,有时不一定保证服务的可用性,作为注册中心又不太合适。
RPC服务注册/发现过程简述如下:
- 服务提供者启动时,会将其服务名称,ip地址注册到配置中心。
- 服务消费者在第一次调用服务时,会通过注册中心找到相应的服务的IP地址列表,并缓存到本地,以供后续使用。当消费者调用服务时,不会再去请求注册中心,而是直接通过负载均衡算法从IP列表中取一个服务提供者的服务器调用服务。
- 当服务提供者的某台服务器宕机或下线时,相应的ip会从服务提供者IP列表中移除。同时,注册中心会将新的服务IP地址列表发送给服务消费者机器,缓存在消费者本机。
- 当某个服务的所有服务器都下线了,那么这个服务也就下线了。
- 同样,当服务提供者的某台服务器上线时,注册中心会将新的服务IP地址列表发送给服务消费者机器,缓存在消费者本机。
- 服务提供方可以根据服务消费者的数量来作为服务下线的依据。
参考
竹子爱熊猫、Java2哥、it楠哥等