Netty是由JBOSS提供的一個java開源框架,是一個高性能、異步事件驅動的NIO框架,它提供了對TCP、UDP和文件傳輸的支持,作為一個異步NIO框架,Netty的所有IO操作都是異步非阻塞
作為當前最流行的NIO框架,Netty在互聯網領域、大數據分布式計算領域、游戲行業、通信行業等獲得了廣泛的應用,一些業界著名的開源組件也基於Netty的NIO框架構建。
為什么選擇Netty
Netty是業界最流行的NIO框架之一,它的健壯性、功能、性能、可定制性和可擴展性在同類框架中都是首屈一指的,它已經得到成百上千的商用項目驗證,例如Hadoop的RPC框架avro使用Netty作為底層通信框架;很多其他業界主流的RPC框架,也使用Netty來構建高性能的異步通信能力。
Netty的特點
-
高並發
Netty是一款基於NIO(Nonblocking I/O,非阻塞IO)開發的網絡通信框架,對比於BIO(Blocking I/O,阻塞IO),他的並發性能得到了很大提高 。
-
傳輸快
Netty的傳輸快其實也是依賴了NIO的一個特性——零拷貝。
-
封裝好
Netty封裝了NIO操作的很多細節,提供易於使用的API。
為什么選擇Netty
JDK 原生也有一套網絡應用程序 API,但是存在一系列問題,主要如下:
1)NIO 的類庫和 API 繁雜,使用麻煩:你需要熟練掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
2)需要具備其他的額外技能做鋪墊:例如熟悉 Java 多線程編程,因為 NIO 編程涉及到 Reactor 模式,你必須對多線程和網路編程非常熟悉,才能編寫出高質量的 NIO 程序。
3)可靠性能力補齊,開發工作量和難度都非常大:例如客戶端面臨斷連重連、網絡閃斷、半包讀寫、失敗緩存、網絡擁塞和異常碼流的處理等等。NIO 編程的特點是功能開發相對容易,但是可靠性能力補齊工作量和難度都非常大。
4)JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它會導致 Selector 空輪詢,最終導致 CPU 100%。官方聲稱在 JDK 1.6 版本的 update 18 修復了該問題,但是直到 JDK 1.7 版本該問題仍舊存在,只不過該 Bug 發生概率降低了一些而已,它並沒有被根本解決。
Netty框架的優勢
-
API使用簡單,開發門檻低;
-
功能強大,預置了多種編解碼功能,支持多種主流協議;
-
定制能力強,可以通過ChannelHandler對通信框架進行靈活地擴展;
-
性能高,通過與其他業界主流的NIO框架對比,Netty的綜合性能最優;
-
成熟、穩定,Netty修復了已經發現的所有JDK NIO BUG,業務開發人員不需要再為NIO的BUG而煩惱;
-
社區活躍,版本迭代周期短,發現的BUG可以被及時修復,同時,更多的新功能會加入;
-
經歷了大規模的商業應用考驗,質量得到驗證。在互聯網、大數據、網絡游戲、企業應用、電信軟件等眾多行業得到成功商用,證明了它已經完全能夠滿足不同行業的商業應用了。
Netty的核心組件
Netty應用中必不可少的組件:
-
Bootstrap or ServerBootstrap
-
EventLoop
-
EventLoopGroup
-
ChannelPipeline
-
Channel
-
Future or ChannelFuture
-
ChannelInitializer
-
ChannelHandler
1.Bootstrap
一個Netty應用通常由一個Bootstrap開始,它主要作用是配置整個Netty程序,串聯起各個組件。
Handler,為了支持各種協議和處理數據的方式,便誕生了Handler組件。Handler主要用來處理各種事件,這里的事件很廣泛,比如可以是連接、數據接收、異常、數據轉換等。
2.ChannelInboundHandler
一個最常用的Handler。這個Handler的作用就是處理接收到數據時的事件,也就是說,我們的業務邏輯一般就是寫在這個Handler里面的,ChannelInboundHandler就是用來處理我們的核心業務邏輯。
3.ChannelInitializer
當一個鏈接建立時,我們需要知道怎么進行接收或者發送數據,當然,我們有各種各樣的Handler實現來處理它,那么ChannelInitializer便是用來配置這些Handler,它會提供一個ChannelPipeline,並把Handler加入到ChannelPipeline。
4.ChannelPipeline
一個Netty應用基於ChannelPipeline機制,這種機制需要依賴於EventLoop和EventLoopGroup,因為它們三個都和事件或者事件處理相關。
EventLoops的目的是為Channel處理IO操作,一個EventLoop可以為多個Channel服務。
EventLoopGroup會包含多個EventLoop。
5.Channel
代表了一個Socket鏈接,或者其它和IO操作相關的組件,它和EventLoop一起用來參與IO處理。
6.Future
在Netty中所有的IO操作都是異步的,因此,你不能立刻得知消息是否被正確處理,但是我們可以過一會等它執行完成或者直接注冊一個監聽,具體的實現就是通過Future和ChannelFutures,他們可以注冊一個監聽,當操作執行成功或失敗時監聽會自動觸發。
總之,所有的操作都會返回一個ChannelFuture。
Netty的應用場景
1.互聯網行業
在分布式系統中,各個節點之間需要遠程服務調用,高性能的RPC框架必不可少,Netty作為異步高新能的通信框架,往往作為基礎通信組件被這些RPC框架使用。
典型的應用有:阿里分布式服務框架Dubbo的RPC框架使用Dubbo協議進行節點間通信,Dubbo協議默認使用Netty作為基礎通信組件,用於實現各進程節點之間的內部通信。
除了 Dubbo 之外,淘寶的消息中間件 RocketMQ 的消息生產者和消息消費者之間,也采用 Netty 進行高性能、異步通信。
2.游戲行業
無論是手游服務端還是大型的網絡游戲,Java語言得到了越來越廣泛的應用。Netty作為高性能的基礎通信組件,它本身提供了TCP/UDP和HTTP協議棧。
非常方便定制和開發私有協議棧,賬號登錄服務器,地圖服務器之間可以方便的通過Netty進行高性能的通信
3.大數據領域
經典的Hadoop的高性能通信和序列化組件Avro的RPC框架,默認采用Netty進行跨界點通信,它的Netty Service基於Netty框架二次封裝實現。
Netty架構分析
Netty 采用了比較典型的三層網絡架構進行設計,邏輯架構圖如下所示:
#第一層,Reactor 通信調度層,它由一系列輔助類完成,包括 NioEventLoop 以及其父類、NioSocketChannel/NioServerSocketChannel 以及其父 類、ByteBuffer 以及由其衍生出來的各種 Buffer、Unsafe 以及其衍生出的各種內部類等。該層的主要職責就是監聽網絡的讀寫和連接操作,負責將網絡層的數據讀取到內存緩沖區中,然后觸發各種網絡事件,例如連接創建、連接激活、讀事 件、寫事件等等,將這些事件觸發到 PipeLine 中,由 PipeLine 充當的職責鏈來進行后續的處理。
#第二層,職責鏈 PipeLine,它負責事件在職責鏈中的有序傳播,同時負責動態的編排職責鏈,職責鏈可以選擇監聽和處理自己關心的事件,它可以攔截處理和向后/向前傳播事件,不同的應用的 Handler 節點的功能也不同,通常情況下,往往會開發編解碼 Handler 用於消息的編解碼,它可以將外部的協議消息轉換成內部的 POJO 對象,這樣上層業務側只需要關心處理業務邏輯即可,不需要感知底層的協議差異和線程模型差異,實現了架構層面的分層隔離。
#第三層,業務邏輯處理層。可以分為兩類:純粹的業務邏輯處理,例如訂單處理;應用層協議管理,例如 HTTP 協議、FTP 協議等。
I/O模型
傳統同步阻塞I/O模式如下圖所示:
幾種I/O模型的功能和特性對比:
Netty的I/O模型基於非阻塞I/O實現,底層依賴的是JDK NIO框架的Selector。Selector提供選擇已經就緒的任務的能力。簡單來講,Selector會不斷地輪詢注冊在其上的Channel,如果某個Channel上面有新的TCP連接接入、讀和寫事件,這個Channel就處於就緒狀態,會被Selector輪詢出來,然后通過SelectionKey可以獲取就緒Channel的集合,從而進行后續的I/O操作。
一個多路復用器Selector可以同時輪詢多個Channel,由於JDK1.5_update10版本(+)使用了epoll()代替傳統的select實現,所以它並沒有最大連接句柄1024/2048的限制。這也就意味着只需要一個線程負責Selector的輪詢,就可以接入成千上萬的客戶端,這確實是個非常巨大的技術進步。使用非阻塞I/O模型之后,Netty解決了傳統同步阻塞I/O帶來的性能、吞吐量和可靠性問題。
線程調度模型
常用的Reactor線程模型有三種,分別如下:
#Reactor單線程模型:Reactor單線程模型,指的是所有的I/O操作都在同一個NIO線程上面完成。對於一些小容量應用場景,可以使用單線程模型。
#Reactor多線程模型:Rector多線程模型與單線程模型最大的區別就是由一組NIO線程處理I/O操作。主要用於高並發、大業務量場景。
#主從Reactor多線程模型:主從Reactor線程模型的特點是服務端用於接收客戶端連接的不再是個1個單獨的NIO線程,而是一個獨立的NIO線程池。利用主從NIO線程模型,可以解決1個服務端監聽線程無法有效處理所有客戶端連接的性能不足問題。
Netty的線程模型
說完了Reactor的三種模型,那么Netty是哪一種呢?其實Netty的線程模型是Reactor模型的變種,那就是去掉線程池的第三種形式的變種,這也是Netty NIO的默認模式。
事實上,Netty的線程模型並非固定不變,通過在啟動輔助類中創建不同的EventLoopGroup實例並通過適當的參數配置,就可以支持上述三種Reactor線程模型.
在大多數場景下,並行多線程處理可以提升系統的並發性能。但是,如果對於共享資源的並發訪問處理不當,會帶來嚴重的鎖競爭,這最終會導致性能的下降。為了盡可能的避免鎖競爭帶來的性能損耗,可以通過串行化設計,即消息的處理盡可能在同一個線程內完成,期間不進行線程切換,這樣就避免了多線程競爭和同步鎖。
為了盡可能提升性能,Netty采用了串行無鎖化設計,在I/O線程內部進行串行操作,避免多線程競爭導致的性能下降。表面上看,串行化設計似乎CPU利用率不高,並發程度不夠。但是,通過調整NIO線程池的線程參數,可以同時啟動多個串行化的線程並行運行,這種局部無鎖化的串行線程設計相比一個隊列-多個工作線程模型性能更優。
Reactor模型
Java NIO非堵塞技術實際是采取反應器模式,或者說是觀察者(observer)模式為我們監察I/O端口,如果有內容進來,會自動通知我們,這樣,我們就不必開啟多個線程死等,從外界看,實現了流暢的I/O讀寫,不堵塞了。
NIO 有一個主要的類Selector,這個類似一個觀察者,只要我們把需要探知的socketchannel告訴Selector,我們接着做別的事情,當有事件發生時,他會通知我們,傳回一組SelectionKey,我們讀取這些Key,就會獲得我們剛剛注冊過的socketchannel,然后,我們從這個Channel中讀取數據,接着我們可以處理這些數據。
反應器模式與觀察者模式在某些方面極為相似:當一個主體發生改變時,所有依屬體都得到通知。不過,觀察者模式與單個事件源關聯,而反應器模式則與多個事件源關聯 。
一般模型
EventLoopGroup:對應於Reactor模式中的定時器的角色,不斷地檢索是否有事件可用(I/O線程-BOSS),然后交給分離者將事件分發給對應的事件綁定的handler(WORK線程)。
經驗分享:在客戶端編程中經常容易出現在EVENTLOOP上做定時任務的,如果定時任務耗時很長或者存在阻塞,那么可能會將I/O操作掛起(因為要等到定時任務做完才能做別的操作)。解決方法:用獨立的EventLoopGroup
序列化方式
影響序列化性能的關鍵因素總結如下:
- 序列化后的碼流大小(網絡帶寬占用)
- 序列化&反序列化的性能(CPU資源占用)
- 並發調用的性能表現:穩定性、線性增長、偶現的時延毛刺等
對Java序列化和二進制編碼分別進行性能測試,編碼100萬次,測試結果表明:Java序列化的性能只有二進制編碼的6.17%左右。
Netty默認提供了對Google Protobuf的支持,通過擴展Netty的編解碼接口,用戶可以實現其它的高性能序列化框架,例如Thrift的壓縮二進制編解碼框架。
不同的應用場景對序列化框架的需求也不同,對於高性能應用場景Netty默認提供了Google的Protobuf二進制序列化框架,如果用戶對其它二進制序列化框架有需求,也可以基於Netty提供的編解碼框架擴展實現。
Netty架構剖析之可靠性
Netty面臨的可靠性挑戰:
\1. 作為RPC框架的基礎網絡通信框架,一旦故障將導致無法進行遠程服務(接口)調用。
\2. 作為應用層協議的基礎通信框架,一旦故障將導致應用協議棧無法正常工作。
\3. 網絡環境復雜(例如推送服務的GSM/3G/WIFI網絡),故障不可避免,業務卻不能中斷。
從應用場景看,Netty是基礎的通信框架,一旦出現Bug,輕則需要重啟應用,重則可能導致整個業務中斷。它的可靠性會影響整個業務集群的數據通信和交換,在當今以分布式為主的軟件架構體系中,通信中斷就意味着整個業務中斷,分布式架構下對通信的可靠性要求非常高。
從運行環境看,Netty會面臨惡劣的網絡環境,這就要求它自身的可靠性要足夠好,平台能夠解決的可靠性問題需要由Netty自身來解決,否則會導致上層用戶關注過多的底層故障,這將降低Netty的易用性,同時增加用戶的開發和運維成本。
Netty的可靠性是如此重要,它的任何故障都可能會導致業務中斷,蒙受巨大的經濟損失。因此,Netty在版本的迭代中不斷加入新的可靠性特性來滿足用戶日益增長的高可靠和健壯性需求。
鏈路有效性檢測
Netty提供的心跳檢測機制分為三種:
- 讀空閑,鏈路持續時間t沒有讀取到任何消息
- 寫空閑,鏈路持續時間t沒有發送任何消息
- 讀寫空閑,鏈路持續時間t沒有接收或者發送任何消息
當網絡發生單通、連接被防火牆攔截住、長時間GC或者通信線程發生非預期異常時,會導致鏈路不可用且不易被及時發現。特別是異常發生在凌晨業務低谷期間,當早晨業務高峰期到來時,由於鏈路不可用會導致瞬間的大批量業務失敗或者超時,這將對系統的可靠性產生重大的威脅。
從技術層面看,要解決鏈路的可靠性問題,必須周期性的對鏈路進行有效性檢測。目前最流行和通用的做法就是心跳檢測。
心跳檢測機制分為三個層面:
\1. TCP層面的心跳檢測,即TCP的Keep-Alive機制,它的作用域是整個TCP協議棧;
\2. 協議層的心跳檢測,主要存在於長連接協議中。例如SMPP協議;
\3. 應用層的心跳檢測,它主要由各業務產品通過約定方式定時給對方發送心跳消息實現。
Keep-Alive僅僅是TCP協議層會發送連通性檢測包,但並不代表設置了Keep-Alive就是長連接了。
心跳檢測的目的就是確認當前鏈路可用,對方活着並且能夠正常接收和發送消息。
做為高可靠的NIO框架,Netty也提供了基於鏈路空閑的心跳檢測機制:
- 讀空閑,鏈路持續時間t沒有讀取到任何消息
- 寫空閑,鏈路持續時間t沒有發送任何消息
- 讀寫空閑,鏈路持續時間t沒有接收或者發送任何消息(netty自帶心跳處理Handler IdleStateHandler)
客戶端和服務端之間連接斷開機制
TCP連接的建立需要三個分節(三次握手),終止則需要四個分節。
對於大量短連接的情況下,經常出現卡在FIN_WAIT2和TIMEWAIT狀態的連接,等待系統回收,但是操作系統底層回收的時間頻率很長,導致SOCKET被耗盡。
TCP狀態圖
TCP/IP半關閉
從上述講的TCP關閉的四個分節可以看出,被動關閉執行方,發送FIN分節的前提是TCP套接字對應應用程序調用close產生的。如果服務端有數據發送給客戶端那么可能存在服務端在接受到FIN之后,需要將數據發送到客戶端才能發送FIN字節。這種處於業務考慮的情形通常稱為半關閉。
半關閉可能導致大量socket處於CLOSE_WAIT狀態
誰負責關閉連接合理
連接關閉觸發的條件通常分為如下幾種:
\1. 數據發送完成(發送到對端並且收到響應),關閉連接
\2. 通信過程中產生異常
\3. 特殊指令強制要求關閉連接
對於第一種,通常關閉時機是,數據發送完成方發起(客戶端觸發居多); 對於第二種,異常產生方觸發(例如殘包、錯誤數據等)發起。但是此種情況可能也導致壓根無法發送FIN。對於第三種,通常是用於運維等。由命令發起方產生。
流量整形
流量整形(Traffic Shaping)是一種主動調整流量輸出速率的措施。
Netty的流量整形有兩個作用:
\1. 防止由於上下游網元性能不均衡導致下游網元被壓垮,業務流程中斷
\2. 防止由於通信模塊接收消息過快,后端業務線程處理不及時導致的"撐死"問題
流量整形的原理示意圖如下:
流量整形(Traffic Shaping)是一種主動調整流量輸出速率的措施。一個典型應用是基於下游網絡結點的TP指標來控制本地流量的輸出。
流量監管TP(Traffic Policing)就是對流量進行控制,通過監督進入網絡的流量速率,對超出部分的流量進行“懲罰”,使進入的流量被限制在一個合理的范圍之內,從而保護網絡資源和用戶的利益。
流量整形與流量監管的主要區別在於,流量整形對流量監管中需要丟棄的報文進行緩存——通常是將它們放入緩沖區或隊列內,也稱流量整形(Traffic Shaping,簡稱TS)。當令牌桶有足夠的令牌時,再均勻的向外發送這些被緩存的報文。流量整形與流量監管的另一區別是,整形可能會增加延遲,而監管幾乎不引入額外的延遲。
#全局流量整形:全局流量整形的作用范圍是進程級的,無論你創建了多少個Channel,它的作用域針對所有的Channel。用戶可以通過參數設置:報文的接收速率、報文的發送速率、整形周期。[GlobalChannelTrafficShapingHandler]
#鏈路級流量整形:單鏈路流量整形與全局流量整形的最大區別就是它以單個鏈路為作用域,可以對不同的鏈路設置不同的整形策略。[ChannelTrafficShapingHandler針對於每個channel]
優雅停機
Netty的優雅停機三部曲: 1. 不再接收新消息 2. 退出前的預處理操作 3. 資源的釋放操作
Java的優雅停機通常通過注冊JDK的ShutdownHook來實現,當系統接收到退出指令后,首先標記系統處於退出狀態,不再接收新的消息,然后將積壓的消息處理完,最后調用資源回收接口將資源銷毀,最后各線程退出執行。
通常優雅退出需要有超時控制機制,例如30S,如果到達超時時間仍然沒有完成退出前的資源回收等操作,則由停機腳本直接調用kill -9 pid,強制退出。
在實際項目中,Netty作為高性能的異步NIO通信框架,往往用作基礎通信框架負責各種協議的接入、解析和調度等,例如在RPC和分布式服務框架中,往往會使用Netty作為內部私有協議的基礎通信框架。 當應用進程優雅退出時,作為通信框架的Netty也需要優雅退出,主要原因如下:
\1. 盡快的釋放NIO線程、句柄等資源
\2. 如果使用flush做批量消息發送,需要將積攢在發送隊列中的待發送消息發送完成
\3. 正在write或者read的消息,需要繼續處理
\4. 設置在NioEventLoop線程調度器中的定時任務,需要執行或者清理
Netty架構剖析之安全性
Netty面臨的安全挑戰:
- 對第三方開放
- 作為應用層協議的基礎通信框架
安全威脅場景分析:
#對第三方開放的通信框架:如果使用Netty做RPC框架或者私有協議棧,RPC框架面向非授信的第三方開放,例如將內部的一些能力通過服務對外開放出去,此時就需要進行安全認證,如果開放的是公網IP,對於安全性要求非常高的一些服務,例如在線支付、訂購等,需要通過SSL/TLS進行通信。
#應用層協議的安全性:作為高性能、異步事件驅動的NIO框架,Netty非常適合構建上層的應用層協議。由於絕大多數應用層協議都是公有的,這意味着底層的Netty需要向上層提供通信層的安全傳輸功能。
SSL/TLS
Netty安全傳輸特性:
- 支持SSL V2和V3
- 支持TLS
- 支持SSL單向認證、雙向認證和第三方CA認證。
SSL單向認證流程圖如下:
Netty通過SslHandler提供了對SSL的支持,它支持的SSL協議類型包括:SSL V2、SSL V3和TLS。
#單向認證:單向認證,即客戶端只驗證服務端的合法性,服務端不驗證客戶端。
#雙向認證:與單向認證不同的是服務端也需要對客戶端進行安全認證。這就意味着客戶端的自簽名證書也需要導入到服務端的數字證書倉庫中。
#CA認證:基於自簽名的SSL雙向認證,只要客戶端或者服務端修改了密鑰和證書,就需要重新進行簽名和證書交換,這種調試和維護工作量是非常大的。因此,在實際的商用系統中往往會使用第三方CA證書頒發機構進行簽名和驗證。我們的瀏覽器就保存了幾個常用的CA_ROOT。每次連接到網站時只要這個網站的證書是經過這些CA_ROOT簽名過的。就可以通過驗證了。
可擴展的安全特性
通過Netty的擴展特性,可以自定義安全策略:
- IP地址黑名單機制
- 接入認證
- 敏感信息加密或者過濾機制
IP地址黑名單是比較常用的弱安全保護策略,它的特點就是服務端在與客戶端通信的過程中,對客戶端的IP地址進行校驗,如果發現對方IP在黑名單列表中,則拒絕與其通信,關閉鏈路。
接入認證策略非常多,通常是較強的安全認證策略,例如基於用戶名+密碼的認證,認證內容往往采用加密的方式,例如Base64+AES等。
Netty架構剖析之擴展性
通過Netty的擴展特性,可以自定義安全策略:
- 線程模型可擴展
- 序列化方式可擴展
- 上層協議棧可擴展
- 提供大量的網絡事件切面,方便用戶功能擴展
Netty的架構可擴展性設計理念如下:
\1. 判斷擴展點,事先預留相關擴展接口,給用戶二次定制和擴展使用
\2. 主要功能點都基於接口編程,方便用戶定制和擴展。
粘連包解決方案
TCP粘包是指發送方發送的若干包數據到接收方接收時粘成一包,從接收緩沖區看,后一包數據的頭緊接着前一包數據的尾。
出現粘包現象的原因是多方面的,它既可能由發送方造成,也可能由接收方造成。發送方引起的粘包是由TCP協議本身造成的,TCP為提高傳輸效率,發送方往往要收集到足夠多的數據后才發送一包數據。若連續幾次發送的數據都很少,通常TCP會根據優化算法把這些數據合成一包后一次發送出去,這樣接收方就收到了粘包數據。接收方引起的粘包是由於接收方用戶進程不及時接收數據,從而導致粘包現象。這是因為接收方先把收到的數據放在系統接收緩沖區,用戶進程從該緩沖區取數據,若下一包數據到達時前一包數據尚未被用戶進程取走,則下一包數據放到系統接收緩沖區時就接到前一包數據之后,而用戶進程根據預先設定的緩沖區大小從系統接收緩沖區取數據,這樣就一次取到了多包數據。
粘包情況有兩種:
\1. 粘在一起的包都是完整的數據包
\2. 粘在一起的包有不完整的包
解決粘連包的方法大致分為如下三種:
\1. 發送方開啟TCP_NODELAY
\2. 接收方簡化或者優化流程盡可能快的接收數據
\3. 認為強制分包每次只讀一個完整的包
對於以上三種方式,第一種會加重網絡負擔,第二種治標不治本,第三種算比較合理的。
第三種又可以分兩種方式:
\1. 每次都只讀取一個完整的包,如果不足一個完整的包,就等下次再接收,如果緩沖區有N個包要接受,那么需要分N次才能接收完成
\2. 有多少接收多少,將接収的數據緩存在一個臨時的緩存中,交由后續的專門解碼的線程/進程處理
以上兩種分包方式,如果強制關閉程序,數據會存在丟失,第一種數據丟失在接收緩沖區;第二種丟失在程序自身緩存。
Netty自帶的幾種粘連包解決方案:
\1. DelimiterBasedFrameDecoder (帶分隔符)
\2. FixedLengthFrameDecoder (定長)
\3. LengthFieldBasedFrameDecoder(將消息分為消息頭和消息體,消息頭中包含消息總長度的字段)
Netty解包組包
對於TCP編程最常遇到的就是根據具體的協議進行組包或者解包。
根據協議的不同大致可以分為如下幾種類型:
\1. JAVA平台之間通過JAVA序列化進行解包組包(object->byte->object)
\2. 固定長度的包結構(定長每個包都是M個字節的長度)
\3. 帶有明確分隔符協議的解包組包(例如HTTP協議\r\n\r\n)
\4. 可動態擴展的協議(每個包都添加一個消息頭),此種協議通常遵循消息頭+消息體的機制,其中消息頭的長度是固定的,消息體的長度根據具體業務的不同長度可能不同。例如(SMPP協議、CMPP協議)
#序列化協議組包解包
可以使用的有:MessagePack、Google Protobuf、Hessian2
#固定長度解包組包
FixedLengthFrameDecoder 解包,MessageToByteEncoder 組包
#帶有分隔符協議的解包組包
DelimiterBasedFrameDecoder 解包,MessageToByteEncoder 組包
#HTTP
io.netty.codec.http
#消息頭固定長度,消息體不固定長度協議解包組包
LengthFieldBasedFrameDecoder
需要注意的是:對於解碼的Handler必須做到在將ByteBuf解析成Object之后,需要將ByteBuf release()。
Netty Client斷網重連機制
對於長連接的程序斷網重連幾乎是程序的標配。
斷網重連具體可以分為兩類:
-
CONNECT失敗,需要重連
-
程序運行過程中斷網、遠程強制關閉連接、收到錯誤包必須重連
對於第一種解決方案是:實現ChannelFutureListener 用來啟動時監測是否連接成功,不成功的話重試。
Future-Listener機制
在並發編程中,我們通常會用到一組非阻塞的模型:Promise,Future,Callback。
其中的Future表示一個可能還沒有實際完成的異步任務的結果,針對這個結果添加Callback以便在執行任務成功或者失敗后做出響應的操作。而經由Promise交給執行者,任務執行者通過Promise可以標記任務完成或者失敗。以上這套模型是很多異步非阻塞框架的基礎。具體的理解可參見JDK的FutureTask和Callable。JDK的實現版本,在獲取最終結果的時候,不得不做一些阻塞的方法等待最終結果的到來。Netty的Future機制是JDK機制的一個子版本,它支持給Future添加Listener,以方便EventLoop在任務調度完成之后調用。
數據安全性之滑動窗口協議
我們假設一個場景,客戶端每次請求服務端必須得到服務端的一個響應,由於TCP的數據發送和數據接收是異步的,就存在必須存在一個等待響應的過程。該過程根據實現方式不同可以分為一下幾類(部分是錯誤案例):
\1. 每次發送一個數據包,然后進入休眠(sleep)或者阻塞(await)狀態,直到響應回來或者超時,整個調用鏈結束。此場景是典型的一問一答的場景,效率極其低下
\2. 讀寫分離,寫模塊只負責寫,讀模塊則負責接收響應,然后做后續的處理。此種場景能盡可能的利用帶寬進行讀寫。但是此場景不做控速操作可能導致大量報文丟失或者重復發送。
\3. 實現類似於Windowed Protocol。此窗口是以上兩種方案的折中版,即允許一定數量的批量發送,又能保證數據的完整性。
對於 Netty ByteBuf 的零拷貝(Zero Copy) 的理解
作者:@xys1228 本文為作者原創,轉載請注明出處:https://www.cnblogs.com/xys1228/p/6088805.html Email:yongshun1228@gmail.com
目錄
通過 CompositeByteBuf 實現零拷貝
通過 wrap 操作實現零拷貝
通過 slice 操作實現零拷貝
通過 FileRegion 實現零拷貝
此文章已同步發布在我的 segmentfault 專欄.
根據 Wiki 對 Zero-copy 的定義:
"Zero-copy" describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.
即所謂的 Zero-copy
, 就是在操作數據時, 不需要將數據 buffer 從一個內存區域拷貝到另一個內存區域. 因為少了一次內存的拷貝, 因此 CPU 的效率就得到的提升.
在 OS 層面上的 Zero-copy
通常指避免在 用戶態(User-space)
與 內核態(Kernel-space)
之間來回拷貝數據. 例如 Linux 提供的 mmap
系統調用, 它可以將一段用戶空間內存映射到內核空間, 當映射成功后, 用戶對這段內存區域的修改可以直接反映到內核空間; 同樣地, 內核空間對這段區域的修改也直接反映用戶空間. 正因為有這樣的映射關系, 我們就不需要在 用戶態(User-space)
與 內核態(Kernel-space)
之間拷貝數據, 提高了數據傳輸的效率.
而需要注意的是, Netty 中的 Zero-copy
與上面我們所提到到 OS 層面上的 Zero-copy
不太一樣, Netty的 Zero-coyp
完全是在用戶態(Java 層面)的, 它的 Zero-copy
的更多的是偏向於 優化數據操作
這樣的概念.
Netty 的 Zero-copy
體現在如下幾個個方面:
-
Netty 提供了
CompositeByteBuf
類, 它可以將多個 ByteBuf 合並為一個邏輯上的 ByteBuf, 避免了各個 ByteBuf 之間的拷貝. -
通過 wrap 操作, 我們可以將 byte[] 數組、ByteBuf、ByteBuffer等包裝成一個 Netty ByteBuf 對象, 進而避免了拷貝操作.
-
ByteBuf 支持 slice 操作, 因此可以將 ByteBuf 分解為多個共享同一個存儲區域的 ByteBuf, 避免了內存的拷貝.
-
通過
FileRegion
包裝的FileChannel.tranferTo
實現文件傳輸, 可以直接將文件緩沖區的數據發送到目標Channel
, 避免了傳統通過循環 write 方式導致的內存拷貝問題.
下面我們就來簡單了解一下這幾種常見的零拷貝操作.
通過 CompositeByteBuf 實現零拷貝
假設我們有一份協議數據, 它由頭部和消息體組成, 而頭部和消息體是分別存放在兩個 ByteBuf 中的, 即:
`ByteBuf header = ...``ByteBuf body = ...`
我們在代碼處理中, 通常希望將 header 和 body 合並為一個 ByteBuf, 方便處理, 那么通常的做法是:
ByteBuf allBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes());
allBuf.writeBytes(header);
allBuf.writeBytes(body);
可以看到, 我們將 header 和 body 都拷貝到了新的 allBuf 中了, 這無形中增加了兩次額外的數據拷貝操作了.
那么有沒有更加高效優雅的方式實現相同的目的呢? 我們來看一下 CompositeByteBuf
是如何實現這樣的需求的吧.
`ByteBuf header = ...`
`ByteBuf body = ...`
`CompositeByteBuf compositeByteBuf=Unpooled.compositeBuffer();`
`compositeByteBuf.addComponents(``true``, header, body);`
上面代碼中, 我們定義了一個 CompositeByteBuf
對象, 然后調用
`public` `CompositeByteBuf addComponents(``boolean` `increaseWriterIndex, ByteBuf... buffers) {``...``}`
方法將 header
與 body
合並為一個邏輯上的 ByteBuf, 即:
不過需要注意的是, 雖然看起來 CompositeByteBuf 是由兩個 ByteBuf 組合而成的, 不過在 CompositeByteBuf 內部, 這兩個 ByteBuf 都是單獨存在的, CompositeByteBuf 只是邏輯上是一個整體.
上面 CompositeByteBuf
代碼還以一個地方值得注意的是, 我們調用 addComponents(boolean increaseWriterIndex, ByteBuf... buffers)
來添加兩個 ByteBuf, 其中第一個參數是 true
, 表示當添加新的 ByteBuf 時, 自動遞增 CompositeByteBuf 的 writeIndex
. 如果我們調用的是
`compositeByteBuf.addComponents(header, body);`
那么其實 compositeByteBuf
的 writeIndex
仍然是0, 因此此時我們就不可能從 compositeByteBuf
中讀取到數據, 這一點希望大家要特別注意.
除了上面直接使用 CompositeByteBuf
類外, 我們還可以使用 Unpooled.wrappedBuffer
方法, 它底層封裝了 CompositeByteBuf
操作, 因此使用起來更加方便:
`ByteBuf header = ...``ByteBuf body = ...`
`ByteBuf allByteBuf = Unpooled.wrappedBuffer(header, body);`
通過 wrap 操作實現零拷貝
例如我們有一個 byte 數組, 我們希望將它轉換為一個 ByteBuf 對象, 以便於后續的操作, 那么傳統的做法是將此 byte 數組拷貝到 ByteBuf 中, 即:
`byte``[] bytes = ...
``ByteBuf byteBuf = Unpooled.buffer();`
`byteBuf.writeBytes(bytes);`
顯然這樣的方式也是有一個額外的拷貝操作的, 我們可以使用 Unpooled 的相關方法, 包裝這個 byte 數組, 生成一個新的 ByteBuf 實例, 而不需要進行拷貝操作. 上面的代碼可以改為:
`byte``[] bytes = ...` `ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);`
可以看到, 我們通過 Unpooled.wrappedBuffer
方法來將 bytes 包裝成為一個 UnpooledHeapByteBuf 對象, 而在包裝的過程中, 是不會有拷貝操作的. 即最后我們生成的生成的 ByteBuf 對象是和 bytes 數組共用了同一個存儲空間, 對 bytes 的修改也會反映到 ByteBuf 對象中.
Unpooled 工具類還提供了很多重載的 wrappedBuffer 方法:
`public` `static` `ByteBuf wrappedBuffer(``byte``[] array)` `public` `static` `ByteBuf wrappedBuffer(``byte``[] array, ``int` `offset, ``int` `length)` `public` `static` `ByteBuf wrappedBuffer(ByteBuffer buffer)` `public` `static` `ByteBuf wrappedBuffer(ByteBuf buffer)` `public` `static` `ByteBuf wrappedBuffer(``byte``[]... arrays)` `public` `static` `ByteBuf wrappedBuffer(ByteBuf... buffers)` `public` `static` `ByteBuf wrappedBuffer(ByteBuffer... buffers)` `public` `static` `ByteBuf wrappedBuffer(``int` `maxNumComponents, ``byte``[]... arrays)` `public` `static` `ByteBuf wrappedBuffer(``int` `maxNumComponents, ByteBuf... buffers)` `public` `static` `ByteBuf wrappedBuffer(``int` `maxNumComponents, ByteBuffer... buffers)`
這些方法可以將一個或多個 buffer 包裝為一個 ByteBuf 對象, 從而避免了拷貝操作.
通過 slice 操作實現零拷貝
slice 操作和 wrap 操作剛好相反, Unpooled.wrappedBuffer
可以將多個 ByteBuf 合並為一個, 而 slice 操作可以將一個 ByteBuf 切片
為多個共享一個存儲區域的 ByteBuf 對象. ByteBuf 提供了兩個 slice 操作方法:
`public` `ByteBuf slice();` `public` `ByteBuf slice(``int` `index, ``int` `length);`
不帶參數的 slice
方法等同於 buf.slice(buf.readerIndex(), buf.readableBytes())
調用, 即返回 buf 中可讀部分的切片. 而 slice(int index, int length)
方法相對就比較靈活了, 我們可以設置不同的參數來獲取到 buf 的不同區域的切片.
下面的例子展示了 ByteBuf.slice
方法的簡單用法:
`ByteBuf byteBuf = ...` `ByteBuf header = byteBuf.slice(``0``, ``5``);` `ByteBuf body = byteBuf.slice(``5``, ``10``);`
用 slice
方法產生 header 和 body 的過程是沒有拷貝操作的, header 和 body 對象在內部其實是共享了 byteBuf 存儲空間的不同部分而已. 即:
通過 FileRegion 實現零拷貝
Netty 中使用 FileRegion 實現文件傳輸的零拷貝, 不過在底層 FileRegion 是依賴於 Java NIO FileChannel.transfer
的零拷貝功能.
首先我們從最基礎的 Java IO 開始吧. 假設我們希望實現一個文件拷貝的功能, 那么使用傳統的方式, 我們有如下實現:
`public` `static` `void` `copyFile(String srcFile, String destFile) ``throws` `Exception {`` ``byte``[] temp = ``new` `byte``[``1024``];`` ``FileInputStream in = ``new` `FileInputStream(srcFile);`` ``FileOutputStream out = ``new` `FileOutputStream(destFile);`` ``int` `length;`` ``while` `((length = in.read(temp)) != -``1``) {`` ``out.write(temp, ``0``, length);`` ``}` ` ``in.close();`` ``out.close();` `}`
上面是一個典型的讀寫二進制文件的代碼實現了. 不用我說, 大家肯定都知道, 上面的代碼中不斷中源文件中讀取定長數據到 temp 數組中, 然后再將 temp 中的內容寫入目的文件, 這樣的拷貝操作對於小文件倒是沒有太大的影響, 但是如果我們需要拷貝大文件時, 頻繁的內存拷貝操作就消耗大量的系統資源了. 下面我們來看一下使用 Java NIO 的 FileChannel
是如何實現零拷貝的:
`public` `static` `void` `copyFileWithFileChannel(String srcFileName, String destFileName) ``throws` `Exception {`` ``RandomAccessFile srcFile = ``new` `RandomAccessFile(srcFileName, ``"r"``);`` ``FileChannel srcFileChannel = srcFile.getChannel();` ` ``RandomAccessFile destFile = ``new` `RandomAccessFile(destFileName, ``"rw"``);`` ``FileChannel destFileChannel = destFile.getChannel();` ` ``long` `position = ``0``;`` ``long` `count = srcFileChannel.size();` ` ``srcFileChannel.transferTo(position, count, destFileChannel);``}`
可以看到, 使用了 FileChannel
后, 我們就可以直接將源文件的內容直接拷貝(transferTo
) 到目的文件中, 而不需要額外借助一個臨時 buffer, 避免了不必要的內存操作.
有了上面的一些理論知識, 我們來看一下在 Netty 中是怎么使用 FileRegion
來實現零拷貝傳輸一個文件的:
`@Override` `public` `void` `channelRead0(ChannelHandlerContext ctx, String msg)``throws` `Exception {`` ``RandomAccessFile raf = ``null``;`` ``long` `length = -``1``;`` ``try` `{`` ``// 1. 通過 RandomAccessFile 打開一個文件.`` ``raf = ``new` `RandomAccessFile(msg, ``"r"``);`` ``length = raf.length();`` ``} ``catch` `(Exception e) {`` ``ctx.writeAndFlush(``"ERR: "` `+ e.getClass().getSimpleName() + ``": "` `+ e.getMessage() + ``'\n'``);`` ``return``;`` ``} ``finally` `{`` ``if` `(length < ``0` `&& raf != ``null``) {`` ``raf.close();`` ``}`` ``}` ` ``ctx.write(``"OK: "` `+ raf.length() + ``'\n'``);`` ``if` `(ctx.pipeline().get(SslHandler.``class``) == ``null``) {`` ``// SSL not enabled - can use zero-copy file transfer.`` ``// 2. 調用 raf.getChannel() 獲取一個 FileChannel.`` ``// 3. 將 FileChannel 封裝成一個 DefaultFileRegion`` ``ctx.write(``new` `DefaultFileRegion(raf.getChannel(), ``0``, length));`` ``} ``else` `{`` ``// SSL enabled - cannot use zero-copy file transfer.`` ``ctx.write(``new` `ChunkedFile(raf));`` ``}`` ``ctx.writeAndFlush(``"\n"``);``}`
上面的代碼是 Netty 的一個例子, 其源碼在 netty/example/src/main/java/io/netty/example/file/FileServerHandler.java 可以看到, 第一步是通過 RandomAccessFile
打開一個文件, 然后 Netty 使用了 DefaultFileRegion
來封裝一個 FileChannel
即:
`new` `DefaultFileRegion(raf.getChannel(), ``0``, length)`
當有了 FileRegion 后, 我們就可以直接通過它將文件的內容直接寫入 Channel 中, 而不需要像傳統的做法: 拷貝文件內容到臨時 buffer, 然后再將 buffer 寫入 Channel. 通過這樣的零拷貝操作, 無疑對傳輸大文件很有幫助.
精彩問答
問:據我之前了解到,Java的NIO selector底層在Windows下的實現是起兩個隨機端口互聯來監測連接或讀寫事件,在Linux上是利用管道實現的;我有遇到過這樣的需求,需要占用很多個固定端口做服務端,如果在Windows下,利用NIO框架(Mina或Netty)就有可能會造成端口沖突,這種情況有什么好的解決方案嗎?
你說的問題確實存在,Linux使用Pipe實現網絡監聽,Windows要啟動端口。目前沒有更好的辦法,建議的方式是作為服務端的端口可以規划一個范圍,然后根據節點和進程信息動態生成,如果發現端口沖突,可以在規划范圍內基於算法重新生成一個新的端口。
問:請我,我現在將Spring與Netty做了整合,使用Spring的Service開啟 Netty主線程,但是停止整個運行容器的時候,Netty的TCP Server端口不能釋放?退出處理時,有什么好的辦法釋放Netty Server端口么?
實際上,由誰拉起Netty 主線程並不重要。我們需要做的就是當應用容器退出的時候(Spring Context銷毀),在退出之前調用Netty 的優雅退出接口即可實現端口、NIO線程資源的釋放。請參考這篇文章:http://www.infoq.com/cn/articles/netty-elegant-exit-mechanism-and-principles
問:適合用Netty寫Web通信么?
Netty不是Web框架,無法解析JSP、HTML、JS等,但是它可以做Web 通信,例如可以使用Netty重寫Tomcat的HTTP/HTTPS 通信協議棧。
問:能不能講解一下Netty的串行無鎖化設計,如何在串行和並行中達到最優?
為了盡可能提升性能,Netty采用了串行無鎖化設計,在IO線程內部進行串行操作,避免多線程競爭導致的性能下降。表面上看,串行化設計似乎CPU利用率不高,並發程度不夠。但是,通過調整NIO線程池的線程參數,可以同時啟動多個串行化的線程並行運行,這種局部無鎖化的串行線程設計相比一個隊列-多個工作線程模型性能更優。Netty的NioEventLoop讀取到消息之后,直接調用ChannelPipeline的fireChannelRead(Object msg),只要用戶不主動切換線程,一直會由NioEventLoop調用到用戶的Handler,期間不進行線程切換,這種串行化處理方式避免了多線程操作導致的鎖的競爭