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 直接寫的優勢有這些:

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

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

瓶頸是什麼

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

其實目標主要就兩個:

  1. 更多的連接

  2. 更高的 QPS

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

更多的連接

非阻塞 IO

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

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

Java NIO 實現百萬連接

 1ServerSocketChannel ssc = ServerSocketChannel.open();
 2Selector sel = Selector.open();
 3
 4ssc.configureBlocking(false);
 5ssc.socket().bind(new InetSocketAddress(8080));
 6SelectionKey key = ssc.register(sel, SelectionKey.OP_ACCEPT);
 7
 8while(true) {
 9    sel.select();
10    Iterator it = sel.selectedKeys().iterator();
11    while(it.hasNext()) {
12        SelectionKey skey = (SelectionKey)it.next();
13        it.remove();
14        if(skey.isAcceptable()) {
15            ch = ssc.accept();
16        }
17    }
18}
19
20

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

Netty 實現百萬連接

 1NioEventLoopGroup bossGroup =  new NioEventLoopGroup();
 2NioEventLoopGroup workerGroup= new NioEventLoopGroup();
 3ServerBootstrap bootstrap = new ServerBootstrap();
 4bootstrap.group(bossGroup, workerGroup);
 5
 6bootstrap.channel( NioServerSocketChannel.class);
 7
 8bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
 9    @Override protected void initChannel(SocketChannel ch) throws Exception {
10        ChannelPipeline pipeline = ch.pipeline();
11        //todo: add handler
12    }});
13bootstrap.bind(8080).sync();
14
15

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

瓶頸到底在哪

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

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

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

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

這裏直接貼幾篇文章,介紹了相關配置的修改方式:

構建 C1000K 的服務器

100 萬併發連接服務器筆記之 1M 併發連接目標達成

淘寶技術分享 HTTP 長連接 200 萬嘗試及調優

如何驗證

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

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

 1NioEventLoopGroup workerGroup =  new NioEventLoopGroup();
 2Bootstrap b = new Bootstrap();
 3b.group(workerGroup);
 4b.channel( NioSocketChannel.class);
 5
 6b.handler(new ChannelInitializer<SocketChannel>() {
 7    @Override
 8    public void initChannel(SocketChannel ch) throws Exception {
 9        ChannelPipeline pipeline = ch.pipeline();
10        //todo:add handler
11    }
12    });
13
14for (int k = 0; k < 60000; k++) {
15    //請自行修改成服務端的IP
16    b.connect(127.0.0.1, 8080);
17}
18
19

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

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

怎麼去找那麼多機器

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

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

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

討巧的做法

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

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

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

  1. 啓動服務端,千萬別設置 Socket 的keep-alive屬性,默認是不設置的

  2. 用虛擬機連接服務器

  3. 強制關閉虛擬機

  4. 修改虛擬機網卡的 MAC 地址,重新啓動並連接服務器

  5. 服務端接受新的連接,並保持之前的連接不斷

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

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

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

更高的 QPS

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

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

是數據結構的設計!

如何優化數據結構

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

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

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

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

解決 CPU 瓶頸

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

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

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

備註:Sample 和 Profiler 有什麼區別?前者是抽樣,數據不是最準但是不影響性能;後者是統計準確,但是非常影響性能。如果你的程序非常耗 CPU,那麼儘量用 Sample,否則開啓 Profiler 後降低性能,反而會影響準確性。

sample

還記得我們項目第一次發現的瓶頸竟然是ConcurrentLinkedQueue這個類中的size()方法。量小的時候沒有影響,但是Queue很大的時候,它每次都是從頭統計總數的,而這個size()方法我們又是非常頻繁地調用的,所以對性能產生了影響。

size()的實現如下:

 1public int size() {
 2    int count = 0;
 3    for (Node<E> p = first(); p != null; p = succ(p))
 4    if (p.item != null)
 5    // Collection.size() spec says to max out
 6    if (++count == Integer.MAX_VALUE)
 7    break;
 8    return count;
 9}
10
11

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

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

解決 GC 瓶頸

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

這裏還是在用 VisualVM,但是你需要裝一個插件:VisualGC

GC

有了這個插件後,你就可以直觀的看到 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 能有更大的突破!

轉載自:https://www.dozer.cc/2014/12/netty-long-connection.html

原文作者:dozer

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/v-WpOgEQkoWOuAurMFJfdg