[07] 案例&优化
1. 通信框架功能设计
1.1 功能描述
通信框架承载了业务内部各模块之间的消息交互和服务调用,它的主要功能如下:
- 基于 Netty 的 NIO 通信框架,提供高性能的异步通信能力;
- 提供消息的编解码框架,可以实现 POJO 的序列化和反序列化;
- 消息内容的防篡改机制
- 提供基于 IP 地址的白名单接入认证机制;
- 链路的有效性校验机制;
- 链路的断连重连机制。
1.2 通信模型
- 客户端发送应用握手请求消息,携带节点 ID 等有效身份认证信息;
- 服务端对应用握手请求消息进行合法性校验,包括节点 ID 有效性校验、节点重复登录校验和 IP 地址合法性校验,校验通过后,返回登录成功的应用握手应答消息;
- 链路建立成功之后,客户端发送业务消息 | 服务端发送业务消息 | 服务端发送心跳消息 | 客户端发送心跳消息;
【备注】需要指出的是,协议通信双方链路建立成功之后,双方可以进行全双工通信,无论客户端还是服务端,都可以主动发送请求消息给对方,通信方式可以是 TWO WAY 或者 ONE WAY。双方之间的心跳采用 Ping-Pong 机制,当链路处于空闲状态时,客户端主动发送 Ping 消息给服务端,服务端接收到 Ping 消息后发送应答消息 Pong 给客户端,如果客户端连续发送 N 条 Ping 消息都没有接收到服务端返回的 Pong 消息,说明链路已经挂死或者对方处于异常状态,客户端主动关闭连接,间隔周期 T 后发起重连操作,直到重连成功。
1.3 消息定义
消息定义包含两部分:消息头、消息体
在消息的定义上,因为是同步处理模式,不考虑应答消息需要填入请求消息 ID,所以消息头中只有一个消息的 ID。如果要支持异步模式,则请求消息头和应答消息头最好分开设计,应答消息头中除了包括本消息的 ID 外,还应该包括请求消息 ID,以方便请求消息的发送方根据请求消息 ID 做对应的业务处理。
消息体则支持 Java 对象类型的消息内容。
1.4 链路建立
如果 A 节点需要调用 B 节点的服务,但是 A 和 B 之间还没有建立物理链路,则有调用方主动发起连接,此时,调用方为客户端,被调用方为服务端。
考虑到安全,链路建立需要通过基于 IP 地址或者号段的黑白名单安全认证机制,作为样例,本协议使用基于 IP 地址的安全认证,如果有多个 IP,通过逗号进行分割。在实际的商用项目中,安全认证机制会更加严格,例如通过密钥对用户名和密码进行安全认证。
客户端与服务端链路建立成功之后,由客户端发送业务握手请求的认证消息,服务端接收到客户端的握手请求消息之后,如果 IP 校验通过,返回握手成功应答消息给客户端,应用层链路建立成功。握手应答消息中消息体为 byte 类型的结果:0 认证成功;-1 认证失败,服务端关闭连接。
链路建立成功之后,客户端和服务端就可以互相发送业务消息了,在客户端和服务端的消息通信过程中,业务消息体的内容需要通过 MD5 进行摘要防篡改。
1.5 可靠性设计
a. 心跳机制
在凌晨等业务低谷时段,如果发生网络闪断、连接被 HangUp 等问题时,由于没有业务消息,应用程序很难发现。到了白天业务高峰期时,会发生大量的网络通信失败,严重的会导致一段时间进程内无法处理业务消息。为了解决这个问题,在网络空闲时采用心跳机制来检测链路的互通性,一旦发现网络故障,立即关闭链路,主动重连。
当读或者写心跳消息发生 I/O 异常的时候,说明已经中断,此时需要立即关闭连接,如果是客户端,需要重新发起连接。如果是服务端,需要清空缓存的半包信息,等到客户端重连。
检测空闲连接以及超时对于及时释放资源来说是至关重要的。由于这是一项常见的任务,Netty 特地为它提供了几个 ChannelHandler 实现。
- IdleStateHandler:当连接空闲时间太长时,将会触发一个 IdleStateEvent 事件。然后,可以通过在 ChannelInboundHandler 中重写 userEventTriggered() 方法来处理该 IdleStateEvent 事件;
- ReadTimeoutHandler:如果在指定的时间间隔内没有收到任何的入站数据,则抛出一个 ReadTimeoutException 并关闭对应的 Channel。可以通过重写你的 ChannelHandler 中的 exceptionCaught() 方法来检测该 ReadTimeoutException。
b. 重试机制
如果链路中断,等到 INTEVAL 时间后,由客户端发起重连操作,如果重连失败,间隔周期 INTERVAL 后再次发起重连,直到重连成功。
为了保持服务端能够有充足的时间释放句柄资源,在首次断连时客户端需要等待 INTERVAL 时间之后再发起重连,而不是失败后立即重连。
为了保证句柄资源能够及时释放,无论什么场景下重连失败,客户端必须保证自身的资源被及时释放,包括但不限于 SocketChannel、Socket 等。
重连失败后,可以打印异常堆栈信息,方便后续的问题定位。
c. 重复登录保护
当客户端握手成功之后,在链路处于正常状态下,不允许客户端重复登录,以防止客户端在异常状态下反复重连导致句柄资源被耗尽。
服务端接收到客户端的握手请求消息之后,对 IP 地址进行合法性校验,如果校验成功,在缓存的地址表中查看客户端是否已经登录,如果登录,则拒绝重复登录,同时关闭 TCP 链路,并在服务端的日志中打印握手失败的原因。
客户端接收到握手失败的应答消息之后,关闭客户端的 TCP 连接,等待 INTERVAL 时间之后,再次发起 TCP 连接,直到认证成功。
1.6 实现流程
2. 常见问题
2.1 空轮询bug
Netty 是如何解决 JDK 中的 Selector BUG 的?
这是 JDK NIO 的 BUG,它会导致 Selector 空轮询,最终导致 CPU 100%。官方声称在 JDK1.6 版本的 update18 修复了该问题,但是直到 JDK1.7 版本该问题仍旧存在,只不过该 BUG 发生概率降低了一些而已,它并没有被根本解决,甚至 JDK1.8 的 131 版本中依然存在。
(1)JDK 官方认为这是 Linux Kernel 版本的 bug
简单来说,JDK 认为 Linux 的 epoll 告诉我事件来了,但是 JDK 没有拿到任何事件(READ、WRITE、CONNECT、ACCPET),但此时 select() 不再选择阻塞了,而是选择返回了 0,于是就会进入一种无限循环,最终导致 CPU 100%。
(2)该问题的具体原因
https://bugs.java.com/bugdatabase/view_bug.do?bug_id=2147719
This is an issue with poll (and epoll) on Linux. If a file descriptor for a connected socket is polled with a request event mask of 0, and if the connection is abruptly terminated (RST) then the poll wakes up with the POLLHUP (and maybe POLLERR) bit set in the returned event set. The implication of this behaviour is that Selector will wakeup and as the interest set for the SocketChannel is 0 it means there aren't any selected events and the select method returns 0.
在部分 Linux 2.6 的内核中,如果一个 socket 文件描述符注册的事件集合码为 0,然后连接突然被对端中断,那么 poll/epoll 会被 POLLHUP/POLLERR 事件给唤醒,并返回到 eventSet 事件集中去。而 eventSet 事件集合发生了变化,这就可能导致 Selector 会被唤醒。
但是这个时候 Selector 的 select() 返回 numKeys 是 0,所以下面本应该对 key 值进行遍历的事件处理根本执行不了,就又回到最上面的 while(true) 循环,循环往复,不断的轮询,直到 Linux 系统出现 100% 的 CPU 情况,最终导致程序崩溃。
(3)Netty 解决办法
对 Selector 的 select 操作周期进行统计,每完成一次空的 select 操作进行一次计数,若在某个周期内连续发生 N 次空轮询,则表明触发了 epoll 死循环 bug。重建 Selector,判断是否是其他线程发起的重建请求,若不是则将原 SocketChannel 从旧的 Selector 上去除注册,重新注册到新的 Selector 上,并将原来的 Selector 关闭。
具体代码在 NioEventLoop#select() 中:
2.2 单机支持百万长连接
如何让单机下 Netty 支持百万长连接?
a. 操作系统
首先就是要突破操作系统的限制。
在 Linux 平台上,无论编写客户端程序还是服务端程序,在进行高并发 TCP 连接处理时,最高的并发数量都要受到系统对用户单一进程同时可打开文件数量的限制(这是因为系统为每个 TCP 连接都要创建一个 socket 句柄,每个 socket 句柄同时也是一个文件句柄)。
可使用 ulimit
命令查看系统允许当前用户进程打开的句柄数限制:ulimit -n
,默认 1024 个。
这表示当前用户的每个进程最多允许同时打开 1024 个句柄,这 1024 个句柄中还得除去每个进程必然打开的标准输入、标准输出、标准错误、服务器监听 socket、进程间通讯的 unix 域 socket 等文件,那么剩下的可用于客户端 socket 连接的文件数就只有大概 1024-10=1014 个左右。也就是说缺省情况下,基于 Linux 的通讯程序最多允许同时 1014 个 TCP 并发连接。
对于想支持更高数量的 TCP 并发连接的通讯处理程序,就必须修改 Linux 对当前用户的进程同时打开的文件数量。
修改单个进程打开最大文件数限制的最简单的办法就是使用 ulimit
命令:ulimit –n 1000000
。
如果系统回显类似于“Operation not permitted”之类的话,说明上述限制修改失败,实际上是因为在中指定的数值超过了 Linux 系统对该用户打开文件数的软限制或硬限制。因此,就需要修改 Linux 系统对用户的关于打开文件数的软限制和硬限制。
- 软限制(soft limit):是指 Linux 在当前系统能够承受的范围内进一步限制一个进程同时打开的文件数;
- 硬限制(hardlimit):是根据系统硬件资源状况(主要是系统内存)计算出来的系统最多可同时打开的文件数量。
(1)修改 /etc/security/limits.conf 文件,在文件中添加如下行:
# '*'号表示修改所有用户的限制
* soft nofile 1000000
* hard nofile 1000000
soft 和 hard 为两种限制方式,其中 soft 表示警告的限制,hard 表示真正限制,nofile 表示打开的最大文件数。1000000 则指定了想要修改的新的限制值,即最大打开文件数(请注意软限制值要小于或等于硬限制)。
(2)修改 /etc/pam.d/login 文件,在文件中添加如下行:
session required /lib/security/pam_limits.so
这是告诉 Linux 在用户完成系统登录后,应该调用 pam_limits.so 模块来设置系统对该用户可使用的各种资源数量的最大限制(包括用户可打开的最大文件数限制),而 pam_limits.so 模块就会从 /etc/security/limits.conf 文件中读取配置来设置这些限制值。
查看 Linux 系统级的最大打开文件数限制,使用如下命令:cat /proc/sys/fs/file-max
。
这表明这台 Linux 系统最多允许同时打开(即包含所有用户打开文件数总和)12158 个文件,是 Linux 系统级硬限制,所有用户级的打开文件数限制都不应超过这个数值。如果没有特殊需要,不应该修改此限制,除非想为用户级打开文件数限制设置超过此限制的值。
(3)如何修改这个系统最大文件描述符的限制呢?
# 修改 sysctl.conf 文件
vi /etc/sysctl.conf
# 在末尾添加
fs.file_max = 1000000
# 立即生效
sysctl -p
b. Netty 调优
(1)设置合理的线程数
对于线程池的调优,主要集中在用于接收海量设备 TCP 连接、TLS 握手的 Acceptor 线程池(Boss NioEventLoop Group)上,以及用于处理网络数据读写、心跳发送的 IO 工作线程池(Work NioEventLoop Group)上。
对于服务端,通常只需要启动一个监听端口用于端侧设备接入即可,但是如果服务端集群实例比较少,甚至是单机(或者双机冷备)部署,在端侧设备在短时间内大量接入时,需要对服务端的监听方式和线程模型做优化,以满足短时间内(例如 30s)百万级的端侧设备接入的需要。
服务端可以监听多个端口,利用主从 Reactor 线程模型做接入优化,前端通过 SLB 做 4 层门 7 层负载均衡。
主从 Reactor 线程模型特点:服务端用于接收客户端连接的不再是一个单独的 NIO 线程,而是一个独立的 NIO 线程池;Acceptor 接收到客户端 TCP 连接请求并处理后(可能包含接入认证等),将新创建的 SocketChannel 注册到 I/O 线程池(SubReactor 线程池)的某个 IO 线程,由它负责 SocketChannel 的读写和编解码工作。Acceptor 线程池仅用于客户端的登录、握手和安全认证等,一旦链路建立成功,就将链路注册到后端 SubReactor 线程池的 IO 线程,由 IO 线程负责后续的 IO 操作。
对于 IO 工作线程池的优化,可以先采用系统默认值(即 CPU 内核数 ×2)进行性能测试,在性能测试过程中采集 IO 线程的 CPU 占用大小,看是否存在瓶颈, 具体可以观察线程堆栈,如果连续采集几次进行对比,发现线程堆栈都停留在 Selectorlmpl#lockAndDoSelect(),则说明 IO 线程比较空闲,无须对工作线程数做调整。
如果发现 IO 线程的热点停留在读或者写操作或者停留在 ChannelHandler 的执行处,则可以通过适当调大 NioEventLoop 线程的个数来提升网络的读写性能。
(2)心跳优化
针对海量设备接入的服务端,心跳优化策略如下。
- 要能够及时检测失效的连接,并将其剔除,防止无效的连接句柄积压,导致 OOM 等问题;
- 设置合理的心跳周期,防止心跳定时任务积压,造成频繁的老年代 GC(新生代和老年代都有导致 STW 的 GC,不过耗时差异较大),导致应用暂停;
- 使用 Netty 提供的链路空闲检测机制,不要自己创建定时任务线程池,加重系统的负担,以及增加潜在的并发安全问题。
当设备突然掉电、连接被防火墙挡住、长时间 GC 或者通信线程发生非预期异常时,会导致链路不可用且不易被及时发现。特别是如果异常发生在凌晨业务低谷期间,当早晨业务高峰期到来时,由于链路不可用会导致瞬间大批量业务失败或者超时,这将对系统的可靠性产生重大的威胁。
从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。心跳检测机制分为 3 个层面:
- TCP 层的心跳检测,即 TCP 的 Keep-Alive 机制,它的作用域是整个 TCP 协议栈;
- 协议层的心跳检测,主要存在于长连接协议中,例如 MQTT;
- 应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。
心跳检测的目的就是确认当前链路是否可用,对方是否活着并且能够正常接收和发送消息。作为高可靠的 NIO 框架,Netty 也提供了心跳检测机制。
一般的心跳检测策略如下:
- 连续 N 次心跳检测都没有收到对方的 Pong 应答消息或者 Ping 请求消息,则认为链路已经发生逻辑失效,这被称为“心跳超时”;
- 在读取和发送心跳消息的时候如果直接发生了 IO 异常,说明链路已经失效,这被称为“心跳失败”。无论发生心跳超时还是心跳失败,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常。
Netty 提供了 3 种链路空闲检测机制,利用该机制可以轻松地实现心跳检测:
- 读空闲,链路持续时间 T 没有读取到任何消息;
- 写空闲,链路持续时间 T 没有发送任何消息;
- 读写空闲,链路持续时间 T 没有接收或者发送任何消息。
对于百万级的服务器,一般不建议很长的心跳周期和超时时长。
(3)接收和发送缓冲区调优
在一些场景下,端侧设备会周期性地上报数据和发送心跳,单个链路的消息收发量并不大,针对此类场景,可以通过调小 TCP 的接收和发送缓冲区来降低单个 TCP 连接的资源占用率。
当然对于不同的应用场景,收发缓冲区的最优值可能不同,用户需要根据实际场景,结合性能测试数据进行针对性的调优。
(4)合理使用内存池
随着 JVM 虚拟机和 JIT 即时编译技术的发展,对象的分配和回收是一个非常轻量级的工作。但是对于缓冲区 Buffer,情况却稍有不同,特别是堆外直接内存的分配和回收,是一个耗时的操作。
为了尽量重用缓冲区,Netty 提供了〈基于内存池的缓冲区重用机制〉。
在百万级的情况下,需要为每个接入的端侧设备至少分配一个接收和发送 ByteBuf 缓冲区对象,采用传统的非池模式,每次消息读写都需要创建和释放 ByteBuf 对象,如果有 100 万个连接,每秒上报一次数据或者心跳,就会有 100 万次/s 的 ByteBuf 对象申请和释放,即便服务端的内存可以满足要求,GC 的压力也会非常大。
以上问题最有效的解决方法就是使用内存池。
每个 NioEventLoop 线程处理 N 个链路,在线程内部,链路的处理是串行的。假如 A 链路首先被处理,它会创建接收缓冲区等对象,待解码完成,构造的 POJO 对象被封装成任务后投递到后台的线程池中执行,然后接收缓冲区会被释放,每条消息的接收和处理都会重复接收缓冲区的创建和释放。如果使用内存池,则当 A 链路接收到新的数据报时,从 NioEventLoop 的内存池中申请空闲的 ByteBuf,解码后调用 release 将 ByteBuf 释放到内存池中,供后续的 B 链路使用。
Netty 内存池从实现上可以分为两类:〈堆外直接内存〉和〈堆内存〉。由于 ByteBuf 主要用于网络 IO 读写,因此采用堆外直接内存会减少一次从用户堆内存到内核态的字节数组拷贝,所以性能更高。由于 DirectByteBuf 的创建成本比较高,因此如果使用 DirectByteBuf,则需要配合内存池使用,否则性价比可能还不如 Heap Byte。
Netty 默认的 IO 读写操作采用的都是内存池的堆外直接内存模式,如果用户需要额外使用 ByteBuf,建议也采用内存池方式;如果不涉及网络 IO 操作(只是纯粹的内存操作),可以使用堆内存池,这样内存的创建效率会更高一些。
(5)IO 线程和业务线程分离
如果服务端不做复杂的业务逻辑操作,仅是简单的内存操作和消息转发,则可以通过调大 NioEventLoop 工作线程池的方式,直接在 IO 线程中执行业务 ChannelHandler,这样便减少了一次线程上下文切换,性能反而更高。
如果有复杂的业务逻辑操作,则建议 IO 线程和业务线程分离,对于 IO 线程,由于互相之间不存在锁竞争,可以创建一个大的 NioEventLoopGroup 线程组,所有 Channel 都共享同一个线程池。
对于后端的业务线程池,则建议创建多个小的业务线程池,线程池可以与 IO 线程绑定,这样既减少了锁竞争,又提升了后端的处理性能。
(6)针对服务端并发连接数的流控
无论服务端的性能优化到多少,都需要考虑流控功能。当资源成为瓶颈,或者遇到端侧设备的大量接入,需要通过流控对系统做保护。
流控的策略有很多种,比如针对端侧连接数的流控:新增一个 FlowControlChannelHandler,然后添加到 ChannelPipeline 靠前的位置,覆盖 channelActive() 方法,创建 TCP 链路后,执行流控逻辑,如果达到流控阈值,则拒绝该连接,调用 ChannelHandlerContext#close() 方法关闭连接。
c. JVM 优化
当客户端的并发连接数达到数十万或者数百万时,系统一个较小的抖动就会导致很严重的后果,例如服务端的 GC,导致应用暂停(STW)的 GC 持续几秒,就会导致海量的客户端设备掉线或者消息积压,一旦系统恢复,会有海量的设备接入或者海量的数据发送很可能瞬间就把服务端冲垮。
JVM 层面的调优主要涉及 GC 参数优化,GC 参数设置不当会导致频繁 GC,甚至 OOM 异常,对服务端的稳定运行产生重大影响。
(1)确定 GC 优化目标
GC(垃圾收集)有三个主要指标:
- 【吞吐量】是评价 GC 能力的重要指标,在不考虑 GC 引起的停顿时间或内存消耗时,吞吐量是 GC 能支撑应用程序达到的最高性能指标;
- 【延迟】GC 能力的最重要指标之一,是由于 GC 引起的停顿时间,优化目标是缩短延迟时间或完全消除停顿(STW),避免应用程序在运行过程中发生抖动;
- 【内存占用】GC 正常时占用的内存量。
JVM GC 调优的三个基本原则:
- 【Minor GC 回收原则】每次新生代 GC 回收尽可能多的内存,减少应用程序发生 Full GC 的频率;
- 【GC 内存最大化原则】垃圾收集器能够使用的内存越大,垃圾收集效率越高,应用程序运行也越流畅。但是过大的内存一次 Full GC 耗时可能较长。如何能够有效避免 Full GC,就需要做精细化调优;
- 【3 选 2 原则】吞吐量、延迟和内存占用不能兼得,无法同时做到吞吐量和暂停时间都最优,需要根据业务场景做选择。对于大多数应用,吞吐量优先,其次是延迟。当然对于时延敏感型的业务,需要调整次序。
(2)确定服务端内存占用
在优化 GC 之前,需要确定应用程序的内存占用大小,以便为应用程序设置合适的内存,提升 GC 效率。内存占用与活跃数据有关,活跃数据指的是应用程序稳定运行时长时间存活的 Java 对象。
活跃数据的计算方式:通过 GC 日志采集 GC 数据,获取应用程序稳定时老年代占用的 Java 堆大小,以及方法区占用的 Java 堆大小,两者之和就是活跃数据的内存占用大小。
(3)GC 优化过程
- GC 数据的采集和研读;
- 设置合适的 JVM 堆大小;
- 选择合适的垃圾回收器和回收策略。
2.3 LT & ET
epoll 对文件描述符有两种操作模式:LT(Level Trigger,水平触发)模式和 ET(Edge Trigger,边沿触发)模式。
LT 模式是默认的工作模式。当往 epoll 内核事件表中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以高效的 ET 模式来操作该文件描述符。
Level_triggered(水平触发)
对于 LT 模式操作的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用 epoll_wait 时, 还会再次向应用程序通告此事件,直到该事件被处理。
在 LT 模式下,当 epoll 检测到事件就绪的时候,可以不处理或处理一部分,但是可以连续多次调用 epoll_wait 对事件进行处理,简单点来说的话就是如果事件来了,不管来了几个,只要仍然有未处理的事件,epoll 都会通知你。
Edge_triggered(边缘触发)
对于 ET 模式操作的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的 epoll_wait 调用将不再向应用程序通知这一事件。所以 ET 模式在很大程度上降低了同一个 epoll 事件被重复触发的次数,因此效率比 LT 模式高。
在 ET 模式下,当 epoll 检测到事件就绪的时候,会立即进行处理,并且只会处理一次,换句话说就是文件描述符上的事件就绪之后,只有一次处理机会。 简单来说就是如果事件来了,不管来了几个,你若不处理或者没有处理完,除非下一个事件到来,否则 epoll 将不会再通知你。ET 的性能比 LT 性能更高(epoll_wait 返回的次数少了很多)。
select()、poll() 模型都是水平触发模式,信号驱动 IO 是边缘触发模式,epoll() 模型既支持水平触发,也支持边缘触发,默认是水平触发。JDK 中的 select 实现是水平触发,而 Netty 提供的 epoll 的实现中是边缘触发,Nginx 实现采用 ET 模式。
【补充】https://blog.csdn.net/weixin_51609435/article/details/127471556
3. 手写 RPC·说明
RPC 优势:
- RPC 框架一般使用长链接,不必每次通信都要 3 次握手,减少网络开销。
- RPC 框架一般都有注册中心,有丰富的监控管理。
- 发布、下线接口、动态扩展等,对调用方来说是无感知、统一化的操作。
- 协议私密,安全性较高。
- RPC 能做到协议更简单内容更小,效率更高。
- RPC 是面向服务的更高级的抽象,支持服务注册发现、负载均衡、超时重试、熔断降级等高级特性。
3.1 RPC 架构设计
站在一个编写 RPC 框架的人的视角上来考虑,我们要做哪些事情、有哪些痛点、要怎么解决?
RPC 调用时序:
3.2 RPC 实现要点
a. 注册中心
服务注册发现的作用:
在高可用的生产环境中,服务一般都以集群方式提供服务,集群里面的 IP 等重要参数信息可能随时会发生变化,节点也可能会动态扩缩容,客户端需要能够及时感知服务端的变化,获取集群最新服务节点的连接信息,而这些变化要求是要对调用方应用无感知的。
主流服务注册工具:
- Zookeeper
- Consul
- Nacos
- ...
Dubbo 服务在 ZK 中注册的结构:
b. 代理技术
为什么要用代理? RPC 的调用对用户来讲是透明的,内部核心技术采用的就是代理技术,RPC 会自动给接口生成一个代理实现,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理实现。在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样就可以在生成的代理类里面,加入其他调用处理逻辑。
(1)JDK 动态代理
在运行期动态的创建代理类,它是通过接口生成代理类的,与静态代理相比更加灵活,但是也有一定的限制,第一是代理对象必须实现一个接口,否则会报异常。第二是有性能问题,因为是通过反射来实现调用的,所以比正常的直接调用来得慢,并且通过生成类文件也会多消耗部分方法区空间,可能引起 Full GC。
(2)ASM
ASM 是一个 Java 字节码操控框架。它能够以二进制形式修改已有类或者动态生成类。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为(也就是生成的代码可以覆盖原来的类也可以是原始类的子类)。不过 ASM 在创建 class 字节码的过程中,操纵的是底层 JVM 的汇编指令级别,这要求 ASM 使用者要对 class 组织结构和 JVM 汇编指令有一定的了解。
(3)CGLIB
CGLIB(Code Generation Library)是一个基于 ASM 的字节码生成库。其原理是动态生成一个要代理类的子类,子类重写要代理的类的所有不是 final 的方法,在子类中采用方法拦截的技术拦截所有父类方法的调用,顺势织入横切逻辑。它比使用 Java 反射的 JDK 动态代理要快。
(4)Byte Buddy
Byte Buddy 本身也是基于 ASM API 实现的,是一个较高层级的抽象的字节码操作工具,通过使用 Byte Buddy,任何熟悉 Java 编程语言的人都有望非常容易地进行字节码操作。
(5)Javassist
Javassist 使操作 Java 字节码变得简单,一个可以用于编辑 Java 字节码的类库,提供了两种级别的 API:源码级别和字节码级别。
如果用户使用源码级 API,他们可以在不需要过多了解 Java 字节码规范的前提下使用它提供的基于 Java 语言的 API 来编辑字节码文件。如果使用字节码级 API 则允许用户直接编辑字节码文件。Javassist 在复杂的字节码级操作上提供了更高级别的抽象层。另外 Javassist 使用了反射机制,这使得它在运行时比 ASM 慢。
c. 序列化技术
序列化的作用:
在网络传输中,数据必须采用二进制形式, 所以在 RPC 调用过程中, 需要采用序列化技术,对入参和出参进行序列化与反序列化。
常见序列化技术框架:
(1)Java
- Java 语言本身提供,使用比较方便和简单
- 不支持跨语言处理,性能相对不是很好,序列化以后产生的数据相对较大。
(2)Json
可读性好,方便阅读和调试,多语言支持,序列化以后的字节码文件相对较大,效率相对不高,但对比 XML 序列化后的字节流更小,在企业运用普遍,特别是对前端和三方提供 API。
(3)Hessian
- Hessian 是一个动态类型,二进制序列化,并且支持跨语言特性的序列化框架。
- Hessian 性能上要比 JDK、JSON 序列化高效很多,并且生成的字节数也更小。有非常好的兼容性和稳定性,所以 Hessian 更加适合作为 RPC 框架远程通信的序列化协议。
(4)Protobuf
- Google 推出的开源序列库,它是一种轻便、高效的结构化数据存储格式,多语言支持。
- 速度快,压缩比高,体积小,序列化后体积相比 JSON、Hessian 小很多。
- 消息格式的扩展、升级和兼容性都不错,可以做到向后兼容。
d. 网络通信
一、通信协议
二、系统 IO
RPC 的调用过程中涉及到网络 IO 的操作,一般来说网络 IO 往往会成为系统的瓶颈所在,而不管上层应用如何使用,底层都是基于操作系统的 IO 模型。
三、线程模型
异步如何实现?
常用的方式就是 Future 方式,它是返回 Future 对象,通过 GET 方式获取结果;或者采用入参为 Callback 对象的回调方式,处理结果。
e. 超时重试
RPC 客户端定时监测超时 RPC 请求:
有很多无意义的遍历操作开销,浪费 CPU。
【时间轮算法】在时钟轮机制中,有「时间槽」和「时钟轮」的概念:时间槽就相当于时钟的刻度,而时钟轮就相当于指针跳动的一个周期,我们可以将每个任务放到对应的时间槽位上。
f. 路由/负载
RPC Server 为了高可用,可用选择做集群,因此在 RPC Client 端调用时要使用相应的均衡策略,这属于「客户端负载均衡」。
g. 熔断/限流
熔断作用:
熔断器如同电力过载保护器。它可以实现快速失败,如果它在一段时间内侦测到许多类似的错误,会强迫其以后的多个调用快速失败,不再访问远程服务器,从而防止应用程序不断地尝试执行可能会失败的操作,使得应用程序继续执行而不用等待修正错误,或者浪费 CPU 时间去等到长时间的超时产生。熔断器也可以使应用程序能够诊断错误是否已经修正,如果已经修正,应用程序会再次尝试恢复调用操作。
限流作用:
实际生产环境中,每个服务节点都可能由于访问量过大而引起一系列问题,就需要业务提供方能够进行自我保护,从而保证在高访问量、高并发的场景下,系统依然能够稳定,高效运行。限流器的作用是用来限制其请求的速率,保护后台响应服务,以免服务过载导致服务不可用现象出现。
4. 手写 RPC·实现
4.1 初始工程导入
a. 项目结构
b. 整体架构
c. 业务代码
(1)rpc-demo/api
只有一个 RPC 接口的声明
package com.itheima.shop.order;
public interface OrderService {
String getOrder(String userId, String orderNo);
}
(2)rpc-demo/service-provider
服务下放置了 api 接口的具体实现:
package com.itheima.shop.order.service;
@HrpcService(interfaceClass = OrderService.class)
public class OrderServiceImpl implements OrderService {
@Autowired
private RpcServerConfiguration serverConfiguration;
@Override
public String getOrder(String userId, String orderNo) {
return String.format("[serverPort=%d, rpcPort=%d] The RPC Call succeeded, orderNo is %s, userId is %s",
serverConfiguration.getServerPort(), serverConfiguration.getRpcPort(), orderNo, userId);
}
}
关于注解 @HrpcService 的功能后续会说。
服务启动类:
@SpringBootApplication(scanBasePackages = {"com.itheima.shop", "com.itheima.rpc"})
public class ShopServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ShopServiceApplication.class, args);
}
}
shop 包放的业务代码,rpc 包放的远程调用的功能代码。
服务 application-yml:
server:
port: 8080
logging:
config: classpath:logback-spring.xml
spring:
application:
name: rpc-client
rpc:
client:
zk:
root: /rpc
addr: 192.168.6.160:2181
switch: true
timeout: 10000
api:
package: com.itcast.rpc.api
cluster:
strategy: random
RpcServerConfiguration 类绑定 rpc 相关配置。
(3)rpc-demo/service-consumer
服务引用了远程接口 OrderService:
@RestController
@RequestMapping("/order")
public class OrderController {
@HrpcRemote
private OrderService orderService;
@GetMapping
public String getOrder(String userId, String orderNo) {
return orderService.getOrder(userId, orderNo);
}
}
关于注解 @HrpcRemote 的功能后续会说。
服务启动类:
@SpringBootApplication(scanBasePackages = {"com.itheima.shop", "com.itheima.rpc"})
public class ShopApplicaiton {
public static void main(String[] args) {
SpringApplication.run(ShopApplicaiton.class, args);
}
}
服务 application-yml:
logging:
config: classpath:logback-spring.xml
server:
port: 8082
spring:
application:
name: rpc-server
rpc:
server:
zk:
# 根节点
root: /rpc
# ZK服务地址
addr: 192.168.6.160:2181
# 链接超时时间
timeout: 10000
# RPC通信端口(Netty端口)
network:
port: 28889
4.2 RPC 服务端实现
a. 引导类
在 hrpc-server 模块中编写一个 Bootstrap 类,引导整个 RPC 服务启动。
package com.itheima.rpc.server.boot;
@Configuration
public class RpcServerBootstrap {
@Autowired
RpcServerRunner rpcServerRunner;
@PostConstruct
public void startServer() {
rpcServerRunner.run();
}
}
b. 启动器
在 hrpc-server 模块中编写 RPC 服务端启动器,负责完成服务信息注册和启动服务接收请求。
package com.itheima.rpc.server.boot;
@Component
public class RpcServerRunner {
@Autowired
RpcRegistry rpcRegistry;
@Autowired
RpcServer rpcServer;
/**
* 1. 服务信息注册
* - 扫描业务代码中要注册的接口(@HrpcService),将接口等信息写到注册中心
* 2. 基于Netty写一个服务端
* - 核心在于分析服务端要提供哪些 In/OutHandler?
* - 一次/二次解码器、编码器
* - 业务处理器
*/
public void run() {
// 服务注册
rpcRegistry.serviceRegistry();
// 启动服务,监听端口,等待接收请求
rpcServer.start();
}
}
c. 接口注册
在 hrpc-server 模块中编写 RpcRegistry 接口以及其 ZK 方式的实现。
package com.itheima.rpc.server.registry;
public interface RpcRegistry {
void serviceRegistry();
}
RpcRegistry 基于 ZK 的实现:
package com.itheima.rpc.server.registry.zk;
@Slf4j
@Component
public class ZkRegistry implements RpcRegistry {
@Autowired
ServerZKit zKit;
@Autowired
RpcServerConfiguration rpcServerConfiguration;
/**
* 虽然用不到,但这个必须留着这,为了让 SpringBeanFactory 先实例化,防止 'SpringBeanFactory#context is null'。
*/
@Autowired
SpringBeanFactory springBeanFactory;
/**
* 基于Zk完成服务注册
* 扫描业务代码中要注册的接口,将接口等信息写到注册中心。
*
* @see @HprcService
*/
@Override
public void serviceRegistry() {
// 创建根结点
zKit.createRootNode();
// 创建服务节点
Map<String, Object> serviceList = SpringBeanFactory.getBeanListByAnnotationClass(HrpcService.class);
if (!CollectionUtils.isEmpty(serviceList)) {
for (Object bean : serviceList.values()) {
HrpcService annotation = bean.getClass().getAnnotation(HrpcService.class);
String regServiceName = annotation.interfaceClass().getName();
zKit.createPersistentNode(regServiceName);
// 创建临时子节点,包含服务提供者IP端口等信息
String childNode = String.format("%s/%s:%s", regServiceName, IpUtil.getRealIp(), rpcServerConfiguration.getServerPort());
zKit.createNode(childNode);
log.info("服务注册成功:serviceName={}, childNode={}", regServiceName, childNode);
}
}
}
}
补充 ServerZKit:
package com.itheima.rpc.server.registry.zk;
@Component
public class ServerZKit {
@Autowired
private ZkClient zkClient;
@Autowired
private RpcServerConfiguration rpcServerConfiguration;
/**
* 根节点创建
*/
public void createRootNode() {
boolean exists = zkClient.exists(rpcServerConfiguration.getZkRoot());
if (!exists) {
zkClient.createPersistent(rpcServerConfiguration.getZkRoot());
}
}
/**
* 创建其他节点
*/
public void createPersistentNode(String path) {
String pathName = rpcServerConfiguration.getZkRoot() + "/" + path;
boolean exists = zkClient.exists(pathName);
if (!exists) {
zkClient.createPersistent(pathName);
}
}
/**
* 创建节点
*/
public void createNode(String path) {
String pathName = rpcServerConfiguration.getZkRoot() + "/" + path;
boolean exists = zkClient.exists(pathName);
if (!exists) {
zkClient.createEphemeral(pathName);
}
}
}
service-provider 服务启动时,启动 RPC 服务,添加 rpc 相关包扫描:
@SpringBootApplication(scanBasePackages = {"com.itheima.shop", "com.itheima.rpc"})
public class ShopServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ShopServiceApplication.class, args);
}
}
启动测试,查看服务是否能正常注册。
d. 启动服务
启动服务,监听端口,等待接收请求。
在 hrpc-server 模块中先声明 RpcServer 接口,用于启动 rpc 服务。
package com.itheima.rpc.server.boot;
public interface RpcServer {
void start();
}
编写基于 Netty 的 RpcServer 实现:
package com.itheima.rpc.server.boot.netty;
@Slf4j
@Component
public class NettyServer implements RpcServer {
@Autowired
RpcServerConfiguration rpcServerConfiguration;
@Override
public void start() {
EventLoopGroup bossGroup = new NioEventLoopGroup(1, new DefaultThreadFactory("boss"));
EventLoopGroup workerGroup = new NioEventLoopGroup(0, new DefaultThreadFactory("worker"));
EventExecutorGroup businessGroup = new UnorderedThreadPoolEventExecutor(
(NettyRuntime.availableProcessors() * 2) + 1, new DefaultThreadFactory("business"));
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.INFO))
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline();
// TODO 编解码器 和 业务处理器 ...
}
});
ChannelFuture bindFuture = serverBootstrap.bind(rpcServerConfiguration.getServerPort()).sync();
bindFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.error("NettyServer throw Ex: ", e);
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
businessGroup.shutdownGracefully();
}
}
}
e. 编解码器
在 hrpc-core 模块中编写对应的「一/二次编解码器」和「处理请求返回响应的 Handler」。
(1)一次编解码器
public class FrameDecoder extends LengthFieldBasedFrameDecoder {
public FrameDecoder() {
super(Integer.MAX_VALUE, 0, 4, 0, 4);
}
}
public class FrameEncoder extends LengthFieldPrepender {
public FrameEncoder() {
super(4);
}
}
(2)二次编解码器
@Slf4j
public class RpcRequestDecoder extends MessageToMessageDecoder<ByteBuf> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
try {
int length = msg.readableBytes();
byte[] bytes = new byte[length];
msg.readBytes(bytes);
RpcRequest request = ProtostuffUtil.deserialize(bytes, RpcRequest.class);
out.add(request);
} catch (Exception e) {
log.error("RpcRequestDecoder decode error, throw Ex: ", e);
throw new RuntimeException(e);
}
}
}
@Slf4j
public class RpcResponseEncoder extends MessageToMessageEncoder<RpcResponse> {
@Override
protected void encode(ChannelHandlerContext ctx, RpcResponse response, List<Object> out) throws Exception {
try {
byte[] bytes = ProtostuffUtil.serialize(response);
ByteBuf buffer = ctx.alloc().buffer(bytes.length);
buffer.writeBytes(bytes);
out.add(buffer);
} catch (Exception e) {
log.error("RpcResponseEncoder encode error, throw Ex: ", e);
throw new RuntimeException(e);
}
}
}
(3)请求处理器
@Slf4j
@ChannelHandler.Sharable
public class RpcRequestHandler extends SimpleChannelInboundHandler<RpcRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, RpcRequest request) throws Exception {
log.info("receive request = {}", request);
RpcResponse response = new RpcResponse();
response.setRequestId(request.getRequestId());
try {
// 请求中还有其他什么信息呢?
String interfaceName = request.getClassName();
String methodName = request.getMethodName();
Class<?>[] parameterTypes = request.getParameterTypes();
Object[] parameters = request.getParameters();
// 从容器中找到目标bean
Object bean = SpringBeanFactory.getBean(Class.forName(interfaceName));
// 获取要执行的方法
Method method = bean.getClass().getMethod(methodName, parameterTypes);
// 执行方法
Object result = method.invoke(bean, parameters);
// 封装结果
response.setResult(result);
} catch (Exception e) {
response.setCause(e);
log.error("rpc server invoke error, throw Ex: ", e);
} finally {
// 将响应写回
log.info("RpcRequestHandler invoke over, response: {}", response);
ctx.writeAndFlush(response);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("RpcRequestHandler throw Ex: ", cause);
super.exceptionCaught(ctx, cause);
}
}
(4)请求/响应类
RpcRequest
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RpcRequest {
private String requestId;
private String className;
private String methodName;
private Class<?>[] parameterTypes;
private Object[] parameters;
}
RpcResponse
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RpcResponse {
private String requestId;
private Object result;
private Throwable cause;
public boolean isError() {
return cause != null;
}
}
f. 启动测试
在 NettyServer 的 initChannel() 中添加对应的编解码器和业务处理器。
ChannelPipeline pipeline = socketChannel.pipeline();
// 编解码
// OUT
pipeline.addLast(new FrameEncoder());
pipeline.addLast(new RpcResponseEncoder());
// IN
pipeline.addLast(new FrameDecoder());
pipeline.addLast(new RpcRequestDecoder());
// 业务处理(在业务线程池中执行)
pipeline.addLast(businessGroup, new RpcRequestHandler());
再启动 service-provider 测试,检查是否出错。
4.3 RPC 客户端实现
a. 引导类
package com.itheima.rpc.client.boot;
@Configuration
public class RpcBootstrap {
@Autowired
private RpcClientRunner rpcClientRunner;
@PostConstruct
public void initRpcClient() {
rpcClientRunner.run();
}
}
b. 启动器
package com.itheima.rpc.client.boot;
@Component
@Slf4j
public class RpcClientRunner {
@Autowired
private RpcServiceDiscovery serviceDiscovery;
/**
* 1、服务发现(从zk中获取根节点下的所有子节点信息,每个子节点就代表一个服务接口,再下面的子节点就是提供者信息)
* 2、考虑代理如何产生?
*/
public void run() {
serviceDiscovery.serviceDiscovery();
}
}
c. 服务发现
编写服务发现接口 RpcServiceDiscovery 以及其 ZK 实现。
package com.itheima.rpc.client.discovery;
public interface RpcServiceDiscovery {
void serviceDiscovery();
}
ZK 方式实现服务发现:
package com.itheima.rpc.client.discovery.zk;
@Component
@Slf4j
public class ZkServiceDiscovery implements RpcServiceDiscovery {
@Autowired
private ClientZKit zKit;
@Autowired
private ServiceProviderCache cache;
@Override
public void serviceDiscovery() {
// 拉取所有的服务列表
List<String> serviceList = zKit.getServiceList();
if (!serviceList.isEmpty()) {
for (String serviceName : serviceList) {
// 获取该接口下的提供者信息
List<ServiceProvider> providers = zKit.getServiceInfos(serviceName);
// 将该接口服务及提供者信息缓存
log.info("订阅的服务名={}, 服务提供者={}", serviceName, providers);
cache.put(serviceName, providers);
// 订阅变更
zKit.subscribeZKEvent(serviceName);
}
}
}
}
启动 service-consumer 测试,查看是否能拉取到服务信息。
补充 ClientZKit:
package com.itheima.rpc.client.discovery.zk;
@Component
public class ClientZKit {
@Autowired
private RpcClientConfiguration configuration;
@Autowired
private ZkClient zkClient;
@Autowired
private ServiceProviderCache cache;
/**
* 服务订阅接口
*/
public void subscribeZKEvent(String serviceName) {
// 1. 组装服务节点信息
String path = configuration.getZkRoot() + "/" + serviceName;
// 2. 订阅服务节点(监听节点变化)
zkClient.subscribeChildChanges(path, new IZkChildListener() {
@Override
public void handleChildChange(String parentPath, List<String> list) throws Exception {
// 3. 判断获取的节点信息,是否为空
if (CollectionUtils.isNotEmpty(list)) {
// 4. 将服务端获取的信息,转换为服务记录对象
List<ServiceProvider> providerServices = convertToProviderService(serviceName, list);
// 5. 更新缓存信息
cache.update(serviceName, providerServices);
} else {
cache.update(serviceName, null);
}
}
});
}
/**
* 所有的服务接口信息
*/
public List<String> getServiceList() {
String path = configuration.getZkRoot();
List<String> children = zkClient.getChildren(path);
return children;
}
/**
* 根据服务名称获取服务节点完整信息
*/
public List<ServiceProvider> getServiceInfos(String serviceName) {
String path = configuration.getZkRoot() + "/" + serviceName;
List<String> children = zkClient.getChildren(path);
List<ServiceProvider> providerServices = convertToProviderService(serviceName, children);
return providerServices;
}
/**
* 将拉取的服务节点信息转换为服务记录对象
*/
private List<ServiceProvider> convertToProviderService(String serviceName, List<String> list) {
if (CollectionUtils.isEmpty(list)) {
return Lists.newArrayListWithCapacity(0);
}
// 将服务节点信息转换为服务记录对象
List<ServiceProvider> providerServices = list.stream().map(v -> {
String[] serviceInfos = v.split(":");
return ServiceProvider.builder()
.serviceName(serviceName)
.serverIp(serviceInfos[0])
.rpcPort(Integer.parseInt(serviceInfos[1]))
.build();
}).collect(Collectors.toList());
return providerServices;
}
}
RpcClientConfiguration
@Data
@Component
public class RpcClientConfiguration {
@Value("${rpc.client.zk.root}")
private String zkRoot;
@Value("${rpc.client.zk.addr}")
private String zkAddr;
@Value("${server.port}")
private String znsClientPort;
@Value("${rpc.client.api.package}")
private String rpcClientApiPackage;
@Value("${rpc.cluster.strategy}")
private String rpcClientClusterStrategy;
@Value("${rpc.client.zk.timeout}")
private Integer connectTimeout;
}
d. 动态代理
检查客户端 Spring 容器内是否有被 @HrpcRemote 注解标识的属性,如果存在则需要对其注入代理对象。
在 hrpc-client 模块中编写 RpcAnnotationProcessor,在 postProcessAfterInitialization() 方法中去检测如果 bean 的某个属性上有 @HrpcRemote 注解,则创建代理,并完成注入。
package com.itheima.rpc.client.spring;
@Slf4j
@Component
public class RpcAnnotationProcessor implements BeanPostProcessor, ApplicationContextAware {
private ProxyFactory proxyFactory;
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
Field[] fields = bean.getClass().getDeclaredFields();
for (Field field : fields) {
if (!field.isAccessible()) {
field.setAccessible(true);
}
HrpcRemote hrpcRemote = field.getAnnotation(HrpcRemote.class);
if (hrpcRemote != null) {
log.info("为 @HrpcRemote 标注的 {} 属性生成的代理对象", field.getName());
Object proxy = proxyFactory.newProxyInstance(field.getType());
if (proxy != null) {
try {
field.set(bean, proxy);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
}
return bean;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.proxyFactory = applicationContext.getBean(ProxyFactory.class);
}
}
e. 封装请求
在代理方法的拦截中完成 RPC 请求。
(1)代理工厂
public interface ProxyFactory {
<T> T newProxyInstance(Class<T> cls);
}
@Component
public class RequestProxyFactory implements ProxyFactory {
public <T> T newProxyInstance(Class<T> cls) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(cls);
enhancer.setCallback(new CglibProxyCallBackHandler());
return (T) enhancer.create();
}
}
(2)代理对象
package com.itheima.rpc.client.proxy;
public class CglibProxyCallBackHandler implements MethodInterceptor {
public Object intercept(Object o, Method method, Object[] parameters, MethodProxy methodProxy) throws Throwable {
// 放过 toString/hashcode/equals 等 Object 方法,采用 Spring 工具类
if (ReflectionUtils.isObjectMethod(method)) {
return method.invoke(method.getDeclaringClass().newInstance(), parameters);
}
// 封装RPC调用
String requestId = RequestIdUtil.requestId();
String serviceName = method.getDeclaringClass().getName();
String methodName = method.getName();
Class<?>[] parameterTypes = method.getParameterTypes();
RpcRequest request = RpcRequest.builder()
.requestId(requestId)
.className(serviceName)
.methodName(methodName)
.parameterTypes(parameterTypes)
.parameters(parameters)
.build();
// 使用请求处理器发送请求获取响应
RpcRequestManager rpcRequestManager = SpringBeanFactory.getBean(RpcRequestManager.class);
if (rpcRequestManager == null) {
throw new RpcException("Spring IOC Exception");
}
RpcResponse response = rpcRequestManager.sendRequest(request);
// 返回响应
return response.getResult();
}
}
f. 请求管理器
package com.itheima.rpc.client.request;
@Slf4j
@Component
public class RpcRequestManager {
@Autowired
private RpcClientConfiguration clientConfiguration;
@Autowired
private ServiceProviderCache providerCache;
@Autowired
StrategyProvider strategyProvider;
public RpcResponse sendRequest(RpcRequest request) throws InterruptedException {
// 根据请求信息找到要将请求发送倒哪儿
List<ServiceProvider> serviceProviders = providerCache.get(request.getClassName());
if (CollectionUtils.isEmpty(serviceProviders)) {
log.info("客户端没有发现可用的服务节点...");
throw new RpcException(StatusEnum.NOT_FOUND_SERVICE_PROVINDER);
}
// 负载均衡#找出一个具体的提供者(三)
LoadBalanceStrategy strategy = strategyProvider.getStrategy();
ServiceProvider provider = strategy.select(serviceProviders);
return requestByNetty(provider, request);
}
private RpcResponse requestByNetty(ServiceProvider provider, RpcRequest request) throws InterruptedException {
Channel channel = null;
// 1. [连接复用] 判断对端的Channel是否已建立好(二)
if (!RpcRequestHolder.channelExist(provider.getServerIp(), provider.getRpcPort())) {
// 2. Netty的客户端代码
EventLoopGroup group = new NioEventLoopGroup(0, new DefaultThreadFactory("worker"));
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
// TODO 编解码器 和 业务处理器 ...
}
});
// 连接建立成功
ChannelFuture future = bootstrap.connect(provider.getServerIp(), provider.getRpcPort()).sync();
if (future.isSuccess()) {
channel = future.channel();
RpcRequestHolder.addChannelMapping(new ChannelMapping(provider.getServerIp(), provider.getRpcPort(), channel));
}
}
channel = RpcRequestHolder.getChannel(provider.getServerIp(), provider.getRpcPort());
// 3. 向对端异步发送数据(一)
// a. 创建Promise
RequestPromise requestPromise = new RequestPromise(channel.eventLoop());
// b. 建立映射
RpcRequestHolder.addRequestPromise(request.getRequestId(), requestPromise);
// c. 发送数据
ChannelFuture channelFuture = channel.writeAndFlush(request);
// d. 添加'发送数据'结果回调监听
channelFuture.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
if (!future.isSuccess()) {
RpcRequestHolder.removeRequestPromise(request.getRequestId());
}
}
});
// e. 添加'接收响应'结果回调监听
requestPromise.addListener(new FutureListener<RpcResponse>() {
@Override
public void operationComplete(Future<RpcResponse> future) throws Exception {
if (!future.isSuccess()) {
RpcRequestHolder.removeRequestPromise(request.getRequestId());
}
}
});
try {
// 4. 同步等待Promise返回结果
// 也可以使用get的重载方法:get(clientConfiguration.getConnectTimeout(), TimeUnit.SECONDS)
RpcResponse response = (RpcResponse) requestPromise.get();
return response;
} catch (Exception e) {
log.error("Waiting for receive response throw Ex: ", e);
} finally {
RpcRequestHolder.removeRequestPromise(request.getRequestId());
}
return new RpcResponse();
}
}
一、如何拿到 RpcResponseHandler 中的响应数据呢?RpcResponseHandler 是在 Netty 的 worker 线程中执行的,如何在当前请求的线程中获取结果?
(1)RequestPromise
/**
* Future 表示一个异步操作的结果,它提供了获取操作结果的方法,可以通过添加监听器来处理操作完成后的逻辑。
* 在 Netty 中,ChannelFuture 是一个常用的 Future 实现,表示一个 I/O 操作的结果。
*
* Promise 是 Future 的扩展,它除了具备 Future 的功能外,还提供了设置操作结果的方法。
* Promise 代表了一个异步操作的执行过程,并允许在操作完成后设置其结果或者失败原因。
* 在 Netty 中,ChannelPromise 是 ChannelFuture 的子类,用于表示一个 I/O 操作的可写的结果。
*
* 二者的关系是,Promise 是对 Future 的功能扩展,它继承了 Future 的特性,并添加了设置结果的能力。
* 换句话说,Promise 继承了 Future 的所有方法,并额外提供了一些设置结果的方法,使得在异步操作完成后
* 可以手动设置结果或失败原因,而不仅仅是获取操作的结果。
*/
public class RequestPromise extends DefaultPromise {
public RequestPromise(EventExecutor executor) {
super(executor);
}
}
(2)ChannelMapping
@Data
public class ChannelMapping {
/**
* 服务提供者节点,格式: ip:port
*/
private String ipWithPort;
/**
* 服务提供者ip
*/
private String ip = "127.0.0.1";
/**
* 服务提供者端口
*/
private int port;
/**
* 客户端连接通道channel
*/
private Channel channel;
public ChannelMapping(String ip, int port, Channel channel) {
this.ip = ip;
this.port = port;
this.channel = channel;
}
public String getIpWithPort() {
return ip + ":" + port;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ChannelMapping that = (ChannelMapping) o;
return port == that.port && ip.equals(that.ip);
}
}
(3)RpcRequestHolder
package com.itheima.rpc.netty.request;
@Slf4j
@Component
public class RpcRequestHolder {
/**
* 存放 'requestId: RpcResponse' 的映射关系
*/
private static Map<String, RequestPromise> requestPromiseMap = new ConcurrentHashMap<>();
/**
* 维护客户端对所有服务节点的映射,达到重复利用已创建好的Channel
*/
private static Map<String, ChannelMapping> channelMappingMap = new ConcurrentHashMap<>();
/**
* 向容器中添加requestPromise
*/
public static void addRequestPromise(String requestId, RequestPromise promise) {
requestPromiseMap.put(requestId, promise);
}
/**
* 获取requestPromise
*/
public static RequestPromise getRequestPromise(String requestId) {
return requestPromiseMap.get(requestId);
}
/**
* 移除requestPromise
*/
public static void removeRequestPromise(String requestId) {
requestPromiseMap.remove(requestId);
}
/**
* 判断客户端是否已存在该服务节点的连接
*/
public static boolean channelExist(String serverIp, int serverPort) {
return getChannel(serverIp, serverPort) != null;
}
/**
* 添加客户端Channel映射
*/
public static void addChannelMapping(ChannelMapping channelMapping) {
channelMappingMap.put(channelMapping.getIpWithPort(), channelMapping);
}
/**
* 获取客户端Channel
*/
public static Channel getChannel(String serverIp, int serverPort) {
String ipWithPort = serverIp + ":" + serverPort;
ChannelMapping channelMapping = channelMappingMap.get(ipWithPort);
if (channelMapping != null) {
return channelMapping.getChannel();
} else {
return null;
}
}
/**
* 移除并关闭Channel
*/
public static void removeChannel(String serverIp, int serverPort) {
String ipWithPort = serverIp + ":" + serverPort;
ChannelMapping channelMapping = channelMappingMap.get(ipWithPort);
if (channelMapping != null) {
channelMapping.getChannel().closeFuture();
channelMapping.getChannel().eventLoop().shutdownGracefully();
log.info("channel已关闭,移除映射");
channelMappingMap.remove(ipWithPort);
}
}
@PreDestroy
private void destroyAllChannel() {
Collection<ChannelMapping> values = channelMappingMap.values();
for (ChannelMapping channelMapping : values) {
channelMapping.getChannel().closeFuture();
channelMapping.getChannel().eventLoop().shutdownGracefully();
String ipWithPort = channelMapping.getIp() + ":" + channelMapping.getPort();
channelMappingMap.remove(ipWithPort);
}
}
}
(4)响应处理器
package com.itheima.rpc.netty.handler;
@Slf4j
@ChannelHandler.Sharable
public class RpcResponseHandler extends SimpleChannelInboundHandler<RpcResponse> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, RpcResponse response) throws Exception {
// 从映射中获取Promise
RequestPromise requestPromise = RpcRequestHolder.getRequestPromise(response.getRequestId());
if (requestPromise != null) {
requestPromise.setSuccess(response);
}
}
}
二、使用 jmeter 压测发现一个优化点,每次请求都创建新的 Netty-client 连接,导致资源快速被耗尽。
客户端复用对服务端的连接,使用 RpcRequestHolder 中的 channelMappingMap 存放已建立的连接,然后对 RpcRequestManager 中的 requestByNetty() 进行改造。
三、客户端·负载均衡
在 hrpc-client 模块中编写各个负载均衡的实现,实现接口 com.itheima.rpc.client.cluster.LoadBalanceStrategy。
public interface LoadBalanceStrategy {
ServiceProvider select(List<ServiceProvider> serviceProviders);
}
有了策略,如何根据客户端的配置,动态选择策略呢?
public interface StrategyProvider {
LoadBalanceStrategy getStrategy();
}
@Component
public class DefaultStrategyProvider implements StrategyProvider, ApplicationContextAware {
@Autowired
private RpcClientConfiguration clientConfiguration;
private LoadBalanceStrategy strategy;
@Override
public LoadBalanceStrategy getStrategy() {
return strategy;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Map<String, Object> beansWithAnnotation = applicationContext.getBeansWithAnnotation(HrpcLoadBalance.class);
for (Object bean : beansWithAnnotation.values()) {
HrpcLoadBalance loadBalance = bean.getClass().getAnnotation(HrpcLoadBalance.class);
if (clientConfiguration.getRpcClientClusterStrategy().equals(loadBalance.strategy())) {
strategy = (LoadBalanceStrategy) bean;
break;
}
}
}
}
g. 编解码器
在 hrpc-core 模块中编写相关编解码器和响应处理器 Handler。
(1)一次编解码器
可复用,无需编写。
(2)二次编解码器
@Slf4j
public class RpcRequestEncoder extends MessageToMessageEncoder<RpcRequest> {
@Override
protected void encode(ChannelHandlerContext ctx, RpcRequest request, List<Object> out) throws Exception {
try {
byte[] bytes = ProtostuffUtil.serialize(request);
ByteBuf buffer = ctx.alloc().buffer(bytes.length);
buffer.writeBytes(bytes);
out.add(buffer);
} catch (Exception e) {
log.error("RpcRequestEncoder encode error, throw Ex: ", e);
throw new RuntimeException(e);
}
}
}
@Slf4j
public class RpcResponseDecoder extends MessageToMessageDecoder<ByteBuf> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
try {
int length = msg.readableBytes();
byte[] bytes = new byte[length];
msg.readBytes(bytes);
RpcResponse response = ProtostuffUtil.deserialize(bytes, RpcResponse.class);
out.add(response);
} catch (Exception e) {
log.error("RpcResponseDecoder decode error, throw Ex: ", e);
throw new RuntimeException(e);
}
}
}
h. 启动测试
在请求管理器中的 NettyClient 补全 Handler。
ChannelPipeline pipeline = ch.pipeline();
// 编码
pipeline.addLast(new FrameEncoder());
pipeline.addLast(new RpcRequestEncoder());
// 解码
pipeline.addLast(new FrameDecoder());
pipeline.addLast(new RpcResponseDecoder());
// 业务
pipeline.addLast(new RpcResponseHandler());
启动 service-consumer 进行完整 RPC 功能测试。