SOFA
Scalable Open Financial Architecture
是螞蟻金服自主研發的金融級分布式中間件,包含了建構金融級雲原生架構所需的各個元件,是在金融場景裡錘煉出來的最佳實踐。
本文為《螞蟻金服通信架構SOFABolt解析》系列第一篇,作者水寒,就職于網易考拉。《螞蟻金服通信架構SOFABolt解析》系列由 SOFA 團隊和源碼愛好者們出品。
基礎介紹
SOFABolt 是螞蟻金融服務集團開發的一套基于 Netty 實作的網絡通信架構。
- 為了讓 Java 程式員能将更多的精力放在基于網絡通信的業務邏輯實作上,而不是過多的糾結于網絡底層 NIO 的實作以及處理難以調試的網絡問題,Netty 應運而生。
- 為了讓中間件開發者能将更多的精力放在産品功能特性實作上,而不是重複地一遍遍制造通信架構的輪子,SOFABolt 應運而生。
Bolt 名字取自迪士尼動畫-閃電狗,是一個基于 Netty 最佳實踐的輕量、易用、高性能、易擴充的通信架構。 這些年我們在微服務與消息中間件在網絡通信上解決過很多問題,積累了很多經驗,并持續的進行着優化和完善,我們希望能把總結出的解決方案沉澱到 SOFABolt 這個基礎元件裡,讓更多的使用網絡通信的場景能夠統一受益。 目前該産品已經運用在了螞蟻中間件的微服務 (
SOFARPC)、消息中心、分布式事務、分布式開關、以及配置中心等衆多産品上。
前言
SOFABolt 提供了設計良好、使用便捷的編解碼功能。本篇我們會依次介紹編解碼的概念, TCP 粘包拆包問題,SOFABolt 私有通信協定的設計,以及SOFABolt 編解碼原理,最後還會介紹一下相較于 Netty,我們做出的優化。歡迎大家與我們讨論交流。
編解碼介紹
每個網絡應用程式都必須定義如何解析在兩個節點之間來回傳輸的原始位元組,以及如何将其和目标應用程式的資料格式做互相轉換。在一個成熟的通信架構中,我們通常都會通過私有通信協定來描述這種定義,通過編解碼技術将理論上的私有通信協定轉化為實踐。
通過編解碼技術,我們可以友善的做一些邏輯,例如雙方可以友善的統一序列化與反序列化方式、解決 TCP 拆包粘包問題等。
下面,我們先來看一下 TCP 粘包拆包問題的産生,然後分析 Netty 是如何解決粘包拆包問題的,最後分析 SOFABolt 是如何解決粘包拆包問題的。
TCP 粘包拆包問題
如上圖所示,三種拆包原因見黃色标簽說明;兩種粘包原因見藍色标簽說明。TCP 本身是面向流的,它無法從源源不斷湧來的資料流中拆分出或者合并出有意義的資訊,通常可以通過以下幾種方式來解決:
- 基于分隔符協定:使用定義的字元來标記一個消息的結尾,在編碼的時候我們在消息尾部添加指定的分隔符,在解碼的時候根據分隔符來拆分或者合并消息。Netty 提供了兩種基于分隔符協定的解碼器 LineBasedFrameDecoder 和 DelimiterBasedFrameDecoder。LineBasedFrameDecoder 指定以 n 或者 rn 作為消息的分隔符;DelimiterBasedFrameDecoder 使用使用者自定義的分隔符來标記消息的結尾。
- 基于定長消息協定:每一個消息在編碼的時候都使用固定的長度,在解碼的時候根據這個長度進行消息的拆分和合并。Netty 提供了 FixedLengthFrameDecoder 解碼器來實作定長消息解碼。
- 基于變長消息協定:每一個消息分為消息頭和消息體兩部分,在編碼時,将消息體的長度設定到消息頭部,在解碼的時候,首先解析出消息頭部的長度資訊,之後拆分或合并出該長度的消息體。Netty 提供了 LengthFieldBasedFrameDecoder 來實作變長消息協定解碼。
- 基于私有通信協定:Netty 提供了 MessageToByteEncoder 和 ByteToMessageDecoder 兩個抽象類,這兩個抽象類提供了基本的編解碼模闆。使用者可以通過繼承這兩個類來實作自定義的編解碼器。SOFABolt 通過繼承 MessageToByteEncoder 實作了自定義的編碼器,通過繼承修改版的 ByteToMessageDecoder 來實作了解碼器。對于處理 TCP 粘包拆包問題,SOFABolt 實際上也是使用變長消息協定,SOFABolt 的私有通信協定将消息體分為三部分 className、header、body,在消息頭對應的提供了 classLen、headerLen、bodyContent 分别辨別三部分的長度,之後就可以基于這三個長度資訊進行消息的拆分和合并。
對于一個成熟的 rpc 架構或者通信架構來講,編解碼器不僅僅是要處理粘包拆包問題,還要實作一些特有的需求,是以必須制定一些私有通信協定,下面來看一下 SOFABolt 的私有通信協定的設計。
SOFABolt 私有通信協定的設計
以下分析以 SOFABolt 1.5.1 版本為例。SOFABolt 定義了兩種協定 RpcProtocol 和 RpcProtocolV2。針對這兩種協定,提供了兩組不同的編解碼器。
RpcProtocol 協定定義
請求指令(協定頭長度:22 byte)
- ProtocolCode :這個字段是必須的。因為需要根據 ProtocolCode 來進入不同的核心編解碼器。該字段可以在想換協定的時候,友善的進行更換。
- RequestType :請求類型,request / response / oneway 三者之一。oneway 之是以需要單獨設定,是因為在處理響應時,需要做特殊判斷,來控制響應是否回傳。
- CommandCode :請求指令類型,request / response / heartbeat 三者之一。
- CommandVersion :請求指令版本号。該字段用來區分請求指令的不同版本。如果修改 Command 版本,不修改協定,那麼就是純粹代碼重構的需求;除此情況,Command 的版本更新,往往會同步做協定的更新。
- RequestId :請求 ID,該字段主要用于異步請求時,保留請求存根使用,便于響應回來時觸發回調。另外,在日志列印與問題調試時,也需要該字段。
- Codec :序列化器。該字段用于儲存在做業務的序列化時,使用的是哪種序列化器。通信架構不限定序列化方式,可以友善的擴充。
- Timeout :逾時字段,用戶端發起請求時,所設定的逾時時間。
- ClassLen :業務請求類名長度
- HeaderLen :業務請求頭長度
- ContentLen :業務請求體長度
- ClassName :業務請求類名。需要注意類名傳輸的時候,務必指定字元集,不要依賴系統的預設字元集。曾經線上的機器,因為運維誤操作,預設的字元集被修改,導緻字元的傳輸出現編解碼問題。而我們的通信架構指定了預設字元集,是以躲過一劫。
- HeaderContent :業務請求頭
- BodyContent :業務請求體
響應指令(協定頭長度:20 byte)
- ResponseStatus :響應碼。從字段精簡的角度,我們不可能每次響應都帶上完整的異常棧給用戶端排查問題,是以,我們會定義一些響應碼,通過編号進行網絡傳輸,友善用戶端定位問題。
RpcProtocolV2 協定定義
請求指令(協定頭長度:24 byte)
- ProtocolVersion :确定了某一種通信協定後,我們還需要考慮協定的微小調整需求,是以需要增加一個 version 的字段,友善在協定上追加新的字段
- Switch :協定開關,用于一些協定級别的開關控制,比如 CRC 校驗,安全校驗等。
- CRC32 :CRC校驗碼,這也是通信場景裡必不可少的一部分,而我們金融業務屬性的特征,這個顯得尤為重要。
響應指令(協定頭長度:22 byte)
SOFABolt 針對 RpcProtocol 和 RpcProtocolV2 這兩種協定,提供了兩組不同的編解碼器。下面我們來看一下編解碼器的設計原理。
SOFABolt 編解碼原理
上圖僅列出編解碼中最主要的類。
- RpcCodec 是工廠類,建立 ProtocolCodeBasedEncoder 和 ProtocolCodeBasedDecoder(實際上是其子類),二者被設定為 netty 的編解碼器 handler - 工廠模式
- MessageToByteEncoder 提供了編碼模闆,該類由 netty 本身提供;AbstractBatchDecoder 提供了解碼模闆,由 SOFABolt 提供,該類是 ByteToMessageDecoder 的 hack 版本,相較于 netty 提供了批量送出的功能 - 模闆模式
- ProtocolCodeBasedEncoder 和 ProtocolCodeBasedDecoder 分别是 CommandEncoder 和 CommandDecoder 的代理類,通過不同的 protocol 協定,指定使用不同的編解碼器 - 代理模式和政策模式
- 最下層的四個編解碼器:Xxx 是 RpcProtocol 協定資料的編解碼器;XxxV2 是RpcProtocolV2 協定資料的編解碼器
編碼原理
如上述類圖所示,SOFABolt 的編碼器 ProtocolCodeBasedEncoder 是繼承 MessageToByteEncoder 的,MessageToByteEncoder 為 ProtocolCodeBasedEncoder 提供了編碼模闆。在 MessageToByteEncoder 中調用了子類 ProtocolCodeBasedEncoder 的實際編碼代碼,大緻流程如下所示:
上圖隻列出了部分核心代碼,詳細代碼見 SOFABolt 源碼與 Netty 源碼。
- 判斷傳入的資料是否是 Serializable 類型(該類型由 MessageToByteEncoder 的泛型指定),如果不是,直接傳播給 pipeline 中的下一個 handler;否則
- 建立一個 ByteBuf 執行個體,用于存儲最終的編碼資料
- 從 channel 的附加屬性中擷取協定辨別 protocolCode,之後從協定管理器中擷取相應的協定對象
- 再從協定對象中擷取相應的 CommandEncoder 實作類執行個體,使用 CommandEncoder 實作類執行個體按照上文所介紹的協定規則将資料寫入到第二步建立好的 ByteBuf 執行個體中
- 如果原始資料是 ReferenceCounted 實作類,則釋放原始資料
- 如果 ByteBuf 中有資料了,則傳播給 pipeline 中的下一個 handler;否則,釋放該 ByteBuf 對象,傳遞一個空的 ByteBuf 給下一個 handler
注意:
- 由第一步可知,在 SOFABolt 中,資料要想經過編碼器的處理,必須實作 Serializable 接口。
- 編碼器是無狀态的,可以标注注解 @ChannelHandler.Sharable
解碼原理
SOFABolt 的解碼器 ProtocolCodeBasedDecoder 是繼承 AbstractBatchDecoder 的,AbstractBatchDecoder 為 ProtocolCodeBasedDecoder 提供了解碼模闆。在 AbstractBatchDecoder 中調用了子類 ProtocolCodeBasedDecoder 的實際解碼代碼,如下所示:
上圖隻列出了部分核心代碼
- 建立或者從 netty 的回收池中擷取一個 RecyclableArrayList 執行個體,用于存儲最終的解碼資料
- 将傳入的 ByteBuf 添加到 Cumulator 累加器執行個體中
- 之後不斷的從 ByteBuf 中讀取資料:首先解碼出protocolCode,之後從協定管理器中擷取相應的協定對象,再從協定對象中擷取相應的 CommandDecoder 實作類執行個體
- 使用 CommandDecoder 實作類執行個體按照上文所介紹的協定規則進行解碼,将解碼好的資料放到 RecyclableArrayList 執行個體中,需要注意的是在解碼之前必須先記錄目前 ByteBuf 的 readerIndex,如果發現資料不夠一個整包長度(發生了拆包粘包問題),則将目前 ByteBuf 的 readerIndex 複原到解碼之前,然後直接傳回,等待讀取更多的資料
- 為了防止發送端發送資料太快導緻OOM,會清理 Cumulator 累加器執行個體或者其空間,将已經讀取的位元組删除,向左壓縮 ByteBuf 空間
- 判斷 RecyclableArrayList 中的元素個數,如果是1個,則将這個元素單個發送給 pipeline 的下一個 handler;如果元素大于1個,則将整個 RecyclableArrayList 以 List 形式發送給 pipeline 的下一個 handler。後續的 handler 就可以以如下的方式進行消息的處理。
- 回收 RecyclableArrayList 執行個體
注意:解碼器是有狀态的,不可标注注解 @ChannelHandler.Sharable
最後我們介紹一下 SOFABolt 解碼器相較于 Netty 作出的優化。
SOFABolt 解碼器相較于 Netty 作出的優化
(圖檔來自
螞蟻通信架構實踐)
Netty 提供了一個友善的解碼工具類 ByteToMessageDecoder ,如圖上半部分所示,這個類具備 accumulate 批量解包能力,可以盡可能的從 socket 裡讀取位元組,然後同步調用 decode 方法,解碼出業務對象,并組成一個 List 。最後再循環周遊該 List ,依次送出到 ChannelPipeline 進行處理。此處我們做了一個細小的改動,如圖下半部分所示,即将送出的内容從單個 command ,改為整個 List 一起送出,如此能減少 pipeline 的執行次數,同時提升吞吐量。這個模式在低并發場景,并沒有什麼優勢,而在高并發場景下對提升吞吐量有不小的性能提升。
參考文檔
- nio-trick-and-trap
- 《netty實戰》
長按關注,擷取分布式架構幹貨
歡迎大家共同打造 SOFAStack
https://github.com/alipay