推送服務
還記得一年半前,做的一個項目需要用到 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