DUICUO

Thrift Serialization Protocolの簡単な分析

著者|  ヤン・チェンシー

背景

Thriftは、Facebookがオープンソース化した高性能で軽量なRPCサービスフレームワークです。シリアル化とサービス通信機能を備え、クロスプラットフォームおよびクロス言語互換性をサポートするフルスタックRPCソリューションです。全体的なアーキテクチャを下図に示します。

Thrift ソフトウェア スタックは明確に定義されており、各レイヤーで疎結合かつプラグ可能なコンポーネントを備えているため、図に示すように、ビジネス シナリオに応じて柔軟に組み合わせることができます。

Thrift は幅広いトピックであるため、この記事ではそのすべてを網羅するのではなく、シリアル化プロトコルについてのみ説明します。

プロトコルの原則

バイナリプロトコル

メッセージ形式

このセクションでは、例を用いてバイナリメッセージ形式を直感的に説明します。IDL定義は次のとおりです。

 // インターフェース
サービス SupService {
SearchDepartmentByKeywordResponse SearchDepartmentByKeyword(
1: SearchDepartmentByKeywordRequestリクエスト)
}

// 聞く
構造体SearchDepartmentByKeywordRequest {
1: オプションの文字列キーワード
2: オプションのi32制限
3: オプションのi32オフセット
}

// リクエストペイロードが次のとおりであると仮定します。
{
キーワード: 「ヒバリ」
制限: 50,
オフセット: なし、
}
エンコード図

特定のコンテンツのエンコード
エンコードされたバイト ストリームをキャプチャしました (見やすくするために 10 進数に変換されています)。

/* インターフェース名の長さ */ 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
/* キーワードフィールドタイプ */ 11
/* キーワードフィールドID */ 0 1
/* キーワード長 */ 0 0 0 4
/* キーワード値 */ 108 97 114 107
/* フィールドタイプを制限する */ 8
/* 制限フィールドID */ 0 2
/* 制限値 */ 0 0 0 50
/* フィールドターミネータ */ 0
意味のエンコード

メッセージヘッダー

  • msg_type: メッセージ タイプ。4 つのタイプがあります。
  • 呼び出し: クライアントメッセージ。リモートメソッドを呼び出し、相手からの応答を待ちます。
  • OneWay: クライアント側メッセージ。応答を期待せずにリモートメソッドを呼び出します。
  • 返信: サーバー側メッセージ。正常な応答です。
  • 例外: サーバー側メッセージ。例外応答。
  • `msg_seq_id`: メッセージシーケンス番号。クライアントは、順序が乱れたレスポンスを処理し、リクエストとレスポンスを一致させるために、メッセージシーケンス番号を使用します。サーバーはこのシーケンス番号を確認する必要はなく、論理的な依存関係を持つこともありません。レスポンスでそのまま返すだけで済みます。

メッセージ本文

メッセージ本文には 2 つのエンコード モードがあります。

  1. 固定長型 -> TVモード、つまり、フィールドタイプ + シーケンス番号 + フィールド値
  2. 可変長タイプ -> TLVパターン、つまり、フィールドタイプ + シーケンス番号 + フィールド長 + フィールド値
  • field_type: フィールド タイプ (String、I64、Struct、Stop など)。フィールド タイプには 2 つの目的があります。
  • Stop タイプは、ネストされた解析を停止するために使用されます。
  • スキップにはノンストップ型が使用されます(スキップ操作は現在のフィールドをスキップします。これは「よくある質問 - 互換性」で説明されています)。
  • fied_id: デコード中にフィールドを識別するために使用されるフィールドシーケンス番号。
  • len: フィールドの長さ。文字列などの可変長型に使用されます。
  • 値: フィールド値

データ形式

1. 固定長データ型

データ型

タイプ識別子(8ビット)

型サイズ(単位:バイト)

ブール

2

1

バイト

3

1

ダブル

4

8

i16

6

2

i32

8

4

i64

10

8

2. 可変長データ型

データ型

タイプ識別子(8ビット)

文字サイズ(長さ + 値)

11

4 + N

構造体

12

ネストされたデータ + 1バイトのストップ文字 (0)

地図

13

1 + 1 + 4 + N*(X+Y) [キーの型 + 値の型 + 長さ + 値]

セット

14

1 + 4 + N [val の型 + 長さ + 値]

リスト

15

1 + 4 + N [val の型 + 長さ + 値]

その他の契約

コンパクトプロトコル

Compactプロトコルは、ほとんどのフィールドのエンコードにおいてBinaryプロトコルとの一貫性を維持したバイナリ圧縮プロトコルです。違いは、整数型(可変長型の長さを含む)が、まずジグザグエンコード、次に可変長整数圧縮エンコードというプロセスを使用して実装され、スペースの節約を最大化する点にあります。

質問は、varint と zigzag とは何ですか?

varintエンコーディング

解決された問題: 固定長整数型の絶対値が小さい場合、大きなスペースが無駄になります。

統計によれば、RPC 通信中に送信される整数値のほとんどは非常に小さく、固定長のストレージを使用すると無駄になります。

たとえば、i32 型の数字 7 をエンコードする場合、最初の 3 バイトは無駄になると言えます。

 00000000 00000000 00000000 00000111 

解決策: 整数型を固定長ストレージから可変長ストレージに変換します (可能な場合は 1 バイトのみを使用し、2 バイトの使用は避けます)。

原理は複雑ではありません。整数は 7 ビットのセグメントに分割され、各バイトの最上位ビットは、次のバイトがデータに属するかどうかを示すフラグとして使用されます。1 は、次のバイトがまだ現在のデータに属していることを意味し、0 は、これが現在のデータの最後のバイトであることを意味します。

i32 型と値 955 を例にとると、元の 4 バイトが 2 バイトに圧縮されていることがわかります。

バイナリエンコード: 00000000 00000000 00000011 10111011
セグメンテーション: 0000 0000000 0000000 0000111 0111011
コンパクトエンコーディング: 00000111 10111011

もちろん、varintエンコーディングにも欠点があります。大きな数値を保存する場合、バイナリよりも多くのスペースを消費する可能性があります。4バイトで保存すべき数値を5バイトで保存する必要がある場合や、8バイトで保存すべき数値を10バイトで保存する必要がある場合などです。

ジグザグ符号化

問題は解決しました。絶対値が小さい負の数は、varint型でエンコードすると大きなメモリオーバーヘッドが発生します。例えば、i32型の負の数🌰、つまり-11がこれに該当します。

元のコード: 10000000 00000000 00000000 00001011
逆コード: 1111111 11111111 11111111 11110100
2の補数: 11111111 11111111 11111111 11110101
可変長整数エンコーディング: 00001111 11111111 11111111 11111111 11110101

明らかに、絶対値が小さい負の数の場合、varint エンコードでは先頭の 1 が多くなりすぎて圧縮が困難になり、バイナリ エンコードよりも大きなスペース オーバーヘッドが発生します。

解決策: 負の数を正の数に変換し、先頭の 1 を先頭の 0 に変換することで、可変長整数の圧縮が容易になります。

アルゴリズムの式と手順とデモンストレーション:

 // アルゴリズム式
32ビット: (n << 1) ^ (n >> 31)
64ビット: (n << 1) ^ (n >> 63)


/*
アルゴリズムの手順:
* 1. 正と負を区別しません。符号ビットは末尾に移動され、値ビットは先頭に移動されます。
* 2. 負の数の場合: 符号ビットは変更されず、値ビットが反転されます。
/


// 負の数の例 (-11)
2の補数: 11111111 11111111 11111111 11110101
符号ビットを末尾に、値ビットを先頭に移動します: 1111111 111111111 11111111 11101011
符号ビットは変更されず、値ビットは反転されます(21):00000000 00000000 00000000 00010101

正の数 (11)
2の補数: 00000000 000000000 00000000 00010101
符号ビットは末尾に移動され、値ビットは先頭にシフトされます(22):00000000 00000000 00000000 00101010

【不思議な事実】なぜジグザグという名前がついたのか?

このアルゴリズムは、負の数を正の奇数として、正の数を偶数としてエンコードします。最終的な結果は、正の数と負の数を交互に並べたものです。

エンコード前 エンコード後
0 0
-1 1
1 2
-23
24

JSONプロトコル

Thrift は、バイナリシリアル化プロトコルだけでなく、JSON などのテキストベースのプロトコルもサポートします。

データ形式

 /* ブール値、i8、i16、i32、i64、倍精度、文字列 */
"シリアルナンバー": {
「タイプ」:「値」
}
// 例
"1": {
"str": "キーワード
}


/* 構造体 */
"シリアルナンバー": {
「rec」:{
「メンバーID」: {
「メンバータイプ」:「メンバー値」
},
...
}
}
// 例
"1": {
「rec」:{
"1": {
「i32」:50
}
}
}


/* マップ */
"シリアルナンバー": {
「マップ」: [
「キータイプ」、
「値の型」、
要素数
「キー1」
「値1」、
...
「キーn」、
「値n」
]
}
// 例
"6": {
「マップ」: [
「i64」、
"str",
1、
666,
「マップ値」
]
}


/* リスト */
"シリアルナンバー": {
"set/lst": [
「値の型」、
要素数
「ele1」、
「ele2」、
「エレン」
]
}
// 例
"2": {
「1st」: [
"str",
2、
「ヒバリ」、「キーワード」
}

ケース分析

フィールドタイプの変更によりRPCタイムアウトが発生しました

現象:サービスAがサービスBにアクセスします。ビジネスロジックは短時間で処理されますが、リクエスト全体が15秒後にタイムアウトします。これは常に発生します。

直接的な原因: IDL タイプが変更され、サーバー (サービス B) のみがアップグレードされ、クライアント (サービス A) はアップグレードされませんでした。

根本的な原因は、`string` が可変長エンコーディングであるのに対し、`i64` は固定長エンコーディングであることです。クライアントがアップグレードされていないため、`signTime` はデシリアライズ時に `string` として解析されます。しかし、可変長エンコーディングでは TLV モードが使用されるため、解析時に `signTime` の下位 4 バイトが `string` の `length` に変換されます。

`signTime` はタイムスタンプであり、たとえば 1624206147902 のような大きな整数で、次のようにバイナリに変換されます。

 00000000 00000000 00000001 01111010 00101010 00111011 00000001 00111110

下位4バイトを10進数に変換すると、378となる。

つまり、SignTime 値としてさらに 378 バイトを読み取る必要があり、これがペイロード全体のサイズを超え、最終的にソケット読み取りがタイムアウトすることになります。

[注] 型を変更しても、必ずしもタイムアウトが発生するわけではありません。値が小さい場合、解析される長さも小さくなり、文字列全体を読み取ることができます。ただし、誤った解析は、以下のような予期しない状況を引き起こす可能性があります。

  1. 文字化けしたテキスト
  2. ヌル値
  3. エラーメッセージ: 不明なデータ型 xxx (例外をスキップ)

よくある質問

互換性

フィールドを追加

追加されたフィールドは `skip` オプションを使用してスキップされ、互換性が確保されます。

フィールドを削除

コンパイルされた解析コードは、field_id に基づく switch-case 構造であり、直接的な構文互換性があります。

フィールド名を変更する

バイナリ プロトコルは名前をエンコードしないため、互換性が損なわれることはありません。

例外

Thrift には 2 種類の例外があります。1 つはフレームワークに組み込まれた例外で、もう 1 つは IDL 定義の例外です。

フレームワークに組み込まれている例外には、「メソッド名が正しくありません」、「メッセージシーケンス番号が正しくありません」、「プロトコルエラー」などがあります。これらの例外はフレームワークによって捕捉され、例外メッセージとしてカプセル化されます。デシリアライズ時に、以下のようにエラーに変換され、上位層にスローされます。

別のタイプの例外は、IDL でキーワード「exception」を使用してユーザーによって定義され、その使用方法は「struct」の使用方法とほとんど変わりません。

オプションと必須の実装原則

optional はフィールドに入力できることを示し、require はフィールドが必須であることを示します。

フィールドがオプションとしてマークされた後:

  • プリミティブ型はポインタ型にコンパイルされます
  • シリアル化コードは null チェックを実行します。フィールドが空の場合はエンコードされません。

フィールドが必須としてマークされた後:

  • 基本型は非ポインタ型にコンパイルされます (複合型の場合、optional と require の間に違いはありません)。
  • シリアル化ではnullチェックは行われず、フィールドは常にエンコードされます。明示的に値が割り当てられていない場合は、デフォルト値(デフォルトのnull値またはIDLで明示的に指定されたデフォルト値)がエンコードされます。