文章摘要
我的Netty通信框架学习是从李林峰老师的《Netty权威指南(第二版)》开始的,书中从原始的I/O模型到Netty的高性能、可靠性等高级特性进行了由浅入深的讲解。偶然的机会,发现了李林峰老师在InfoQ上的专栏:InfoQ_李林峰,当前还保持着不错的更新频率,大家感兴趣的可以关注一下。学习本身就是一个不断获取知识然后投入实践的过程,本文将简单记录下在我自己的项目中是如何实践Netty高性能编程的。
[TOC]
内容
声明:本文将以《Netty权威指南(第二版)》第22章高性能之道为基础,对项目中的实际应用进行梳理,可以看做是自己的一份学习笔记,方便记忆及以后查看,不做其他用途。
传统 RPC 调用性能差的三宗罪
网络传输方式问题:
传统的 RPC 框架或者基于 RMI 等方式的远程服务(过程)调用采用了同步阻塞 IO,当客户端的并发压力或者网络时延增大之后,同步阻塞 IO 会由于频繁的 wait 导致 IO 线程经常性的阻塞,由于线程无法高效的工作,IO 处理能力自然下降。
序列化方式问题:
Java 序列化存在如下几个典型问题:- 无法跨语言使用;
- 序列化后的码流太大,网络传输还是持久化到磁盘,都会导致额外的资源占用;
- 序列化性能差(CPU 资源占用高);
线程模型问题:
由于采用同步阻塞 IO,这会导致每个 TCP 连接都占用 1 个线程,当 IO 读写阻塞导致线程无法及时释放时,会导致系统性能急剧下降;
高性能的三个主题
1) 传输:用什么样的通道将数据发送给对方,BIO、NIO 或者 AIO,IO 模型在很大程度上决定了框架的性能。
2) 协议:采用什么样的通信协议,HTTP 或者内部私有协议。协议的选择不同,性能模型也不同。相比于公有协议,内部私有协议的性能通常可以被设计的更优。
3) 线程:数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,Reactor 线程模型的不同,对性能的影响也非常大。
Netty 高性能编程实践
1. 异步非阻塞通信
与 Socket 类和 ServerSocket 类相对应,NIO 也提供了 SocketChannel 和 ServerSocketChannel 两种不同的套接字通道实现。
1 | if ("Epoll".equals(ServerInfo.getInstance().getEventLoopGroup())) { |
2. 零拷贝
1) Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
1 | public static ByteBuf newBufferType(byte type) { |
2) Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。
1 | public static CompositeByteBuf compositeBuffer(int length) { |
3) Netty 的文件传输采用了 transferTo 方法,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
在实际的开发项目中,没有使用文件传输方法,顾该特性没有使用到。
3. 内存池
为了尽量重用缓冲区,Netty 提供了基于内存池的缓冲区重用机制。Netty 提供了多种内存管理策略,通过在启动辅助类中配置相关参数,可以实现差异化的定制。
PooledDirectByteBuf 的 newInstance 方法实现:
- 通过 RECYCLER 的 get 方法循环使用 ByteBuf 对象,如果是非内存池实现,则直接创建一个新的 ByteBuf 对象。
- 从缓冲池中获取 ByteBuf 之后,调用 AbstractReferenceCountedByteBuf 的 setRefCnt 方法设置引用计数器,用于对象的引用计数和内存回收(类似 JVM 垃圾回收机制)。
在上面零拷贝的实例中,可以看到对内存池UnpooledByteBufAllocator的使用。
4. 高效的 Reactor 线程模型
Reactor 线程模型分为以下三种:
1) Reactor 单线程模型;
2) Reactor 多线程模型;
3) 主从 Reactor 多线程模型;
实际应用中,我们使用的是主从 Reactor 多线程模型,代码实例如下:
1 | bossGroup = new EpollEventLoopGroup(); |
服务端启动时创建了两个NioEventLoopGroup,实际上他们是两个独立的Reactor线程池,BossGroup用于服务端处理客户端建立TCP连接的请求,WorkerGroup用于处理I/O相关的读写操作,或则执行系统的Task,定时Task。
BossGroup线程池具体作用:
1)接收客户端的连接,初始化Channel参数;
2)将链路状态变更时间通知给ChannelPipeline;
WorkerGroup线程池具体作用:
1)异步读取通信对端的数据报,发送读事件到ChannelPipeline;
2)异步发送消息到通信对端,调用ChannelPipeline的消息发送接口;
3)执行系统调用、定时任务Task;
5. 无锁化的串行设计理念
为了尽可能提升性能,Netty 采用了串行无锁化设计,在 IO 线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列 - 多个工作线程模型性能更优。
具体实现:
Netty 的 NioEventLoop 读取到消息之后,直接调用 ChannelPipeline 的 fireChannelRead(Object msg),只要用户不主动切换线程,一直会由 NioEventLoop 调用到用户的 Handler,期间不进行线程切换。
6. 高效的并发编程
1) volatile 的大量、正确使用 ;
2) CAS 和原子类的广泛使用;
3) 线程安全容器的使用;
举例说明:
1 | // 推送注册列表 |
4) 通过读写锁提升并发性能。
举例说明:
1 | ReentrantLock writeLock = new ReentrantLock(); |
7. 高性能的序列化框架
1) 序列化后的码流大小(网络带宽的占用);
首先通过定制私有协议:增量+FAST协议,有效缩减每次请求或推送的数据返回包的大小,然后通过gzip/zip压缩、控制每个返回包的大小等手段实现降低网络带宽的占用。
其中压缩代码实例:
1 | if (ServerInfo.getInstance().isZip()) { |
2) 序列化 & 反序列化的性能(CPU 资源占用);
因为涉及到私有协议的定制,不适合展开细讲,这里大体提一下,在序列化优化这方面,我们使用了增量+FAST协议栈的方式,包体协议定义如下:
1 | UINT[] dps; //标识每一字段数据精度 |
依次读入数据,选取基准数据,后续读入数据根据基准数据做增量,减少返回包数据大小。
增量数据设置:
flag:
①第一位如果设置(isSet(0)),表示是全量数据,后面n-1位没有设置;
②第一位如果没有设置,表示增量,后面n-1位设置过的表示有增量数据,未设置过的表示该位数据与基准数据对应数据相同。
增量数据返回的数据与flag位是否设置相对应。
私有协议同时支持可配置的全量拉取、注册推送等模式,降低服务器压力;
3) 是否支持跨语言(异构系统的对接和开发语言切换)。
本项目中通过定制化私有协议实现了RPC服务与不同的客户端:终端、API、Web服务、Excel等的跨语言通信。
8. 灵活的 TCP 参数配置能力
1) SO_RCVBUF 和 SO_SNDBUF:通常建议值为 128K 或者 256K;
2) SO_TCPNODELAY:NAGLE 算法通过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提高网络应用效率。但是对于时延敏感的应用场景需要关闭该优化算法;
3) 软中断:如果 Linux 内核版本支持 RPS(2.6.35 以上版本),开启 RPS 后可以实现软中断,提升网络吞吐量。
TCP/Socket的设置,具体有 SO_REUSEADDR, TCP_NODELAY, SO_KEEPALIVE,SO_LINGER, SO_TIMEOUT, SO_BACKLOG,SO_SNDBUF, SO_RCVBUF。
总结
迫于篇幅限制,本文只是单纯的对Netty的高性能编程实践进行了简单的罗列,实际在开发过程中,每一项改动都需要结合JVM监控工具及运维监控平台进行大量的参数修改及压力测试,力求找到RPC服务性能的最优解。当然根据生产环境的不同,这些特性的使用也不完全一致,需要我们具体对待。
通过对Netty高性能编程的时间以及JVM调优,线上服务的性能其实是有了不小的提升:
内存占用方面,报价接口服务内存占用从 25G 降低到 21G,实时接口服务内存占用从 26G 降低到 13G;
CPU 占用方面,线上 RPC 服务的 CPU 占用率从高峰期 1100%降低到 600%左右,服务器负载从 16 降低到 11;
I/O 带宽方面,返回包数据大小减小了 30%~36%。
最后还是要感谢李林峰老师,给我RPC服务的Netty性能调优提供了清晰的思路。