Netty 實(shí)現(xiàn)百萬(wàn)級(jí)連接服務(wù)的難點(diǎn)和優(yōu)點(diǎn)分析總結(jié)!
推送服務(wù)
還記得一年半前,做的一個(gè)項(xiàng)目需要用到 Android 推送服務(wù)。和 iOS 不同,Android 生態(tài)中沒(méi)有統(tǒng)一的推送服務(wù)。Google 雖然有 Google Cloud Messaging ,但是連國(guó)外都沒(méi)統(tǒng)一,更別說(shuō)國(guó)內(nèi)了,直接被墻。
所以之前在 Android 上做推送大部分只能靠輪詢。而我們之前在技術(shù)調(diào)研的時(shí)候,搜到了 jPush 的博客,上面介紹了一些他們的技術(shù)特點(diǎn),他們主要做的其實(shí)就是移動(dòng)網(wǎng)絡(luò)下的長(zhǎng)連接服務(wù)。單機(jī) 50W-100W 的連接的確是嚇我一跳!后來(lái)我們也采用了他們的免費(fèi)方案,因?yàn)槭且粋€(gè)受眾面很小的產(chǎn)品,所以他們的免費(fèi)版夠我們用了。一年多下來(lái),運(yùn)作穩(wěn)定,非常不錯(cuò)!
時(shí)隔兩年,換了部門后,竟然接到了一項(xiàng)任務(wù),優(yōu)化公司自己的長(zhǎng)連接服務(wù)端。
再次搜索網(wǎng)上技術(shù)資料后才發(fā)現(xiàn),相關(guān)的很多難點(diǎn)都被攻破,網(wǎng)上也有了很多的總結(jié)文章,單機(jī) 50W-100W 的連接完全不是夢(mèng),其實(shí)人人都可以做到。但是光有連接還不夠,QPS 也要一起上去。
所以,這篇文章就是匯總一下利用 Netty 實(shí)現(xiàn)長(zhǎng)連接服務(wù)過(guò)程中的各種難點(diǎn)和可優(yōu)化點(diǎn)。
Netty 是什么
Netty: http://netty.io/
Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients.
官方的解釋最精準(zhǔn)了,其中最吸引人的就是高性能了。但是很多人會(huì)有這樣的疑問(wèn):直接用 NIO 實(shí)現(xiàn)的話,一定會(huì)更快吧?就像我直接手寫 JDBC 雖然代碼量大了點(diǎn),但是一定比 iBatis 快!
但是,如果了解 Netty 后你才會(huì)發(fā)現(xiàn),這個(gè)還真不一定!
利用 Netty 而不用 NIO 直接寫的優(yōu)勢(shì)有這些:
高性能高擴(kuò)展的架構(gòu)設(shè)計(jì),大部分情況下你只需要關(guān)注業(yè)務(wù)而不需要關(guān)注架構(gòu)
Zero-Copy
?技術(shù)盡量減少內(nèi)存拷貝為 Linux 實(shí)現(xiàn) Native 版 Socket
寫同一份代碼,兼容 java 1.7 的 NIO2 和 1.7 之前版本的 NIO
Pooled Buffers
?大大減輕?Buffer
?和釋放?Buffer
?的壓力
特性太多,大家可以去看一下《Netty in Action》這本書(shū)了解更多。
另外,Netty 源碼是一本很好的教科書(shū)!大家在使用的過(guò)程中可以多看看它的源碼,非常棒!
瓶頸是什么
想要做一個(gè)長(zhǎng)鏈服務(wù)的話,最終的目標(biāo)是什么?而它的瓶頸又是什么?
其實(shí)目標(biāo)主要就兩個(gè):
更多的連接
更高的 QPS
所以,下面就針對(duì)這兩個(gè)目標(biāo)來(lái)說(shuō)說(shuō)他們的難點(diǎn)和注意點(diǎn)吧。
更多的連接
非阻塞 IO
其實(shí)無(wú)論是用 Java NIO 還是用 Netty,達(dá)到百萬(wàn)連接都沒(méi)有任何難度。因?yàn)樗鼈兌际欠亲枞?IO,不需要為每個(gè)連接創(chuàng)建一個(gè)線程了。
欲知詳情,可以搜索一下BIO
,NIO
,AIO
的相關(guān)知識(shí)點(diǎn)。
Java NIO 實(shí)現(xiàn)百萬(wàn)連接
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();??
????????}??
????}??
}??
這段代碼只會(huì)接受連過(guò)來(lái)的連接,不做任何操作,僅僅用來(lái)測(cè)試待機(jī)連接數(shù)極限。
大家可以看到這段代碼是 NIO 的基本寫法,沒(méi)什么特別的。
Netty 實(shí)現(xiàn)百萬(wàn)連接
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();??
這段其實(shí)也是非常簡(jiǎn)單的 Netty 初始化代碼。同樣,為了實(shí)現(xiàn)百萬(wàn)連接根本沒(méi)有什么特殊的地方。
瓶頸到底在哪
上面兩種不同的實(shí)現(xiàn)都非常簡(jiǎn)單,沒(méi)有任何難度,那有人肯定會(huì)問(wèn)了:實(shí)現(xiàn)百萬(wàn)連接的瓶頸到底是什么?
其實(shí)只要 java 中用的是非阻塞 IO(NIO 和 AIO 都算),那么它們都可以用單線程來(lái)實(shí)現(xiàn)大量的 Socket 連接。不會(huì)像 BIO 那樣為每個(gè)連接創(chuàng)建一個(gè)線程,因?yàn)榇a層面不會(huì)成為瓶頸。
其實(shí)真正的瓶頸是在 Linux 內(nèi)核配置上,默認(rèn)的配置會(huì)限制全局最大打開(kāi)文件數(shù)(Max Open Files)還會(huì)限制進(jìn)程數(shù)。所以需要對(duì) Linux 內(nèi)核配置進(jìn)行一定的修改才可以。
這個(gè)東西現(xiàn)在看似很簡(jiǎn)單,按照網(wǎng)上的配置改一下就行了,但是大家一定不知道第一個(gè)研究這個(gè)人有多難。
如何驗(yàn)證
讓服務(wù)器支持百萬(wàn)連接一點(diǎn)也不難,我們當(dāng)時(shí)很快就搞定了一個(gè)測(cè)試服務(wù)端,但是最大的問(wèn)題是,我怎么去驗(yàn)證這個(gè)服務(wù)器可以支撐百萬(wàn)連接呢?
我們用 Netty 寫了一個(gè)測(cè)試客戶端,它同樣用了非阻塞 IO ,所以不用開(kāi)大量的線程。但是一臺(tái)機(jī)器上的端口數(shù)是有限制的,用root
權(quán)限的話,最多也就 6W 多個(gè)連接了。所以我們這里用 Netty 寫一個(gè)客戶端,用盡單機(jī)所有的連接吧。
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++)?{??
????//請(qǐng)自行修改成服務(wù)端的IP??
????b.connect(127.0.0.1,?8080);??
}??
代碼同樣很簡(jiǎn)單,只要連上就行了,不需要做任何其他的操作。
這樣只要找到一臺(tái)電腦啟動(dòng)這個(gè)程序即可。這里需要注意一點(diǎn),客戶端最好和服務(wù)端一樣,修改一下 Linux 內(nèi)核參數(shù)配置。
怎么去找那么多機(jī)器
按照上面的做法,單機(jī)最多可以有 6W 的連接,百萬(wàn)連接起碼需要17臺(tái)機(jī)器!
如何才能突破這個(gè)限制呢?其實(shí)這個(gè)限制來(lái)自于網(wǎng)卡。我們后來(lái)通過(guò)使用虛擬機(jī),并且把虛擬機(jī)的虛擬網(wǎng)卡配置成了橋接模式解決了問(wèn)題。
根據(jù)物理機(jī)內(nèi)存大小,單個(gè)物理機(jī)起碼可以跑4-5個(gè)虛擬機(jī),所以最終百萬(wàn)連接只要4臺(tái)物理機(jī)就夠了。
討巧的做法
除了用虛擬機(jī)充分壓榨機(jī)器資源外,還有一個(gè)非常討巧的做法,這個(gè)做法也是我在驗(yàn)證過(guò)程中偶然發(fā)現(xiàn)的。
根據(jù) TCP/IP 協(xié)議,任何一方發(fā)送FIN
后就會(huì)啟動(dòng)正常的斷開(kāi)流程。而如果遇到網(wǎng)絡(luò)瞬斷的情況,連接并不會(huì)自動(dòng)斷開(kāi)。
那我們是不是可以這樣做?
啟動(dòng)服務(wù)端,千萬(wàn)別設(shè)置 Socket 的
keep-alive
屬性,默認(rèn)是不設(shè)置的用虛擬機(jī)連接服務(wù)器
強(qiáng)制關(guān)閉虛擬機(jī)
修改虛擬機(jī)網(wǎng)卡的 MAC 地址,重新啟動(dòng)并連接服務(wù)器
服務(wù)端接受新的連接,并保持之前的連接不斷
我們要驗(yàn)證的是服務(wù)端的極限,所以只要一直讓服務(wù)端認(rèn)為有那么多連接就行了,不是嗎?
經(jīng)過(guò)我們的試驗(yàn)后,這種方法和用真實(shí)的機(jī)器連接服務(wù)端的表現(xiàn)是一樣的,因?yàn)榉?wù)端只是認(rèn)為對(duì)方網(wǎng)絡(luò)不好罷了,不會(huì)將你斷開(kāi)。
另外,禁用keep-alive
是因?yàn)槿绻唤?,Socket 連接會(huì)自動(dòng)探測(cè)連接是否可用,如果不可用會(huì)強(qiáng)制斷開(kāi)。
更高的 QPS
由于 NIO 和 Netty 都是非阻塞 IO,所以無(wú)論有多少連接,都只需要少量的線程即可。而且 QPS 不會(huì)因?yàn)檫B接數(shù)的增長(zhǎng)而降低(在內(nèi)存足夠的前提下)。
而且 Netty 本身設(shè)計(jì)得足夠好了,Netty 不是高 QPS 的瓶頸。那高 QPS 的瓶頸是什么?
是數(shù)據(jù)結(jié)構(gòu)的設(shè)計(jì)!
如何優(yōu)化數(shù)據(jù)結(jié)構(gòu)
首先要熟悉各種數(shù)據(jù)結(jié)構(gòu)的特點(diǎn)是必需的,但是在復(fù)雜的項(xiàng)目中,不是用了一個(gè)集合就可以搞定的,有時(shí)候往往是各種集合的組合使用。
既要做到高性能,還要做到一致性,還不能有死鎖,這里難度真的不小…
我在這里總結(jié)的經(jīng)驗(yàn)是,不要過(guò)早優(yōu)化。優(yōu)先考慮一致性,保證數(shù)據(jù)的準(zhǔn)確,然后再去想辦法優(yōu)化性能。
因?yàn)橐恢滦员刃阅苤匾枚?,而且很多性能?wèn)題在量小和量大的時(shí)候,瓶頸完全會(huì)在不同的地方。所以,我覺(jué)得最佳的做法是,編寫過(guò)程中以一致性為主,性能為輔;代碼完成后再去找那個(gè) TOP1,然后去解決它!
解決 CPU 瓶頸
在做這個(gè)優(yōu)化前,先在測(cè)試環(huán)境中去狠狠地壓你的服務(wù)器,量小量大,天壤之別。
有了壓力測(cè)試后,就需要用工具來(lái)發(fā)現(xiàn)性能瓶頸了!
我喜歡用的是 VisualVM,打開(kāi)工具后看抽樣器(Sample),根據(jù)自用時(shí)間(Self Time (CPU))倒序,排名第一的就是你需要去優(yōu)化的點(diǎn)了!
備注:Sample 和 Profiler 有什么區(qū)別?前者是抽樣,數(shù)據(jù)不是最準(zhǔn)但是不影響性能;后者是統(tǒng)計(jì)準(zhǔn)確,但是非常影響性能。如果你的程序非常耗 CPU,那么盡量用 Sample,否則開(kāi)啟 Profiler 后降低性能,反而會(huì)影響準(zhǔn)確性。

還記得我們項(xiàng)目第一次發(fā)現(xiàn)的瓶頸竟然是ConcurrentLinkedQueue
這個(gè)類中的size()
方法。量小的時(shí)候沒(méi)有影響,但是Queue
很大的時(shí)候,它每次都是從頭統(tǒng)計(jì)總數(shù)的,而這個(gè)size()
方法我們又是非常頻繁地調(diào)用的,所以對(duì)性能產(chǎn)生了影響。
size()
的實(shí)現(xiàn)如下:
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;??
}??
后來(lái)我們通過(guò)額外使用一個(gè)AtomicInteger
來(lái)計(jì)數(shù),解決了問(wèn)題。但是分離后豈不是做不到高一致性呢?沒(méi)關(guān)系,我們的這部分代碼關(guān)心最終一致性,所以只要保證最終一致就可以了。
總之,具體案例要具體分析,不同的業(yè)務(wù)要用不同的實(shí)現(xiàn)。
解決 GC 瓶頸
GC 瓶頸也是 CPU 瓶頸的一部分,因?yàn)椴缓侠淼?GC 會(huì)大大影響 CPU 性能。
這里還是在用 VisualVM,但是你需要裝一個(gè)插件:VisualGC

有了這個(gè)插件后,你就可以直觀的看到 GC 活動(dòng)情況了。
按照我們的理解,在壓測(cè)的時(shí)候,有大量的 New GC 是很正常的,因?yàn)橛写罅康膶?duì)象在創(chuàng)建和銷毀。
但是一開(kāi)始有很多 Old GC 就有點(diǎn)說(shuō)不過(guò)去了!
后來(lái)發(fā)現(xiàn),在我們壓測(cè)環(huán)境中,因?yàn)?Netty 的 QPS 和連接數(shù)關(guān)聯(lián)不大,所以我們只連接了少量的連接。內(nèi)存分配得也不是很多。
而 JVM 中,默認(rèn)的新生代和老生代的比例是1:2,所以大量的老生代被浪費(fèi)了,新生代不夠用。
通過(guò)調(diào)整?-XX:NewRatio
?后,Old GC 有了顯著的降低。
但是,生產(chǎn)環(huán)境又不一樣了,生產(chǎn)環(huán)境不會(huì)有那么大的 QPS,但是連接會(huì)很多,連接相關(guān)的對(duì)象存活時(shí)間非常長(zhǎng),所以生產(chǎn)環(huán)境更應(yīng)該分配更多的老生代。
總之,GC 優(yōu)化和 CPU 優(yōu)化一樣,也需要不斷調(diào)整,不斷優(yōu)化,不是一蹴而就的。
其他優(yōu)化
如果你已經(jīng)完成了自己的程序,那么一定要看看《Netty in Action》作者的這個(gè)網(wǎng)站:Netty Best Practices a.k.a Faster == Better。
相信你會(huì)受益匪淺,經(jīng)過(guò)里面提到的一些小小的優(yōu)化后,我們的整體 QPS 提升了很多。
最后一點(diǎn)就是,java 1.7 比 java 1.6 性能高很多!因?yàn)?Netty 的編寫風(fēng)格是事件機(jī)制的,看似是 AIO???java 1.6 是沒(méi)有 AIO 的,java 1.7 是支持 AIO 的,所以如果用 java 1.7 的話,性能也會(huì)有顯著提升。
最后成果
經(jīng)過(guò)幾周的不斷壓測(cè)和不斷優(yōu)化了,我們?cè)谝慌_(tái)16核、120G內(nèi)存(JVM只分配8G)的機(jī)器上,用 java 1.6 達(dá)到了60萬(wàn)的連接和20萬(wàn)的QPS。
其實(shí)這還不是極限,JVM 只分配了8G內(nèi)存,內(nèi)存配置再大一點(diǎn)連接數(shù)還可以上去;
QPS 看似很高,System Load Average 很低,也就是說(shuō)明瓶頸不在 CPU 也不在內(nèi)存,那么應(yīng)該是在 IO 了!上面的 Linux 配置是為了達(dá)到百萬(wàn)連接而配置的,并沒(méi)有針對(duì)我們自己的業(yè)務(wù)場(chǎng)景去做優(yōu)化。
因?yàn)槟壳靶阅芡耆珘蛴?,線上單機(jī) QPS 最多才 1W,所以我們先把精力放在了其他地方。相信后面我們還會(huì)去繼續(xù)優(yōu)化這塊的性能,期待 QPS 能有更大的突破!