深入理解 Netty- 从偶现宕机看 Netty 流量控制
一、业务背景
目前移动端的使用场景中会用到大量的消息推送,push 消息可以帮助运营人员更高效地实现运营目标(比如给用户推送营销活动或者提醒 APP 新功能)。
对于推送系统来说需要具备以下两个特性:
消息秒级送到用户,无延时,支持每秒百万推送,单机百万长连接。
支持通知、文本、自定义消息透传等展现形式。正是由于以上原因,对于系统的开发和维护带来了挑战。下图是推送系统的简单描述(API->推送模块->手机)。
二、问题背景
推送系统中长连接集群在稳定性测试、压力测试阶运行一段时间后随机会出现一个进程挂掉的情况,概率较小(频率为一个月左右发生一次),这会影响部分客户端消息送到的时效。
推送系统中的长连接节点(Broker 系统)是基于 Netty 开发,此节点维护了服务端和手机终端的长连接,线上问题出现后,添加 Netty 内存泄露监控参数进行问题排查,观察多天但并未排查出问题。
由于长连接节点是 Netty 开发,为便于读者理解,下面简单介绍一下 Netty。
三、 Netty 介绍
Netty 是一个高性能、异步事件驱动的 NIO 框架,基于 Java NIO 提供的 API 实现。它提供了对 TCP、UDP 和文件传输的支持,作为当前最流行的 NIO 框架,Netty 在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,HBase,Hadoop,Bees,Dubbo 等开源组件也基于 Netty 的 NIO 框架构建。
四、问题分析
4.1 猜想
最初猜想是长连接数导致的,但经过排查日志、分析代码,发现并不是此原因造成。
长连接数:39 万,如下图:
每个 channel 字节大小 1456, 按 40 万长连接计算,不致于产生内存过大现象。
4.2 查看 GC 日志
查看 GC 日志,发现进程挂掉之前频繁 full GC(频率 5 分钟一次),但内存并未降低,怀疑堆外内存泄露。
4.3 分析 heap 内存情况
ChannelOutboundBuffer 对象占将近 5G 内存,泄露原因基本可以确定:ChannelOutboundBuffer 的 entry 数过多导致,查看 ChannelOutboundBuffer 的源码可以分析出,是 ChannelOutboundBuffer 中的数据。
没有写出去,导致一直积压;ChannelOutboundBuffer 内部是一个链表结构。
4.4 从上图分析数据未写出去,为什么会出现这种情况?
代码中实际有判断连接是否可用的情况(Channel.isActive),并且会对超时的连接进行关闭。从历史经验来看,这种情况发生在连接半打开(客户端异常关闭)的情况比较多---双方不进行数据通信无问题。
按上述猜想,测试环境进行重现和测试。
1)模拟客户端集群,并与长连接服务器建立连接,设置客户端节点的防火墙,模拟服务器与客户端网络异常的场景(即要模拟 Channel.isActive 调用成功,但数据实际发送不出去的情况)。
2)调小堆外内存,持续发送测试消息给之前的客户端。消息大小(1K 左右)。
3)按照 128M 内存来计算,实际上调用 9W 多次就会出现。
五、问题解决
5.1 启用 autoRead 机制
当 channel 不可写时,关闭 autoRead;
当数据可写时开启 autoRead;
说明:
autoRead 的作用是更精确的速率控制,如果打开的时候 Netty 就会帮我们注册读事件。当注册了读事件后,如果网络可读,则 Netty 就会从 channel 读取数据。那如果 autoread 关掉后,则 Netty 会不注册读事件。
这样即使是对端发送数据过来了也不会触发读事件,从而也不会从 channel 读取到数据。当 recv_buffer 满时,也就不会再接收数据。
5.2 设置高低水位
注:高低水位配合后面的 isWritable 使用
5.3 增加 channel.isWritable()的判断
channel 是否可用除了校验 channel.isActive()还需要加上 channel.isWrite()的判断,isActive 只是保证连接是否激活,而是否可写由 isWrite 来决定。
注:isWritable 可以来控制 ChannelOutboundBuffer,不让其无限制膨胀。其机制就是利用设置好的 channel 高低水位来进行判断。
5.4 问题验证
修改后再进行测试,发送到 27W 次也并不报错;
六、解决思路分析
一般 Netty 数据处理流程如下:将读取的数据交由业务线程处理,处理完成再发送出去(整个过程是异步的),Netty 为了提高网络的吞吐量,在业务层与 socket 之间增加了一个 ChannelOutboundBuffer。
在调用 channel.write 的时候,所有写出的数据其实并没有写到 socket,而是先写到 ChannelOutboundBuffer。当调用 channel.flush 的时候才真正的向 socket 写出。因为这中间有一个 buffer,就存在速率匹配了,而且这个 buffer 还是无界的(链表),也就是你如果没有控制 channel.write 的速度,会有大量的数据在这个 buffer 里堆积,如果又碰到 socket 写不出数据的时候(isActive 此时判断无效)或者写得慢的情况。
很有可能的结果就是资源耗尽,而且如果 ChannelOutboundBuffer 存放的是 DirectByteBuffer,这会让问题更加难排查。
流程可抽象如下:
从上面的分析可以看出,步骤一写太快(快到处理不过来)或者下游发送不出数据都会造成问题,这实际是一个速率匹配问题。
七、Netty 源码说明
超过高水位
当 ChannelOutboundBuffer 的容量超过高水位设定阈值后,isWritable()返回 false,设置 channel 不可写(setUnwritable),并且触发 fireChannelWritabilityChanged()。
低于低水位
当 ChannelOutboundBuffer 的容量低于低水位设定阈值后,isWritable()返回 true,设置 channel 可写,并且触发 fireChannelWritabilityChanged()。
八、总结
当 ChannelOutboundBuffer 的容量超过高水位设定阈值后,isWritable()返回 false,表明消息产生堆积,需要降低写入速度。
当 ChannelOutboundBuffer 的容量低于低水位设定阈值后,isWritable()返回 true,表明消息过少,需要提高写入速度。通过以上三个步骤修改后,部署线上观察半年未发生问题出现。
作者:vivo 互联网服务器团队-Zhang Lin
版权声明: 本文为 InfoQ 作者【vivo互联网技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/9280ea7bba6dcd9d17a454b0f】。文章转载请联系作者。
评论