Netty防止内存泄漏措施

\u003cblockquote\u003e\n\u003cp\u003e谨以此文献给李林锋新生的爱女。\u003cbr /\u003e\n李林锋此后还将在 InfoQ 上开设 Netty 专题持续出稿,感兴趣的同学可以持续关注。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2\u003e1. 背景\u003c/h2\u003e\n\u003ch3\u003e1.1 直播平台内存泄漏问题\u003c/h3\u003e\n\u003cp\u003e某直播平台,一些网红的直播间在业务高峰期,会有10W+的粉丝接入,如果瞬间发生大量客户端连接掉线、或者一些客户端网络比较慢,发现基于Netty构建的服务端内存会飙升,发生内存泄漏(OOM),导致直播卡顿、或者客户端接收不到服务端推送的消息,用户体验受到很大影响。\u003c/p\u003e\n\u003ch3\u003e1.2 问题分析\u003c/h3\u003e\n\u003cp\u003e首先对GC数据进行分析,发现老年代已满,发生多次Full GC,耗时达3分多,系统已经无法正常运行(示例):\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/ed/5c/ed58c9445b8c9754074d7d6fa6e8fb5c.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图1 直播高峰期服务端GC统计数据\u003c/center\u003e\n\u003cp\u003eDump内存堆栈进行分析,发现大量的发送任务堆积,导致内存溢出(示例):\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/46/8f/46c2fc8633f52b45192364e34116078f.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图2 直播高峰期服务端内存Dump文件分析\u003c/center\u003e\n\u003cp\u003e通过以上分析可以看出,在直播高峰期,服务端向上万客户端推送消息时,发生了发送队列积压,引起内存泄漏,最终导致服务端频繁GC,无法正常处理业务。\u003c/p\u003e\n\u003ch3\u003e1.3 解决策略\u003c/h3\u003e\n\u003cp\u003e服务端在进行消息发送的时候做保护,具体策略如下:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e根据可接入的最大用户数做客户端并发接入数流控,需要根据内存、CPU处理能力,以及性能测试结果做综合评估。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e设置消息发送的高低水位,针对消息的平均大小、客户端并发接入数、JVM内存大小进行计算,得出一个合理的高水位取值。服务端在推送消息时,对Channel的状态进行判断,如果达到高水位之后,Channel的状态会被Netty置为不可写,此时服务端不要继续发送消息,防止发送队列积压。\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e服务端基于上述策略优化了代码,内存泄漏问题得到解决。\u003c/p\u003e\n\u003ch3\u003e1.4.总结\u003c/h3\u003e\n\u003cp\u003e尽管Netty框架本身做了大量的可靠性设计,但是对于具体的业务场景,仍然需要用户做针对特定领域和场景的可靠性设计,这样才能提升应用的可靠性。\u003c/p\u003e\n\u003cp\u003e除了消息发送积压导致的内存泄漏,Netty还有其它常见的一些内存泄漏点,本文将针对这些可能导致内存泄漏的功能点进行分析和总结。\u003c/p\u003e\n\u003ch2\u003e2. 消息收发防内存泄漏策略\u003c/h2\u003e\n\u003ch3\u003e2.1.消息接收\u003c/h3\u003e\n\u003ch4\u003e2.1.1 消息读取\u003c/h4\u003e\n\u003cp\u003eNetty的消息读取并不存在消息队列,但是如果消息解码策略不当,则可能会发生内存泄漏,主要有如下几点:\u003c/p\u003e\n\u003cp\u003e1.畸形码流攻击:如果客户端按照协议规范,将消息长度值故意伪造的非常大,可能会导致接收方内存溢出。\u003c/p\u003e\n\u003cp\u003e2.代码BUG:错误的将消息长度字段设置或者编码成一个非常大的值,可能会导致对方内存溢出。\u003c/p\u003e\n\u003cp\u003e3.高并发场景:单个消息长度比较大,例如几十M的小视频,同时并发接入的客户端过多,会导致所有Channel持有的消息接收ByteBuf内存总和达到上限,发生OOM。\u003c/p\u003e\n\u003cp\u003e避免内存泄漏的策略如下:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e无论采用哪种解码器实现,都对消息的最大长度做限制,当超过限制之后,抛出解码失败异常,用户可以选择忽略当前已经读取的消息,或者直接关闭链接。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e以Netty的DelimiterBasedFrameDecoder代码为例,创建DelimiterBasedFrameDecoder对象实例时,指定一个比较合理的消息最大长度限制,防止内存溢出:\u003c/p\u003e\n\u003cpre\u003e\u003ccode\u003e/**\n\n * Creates a new instance.\n\n *\n\n * @param maxFrameLength the maximum length of the decoded frame.\n\n * A {@link TooLongFrameException} is thrown if\n\n * the length of the frame exceeds this value.\n\n * @param stripDelimiter whether the decoded frame should strip out the\n\n * delimiter or not\n\n * @param delimiter the delimiter\n\n */\n\npublic DelimiterBasedFrameDecoder(\n\n int maxFrameLength, boolean stripDelimiter, ByteBuf delimiter) {\n\n this(maxFrameLength, stripDelimiter, true, delimiter);\n\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003col\u003e\n\u003cli\u003e需要根据单个Netty服务端可以支持的最大客户端并发连接数、消息的最大长度限制以及当前JVM配置的最大内存进行计算,并结合业务场景,合理设置maxFrameLength的取值。\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch4\u003e2.1.2 ChannelHandler的并发执行\u003c/h4\u003e\n\u003cp\u003eNetty的ChannelHandler支持串行和异步并发执行两种策略,在将ChannelHandler加入到ChannelPipeline时,如果指定了EventExecutorGroup,则ChannelHandler将由EventExecutorGroup中的EventExecutor异步执行。这样的好处是可以实现Netty I/O线程与业务ChannelHandler逻辑执行的分离,防止ChannelHandler中耗时业务逻辑的执行阻塞I/O线程。\u003c/p\u003e\n\u003cp\u003eChannelHandler异步执行的流程如下所示:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/b0/70/b032b6cb0b2ba8f45c08e4f6bc118670.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图3 ChannelHandler异步并发执行流程\u003c/center\u003e\n\u003cp\u003e如果业务ChannelHandler中执行的业务逻辑耗时较长,消息的读取速度又比较快,很容易发生消息在EventExecutor中积压的问题,如果创建EventExecutor时没有通过io.netty.eventexecutor.maxPendingTasks参数指定积压的最大消息个数,则默认取值为0x7fffffff,长时间的积压将导致内存溢出,相关代码如下所示(异步执行ChannelHandler,将消息封装成Task加入到taskQueue中):\u003c/p\u003e\n\u003cpre\u003e\u003ccode\u003epublic void execute(Runnable task) {\n\n if (task == null) {\n\n throw new NullPointerException(\u0026quot;task\u0026quot;);\n\n }\n\n boolean inEventLoop = inEventLoop();\n\n if (inEventLoop) {\n\n addTask(task);\n\n } else {\n\n startThread();\n\n addTask(task);\n\n if (isShutdown() \u0026amp;\u0026amp; removeTask(task)) {\n\n reject();\n\n }\n\n }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e解决对策:对EventExecutor中任务队列的容量做限制,可以通过io.netty.eventexecutor.maxPendingTasks参数做全局设置,也可以通过构造方法传参设置。结合EventExecutorGroup中EventExecutor的个数来计算taskQueue的个数,根据taskQueue * N * 任务队列平均大小 * maxPendingTasks \u0026lt; 系数K(0 \u0026lt; K \u0026lt; 1)* 总内存的公式来进行计算和评估。\u003c/p\u003e\n\u003ch3\u003e2.2.消息发送\u003c/h3\u003e\n\u003ch4\u003e2.2.1 如何防止发送队列积压\u003c/h4\u003e\n\u003cp\u003e为了防止高并发场景下,由于对方处理慢导致自身消息积压,除了服务端做流控之外,客户端也需要做并发保护,防止自身发生消息积压。\u003c/p\u003e\n\u003cp\u003e利用Netty提供的高低水位机制,可以实现客户端更精准的流控,它的工作原理如下:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/78/0c/78661d909223110e33f59fd1c78f410c.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图4 Netty高水位接口说明\u003c/center\u003e\n\u003cp\u003e当发送队列待发送的字节数组达到高水位上限时,对应的Channel就变为不可写状态。由于高水位并不影响业务线程调用write方法并把消息加入到待发送队列中,因此,必须要在消息发送时对Channel的状态进行判断:当到达高水位时,Channel的状态被设置为不可写,通过对Channel的可写状态进行判断来决定是否发送消息。\u003c/p\u003e\n\u003cp\u003e在消息发送时设置高低水位并对Channel状态进行判断,相关代码示例如下:\u003c/p\u003e\n\u003cpre\u003e\u003ccode\u003epublic void channelActive(final ChannelHandlerContext ctx) {\n\n **ctx.channel().config().setWriteBufferHighWaterMark(10 \\* 1024 * 1024);**\n\n loadRunner = new Runnable() {\n\n @Override\n\n public void run() {\n\n try {\n\n TimeUnit.SECONDS.sleep(30);\n\n } catch (InterruptedException e) {\n\n e.printStackTrace();\n\n }\n\n ByteBuf msg = null;\n\n while (true) {\n\n **if (ctx.channel().isWritable()) {**\n\n msg = Unpooled.wrappedBuffer(\u0026quot;Netty OOM Example\u0026quot;.getBytes());\n\n ctx.writeAndFlush(msg);\n\n } else {\n\n LOG.warning(\u0026quot;The write queue is busy : \u0026quot; + ctx.channel().unsafe().outboundBuffer().nioBufferSize());\n\n }\n\n }\n\n }\n\n };\n\n new Thread(loadRunner, \u0026quot;LoadRunner-Thread\u0026quot;).start();\n\n }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e对上述代码做验证,客户端代码中打印队列积压相关日志,说明基于高水位的流控机制生效,日志如下:\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e警告: The write queue is busy : 17\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e通过内存监控,发现内存占用平稳:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/ec/0c/ecedaea03a5d397cdd58be29c18f020c.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图5 进行高低水位保护优化之后内存占用情况\u003c/center\u003e\n\u003cp\u003e在实际项目中,根据业务QPS规划、客户端处理性能、网络带宽、链路数、消息平均码流大小等综合因素计算并设置高水位(WriteBufferHighWaterMark)阈值,利用高水位做消息发送速率的流控,既可以保护自身,同时又能减轻服务端的压力,防止服务端被压挂。\u003c/p\u003e\n\u003ch4\u003e2.2.2 其它可能导致发送队列积压的因素\u003c/h4\u003e\n\u003cp\u003e需要指出的是,并非只有高并发场景才会触发消息积压,在一些异常场景下,尽管系统流量不大,但仍然可能会导致消息积压,可能的场景包括:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e网络瓶颈,发送速率超过网络链接处理能力时,会导致发送队列积压。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e对端读取速度小于己方发送速度,导致自身TCP发送缓冲区满,频繁发生write 0字节时,待发送消息会在Netty发送队列排队。\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e当出现大量排队时,很容易导致Netty的直接内存泄漏,示例如下:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/33/a0/336ff24f87e3b4c539376cf6701385a0.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图6 消息积压导致内存泄漏相关堆栈\u003c/center\u003e\n\u003cp\u003e我们在设计系统时,需要根据业务的场景、所处的网络环境等因素进行综合设计,为潜在的各种故障做容错和保护,防止因为外部因素导致自身发生内存泄漏。\u003c/p\u003e\n\u003ch2\u003e3. ByteBuf的申请和释放策略\u003c/h2\u003e\n\u003ch2\u003e3.1 ByteBuf申请和释放的理解误区\u003c/h2\u003e\n\u003cp\u003e有一种说法认为Netty框架分配的ByteBuf框架会自动释放,业务不需要释放;业务创建的ByteBuf则需要自己释放,Netty框架不会释放。\u003c/p\u003e\n\u003cp\u003e事实上,这种观点是错误的,即便ByteBuf是Netty创建的,如果使用不当仍然会发生内存泄漏。在实际项目中如何更好的管理ByteBuf,下面我们分四种场景进行说明。\u003c/p\u003e\n\u003ch3\u003e3.2 ByteBuf的释放策略\u003c/h3\u003e\n\u003ch4\u003e3.2.1 基于内存池的请求ByteBuf\u003c/h4\u003e\n\u003cp\u003e这类ByteBuf主要包括PooledDirectByteBuf和PooledHeapByteBuf,它由Netty的NioEventLoop线程在处理Channel的读操作时分配,需要在业务ChannelInboundHandler处理完请求消息之后释放(通常是解码之后),它的释放有2种策略:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e策略1:业务ChannelInboundHandler继承自SimpleChannelInboundHandler,实现它的抽象方法channelRead0(ChannelHandlerContext ctx, I msg),ByteBuf的释放业务不用关心,由SimpleChannelInboundHandler负责释放,相关代码如下所示(SimpleChannelInboundHandler):\u003c/li\u003e\n\u003c/ol\u003e\n\u003cpre\u003e\u003ccode\u003e @Override\n\n public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {\n\n boolean release = true;\n\n try {\n\n if (acceptInboundMessage(msg)) {\n\n I imsg = (I) msg;\n\n channelRead0(ctx, imsg);\n\n } else {\n\n release = false;\n\n ctx.fireChannelRead(msg);\n\n }\n\n } finally {\n\n **if (autoRelease \u0026amp;\u0026amp; release) {**\n\n **ReferenceCountUtil.release(msg);**\n\n **}**\n\n }\n\n }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e如果当前业务ChannelInboundHandler需要执行,则调用完channelRead0之后执行ReferenceCountUtil.release(msg)释放当前请求消息。如果没有匹配上需要继续执行后续的ChannelInboundHandler,则不释放当前请求消息,调用ctx.fireChannelRead(msg)驱动ChannelPipeline继续执行。\u003c/p\u003e\n\u003cp\u003e继承自SimpleChannelInboundHandler,即便业务不释放请求ByteBuf对象,依然不会发生内存泄漏,相关示例代码如下所示:\u003c/p\u003e\n\u003cpre\u003e\u003ccode\u003e public class RouterServerHandlerV2 **extends SimpleChannelInboundHandler\u0026lt;ByteBuf\u0026gt;** {\n\n//代码省略...\n\n@Override\n\n public void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {\n\n byte [] body = new byte[msg.readableBytes()];\n\n executorService.execute(()-\u0026gt;\n\n {\n\n //解析请求消息,做路由转发,代码省略...\n\n //转发成功,返回响应给客户端\n\n ByteBuf respMsg = allocator.heapBuffer(body.length);\n\n respMsg.writeBytes(body);//作为示例,简化处理,将请求返回\n\n ctx.writeAndFlush(respMsg);\n\n });\n\n }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e对上述代码做性能测试,发现内存占用平稳,无内存泄漏问题,验证了之前的分析结论。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e策略2:在业务ChannelInboundHandler中调用ctx.fireChannelRead(msg)方法,让请求消息继续向后执行,直到调用到DefaultChannelPipeline的内部类TailContext,由它来负责释放请求消息,代码如下所示(TailContext):\u003c/li\u003e\n\u003c/ol\u003e\n\u003cpre\u003e\u003ccode\u003e protected void onUnhandledInboundMessage(Object msg) {\n\n try {\n\n logger.debug(\n\n \u0026quot;Discarded inbound message {} that reached at the tail of the pipeline. \u0026quot; +\n\n \u0026quot;Please check your pipeline configuration.\u0026quot;, msg);\n\n **} finally {**\n\n **ReferenceCountUtil.release(msg);**\n\n **}**\n\n }\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch4\u003e3.2.2 基于非内存池的请求ByteBuf\u003c/h4\u003e\n\u003cp\u003e如果业务使用非内存池模式覆盖Netty默认的内存池模式创建请求ByteBuf,例如通过如下代码修改内存申请策略为Unpooled:\u003c/p\u003e\n\u003cpre\u003e\u003ccode\u003e//代码省略... \n\n.childHandler(new ChannelInitializer\u0026lt;SocketChannel\u0026gt;() {\n\n @Override\n\n public void initChannel(SocketChannel ch) throws Exception {\n\n ChannelPipeline p = ch.pipeline(); ch.config().setAllocator(UnpooledByteBufAllocator.DEFAULT);\n\n p.addLast(new RouterServerHandler());\n\n }\n\n }); \n\n }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e也需要按照内存池的方式去释放内存。\u003c/p\u003e\n\u003ch4\u003e3.2.3 基于内存池的响应ByteBuf\u003c/h4\u003e\n\u003cp\u003e只要调用了writeAndFlush或者flush方法,在消息发送完成之后都会由Netty框架进行内存释放,业务不需要主动释放内存。\u003c/p\u003e\n\u003cp\u003e它的工作原理如下:\u003c/p\u003e\n\u003cp\u003e调用ctx.writeAndFlush(respMsg)方法,当消息发送完成之后,Netty框架会主动帮助应用来释放内存,内存的释放分为两种场景:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e如果是堆内存(PooledHeapByteBuf),则将HeapByteBuffer转换成DirectByteBuffer,并释放PooledHeapByteBuf到内存池,代码如下(AbstractNioChannel类):\u003c/li\u003e\n\u003c/ol\u003e\n\u003cpre\u003e\u003ccode\u003eprotected final ByteBuf newDirectBuffer(ByteBuf buf) {\n\n​ final int readableBytes = buf.readableBytes();\n\n​ if (readableBytes == 0) {\n\n​ **ReferenceCountUtil.safeRelease(buf);**\n\n​ return Unpooled.EMPTY_BUFFER;\n\n​ }\n\n​ final ByteBufAllocator alloc = alloc();\n\n​ if (alloc.isDirectBufferPooled()) {\n\n​ ByteBuf directBuf = alloc.directBuffer(readableBytes);\n\n​ directBuf.writeBytes(buf, buf.readerIndex(), readableBytes);\n\n​ **ReferenceCountUtil.safeRelease(buf);**\n\n​ return directBuf;\n\n​ } }\n\n //后续代码省略\n\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e如果消息完整的被写到SocketChannel中,则释放DirectByteBuffer,代码如下(ChannelOutboundBuffer)所示:\u003c/p\u003e\n\u003cpre\u003e\u003ccode\u003epublic boolean remove() {\n\n​ Entry e = flushedEntry;\n\n​ if (e == null) {\n\n​ clearNioBuffers();\n\n​ return false;\n\n​ }\n\n​ Object msg = e.msg;\n\n​ ChannelPromise promise = e.promise;\n\n​ int size = e.pendingSize;\n\n​ removeEntry(e);\n\n​ if (!e.cancelled) {\n\n​ **ReferenceCountUtil.safeRelease(msg);**\n\n​ safeSuccess(promise);\n\n​ decrementPendingOutboundBytes(size, false, true);\n\n​ } \n\n //后续代码省略\n\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e对Netty源码进行断点调试,验证上述分析:\u003c/p\u003e\n\u003cp\u003e断点1:在响应消息发送处打印断点,获取到PooledUnsafeHeapByteBuf实例ID为1506。\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/27/a2/27f06a9eef7d8e94cbd49ba2636d30a2.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图7 响应发送处断点调试\u003c/center\u003e\n\u003cp\u003e断点2:在HeapByteBuffer转换成DirectByteBuffer处打断点,发现实例ID为1506的PooledUnsafeHeapByteBuf被释放。\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/31/ca/31693e2a745c2bafef2717020cdfefca.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图8 响应消息释放处断点\u003c/center\u003e\n\u003cp\u003e断点3:转换之后待发送的响应消息PooledUnsafeDirectByteBuf实例ID为1527。\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/09/90/0971748499f62d4545452440dae33d90.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图9 响应消息转换处断点\u003c/center\u003e\n\u003cp\u003e断点4:响应消息发送完成之后,实例ID为1527的PooledUnsafeDirectByteBuf被释放到内存池。\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/da/66/da6dbd203bc408c0cd7c5c08796d8666.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图10 转换之后的响应消息释放处断点\u003c/center\u003e\n\u003col\u003e\n\u003cli\u003e如果是DirectByteBuffer,则不需要转换,当消息发送完成之后,由ChannelOutboundBuffer的remove()负责释放。\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch4\u003e3.2.4 基于非内存池的响应ByteBuf\u003c/h4\u003e\n\u003cp\u003e无论是基于内存池还是非内存池分配的ByteBuf,如果是堆内存,则将堆内存转换成堆外内存,然后释放HeapByteBuffer,待消息发送完成之后,再释放转换后的DirectByteBuf;如果是DirectByteBuffer,则无需转换,待消息发送完成之后释放。因此对于需要发送的响应ByteBuf,由业务创建,但是不需要业务来释放。\u003c/p\u003e\n\u003ch2\u003e4. Netty服务端高并发保护\u003c/h2\u003e\n\u003ch3\u003e4.1 高并发场景下的OOM问题\u003c/h3\u003e\n\u003cp\u003e在RPC调用时,如果客户端并发连接数过多,服务端又没有针对并发连接数的流控机制,一旦服务端处理慢,就很容易发生批量超时和断连重连问题。\u003c/p\u003e\n\u003cp\u003e以Netty HTTPS服务端为例,典型的业务组网示例如下所示:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/18/8d/186128ea8236cea535158920f062dc8d.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图11 Netty HTTPS组网图\u003c/center\u003e\n\u003cp\u003e客户端采用HTTP连接池的方式与服务端进行RPC调用,单个客户端连接池上限为200,客户端部署了30个实例,而服务端只部署了3个实例。在业务高峰期,每个服务端需要处理6000个HTTP连接,当服务端时延增大之后,会导致客户端批量超时,超时之后客户端会关闭连接重新发起connect操作,在某个瞬间,几千个HTTPS连接同时发起SSL握手操作,由于服务端此时也处于高负荷运行状态,就会导致部分连接SSL握手失败或者超时,超时之后客户端会继续重连,进一步加重服务端的处理压力,最终导致服务端来不及释放客户端close的连接,引起NioSocketChannel大量积压,最终OOM。\u003c/p\u003e\n\u003cp\u003e通过客户端的运行日志可以看到一些SSL握手发生了超时,示例如下:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/25/aa/2526fe27b31cb769e8133f7407e91baa.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图12 SSL握手超时日志\u003c/center\u003e\n\u003cp\u003e服务端并没有对客户端的连接数做限制,这会导致尽管ESTABLISHED状态的连接数并不会超过6000上限,但是由于一些SSL连接握手失败,再加上积压在服务端的连接并没有及时释放,最终引起了NioSocketChannel的大量积压。\u003c/p\u003e\n\u003ch3\u003e4.2.Netty HTTS并发连接数流控\u003c/h3\u003e\n\u003cp\u003e在服务端增加对客户端并发连接数的控制,原理如下所示:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/5e/c6/5e361777ccf2bbf1e0150cdac52123c6.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图13 服务端HTTS连接数流控\u003c/center\u003e\n\u003cp\u003e基于Netty的Pipeline机制,可以对SSL握手成功、SSL连接关闭做切面拦截(类似于Spring的AOP机制,但是没采用反射机制,性能更高),通过流控切面接口,对HTTPS连接做计数,根据计数器做流控,服务端的流控算法如下:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e获取流控阈值。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e从全局上下文中获取当前的并发连接数,与流控阈值对比,如果小于流控阈值,则对当前的计数器做原子自增,允许客户端连接接入。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e如果等于或者大于流控阈值,则抛出流控异常给客户端。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003eSSL连接关闭时,获取上下文中的并发连接数,做原子自减。\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e在实现服务端流控时,需要注意如下几点:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e流控的ChannelHandler声明为@ChannelHandler.Sharable,这样全局创建一个流控实例,就可以在所有的SSL连接中共享。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e通过userEventTriggered方法拦截SslHandshakeCompletionEvent和SslCloseCompletionEvent事件,在SSL握手成功和SSL连接关闭时更新流控计数器。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e流控并不是单针对ESTABLISHED状态的HTTP连接,而是针对所有状态的连接,因为客户端关闭连接,并不意味着服务端也同时关闭了连接,只有SslCloseCompletionEvent事件触发时,服务端才真正的关闭了NioSocketChannel,GC才会回收连接关联的内存。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e流控ChannelHandler会被多个NioEventLoop线程调用,因此对于相关的计数器更新等操作,要保证并发安全性,避免使用全局锁,可以通过原子类等提升性能。\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2\u003e5. 总结\u003c/h2\u003e\n\u003ch3\u003e5.1.其它的防内存泄漏措施\u003c/h3\u003e\n\u003ch4\u003e5.1.1 NioEventLoop\u003c/h4\u003e\n\u003cp\u003e执行它的execute(Runnable task)以及定时任务相关接口时,如果任务执行耗时过长、任务执行频度过高,可能会导致任务队列积压,进而引起OOM:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/de/10/debcfba3963165d3d59d1ff5a159b310.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图14 NioEventLoop定时任务执行接口\u003c/center\u003e\n\u003cp\u003e建议业务在使用时,对NioEventLoop队列的积压情况进行采集和告警。\u003c/p\u003e\n\u003ch4\u003e5.1.2 客户端连接池\u003c/h4\u003e\n\u003cp\u003e业务在初始化连接池时,如果采用每个客户端连接对应一个EventLoopGroup实例的方式,即每创建一个客户端连接,就会同时创建一个NioEventLoop线程来处理客户端连接以及后续的网络读写操作,采用的策略是典型的1个TCP连接对应一个NIO线程的模式。当系统的连接数很多、堆内存又不足时,就会发生内存泄漏或者线程创建失败异常。问题示意如下:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/a1/55/a15dabd1781e38910585c50efe370a55.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图15 错误的客户端线程模型\u003c/center\u003e\n\u003cp\u003e优化策略:客户端创建连接池时,EventLoopGroup可以重用,优化之后的连接池线程模型如下所示:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/15/80/151444770d0aef1d5d84053fb5c59780.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图16 正确的客户端线程模型\u003c/center\u003e\n\u003ch3\u003e5.2 内存泄漏问题定位\u003c/h3\u003e\n\u003ch4\u003e5.2.1 堆内存泄漏\u003c/h4\u003e\n\u003cp\u003e通过jmap -dump:format=b,file=xx pid命令Dump内存堆栈,然后使用MemoryAnalyzer工具对内存占用进行分析,查找内存泄漏点,然后结合代码进行分析,定位内存泄漏的具体原因,示例如下所示:\u003c/p\u003e\n\u003cp\u003e\u003cimg src=\"https://static001.infoq.cn/resource/image/42/05/42bcf421961436d6dfafac017db32805.jpg\" alt=\"\" /\u003e\u003c/p\u003e\n\u003ccenter\u003e图17 通过MemoryAnalyzer工具分析内存堆栈\u003c/center\u003e\n\u003ch4\u003e5.2.2 堆外内存泄漏\u003c/h4\u003e\n\u003cp\u003e建议策略如下:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e排查下业务代码,看使用堆外内存的地方是否存在忘记释放问题。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e如果使用到了Netty的TLS/SSL/openssl,建议到Netty社区查下BUG列表,看是否是Netty老版本已知的BUG,此类BUG通过升级Netty版本可以解决。\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e如果上述两个步骤排查没有结果,则可以通过google-perftools工具协助进行堆外内存分析。\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2\u003e6. 作者简介\u003c/h2\u003e\n\u003cp\u003e李林锋,10年Java NIO、平台中间件设计和开发经验,精通Netty、Mina、分布式服务框架、API Gateway、PaaS等,《Netty进阶之路》、《分布式服务框架原理与实践》作者。目前在华为终端应用市场负责业务微服务化、云化、全球化等相关设计和开发工作。\u003c/p\u003e\n\u003cp\u003e联系方式:新浪微博 Nettying 微信:Nettying\u003c/p\u003e\n\u003cp\u003eEmail:[email protected]\u003c/p\u003e\n

你可能感兴趣的:(Netty防止内存泄漏措施)