Netty 實現長連接服務的難點和優化點

推送服務

還記得一年半前,做的一個項目需要用到 Android 推送服務。和 iOS 不同,Android 生態中沒有統一的推送服務。Google 雖然有 Google Cloud Messaging ,但是連國外都沒統一,更別說國內了,直接被牆。

所以之前在 Android 上做推送大部分只能靠輪詢。而我們之前在技術調研的時候,搜到了 jPush 的博客,上面介紹了一些他們的技術特點,他們主要做的其實就是移動網絡下的長連接服務。單機 50W-100W 的連接的確是嚇我一跳!後來我們也採用了他們的免費方案,因爲是一個受衆面很小的產品,所以他們的免費版夠我們用了。一年多下來,運作穩定,非常不錯!

時隔兩年,換了部門後,竟然接到了一項任務,優化公司自己的長連接服務端。

再次搜索網上技術資料後才發現,相關的很多難點都被攻破,網上也有了很多的總結文章,單機 50W-100W 的連接完全不是夢,其實人人都可以做到。但是光有連接還不夠,QPS 也要一起上去。

所以,這篇文章就是彙總一下利用 Netty 實現長連接服務過程中的各種難點和可優化點。

Netty 是什麼

Netty: http://netty.io/

Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients.

官方的解釋最精準了,其中最吸引人的就是高性能了。但是很多人會有這樣的疑問:直接用 NIO 實現的話,一定會更快吧?就像我直接手寫 JDBC 雖然代碼量大了點,但是一定比 iBatis 快!

但是,如果瞭解 Netty 後你纔會發現,這個還真不一定!

利用 Netty 而不用 NIO 直接寫的優勢有這些:

  • 高性能高擴展的架構設計,大部分情況下你只需要關注業務而不需要關注架構
  • Zero-Copy 技術儘量減少內存拷貝
  • 爲 Linux 實現 Native 版 Socket
  • 寫同一份代碼,兼容 java 1.7 的 NIO2 和 1.7 之前版本的 NIO
  • Pooled Buffers 大大減輕 Buffer 和釋放 Buffer 的壓力
  • ……

特性太多,大家可以去看一下《Netty in Action》這本書瞭解更多。

另外,Netty 源碼是一本很好的教科書!大家在使用的過程中可以多看看它的源碼,非常棒!

瓶頸是什麼

想要做一個長鏈服務的話,最終的目標是什麼?而它的瓶頸又是什麼?

其實目標主要就兩個:

  • 更多的連接
  • 更高的 QPS

所以,下面就針對這兩個目標來說說他們的難點和注意點吧。

更多的連接

非阻塞 IO 其實無論是用 Java NIO 還是用 Netty,達到百萬連接都沒有任何難度。因爲它們都是非阻塞的 IO,不需要爲每個連接創建一個線程了。

欲知詳情,可以搜索一下BIO,NIO,AIO的相關知識點。

Java NIO 實現百萬連接

ServerSocketChannel ssc = ServerSocketChannel.open();  
Selector sel = Selector.open();  
  
ssc.configureBlocking(false);  
ssc.socket().bind(new InetSocketAddress(8080));  
SelectionKey key = ssc.register(sel, SelectionKey.OP_ACCEPT);  
  
while(true) {  
    sel.select();  
    Iterator it = sel.selectedKeys().iterator();  
    while(it.hasNext()) {  
        SelectionKey skey = (SelectionKey)it.next();  
        it.remove();  
        if(skey.isAcceptable()) {  
            ch = ssc.accept();  
        }  
    }  
}  

這段代碼只會接受連過來的連接,不做任何操作,僅僅用來測試待機連接數極限。

大家可以看到這段代碼是 NIO 的基本寫法,沒什麼特別的。

Netty 實現百萬連接

NioEventLoopGroup bossGroup =  new NioEventLoopGroup();  
NioEventLoopGroup workerGroup= new NioEventLoopGroup();  
ServerBootstrap bootstrap = new ServerBootstrap();  
bootstrap.group(bossGroup, workerGroup);  
  
bootstrap.channel( NioServerSocketChannel.class);  
  
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {  
    @Override protected void initChannel(SocketChannel ch) throws Exception {  
        ChannelPipeline pipeline = ch.pipeline();  
        //todo: add handler  
    }});  
bootstrap.bind(8080).sync();  

這段其實也是非常簡單的 Netty 初始化代碼。同樣,爲了實現百萬連接根本沒有什麼特殊的地方。

瓶頸到底在哪 上面兩種不同的實現都非常簡單,沒有任何難度,那有人肯定會問了:實現百萬連接的瓶頸到底是什麼?

其實只要 java 中用的是非阻塞 IO(NIO 和 AIO 都算),那麼它們都可以用單線程來實現大量的 Socket 連接。不會像 BIO 那樣爲每個連接創建一個線程,因爲代碼層面不會成爲瓶頸。

其實真正的瓶頸是在 Linux 內核配置上,默認的配置會限制全局最大打開文件數(Max Open Files)還會限制進程數。所以需要對 Linux 內核配置進行一定的修改纔可以。

這個東西現在看似很簡單,按照網上的配置改一下就行了,但是大家一定不知道第一個研究這個人有多難。

如何驗證 讓服務器支持百萬連接一點也不難,我們當時很快就搞定了一個測試服務端,但是最大的問題是,我怎麼去驗證這個服務器可以支撐百萬連接呢?

我們用 Netty 寫了一個測試客戶端,它同樣用了非阻塞 IO ,所以不用開大量的線程。但是一臺機器上的端口數是有限制的,用root權限的話,最多也就 6W 多個連接了。所以我們這裏用 Netty 寫一個客戶端,用盡單機所有的連接吧。

NioEventLoopGroup workerGroup =  new NioEventLoopGroup();  
Bootstrap b = new Bootstrap();  
b.group(workerGroup);  
b.channel( NioSocketChannel.class);  
  
b.handler(new ChannelInitializer<SocketChannel>() {  
    @Override  
    public void initChannel(SocketChannel ch) throws Exception {  
        ChannelPipeline pipeline = ch.pipeline();  
        //todo:add handler  
    }  
    });  
  
for (int k = 0; k < 60000; k++) {  
    //請自行修改成服務端的IP  
    b.connect(127.0.0.1, 8080);  
}  

代碼同樣很簡單,只要連上就行了,不需要做任何其他的操作。

這樣只要找到一臺電腦啓動這個程序即可。這裏需要注意一點,客戶端最好和服務端一樣,修改一下 Linux 內核參數配置。

怎麼去找那麼多機器

按照上面的做法,單機最多可以有 6W 的連接,百萬連接起碼需要17臺機器!

如何才能突破這個限制呢?其實這個限制來自於網卡。我們後來通過使用虛擬機,並且把虛擬機的虛擬網卡配置成了橋接模式解決了問題。

根據物理機內存大小,單個物理機起碼可以跑4-5個虛擬機,所以最終百萬連接只要4臺物理機就夠了。

討巧的做法

除了用虛擬機充分壓榨機器資源外,還有一個非常討巧的做法,這個做法也是我在驗證過程中偶然發現的。

根據 TCP/IP 協議,任何一方發送FIN後就會啓動正常的斷開流程。而如果遇到網絡瞬斷的情況,連接並不會自動斷開。

那我們是不是可以這樣做?

  • 啓動服務端,千萬別設置 Socket 的keep-alive屬性,默認是不設置的
  • 用虛擬機連接服務器
  • 強制關閉虛擬機
  • 修改虛擬機網卡的 MAC 地址,重新啓動並連接服務器
  • 服務端接受新的連接,並保持之前的連接不斷

我們要驗證的是服務端的極限,所以只要一直讓服務端認爲有那麼多連接就行了,不是嗎?

經過我們的試驗後,這種方法和用真實的機器連接服務端的表現是一樣的,因爲服務端只是認爲對方網絡不好罷了,不會將你斷開。

另外,禁用keep-alive是因爲如果不禁用,Socket 連接會自動探測連接是否可用,如果不可用會強制斷開。

更高的 QPS

由於 NIO 和 Netty 都是非阻塞 IO,所以無論有多少連接,都只需要少量的線程即可。而且 QPS 不會因爲連接數的增長而降低(在內存足夠的前提下)。

而且 Netty 本身設計得足夠好了,Netty 不是高 QPS 的瓶頸。那高 QPS 的瓶頸是什麼?

是數據結構的設計!

如何優化數據結構 首先要熟悉各種數據結構的特點是必需的,但是在複雜的項目中,不是用了一個集合就可以搞定的,有時候往往是各種集合的組合使用。

既要做到高性能,還要做到一致性,還不能有死鎖,這裏難度真的不小…

我在這裏總結的經驗是,不要過早優化。優先考慮一致性,保證數據的準確,然後再去想辦法優化性能。

因爲一致性比性能重要得多,而且很多性能問題在量小和量大的時候,瓶頸完全會在不同的地方。所以,我覺得最佳的做法是,編寫過程中以一致性爲主,性能爲輔;代碼完成後再去找那個 TOP1,然後去解決它!

解決 CPU 瓶頸 在做這個優化前,先在測試環境中去狠狠地壓你的服務器,量小量大,天壤之別。

有了壓力測試後,就需要用工具來發現性能瓶頸了!

我喜歡用的是 VisualVM,打開工具後看抽樣器(Sample),根據自用時間(Self Time (CPU))倒序,排名第一的就是你需要去優化的點了!

備註:Sample 和 Profiler 有什麼區別?前者是抽樣,數據不是最準但是不影響性能;後者是統計準確,但是非常影響性能。如果你的程序非常耗 CPU,那麼儘量用 Sample,否則開啓 Profiler 後降低性能,反而會影響準確性。 在這裏插入圖片描述 還記得我們項目第一次發現的瓶頸竟然是ConcurrentLinkedQueue這個類中的size()方法。量小的時候沒有影響,但是Queue很大的時候,它每次都是從頭統計總數的,而這個size()方法我們又是非常頻繁地調用的,所以對性能產生了影響。

size()的實現如下:

public int size() {  
    int count = 0;  
    for (Node<E> p = first(); p != null; p = succ(p))  
    if (p.item != null)  
    // Collection.size() spec says to max out  
    if (++count == Integer.MAX_VALUE)  
    break;  
    return count;  
}  

後來我們通過額外使用一個AtomicInteger來計數,解決了問題。但是分離後豈不是做不到高一致性呢?沒關係,我們的這部分代碼關心最終一致性,所以只要保證最終一致就可以了。

總之,具體案例要具體分析,不同的業務要用不同的實現。

解決 GC 瓶頸 GC 瓶頸也是 CPU 瓶頸的一部分,因爲不合理的 GC 會大大影響 CPU 性能。

這裏還是在用 VisualVM,但是你需要裝一個插件:VisualGC 在這裏插入圖片描述 有了這個插件後,你就可以直觀的看到 GC 活動情況了。

按照我們的理解,在壓測的時候,有大量的 New GC 是很正常的,因爲有大量的對象在創建和銷燬。

但是一開始有很多 Old GC 就有點說不過去了!

後來發現,在我們壓測環境中,因爲 Netty 的 QPS 和連接數關聯不大,所以我們只連接了少量的連接。內存分配得也不是很多。

而 JVM 中,默認的新生代和老生代的比例是1:2,所以大量的老生代被浪費了,新生代不夠用。

通過調整 -XX:NewRatio 後,Old GC 有了顯著的降低。

但是,生產環境又不一樣了,生產環境不會有那麼大的 QPS,但是連接會很多,連接相關的對象存活時間非常長,所以生產環境更應該分配更多的老生代。

總之,GC 優化和 CPU 優化一樣,也需要不斷調整,不斷優化,不是一蹴而就的。

其他優化

如果你已經完成了自己的程序,那麼一定要看看《Netty in Action》作者的這個網站:Netty Best Practices a.k.a Faster == Better。

相信你會受益匪淺,經過裏面提到的一些小小的優化後,我們的整體 QPS 提升了很多。

最後一點就是,java 1.7 比 java 1.6 性能高很多!因爲 Netty 的編寫風格是事件機制的,看似是 AIO。可 java 1.6 是沒有 AIO 的,java 1.7 是支持 AIO 的,所以如果用 java 1.7 的話,性能也會有顯著提升。

最後成果

經過幾周的不斷壓測和不斷優化了,我們在一臺16核、120G內存(JVM只分配8G)的機器上,用 java 1.6 達到了60萬的連接和20萬的QPS。

其實這還不是極限,JVM 只分配了8G內存,內存配置再大一點連接數還可以上去;

QPS 看似很高,System Load Average 很低,也就是說明瓶頸不在 CPU 也不在內存,那麼應該是在 IO 了!上面的 Linux 配置是爲了達到百萬連接而配置的,並沒有針對我們自己的業務場景去做優化。

因爲目前性能完全夠用,線上單機 QPS 最多才 1W,所以我們先把精力放在了其他地方。相信後面我們還會去繼續優化這塊的性能,期待 QPS 能有更大的突破!

來源:dozer.cc/2014/12/netty-long-connection.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章