深入理解Netty-從偶現宕機看Netty流量控制
作者:vivo互聯網服務器團隊-Zhang Lin
一、業(yè)務背景
目前移動端的使用場景中會用到大量的消息推送,push消息可以幫助運營人員更高效地實現運營目標(比如給用戶推送營銷活動或者提醒APP新功能)。
對于推送系統(tǒng)來說需要具備以下兩個特性:
- 消息秒級送到用戶,無延時,支持每秒百萬推送,單機百萬長連接。
- 支持通知、文本、自定義消息透傳等展現形式。正是由于以上原因,對于系統(tǒng)的開發(fā)和維護帶來了挑戰(zhàn)。下圖是推送系統(tǒng)的簡單描述(API->推送模塊->手機)。

二、問題背景
推送系統(tǒng)中長連接集群在穩(wěn)定性測試、壓力測試階運行一段時間后隨機會出現一個進程掛掉的情況,概率較?。l率為一個月左右發(fā)生一次),這會影響部分客戶端消息送到的時效。
推送系統(tǒng)中的長連接節(jié)點(Broker系統(tǒng))是基于Netty開發(fā),此節(jié)點維護了服務端和手機終端的長連接,線上問題出現后,添加Netty內存泄露監(jiān)控參數進行問題排查,觀察多天但并未排查出問題。
由于長連接節(jié)點是Netty開發(fā),為便于讀者理解,下面簡單介紹一下Netty。
三、?Netty介紹
Netty是一個高性能、異步事件驅動的NIO框架,基于Java NIO提供的API實現。它提供了對TCP、UDP和文件傳輸的支持,作為當前最流行的NIO框架,Netty在互聯網領域、大數據分布式計算領域、游戲行業(yè)、通信行業(yè)等獲得了廣泛的應用,HBase,Hadoop,Bees,Dubbo等開源組件也基于Netty的NIO框架構建。
四、問題分析
4.1 猜想
最初猜想是長連接數導致的,但經過排查日志、分析代碼,發(fā)現并不是此原因造成。
長連接數:39萬,如下圖:

每個channel字節(jié)大小1456, 按40萬長連接計算,不致于產生內存過大現象。
4.2 查看GC日志
查看GC日志,發(fā)現進程掛掉之前頻繁full GC(頻率5分鐘一次),但內存并未降低,懷疑堆外內存泄露。
4.3 分析heap內存情況
ChannelOutboundBuffer對象占將近5G內存,泄露原因基本可以確定:ChannelOutboundBuffer的entry數過多導致,查看ChannelOutboundBuffer的源碼可以分析出,是ChannelOutboundBuffer中的數據。
沒有寫出去,導致一直積壓;ChannelOutboundBuffer內部是一個鏈表結構。

4.4 從上圖分析數據未寫出去,為什么會出現這種情況?
代碼中實際有判斷連接是否可用的情況(Channel.isActive),并且會對超時的連接進行關閉。從歷史經驗來看,這種情況發(fā)生在連接半打開(客戶端異常關閉)的情況比較多---雙方不進行數據通信無問題。
按上述猜想,測試環(huán)境進行重現和測試。
1)模擬客戶端集群,并與長連接服務器建立連接,設置客戶端節(jié)點的防火墻,模擬服務器與客戶端網絡異常的場景(即要模擬Channel.isActive調用成功,但數據實際發(fā)送不出去的情況)。
2)調小堆外內存,持續(xù)發(fā)送測試消息給之前的客戶端。消息大?。?K左右)。
3)按照128M內存來計算,實際上調用9W多次就會出現。

五、問題解決
5.1 啟用autoRead機制
當channel不可寫時,關閉autoRead;
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { if (!ctx.channel().isWritable()) { Channel channel = ctx.channel(); ChannelInfo channelInfo = ChannelManager.CHANNEL_CHANNELINFO.get(channel); String clientId = ""; if (channelInfo != null) { clientId = channelInfo.getClientId(); }
LOGGER.info("channel is unwritable, turn off autoread, clientId:{}", clientId); channel.config().setAutoRead(false); }}當數據可寫時開啟autoRead;
@Overridepublic void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception{ Channel channel = ctx.channel(); ChannelInfo channelInfo = ChannelManager.CHANNEL_CHANNELINFO.get(channel); String clientId = ""; if (channelInfo != null) { clientId = channelInfo.getClientId(); } if (channel.isWritable()) { LOGGER.info("channel is writable again, turn on autoread, clientId:{}", clientId); channel.config().setAutoRead(true); }}說明:

autoRead的作用是更精確的速率控制,如果打開的時候Netty就會幫我們注冊讀事件。當注冊了讀事件后,如果網絡可讀,則Netty就會從channel讀取數據。那如果autoread關掉后,則Netty會不注冊讀事件。
這樣即使是對端發(fā)送數據過來了也不會觸發(fā)讀事件,從而也不會從channel讀取到數據。當recv_buffer滿時,也就不會再接收數據。
5.2 設置高低水位
serverBootstrap.option(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(1024 * 1024, 8 * 1024 * 1024));注:高低水位配合后面的isWritable使用
5.3 增加channel.isWritable()的判斷
channel是否可用除了校驗channel.isActive()還需要加上channel.isWrite()的判斷,isActive只是保證連接是否激活,而是否可寫由isWrite來決定。
private void writeBackMessage(ChannelHandlerContext ctx, MqttMessage message) { Channel channel = ctx.channel(); //增加channel.isWritable()的判斷 if (channel.isActive() 




