DECEMBER 29TH, 2014html
還記得一年半前,作的一個項目須要用到 Android 推送服務。和 iOS 不一樣,Android 生態中沒有統一的推送服務。Google 雖然有 Google Cloud Messaging ,可是連國外都沒統一,更別說國內了,直接被牆。java
因此以前在 Android 上作推送大部分只能靠輪詢。而咱們以前在技術調研的時候,搜到了 jPush 的博客,上面介紹了一些他們的技術特色,他們主要作的其實就是移動網絡下的長鏈接服務。單機 50W-100W 的鏈接的確是嚇我一跳!後來咱們也採用了他們的免費方案,由於是一個受衆面很小的產品,因此他們的免費版夠咱們用了。一年多下來,運做穩定,很是不錯!linux
時隔兩年,換了部門後,居然接到了一項任務,優化公司本身的長鏈接服務端。bootstrap
再次搜索網上技術資料後才發現,相關的不少難點都被攻破,網上也有了不少的總結文章,單機 50W-100W 的鏈接徹底不是夢,其實人人均可以作到。可是光有鏈接還不夠,QPS 也要一塊兒上去。api
因此,這篇文章就是彙總一下利用 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
技術儘可能減小內存拷貝Pooled Buffers
大大減輕 Buffer
和釋放 Buffer
的壓力特性太多,你們能夠去看一下《Netty in Action》這本書瞭解更多。
另外,Netty 源碼是一本很好的教科書!你們在使用的過程當中能夠多看看它的源碼,很是棒!
想要作一個長鏈服務的話,最終的目標是什麼?而它的瓶頸又是什麼?
其實目標主要就兩個:
因此,下面就針對這連個目標來講說他們的難點和注意點吧。
其實不管是用 Java NIO 仍是用 Netty,達到百萬鏈接都沒有任何難度。由於它們都是非阻塞的 IO,不須要爲每一個鏈接建立一個線程了。
欲知詳情,能夠搜索一下BIO
,NIO
,AIO
的相關知識點。
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 的基本寫法,沒什麼特別的。
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
後就會啓動正常的斷開流程。而若是遇到網絡瞬斷的狀況,鏈接並不會自動斷開。
那咱們是否是能夠這樣作?
keep-alive
屬性,默認是不設置的咱們要驗證的是服務端的極限,因此只要一直讓服務端認爲有那麼多鏈接就好了,不是嗎?
通過咱們的試驗後,這種方法和用真實的機器鏈接服務端的表現是同樣的,由於服務端只是認爲對方網絡很差罷了,不會將你斷開。
另外,禁用keep-alive
是由於若是不由用,Socket 鏈接會自動探測鏈接是否可用,若是不可用會強制斷開。
因爲 NIO 和 Netty 都是非阻塞 IO,因此不管有多少鏈接,都只須要少許的線程便可。並且 QPS 不會由於鏈接數的增加而下降(在內存足夠的前提下)。
並且 Netty 自己設計得足夠好了,Netty 不是高 QPS 的瓶頸。那高 QPS 的瓶頸是什麼?
是數據結構的設計!
首先要熟悉各類數據結構的特色是必需的,可是在複雜的項目中,不是用了一個集合就能夠搞定的,有時候每每是各類集合的組合使用。
既要作到高性能,還要作到一致性,還不能有死鎖,這裏難度真的不小…
我在這裏總結的經驗是,不要過早優化。優先考慮一致性,保證數據的準確,而後再去想辦法優化性能。
由於一致性比性能重要得多,並且不少性能問題在量小和量大的時候,瓶頸徹底會在不一樣的地方。 因此,我以爲最佳的作法是,編寫過程當中以一致性爲主,性能爲輔;代碼完成後再去找那個 TOP1,而後去解決它!
在作這個優化前,先在測試環境中去狠狠地壓你的服務器,量小量大,天壤之別。
有了壓力測試後,就須要用工具來發現性能瓶頸了!
我喜歡用的是 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 瓶頸也是 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 創做,採用 知識共享署名-非商業性使用 4.0 國際許可協議 進行許可。