需求场景
项目中用netty的游戏服务器和客户端通信,连接是TCP协议,上层用protobuf编码。
现在要做一个基础功能,把服务器中每个玩家的行为,广播给其他的所有玩家,随着在线玩家数量增长,广播的总数据量也会平方增长。
服务器中除了这块还有其他业务功能,广播会带来多少性能压力值得研究一下,如果广播占用了太多的CPU和内存资源,还得考虑是否要拆出来一个网关服,用网关服去处理广播,这种情况下网关并不是对业务透明的,可以说是个很麻烦的脏活,还会让架构变得复杂。所以首选还是希望把广播的消耗优化到可以接受的程度。
初步测试
做了一些测试,用10000个客户端连一个服务器,服务器每秒向所有客户端发送一条protobuf消息,观察到几个问题:
- 一段时间后服务器进程占用内存达到10个G
- 服务器CPU占用过高。CPU是4核2.4G主频,CPU占用就达到40%。
- 一段时间后有客户端自动断开连接。
研究了下这些问题,发现内存占用过高是由于Netty存在无限制的发送缓存区,然后服务器带宽受限时,网络传输太慢,数据就堆积在了发送缓冲区,占用了大量内存。
而CPU占用过高,是由于protobuf消息编码过程很耗CPU,应该避免重复编码。
连接自动断开,也是因为网络带宽受限,TCP重传超过一定次数还得不到响应,linux内核就会自动关闭连接,这一点不算服务器问题。
最后总结出以下优化方法以及相关的知识点。
解决方案
优化一:改成伪实时的定时广播
原本是实时广播,每个玩家的动作,都及时广播给所有其他玩家。
改成服务器每秒广播一次,把这一秒内的数据收集起来,然后打包广播给所有玩家。避免了随着玩家数增长,广播次数平方增长。
这一条是比较基础的,但是也要业务上允许这样做。
优化二:限制发送缓冲区大小,及时关闭无响应的连接,以控制内存消耗
我们知道TCP协议提供的是可靠连接,服务器发送了什么数据,客户端要么是原样收到这些数据,要么是由于异常断开连接。如果网络不稳定客户端不能及时接受数据,但是也没有断开连接,那么服务器发出的数据就暂存在了发送缓冲区。
发送缓冲区有内核发送缓冲区和Netty发送缓冲区两部分。
内核缓冲区
内核缓冲区有发送缓冲区和接收缓冲区,接收缓冲区也会占用内存,一般是服务器进程不能及时读取消息,或者粘包未完成(这时在netty中占用)时占用内存。但是这里主要分析发送缓冲区。
发送缓冲区和接收缓冲区的大小分别用SO_SNDBUF
和SO_RCVBUF
配置。可以在建立连接时配置,如果没有配置则用系统的默认值。
发送缓冲区默认值是 cat /proc/sys/net/core/wmem_default
接收缓冲区默认值是 cat /proc/sys/net/core/rmem_default
在netty配置发送缓冲区:
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.childOption(ChannelOption.SO_SNDBUF, 100 * 1024); // 100KB
或者是
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.config().setOption(ChannelOption.SO_SNDBUF, 100 * 1024); // 100KB
//加上其他的初始化...
}
});
还有一点要知道,内核发送缓冲区实际大小是配置值的2倍,比如配置了100KB,那么实际是200KB。这里有一个解释:Linux man page
SO_SNDBUF
Sets or gets the maximum socket send buffer in bytes. The kernel doubles this value (to allow space for bookkeeping overhead) when it is set using setsockopt(2), and this doubled value is returned by getsockopt(2). The default value is set by the /proc/sys/net/core/wmem_default file and the maximum allowed value is set by the /proc/sys/net/core/wmem_max file. The minimum (doubled) value for this option is 2048.
Netty的发送缓冲区
当内核的发送缓冲区满了之后,继续写数据会返回失败。但是我们不会直接使用到这个返回值,因为实际上netty还有一层发送缓冲区,即每个Channel有一个ChannelOutboundBuffer
,以链表形式缓存要发送的ByteBuf
。我们发送数据时调用netty的Channel#write
方法,最终会生成一个ByteBuf
添加到ChannelOutboundBuffer
中的链表中,并返回一个ChannelFuture
。Channel自动的把ChannelOutboundBuffer
里面的ByteBuf
写入到内核缓冲区,并通知对应的ChannelFuture
。
Channel#write
方法是异步的,我们通过Channel#write
返回的ChannelFuture
来判断是否发送成功,ChannelFuture
的成功状态是指数据写入了内核缓冲区。写入到内核缓冲区的数据,最后也未必会被客户端收到,所以ChannelFuture#isSuccess
不代表客户端收到了数据,只代表数据写入了内核缓冲区。
ChannelOutboundBuffer
里面用链表存储ByteBuf
,这个链表size是没有上限的,即使网络阻塞,内核缓冲区已填满,你也可以无限的调用write
方法,直到OOM。所有要想控制连接的内存占用,也要考虑到这点。好在netty提供了检测Channel可写能力的方法,ChannelOutboundBuffer
里面有一个字段totalPendingSize
记录了待发送的总数据量,当数据量超过writeBufferHighWaterMark
(高水位线)之后,会给Channel标记不可写,然后Channel#isWritable
返回false,由此可以判断要停止发送数据。当总数据量低于writeBufferLowWaterMark
(低水位线)之后,重新标记Channel为可写。高低水位线可以在ChannelConfig
中配置。
所以要实现对netty写缓冲区大小的限制,一方面要在ChannelConfig
中配置水位线,水位线就是我们想要的缓冲区上限,一方面要判断Channel是否可写。如果是在调用Channel#write
之前去判断isWritable,似乎不是一个好办法。在我们的需求场景中 ,如果一个Channel持续一段时间不可写,说明这个Channel已经吃掉了一些内存,而且Channel的网络速率小于服务器写的速率,应该自动关闭这个Channel。netty提供了一个方法ChannelInboundHandler#channelWritabilityChanged
可以监听Channel可写性改变的消息,当Channel变成不可写的时候,我们给它加一个定时器,如果没有在规定时间内恢复可写,就可以把这个Channel干掉。
小结:
- 使用SO_SNDBUF限制内核的发送缓冲区大小
- 配置Channel的写缓冲区水位线,并及时关闭持续不可写的Channel.
优化三:IO内存使用堆外内存池,降低GC损耗
假设在比较糟糕的网络下,一个连接需要占用1MB的内存,10000个连接就是约10G内存。如果在原本的堆内存上多加10G,也是不得不慎重考虑的,因为会增加GC压力,而且java进程内存大于32G之后对象地址会变成64位,也会导致更多的性能损耗。
堆外内存以及内存池
这时候亮出netty的一大利器了——堆外内存池,堆外内存也叫直接内存。先简单介绍下java的自带堆外内存管理,java有自带的java.nio.DirectByteBuffer
类,创建DirectByteBuffer
对象就等于申请了堆外内存,DirectByteBuffer#address
方法返回分配的内存地址,DirectByteBuffer
中持有一个sun.misc.Clenaer
,Clenaer
是一个虚引用(PhantomReference
),虚引用对象在被回收的时候,会触发一个自定义的逻辑,那么在对于DirectByteBuffer
的Cleaner
来说,就是在被回收的时候释放所分配内存。也就是说通过java自带的DirectByteBuffer
申请的堆外内存,最终还是要靠GC去回收,是不太可控的,特别是对于分代GC,对象进入年老代之后就很难及时回收。同时这种方式也是增加了GC的性能消耗。
netty对直接内存的管理做了进一步的优化,netty对直接内存的管理方式有两种,一种是使用Clenaer
,也就是通过java自带的DirectByteBuffer
管理直接内存,另一种是不使用Clenaer
,netty自己去调用sun.misc.Unsafe
的接口来分配和释放内存(Clenaer
的实现也是调用Unsafe的接口)。实际使用哪一种和JDK的版本有关,netty自己判断,不需要配置。我们可以调用PlatformDependent#useDirectBufferNoCleaner
来查看是哪一种。
netty还提供了内存池,使用在频繁申请和释放内存的场景中能有更好的性能。
直接内存空间大小可以单独配置,总得来说,使用直接内存池,可以使IO内存尽量的独立,减少对原本进程的影响。
如何配置和使用
netty默认使用直接内存(如果JDK支持),要禁用直接内存可以用以下参数。
禁用直接内存:-Dio.netty.noPreferDirect=true
配置堆外内存的大小:
使用Cleaner
的话用-XX:MaxDirectMemorySize
参数,不使用Cleaner
的用-Dio.netty.maxDirectMemory
参数。
查看是否默认使用直接内存:
调用此方法:io.netty.util.internal.PlatformDependent#directBufferPreferred
netty默认使用内存池,也可以手动配置:
禁用内存池:-Dio.netty.allocator.type=unpooled
启用内存池:-Dio.netty.allocator.type=pooled
netty中分配内存是用io.netty.buffer.ByteBufAllocator
接口,如果没有给Channel配置单独的ByteBufAllocator
,那么Channel默认使用ByteBufAllocator.DEFAULT
,Channel在收到数据时会使用allocator分配ByteBuf
,在发送消息时,如果需要创建临时的ByteBuf
,也一般是使用Channel上的allocator。我们可以查看ByteBufAllocator.DEFAULT
对象来确认当前默认的内存管理方式。
给Channel配置ByteBufAllocator
:
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.childOption(ChannelOption.ALLOCATOR, new PooledByteBufAllocator(true));
查看当前堆外内存占用
使用命令:jcmd <pid> VM.native_memory summary
查看内存统计。
用这个命令之前需要加上一个JVM参数 -XX:NativeMemoryTracking=summary
,注意,加上之后有性能损耗!!, 平时不要在生产环境上用
还可以在进程中查看当前直接内存占用,这样可以不用加额外的JVM参数:
((PooledByteBufAllocator) ByteBufAllocator.DEFAULT).metric().usedDirectMemory()
优化四:使用零拷贝,减少Bytebuf的复制
Netty支持多种用法零拷贝,这里就不全部列举了。其中最关键的一点是,只要从pipeline最终发送出的ByteBuf,也就是进入到ChannelOutboundBuffer
的ByteBuf,是用的直接内存,就可以利用到零拷贝。
从Netty的源码上看,发送的ByteBuf在AbstractNioByteChannel#filterOutboundMessage
会经过一次判断,如果是direct ByteBuf
或FileRegion
,则直接添加到ChannelOutboundBuffer
。否则判断alloctor能使用直接内存,如果可以就申请一个新的direct ByteBuf,并复制数据到direct ByteBuf,最后添加到ChannelOutboundBuffer
。(如果分配时由于内存不足抛出异常,则调用对应的Promise
通知write失败)。所以发送的数据要尽量编码在direct ByteBuf。
优化五:避免批量广播的重复编码(不在Pipeline中编码),降低CPU和内存消耗
避免重复编码
我们是用的protobuf传数据,经过测试发现,发送大量广播的时候,protobuf消息编码成二进制数据占用了大部分的CPU,向一万个Channel发送同一个protobuf消息,编码过程就重复了一万次。
对此我们做了一步到位的优化,原本写消息的时候,要经过pipeline中的两个encoder hanlder,现在把encode过程抽了出来,先在外面把protobuf消息编码成ByteBuf,再直接把ByteBuf从pipeline的头部发出去,这样批量广播就绕过pipeline编码过程,正常发送消息仍然走pipeline,优化之后CPU占用显著降低。
使用direct ByteBuf
广播的场景下,是一个ByteBuf发送给多个Channel,ByteBuf要使用直接内存,否则有多少个Channel就要复制多少份ByteBuf。
优化后的批量广播代码大概上是长这样:
ByteBuf byteBuf = PooledByteBufAllocator.DEFAULT.directBuffer(size); // 使用直接内存
try {
encode(message, byteBuf); // 编码
for (Channel channel : channels) {
ByteBuf duplicate = byteBuf.retainedDuplicate();
try {
channel.pipeline().firstContext().writeAndFlush(byteBuf); //从pipeline头发出
} catch (Exception e) {
//处理channel已关闭导致的异常
} finally {
duplicate.release();
}
}
} finally {
byteBuf.release();
}
总结
做了以上的优化之后,基本上解决了广播量大的问题。IO内存独立管理,无需担心对原本进程内存的影响。CPU消耗也可以降低到可以接受的程度。也就避免了引入对业务不完全透明的网关服的麻烦。
测试数据
CPU是4核2.4G主频,测试大量连接,每秒向每个连接发一条消息,批量广播优化之后的CPU占用:
10000条连接,CPU占用3%左右
30000条连接,CPU占用7%左右