制剖析:責(zé)任鏈與事件流轉(zhuǎn)實戰(zhàn))
排查過一次線上偶發(fā)斷連問題后我對Netty里的ChannelHandler算是真正服氣了。當(dāng)時問題表象是客戶端偶爾收不到響應(yīng)服務(wù)端日志一片正常最后在Pipeline上加了日志Handler才定位到某個中間Handler拋了異常后續(xù)Handler根本沒機(jī)會執(zhí)行。從那時起我就意識到如果只停留在會用addLast、不會追問事件到底怎么流轉(zhuǎn)遇到詭異問題基本只能靠猜。ChannelHandler是Netty最核心的抽象之一它決定了一條連接上所有數(shù)據(jù)幀的進(jìn)出路徑、業(yè)務(wù)編排方式和異常兜底策略。這篇文章我會從責(zé)任鏈設(shè)計、Handler類型與生命周期、事件雙向流轉(zhuǎn)的底層邏輯再到粘包拆包這一最高頻實戰(zhàn)場景把ChannelHandler的機(jī)制和工作原理講透。適合剛接觸Netty想系統(tǒng)理解Pipeline的開發(fā)者也適合工作中被Handler順序、ByteBuf釋放、異常傳播折磨過的老手。1. ChannelHandler在Netty整體架構(gòu)中的定位從一條連接說起1.1 一次連接的生命周期視角一條TCP連接在Netty里對應(yīng)一個Channel準(zhǔn)確說是NioSocketChannel或EpollSocketChannel這類實現(xiàn)。這個Channel從accept到read再到write中間要經(jīng)過非常多的處理解幀、反序列化、鑒權(quán)、業(yè)務(wù)邏輯、序列化、組幀、寫回。如果把這些邏輯全塞進(jìn)一個回調(diào)里代碼會迅速腐化而且完全沒辦法組合復(fù)用。Netty給出的答案是ChannelPipeline一條雙向鏈表。鏈表上的每個節(jié)點就是一個ChannelHandler。數(shù)據(jù)從網(wǎng)絡(luò)進(jìn)來按鏈表順序流經(jīng)注冊的Handler數(shù)據(jù)要發(fā)出去也按順序反向流經(jīng)Handler。核心設(shè)計就是責(zé)任鏈模式每個Handler只關(guān)心自己該處理的那部分處理完用fire方法把事件交給下一個節(jié)點不處理就透明通過。打個比方這很像機(jī)場安檢通道。旅客數(shù)據(jù)從入口進(jìn)入依次經(jīng)過證件查驗、行李掃描、人身檢查幾個環(huán)節(jié)。每個環(huán)節(jié)只干自己那件事完后把人交給下一個環(huán)節(jié)。如果某個環(huán)節(jié)發(fā)現(xiàn)異常整條通道就要攔截。Netty的Pipeline就是這個安檢通道ChannelHandler就是那些安檢柜位。1.2 為什么選責(zé)任鏈而不是強(qiáng)綁定回調(diào)很多新框架更愿意用dispatcher或者回調(diào)注冊的方式來處理請求比如一條連接上綁一個onData回調(diào)、onClose回調(diào)。這種模型在簡單場景下很清爽一旦鏈路變長比如要同時支持協(xié)議升級、流量統(tǒng)計、日志采集、多版本編解碼回調(diào)之間就變成一團(tuán)亂麻誰先執(zhí)行、誰的數(shù)據(jù)要透傳給誰、出錯誰兜底全得靠約定。責(zé)任鏈的好處在于順序就是規(guī)則Pipeline里Handler的排列順序直接決定了處理順序。這一點對協(xié)議處理至關(guān)重要。比如粘包拆包的Decode必須排在業(yè)務(wù)Handler前因為業(yè)務(wù)Handler拿到的必須已經(jīng)是一個完整的消息鑒權(quán)Handler又必須排在使用身份信息的Handler前。順序可插拔行為可組合這是大型服務(wù)器程序非常需要的彈性。還有一個容易被忽略的點責(zé)任鏈天然支持動態(tài)修改。調(diào)用Pipeline的addBefore、addAfter、remove方法可以在線調(diào)整處理鏈路不用重啟服務(wù)就能改協(xié)議行為。這在灰度發(fā)布、動態(tài)協(xié)議適配場景里價值巨大。從實現(xiàn)角度看Pipeline內(nèi)部維護(hù)著DefaultChannelHandlerContext組成的雙向鏈表每個Context包裹一個Handler實例同時保存著Channel、Executor等信息。鏈表的頭部是HeadContext尾部是TailContext這兩個是Netty內(nèi)置的不對外暴露也不能刪除。所有用戶Handler都夾在它們之間。2. Handler類型與核心方法不只是讀和寫2.1 三個族譜Inbound、Outbound、DuplexHandlerChannelHandler接口本身是個標(biāo)記接口只有兩個生命周期方法handlerAdded和handlerRemoved。真正干活的是它的子接口。ChannelInboundHandler處理入站事件也就是數(shù)據(jù)從網(wǎng)絡(luò)進(jìn)來后觸發(fā)的事件包括channelRegistered、channelActive、channelRead、channelReadComplete、exceptionCaught、channelInactive等。這些方法命名基本都是“事件被動發(fā)生”由Netty的事件循環(huán)調(diào)用。ChannelOutboundHandler處理出站事件包括bind、connect、write、flush、close、read等。注意這里有個非常重要的語義區(qū)別入站Handler里你被動接收事件出站Handler里你主動發(fā)起操作。比如調(diào)用ctx.write(data)并不是直接把數(shù)據(jù)塞進(jìn)Socket而是發(fā)起一個出站事件讓出站鏈路沿途的Handler都能處理。ChannelDuplexHandler同時繼承兩者既能處理入站也能攔截出站適合做日志、統(tǒng)計、鑒權(quán)、編解碼這類橫切邏輯。協(xié)議編解碼器其實最適合用DuplexHandler實現(xiàn)因為編碼管出站、解碼管入站兩者本來就是對同一協(xié)議的正反兩面。2.2 生命周期回調(diào)從注冊到斷開Handler掛在Pipeline上之后會隨著Channel的狀態(tài)變化觸發(fā)一系列生命周期回調(diào)。順序大致是handlerAddedHandler被加入Pipeline時觸發(fā)。可以用來做資源初始化。channelRegisteredChannel綁定到EventLoop后觸發(fā)。channelActive連接建立完成后觸發(fā)TCP層面可以開始讀寫。channelRead收到數(shù)據(jù)幀時觸發(fā)。注意這里是已經(jīng)經(jīng)過解碼的數(shù)據(jù)。channelReadComplete一次讀循環(huán)讀取完所有數(shù)據(jù)后觸發(fā)。適合批量刷新、發(fā)送心跳等。channelInactive連接斷開或失效時觸發(fā)。handlerRemovedHandler從Pipeline移除時觸發(fā)。適合釋放資源。理解這個順序很重要。channelActive是發(fā)送歡迎消息的理想時機(jī)因為此刻連接真正可用channelInactive是清理連接級狀態(tài)的位置channelReadComplete則是你處理完一批數(shù)據(jù)的收尾點。很多人會把channelRead里做太多事但把flush、批量提交這類操作放在channelReadComplete會更合適。2.3 ChannelHandlerContext每個Handler的隱形傳話人每個Handler在Pipeline里都有一個對應(yīng)的ChannelHandlerContext。這個Context暴露了幾乎全部交互入口讀寫數(shù)據(jù)、觸發(fā)下一個Handler、獲取Channel和EventLoop。一定要養(yǎng)成通過ctx去調(diào)用傳播方法fireChannelRead、write等的習(xí)慣而不是用Channel的write方法。原因很微妙ctx.fireChannelRead是從當(dāng)前節(jié)點的下一個節(jié)點開始傳播channel.write則是從Pipeline的Tail開始反向走完整條鏈路。這會導(dǎo)致行為完全不同后面我會專門展開。另外ChannelHandlerContext里還有一個executor()方法返回Handler執(zhí)行的EventLoop。如果你了解Netty的線程模型就會知道每個Channel綁定一個EventLoop線程Handler基本上都在這個線程里被調(diào)用。所以單個Channel內(nèi)Handler狀態(tài)天然不用加鎖這是Netty高性能的基石之一。但如果你在Handler里把數(shù)據(jù)提交到別的線程池處理那就要注意跨線程同步了。3. 事件在流水線上流轉(zhuǎn)的底層邏輯入站與出站的兩個方向3.1 fireXxx方法是如何觸發(fā)下一個節(jié)點的當(dāng)Socket讀到了字節(jié)流Netty這個內(nèi)部會封裝成ByteBuf并觸發(fā)一次channelRead入站事件。這個事件的起點其實是HeadContext它調(diào)用下一個Handler的channelRead方法然后用戶代碼手動調(diào)用ctx.fireChannelRead(msg)再沿著鏈表向下傳遞最終到達(dá)TailContext被丟棄或釋放。看到?jīng)]有這里的關(guān)鍵是“手動”兩個字。Handler要主動調(diào)用fire方法事件才能繼續(xù)往下走。你不調(diào)鏈路就斷在這里。這既是責(zé)任鏈的靈活性也是最大的坑很多新手在channelRead里處理完消息后忘了調(diào)fireChannelRead導(dǎo)致后面Handler永遠(yuǎn)等不到數(shù)據(jù)。入站傳播方法包括fireChannelRegistered、fireChannelActive、fireChannelRead、fireChannelReadComplete、fireExceptionCaught、fireUserEventTriggered等它們都遵循同一個規(guī)則從當(dāng)前Context的下一個節(jié)點開始沿正向鏈表傳播。3.2 出站方向的write事件為什么需要自己調(diào)ctx.write出站方向麻煩一點。當(dāng)你要向客戶端寫數(shù)據(jù)時調(diào)用的是ctx.writeAndFlush(data)。這不是直接把數(shù)據(jù)推到Socket而是發(fā)起一個出站事件讓數(shù)據(jù)從當(dāng)前Context開始沿鏈表反向傳播。也就是說出站事件是“從后往前”走的最靠近Tail的Handler反而先執(zhí)行。這帶來一個非常容易踩坑的設(shè)計Encoder編碼器通常放在Pipeline靠前的位置也就是越靠近ChannelInboundHandler報讀之后的位置越靠前但出站時編碼器卻是靠后執(zhí)行的。為什么呢因為出站事件從寫入點開始逆向往Head方向傳播Encoder放在前面意味著它比后面的Handler更靠近Head會在鏈路靠后階段執(zhí)行此時數(shù)據(jù)已經(jīng)被靠前執(zhí)行的Handler包裝過最終交給Head寫入Socket。我舉個例子。Pipeline順序是StringEncoderFrameLengthDecoder業(yè)務(wù)Handler當(dāng)業(yè)務(wù)Handler調(diào)用ctx.writeAndFlush({json字符串})出站事件從業(yè)務(wù)Handler所在Context開始往前找先到FrameLengthDecoder然后再到StringEncoder。StringEncoder把字符串編碼成ByteBuf后再繼續(xù)傳向HeadContext最后寫入Socket。如果你在Pipeline里把Encoder放在業(yè)務(wù)Handler后面那出站事件從業(yè)務(wù)Handler開始往前找永遠(yuǎn)找不到Encoder字符串就原樣寫出去了。很多人Handler順序排得奇怪就是因為他們沒有理解出站傳播方向。3.3 HeadContext與TailContext管道兩端發(fā)生了什么Pipeline的兩端是內(nèi)置節(jié)點。HeadContext既實現(xiàn)ChannelInboundHandler也實現(xiàn)ChannelOutboundHandler它的一頭連接著事件循環(huán)和底層Socket另一頭對接Handler鏈TailContext大多數(shù)情況下是個“兜底”節(jié)點入站事件到了它這里如果沒有被消費默認(rèn)是釋放消息防止內(nèi)存泄漏。有意思的是當(dāng)你調(diào)用channel.write(data)時事件其實是從TailContext開始反向傳播的所以鏈條上所有出站Handler都會看到這條數(shù)據(jù)。從這個角度來看調(diào)用channel.write和ctx.write有本質(zhì)區(qū)別channel.write一定會被整條出站鏈路處理ctx.write只會被當(dāng)前節(jié)點之前的出站節(jié)點處理。如果你在業(yè)務(wù)Handler里不小心用了channel.writeAndFlush那下行數(shù)據(jù)會無視你之前的Handler順序直接沖到Head該有的加密、編碼全部被跳過。我在項目里看到過這種事故加密Handler放在Pipeline比較靠后業(yè)務(wù)Handler調(diào)用channel.writeAndFlush結(jié)果所有數(shù)據(jù)都是明文發(fā)出去的正是這個原因。4. 實戰(zhàn)一個粘包拆包的Handler鏈設(shè)計4.1 粘包拆包問題的根源TCP是流式協(xié)議沒有消息邊界。上層發(fā)的三條消息可能在底層被合并成一個包發(fā)出去粘包也可能一條消息被拆成多個小包分批到達(dá)拆包。比如客戶端連續(xù)調(diào)了三次write操作系統(tǒng)可能為了效率把三次數(shù)據(jù)一次flush出去接收方一次性就會讀到一個拼接后的ByteBuf反過來如果一條消息有4KB而TCP窗口只允許讀2KB接收方就要分兩次才能收完。這就是Netty用戶最常提到的“粘包處理”場景。解決粘包問題的本質(zhì)是確定消息邊界。常見方案有四種固定長度、分隔符、長度字段、自定義協(xié)議頭。Netty里對應(yīng)的解碼器分別是FixedLengthFrameDecoder、LineBasedFrameDecoder、LengthFieldBasedFrameDecoder和自定義Decoder。4.2 用解碼器解決ByteToMessageDecoder與常見FrameDecoder核心類是ByteToMessageDecoder它是一個ChannelInboundHandlerAdapter的抽象子類專門用來把入站ByteBuf解碼成一個個業(yè)務(wù)消息對象。使用它時你只需要重寫decode方法每調(diào)用一次傳入一個ByteBuf和一個Listout你把解析出的完整消息add到out里Netty會替你把out里的每個對象逐個在Pipeline上傳播出去。LengthFieldBasedFrameDecoder是最常用的通用解碼器。它根據(jù)消息頭里的長度字段來確定完整幀長度。假設(shè)協(xié)議格式是“2字節(jié)魔數(shù) 2字節(jié)長度 N字節(jié)內(nèi)容”配置如下new LengthFieldBasedFrameDecoder( // 最大幀長超過拋異常防止惡意包 1024, // 長度字段偏移量前面有2字節(jié)魔數(shù) 2, // 長度字段占用的字節(jié)數(shù) 2, // 長度校正值比如長度字段只統(tǒng)計內(nèi)容長度則不需要調(diào)整 0, // 跳過前多少字節(jié)通常是剝掉頭部 0 )解碼器放在Pipeline最前面業(yè)務(wù)Handler放在后面粘包拆包問題就基本解決了。需要注意解碼器是有狀態(tài)的它內(nèi)部要緩存上一次decode剩下的半包數(shù)據(jù)所以千萬不要用Sharable注解標(biāo)注這個類更不要多個Channel復(fù)用同一個實例否則跨連接的狀態(tài)會互相污染。4.3 一個完整示例自定義Decoder 業(yè)務(wù)Handler如何連接下面是一個最簡可跑的Demo級鏈路ServerBootstrap b new ServerBootstrap(); b.group(boss, worker) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ChannelPipeline p ch.pipeline(); // 先拆包按自定義協(xié)議長度字段拆幀 p.addLast(frameDecoder, new LengthFieldBasedFrameDecoder(4096, 2, 2, 0, 0)); // 再反序列化ByteBuf - 一個Request對象 p.addLast(msgDecoder, new ByteToMessageDecoder() { Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { // 走到這里已經(jīng)是一個完整幀 byte[] bytes new byte[in.readableBytes()]; in.readBytes(bytes); out.add(new Request(bytes)); } }); // 業(yè)務(wù)處理 p.addLast(bizHandler, new SimpleChannelInboundHandlerRequest() { Override protected void channelRead0(ChannelHandlerContext ctx, Request msg) { // 這里收到的一定是完整的Request對象 ctx.writeAndFlush(handleBiz(msg)); } }); } });這里有個細(xì)節(jié)值得多說一句第二個Decoder本質(zhì)上還是入站Handler它把ByteBuf變成Request對象后繼續(xù)調(diào)fireChannelRead最終進(jìn)入SimpleChannelInboundHandler。SimpleChannelInboundHandler好用的地方在于它自動釋放非引用計數(shù)的消息資源如果你用手寫ChannelInboundHandler一定記得主動釋放ByteBuf或做引用計數(shù)管理否則時間一長老是內(nèi)存泄漏。有一種情況更需要警惕就是自定義Decoder的循環(huán)粘包問題。ByteToMessageDecoder的decode方法可能被調(diào)用多次如果一整批數(shù)據(jù)里包含兩個完整報文你需要在一次decode里把兩個報文都解析出來或者配合decodeLast。如果你的decode方法里new了一個對象就return第二幀就永遠(yuǎn)處理不到表現(xiàn)就是偶發(fā)丟消息但沒異常。我踩過這個坑排查時把Decode調(diào)用次數(shù)打了日志才發(fā)現(xiàn)。5. 使用ChannelHandler的若干血淚經(jīng)驗5.1 Handler能不能被共享Sharable到底該怎么用ChannelHandler里有個Sharable注解加了它表示這個Handler實例可以被多個Channel共享。不加注解的Handler每個Channel都應(yīng)該有自己獨立的實例或者說至少每個Channel要new一個否則狀態(tài)會串。最常見的反例有人為了省內(nèi)存把統(tǒng)計用的Handler直接add到所有Channel的Pipeline上標(biāo)簽類也沒有狀態(tài)用Sharable標(biāo)注后安全無害。但一旦Handler里有個計數(shù)器字段或者緩存了某個Connection的上下文共享實例就會讓不同連接互相污染這種Bug非常難查。我的建議是除非確認(rèn)Handler完全無狀態(tài)或者狀態(tài)本身就是全局共享的比如全局計數(shù)器、全局RateLimiter否則一律每個Channel新建實例。ChannelInitializer里每條連接都會執(zhí)行initChannel在它內(nèi)部new Handler最安全。不要圖省事在ServerBootstrap上加共享Handler除非你真的很清楚你在做什么。5.2 阻塞調(diào)用、ByteBuf釋放與引用計數(shù)Netty的EventLoop是單線程串行執(zhí)行事件一個Channel的Handler幾乎都在同一個線程里跑。如果你在Handler里做了阻塞操作比如調(diào)用數(shù)據(jù)庫同步查詢、RPC同步調(diào)用、Thread.sleep那么整個EventLoop都會被卡住。這個EventLoop上注冊的其他Channel全部停止處理相當(dāng)于一臺服務(wù)器因為一條慢請求癱瘓了。正確的做法是把耗時操作提交到獨立的業(yè)務(wù)線程池處理完再通過Channel的EventLoop切回IO線程去寫回。Netty官方推薦在Handler里用ctx.executor()或者提交給單獨的ExecutorService后用ctx.channel().eventLoop().execute()再切回來。ByteBuf釋放的問題同樣隱蔽。ByteBuf是引用計數(shù)對象假如你在handler里new了一個ByteBuf或者接收了一個ByteBuf必須確認(rèn)它被release否則計數(shù)器歸不了零內(nèi)存池就泄漏。SimpleChannelInboundHandler會在channelRead0返回后自動release msg而普通ChannelInboundHandler里的msg不會自動釋放除非你調(diào)用ReferenceCountUtil.release(msg)或調(diào)ctx.fireChannelRead把釋放責(zé)任繼續(xù)傳遞下去。這里的核心原則是誰最后消費消息誰負(fù)責(zé)釋放每new一個ByteBuf就要匹配一次release。5.3 異常傳播機(jī)制以及為什么不能亂catchexceptionCaught是入站異常事件它的傳播方向也是從頭到尾。如果在某個Handler里業(yè)務(wù)代碼拋異常且沒有被catchNetty會捕獲它并調(diào)用fireExceptionCaught異常事件開始沿著Pipeline傳播。如果沒有Handler處理最終會到達(dá)TailContext被日志輸出。這里的關(guān)鍵是一旦你catch住某個異常卻沒有調(diào)用ctx.fireExceptionCaught這個異常就被吞掉了后續(xù)Handler完全不知情。有些場景你覺得“我已經(jīng)處理好了”但下游Handler可能需要感知這條消息處理失敗來做補(bǔ)償或統(tǒng)計。所以我建議自己無法覆蓋的異常一律繼續(xù)fire不要默默catch。異常Handler的擺放位置也有講究。如果想做全局兜底通常把異常兜底Handler加在Pipeline最前面最靠近Head解碼器的位置或者最后面最靠近Tail的位置。個人經(jīng)驗是放在尾部做兜底日志和連接關(guān)閉在業(yè)務(wù)層只處理自己關(guān)心的局部異常。放在最前面能捕獲后面所有Handler的異常但因為異常從觸發(fā)點開始傳播如果這個Handler放在業(yè)務(wù)Handler前面后續(xù)Handler都能收到異常這點很多新手容易搞反。5.4 調(diào)試技巧完整的日志鏈路與線程狀態(tài)輔助排查Netty問題有一個性價比極高的做法在Pipeline首尾各掛一個日志Handler把所有入站出站事件的event類型、channelId、線程名打出來。這樣你一眼就能看出事件是否斷層、順序是否顛倒、write是否沒走到Encoder。我在調(diào)試某次協(xié)議兼容問題時專門寫了個DebugHandlerpublic class DebugHandler extends ChannelDuplexHandler { Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { logger.info([IN] {} has bytes {}, ctx.channel().id(), msg instanceof ByteBuf ? ((ByteBuf) msg).readableBytes() : msg.getClass()); super.channelRead(ctx, msg); } Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { logger.info([OUT] {} has bytes {}, ctx.channel().id(), msg instanceof ByteBuf ? ((ByteBuf) msg).readableBytes() : msg.getClass()); super.write(ctx, msg, promise); } }把這樣的Handler加在Pipeline最前面和業(yè)務(wù)Handler前面各一份基本能還原完整的事件鏈條。打日志的時候順手把當(dāng)前線程名打出來借助Netty內(nèi)部線程名如nioEventLoopGroup-x-y還能輔助確認(rèn)阻塞問題是否導(dǎo)致同一個EventLoop線程被長時間占用判斷是哪條連接拉低了整個線程池。另外一個建議是不要只靠斷點調(diào)試Netty。因為EventLoop線程里的斷點會阻塞整個IO線程影響并發(fā)連接的行為容易掩蓋問題。依賴詳細(xì)日志分析效果通常好得多。Netty的ChannelHandler機(jī)制說穿了其實不復(fù)雜一個雙向鏈表、兩個方向的事件流、每個節(jié)點自己決定怎么處理以及是否接力。但正是這個簡單的模型撐起了大量高并發(fā)服務(wù)器的協(xié)議層邏輯。我自己折騰完那臺線上問題機(jī)器之后最大的體會是凡是Handler行為不如預(yù)期先打印事件鏈條而不是先懷疑框架凡是內(nèi)存異常增長先排查ByteBuf有沒有被正確釋放而不是先加堆內(nèi)存。把這些基本功練扎實了Netty項目里一大半的疑難雜癥都能被你用日志和順序推理直接揪出來。