Thrift 序列化協議淺析
背景
Thrift 是 Facebook 開源的一個高性能,輕量級 RPC 服務框架,是一套全棧式的 RPC 解決方案,包含序列化與服務通信能力,並支持跨平臺 / 跨語言。整體架構如圖所示:
Thrift 軟件棧定義清晰,各層的組件松耦合、可插拔,能夠根據業務場景靈活組合,如圖所示:
Thrift 本身是一個比較大的話題,這篇文章不會涉及到全部內容,只會涉及到其中的序列化協議。
協議原理
Binary 協議
消息格式
這裏通過一個示例對 Binary 消息格式進行直觀的展示,IDL 定義如下:
// 接口
service SupService {
SearchDepartmentByKeywordResponse SearchDepartmentByKeyword(
1: SearchDepartmentByKeywordRequest request)
}
// 請求
struct SearchDepartmentByKeywordRequest {
1: optional string Keyword
2: optional i32 Limit
3: optional i32 Offset
}
// 假設request的payload如下:
{
Keyword: "lark",
Limit: 50,
Offset: nil,
}
編碼簡圖
編碼具體內容
抓包拿到編碼後的字節流(轉成了十進制,方便大家看)
/* 接口名長度 */ 0 0 0 25
/* 接口名 */ 83 101 97 114 99 104 68 101 112 97 114 116
109 101 110 116 66 121 75 101 121 119 111
114 100
/* 消息類型 */ 1
/* 消息序號 */ 0 0 0 1
/* keyword 字段類型 */ 11
/* keyword 字段ID*/ 0 1
/* keyword len */ 0 0 0 4
/* keyword value */ 108 97 114 107
/* limit 字段類型 */ 8
/* limit 字段ID*/ 0 2
/* limit value */ 0 0 0 50
/* 字段終止符 */ 0
編碼含義
消息頭
-
msg_type:消息類型,包含四種類型
-
Call:客戶端消息。調用遠程方法,並且期待對方發送響應。
-
OneWay:客戶端消息。調用遠程方法,不期待響應。
-
Reply:服務端消息。正常響應。
-
Exception:服務端消息。異常響應。
-
msg_seq_id:消息序號。客戶端使用消息序號來處理響應的失序到達,實現請求和響應的匹配。服務端不需要檢查該序列號,也不能對序列號有任何的邏輯依賴,只需要響應的時候將其原樣返回即可。
消息體
消息體分爲兩種編碼模式:
-
定長類型 -> T-V 模式,即:字段類型 + 序號 + 字段值
-
變長類型 -> T-L-V 模式,即:字段類型 + 序號 + 字段長度 + 字段值
-
field_type:字段類型,包括 String、I64、Struct、Stop 等。字段類型有兩個作用:
-
Stop 類型用於停止嵌套解析
-
非 Stop 類型用於 Skip(Skip 操作是跳過當前字段,會在「常見問題 - 兼容性」進行講解)
-
fied_id:字段序號,解碼時通過序號確定字段
-
len:字段長度,用於變長類型,如 String
-
value:字段值
數據格式
1. 定長數據類型
2. 變長數據類型
其他協議
Compact 協議
Compact 協議是二進制壓縮協議,在大部分字段的編碼方式上與 Binary 協議保持一致。區別在於整數類型(包括變長類型的長度)採用了【先 zigzag 編碼 ,再 varint 壓縮編碼】實現,最大化節省空間開銷。
那麼問題來了,varint 和 zigzag 是什麼?
varint 編碼
解決的問題:定長存儲的整數類型絕對值較小時空間浪費大
據統計,RPC 通信時大部分時候傳遞的整數值都很小,如果使用定長存儲會很浪費。
舉個 🌰,對 i32 類型的 7 進行編碼,可以說前面 3 個字節都浪費了:
00000000 00000000 00000000 00000111
解決思路:將整數類型由定長存儲轉爲變長存儲(能用 1 個字節存下就堅決不用 2 個字節)
原理並不複雜,就是將整數按 7bit 分段,每個字節的最高位作爲標識位,標識後一個字節是否屬於該數據。1 代表後面的字節還是屬於當前數據,0 代表這是當前數據的最後一個字節。
以 i32 類型,數值 955 爲例,可以看出,由原來的 4 字節壓縮到了 2 字節:
binary編碼: 00000000 00000000 00000011 10111011
切分: 0000 0000000 0000000 0000111 0111011
compact編碼: 00000111 10111011
當然,varint 編碼同樣存在缺陷,那就是存儲大數的時候,反而會比 binary 的空間開銷更大:本來 4 個字節存下的數可能需要 5 個字節,8 個字節存下的數可能需要 10 個字節。
zigzag 編碼
解決的問題:絕對值較小的負數經過 varint 編碼後空間開銷較大 舉個 🌰,i32 類型的負數(-11)
原碼: 10000000 00000000 00000000 00001011
反碼: 11111111 11111111 11111111 11110100
補碼: 11111111 11111111 11111111 11110101
varint編碼: 00001111 11111111 11111111 11111111 11110101
顯然,對於絕對值較小的負數,用 varint 編碼以後前導 1 過多,難以壓縮,空間開銷比 binary 編碼還大。
解決思路:負數轉正數,從而把前導 1 轉成前導 0,便於 varint 壓縮
算法公式 & 步驟 & 示範:
// 算法公式
32位: (n << 1) ^ (n >> 31)
64位: (n << 1) ^ (n >> 63)
/*
* 算法步驟:
* 1. 不分正負:符號位後置,數值位前移
* 2. 對於負數:符號位不變,數值位取反
*/
// 示例
負數(-11)
補碼: 11111111 11111111 11111111 11110101
符號位後置,數值位前移: 11111111 11111111 11111111 11101011
符號位不變,數值位取反(21): 00000000 00000000 00000000 00010101
正數(11)
補碼: 00000000 00000000 00000000 00010101
符號位後置,數值位前移(22): 00000000 00000000 00000000 00101010
【奇怪的知識】爲什麼取名叫 zigzag?
因爲這個算法將負數編碼成正奇數,正數編碼成偶數。最後效果是正負數穿插向前,就像這樣:
編碼前 編碼後
0 0
-1 1
1 2
-2 3
2 4
Json 協議
Thrift 不僅支持二進制序列化協議,也支持 Json 這種文本協議
數據格式
/* bool、i8、i16、i32、i64、double、string */
"編號": {
"類型": "值"
}
// 示例
"1": {
"str": "keyword"
}
/* struct */
"編號": {
"rec": {
"成員編號": {
"成員類型": "成員值"
},
...
}
}
// 示例
"1": {
"rec": {
"1": {
"i32": 50
}
}
}
/* map */
"編號": {
"map": [
"鍵類型",
"值類型",
元素個數,
"鍵1",
"值1",
...
"鍵n",
"值n"
]
}
// 示例
"6": {
"map": [
"i64",
"str",
1,
666,
"mapValue"
]
}
/* List */
"編號": {
"set/lst": [
"值類型",
元素個數,
"ele1",
"ele2",
"elen"
]
}
// 示例
"2": {
"lst": [
"str",
2,
"lark","keyword"]
}
case 分析
修改字段類型導致 RPC 超時
現象:A 服務訪問 B 服務,業務邏輯短時間處理完,但整個請求 15s 超時,必現。
直接原因:IDL 類型被修改;並且只升級了服務端(B 服務),沒升級客戶端(A 服務)
本質原因:string 是變長編碼,i64 是定長編碼。由於客戶端沒有升級,所以反序列化的時候,會把 signTime 當做 string 類型來解析。而變長編碼是 T-L-V 模式,所以解析的時候會把 signTime 的低位 4 字節翻譯成 string 的 length。
signTime 是時間戳,大整數,比如:1624206147902,轉成二進制爲:
00000000 00000000 00000001 01111010 00101010 00111011 00000001 00111110
低位 4 字節轉成十進制爲:378
也就是要再讀 378 個字節作爲 SignTime 的值,這已經超過了整個 payload 的大小,最終導致 Socket 讀超時。
【注】修改類型不一定就會導致超時,如果 value 的值比較小,解析到的 length 也比較小,能夠保證讀完。但是錯誤的解析可能會導致各種預期之外的情況,包括:
亂碼
空值
報錯:unknown data type xxx (skip 異常)
常見問題
兼容性
增加字段
通過 skip 來跳過增加的字段,從而保證兼容性
刪除字段
編譯生成的解析代碼是基於 field_id 的 switch-case 結構,語法結構上直接具備兼容性。
修改字段名
不破壞兼容性,因爲 binary 協議不會對 name 進行編碼
Exception
Thrift 有兩種 Exception,一種是框架內置的異常,一種是 IDL 自定義的異常。
框架內置的異常包括:「方法名錯誤」、「消息序列號錯誤」、「協議錯誤」,這些異常由框架捕獲並封裝成 Exception 消息,反序列化時會轉成 error 並拋給上層,邏輯如下:
另一種異常是由用戶在 IDL 中自定義的,關鍵字是 exception,用法上跟 struct 沒有太大區別。
optional、require 實現原理
optional 表示字段可填,require 表示必填
-
字段被標識爲 optional 之後:
-
基本類型會被編譯爲指針類型
-
序列化代碼會做空值判斷,如果字段爲空,則不會被編碼
-
字段被標識爲 require 之後:
-
基本類型會被編譯爲非指針類型(複合類型 optional 和 require 沒區別)
-
序列化不會做空值判斷,字段一定會被編碼。如果沒有顯式賦值,就編碼默認值(默認空值,或者 IDL 顯式指定的默認值)
字節跳動技術團隊 字節跳動的技術實踐分享
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/qb0K14nS3j2k2xxlJ9e7eA