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

編碼含義

消息頭

消息體

消息體分爲兩種編碼模式:

  1. 定長類型 -> T-V 模式,即:字段類型 + 序號 + 字段值

  2. 變長類型 -> T-L-V 模式,即:字段類型 + 序號 + 字段長度 + 字段值

數據格式

1. 定長數據類型

BF2Jhv

2. 變長數據類型

jcjxgz

其他協議

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 也比較小,能夠保證讀完。但是錯誤的解析可能會導致各種預期之外的情況,包括:

  1. 亂碼

  2. 空值

  3. 報錯: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 表示必填

字節跳動技術團隊 字節跳動的技術實踐分享

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qb0K14nS3j2k2xxlJ9e7eA