线上服务性能调优:Netty高性能编程

文章摘要

我的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
2
3
4
5
6
7
8
9
10
11
if ("Epoll".equals(ServerInfo.getInstance().getEventLoopGroup())) {
bossGroup = new EpollEventLoopGroup();
workerGroup = new EpollEventLoopGroup();
serverBootstrap = serverBootstrap.group(bossGroup, workerGroup)
.channel(EpollServerSocketChannel.class);
} else {
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup();
serverBootstrap = serverBootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class);
}

2. 零拷贝

​ 1) Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static ByteBuf newBufferType(byte type) {
if (type == BYTEBUF_TYPE_POOLED_DIRECT) {
return PooledByteBufAllocator.DEFAULT.directBuffer().order(ByteOrder.LITTLE_ENDIAN);
} else if (type == BYTEBUF_TYPE_UNPOOLED_DIRECT) {
return UnpooledByteBufAllocator.DEFAULT.directBuffer().order(ByteOrder.LITTLE_ENDIAN);
} else if (type == BYTEBUF_TYPE_POOLED_HEAP) {
return PooledByteBufAllocator.DEFAULT.buffer().order(ByteOrder.LITTLE_ENDIAN);
} else if (type == BYTEBUF_TYPE_UNPOOLED_HEAP) {
return UnpooledByteBufAllocator.DEFAULT.buffer().order(ByteOrder.LITTLE_ENDIAN);
} else {
return UnpooledByteBufAllocator.DEFAULT.buffer().order(ByteOrder.LITTLE_ENDIAN);
}
}

​ 2) Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static CompositeByteBuf compositeBuffer(int length) {
if (ServerInfo.getInstance().getByteBufType() == BYTEBUF_TYPE_POOLED_DIRECT) {
return PooledByteBufAllocator.DEFAULT.compositeDirectBuffer();
} else if (ServerInfo.getInstance().getByteBufType() == BYTEBUF_TYPE_UNPOOLED_DIRECT) {
return UnpooledByteBufAllocator.DEFAULT.compositeDirectBuffer();
} else if (ServerInfo.getInstance().getByteBufType() == BYTEBUF_TYPE_POOLED_HEAP) {
return PooledByteBufAllocator.DEFAULT.compositeBuffer();
} else if (ServerInfo.getInstance().getByteBufType() == BYTEBUF_TYPE_UNPOOLED_HEAP) {
return UnpooledByteBufAllocator.DEFAULT.compositeBuffer();
} else {
return UnpooledByteBufAllocator.DEFAULT.compositeBuffer();
}
}

​ 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
2
3
4
bossGroup = new EpollEventLoopGroup();
workerGroup = new EpollEventLoopGroup();
serverBootstrap = serverBootstrap.group(bossGroup, workerGroup)
.channel(EpollServerSocketChannel.class);

服务端启动时创建了两个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
2
3
4
// 推送注册列表
private ConcurrentHashMap<Short, ResPush> pushList = new ConcurrentHashMap<Short, ResPush>();
// 推送数据队列
final private ArrayBlockingQueue<Object> dataQueue = new ArrayBlockingQueue<Object>(QUEUE_SIZE_MAX);

4) 通过读写锁提升并发性能。

举例说明:

1
2
3
4
5
6
7
ReentrantLock writeLock = new ReentrantLock();

if (!writeLock.tryLock(10, TimeUnit.MILLISECONDS)) {
return;
} else {
//写入数据操作
}

7. 高性能的序列化框架

1) 序列化后的码流大小(网络带宽的占用);

首先通过定制私有协议:增量+FAST协议,有效缩减每次请求或推送的数据返回包的大小,然后通过gzip/zip压缩、控制每个返回包的大小等手段实现降低网络带宽的占用。

其中压缩代码实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
if (ServerInfo.getInstance().isZip()) {
if (header.getZip() == ResHeader.RES_HEADER_ZIP_TO_GZIP) {
//压缩写入out,压缩后长度
bodyLen = GZipUtils.compress(bodyBuf, out);
header.setZip(ResHeader.RES_HEADER_ZIP_GZIP);
} else if (header.getZip() == ResHeader.RES_HEADER_ZIP_TO_ZIP) {
byte[] bytes = new byte[bodyBuf.readableBytes()];
bodyBuf.readBytes(bytes);
byte[] compress = ZipUtil.compress(bytes);
out.writeBytes(compress);
header.setZip(ResHeader.RES_HEADER_ZIP_ZIP);
bodyLen = compress.length;
} else {
bodyLen = bodyBuf.readableBytes();
if (bodyLen >= ServerInfo.getInstance().getZipMaxLength()) {
//压缩写入out,压缩后长度
bodyLen = GZipUtils.compress(bodyBuf, out);
//压缩
header.setZip(ResHeader.RES_HEADER_ZIP_GZIP);
} else {
out.writeBytes(bodyBuf);
header.setZip(ResHeader.RES_HEADER_ZIP_UNZIP);
}
}
}

2) 序列化 & 反序列化的性能(CPU 资源占用);

因为涉及到私有协议的定制,不适合展开细讲,这里大体提一下,在序列化优化这方面,我们使用了增量+FAST协议栈的方式,包体协议定义如下:

1
2
3
4
5
6
7
UINT[] dps;	 //标识每一字段数据精度
UINT baseNum; //基准数据id (int)
TmpData
{
BIT_VECTOR flag; //数据二进制位
UINT[] diff; //增量数据 (int)
}
  • 依次读入数据,选取基准数据,后续读入数据根据基准数据做增量,减少返回包数据大小。

  • 增量数据设置:

    • 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性能调优提供了清晰的思路。