RPC
関数を呼ぶように別プロセスの処理を呼ぶ RPC を自作する。型のある要求と応答をバイト列にして戻すシリアライズ(protobuf 風のタグ + varint)、境界の無いバイトストリームから長さを前置きして 1 メッセージを切り出すフレーミング、1 本の接続に多重化した応答を要求 ID で突き合わせる相関、の 3 つで組む。タイムアウトや部分故障が起きるので、再送には冪等な受け手が要る。
この章で作るもの
メッセージキューが「あとで処理する」非同期の繋ぎ方なら、RPC は「今すぐ答えを返して」の同期の繋ぎ方。gRPC のように、別のサーバのメソッドを、あたかもローカル関数のように呼ぶ。だが実体はネットワーク越しのメッセージ交換で、その正体を3つの部品で作る。
順に見ていく。
- シリアライズ: 型のある要求/応答をバイト列に変換する。protobuf 風に「[フィールド番号][値]」を並べ、整数は varint(小さい値ほど短い)、文字列は「[長さ][中身]」で書く
- フレーミング: TCP はバイトの列であって、メッセージの列ではない。区切りを自分で作らないと、2つの送信がくっついたり途中で切れたりする。長さを前置きして1メッセージを切り出す
- 相関(correlation): 1本の接続に複数の要求を多重化し、応答を要求IDで突き合わせる。サーバは並行に処理するので、応答は送った順に返ってこない
シリアライズ: 型をバイトにする
RPC の引数と結果は、ネットワークに流すためにバイト列にしないといけない。この実装は protobuf を薄くまねる。各フィールドを「[フィールド番号][値]」で並べ、整数は varint、文字列/バイト列は「[長さ][中身]」で書く。デコード側はフィールド番号を見て、対応する変数へ振り分ける。
要求 Call{ID, Method, Body} なら「ID=1 の値」「Method=2 の値(長さ+文字)」「Body=3 の値」と並ぶ。番号で振り分けるので、未知のフィールドは飛ばせる(前方互換)。JSON より小さく速いのが protobuf 系の利点。
フレーミング: ストリームに境界を作る
一番はまりやすいのがここ。conn.Write(msgA); conn.Write(msgB) と送っても、受け手が conn.Read でmsgA と msgB がくっついた塊を受け取ることがある。TCP はバイトの列で、「1回の Write = 1メッセージ」を保証しない。だから自分で境界を作る。素直な方法が長さ前置きだ。「これから N バイト」と書いてから中身を書く:
// writeFrame は payload の前に長さ(uvarint)を書いて1フレームとして送る。
func writeFrame(w io.Writer, payload []byte) error {
var hdr [binary.MaxVarintLen64]byte
n := binary.PutUvarint(hdr[:], uint64(len(payload)))
if _, err := w.Write(hdr[:n]); err != nil {
return err
}
_, err := w.Write(payload)
return err
}
// readFrame は1フレーム(長さ + payload)を読み出す。
func readFrame(r io.Reader) ([]byte, error) {
br, ok := r.(io.ByteReader)
if !ok {
br = &byteReader{r: r}
}
n, err := binary.ReadUvarint(br)
if err != nil {
return nil, err
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf, nil
} 送信: writeFrame("hello") writeFrame("hi")
ストリーム: [5][hello][2][hi]
└長さ┘ └長さ┘
受信: 5を読む→5バイト読む="hello"、次に 2を読む→2バイト読む="hi"
(Read が [5][hel と [lo][2][hi] に分かれて届いても、長さ基準で切り直せる)相関: 応答を要求IDで突き合わせる
1本の接続に要求を多重化すると速い(1件ごとに返事を待たなくていい)。だが応答が戻ってきたとき、どの要求への返事かが分からない。サーバは並行に処理するので、応答は送った順に返るとは限らないからだ。
解決は単純。各要求に ID を振り、応答にも同じ ID を載せて返す。クライアントは「ID → 待っている呼び出し」の対応表を持ち、応答が来たら ID で相手を見つけて結果を渡す。
クライアント サーバ
#1 add ──────▶ (処理中)
#2 echo ──────▶ #2 完了 ──┐
#3 slow ──────▶ (処理中) │
#1 完了 ─┐ │
◀──────────────────────┘ │ 応答は #2, #1, #3 の順で返る
◀────────────────────────┘ → ID で正しい呼び出しに相関動かす
下のデモで「echo/add/slow を呼ぶ」と、要求が接続に多重化される(上段のフレーム=長さ前置き)。「サーバが応答(後着から)」を押すと、わざと送信と逆順に応答が返る。それでも各呼び出しは ID で相関して正しい結果を受け取る。「タイムアウト」を押した呼び出しに後から応答が来ると、相関先が無く破棄される。
メソッドを呼ぶと接続に多重化される。『サーバが応答』は後着から返し、順不同を作る
RPC はローカル呼び出しではない
見た目は関数呼び出しでも、中身はネットワーク越し。ここを忘れると事故る。ローカル関数と違い、RPC は:
- 返ってこないことがある: ネットワーク断・サーバ停止で、応答が永遠に来ない。だからタイムアウト(ctx)で待つのをやめる必要がある。デモの「タイムアウト」がこれ
- 部分故障する: 「要求は届いたが応答が失われた」とき、クライアントには成功か失敗か分からない。再送すると処理が二重に走りうる
- だから再送とセットで受け手を冪等にする。ここで メッセージキューの冪等が効いてくる。「二重に届いても副作用は1回」にしておけば、安心して再送できる
「RPC はローカル呼び出しのように見えて、遅延・故障・部分故障を隠せない」。これが分散システムの古典的な教訓(fallacies of distributed computing)。速い透過的な呼び出しに見せかけるほど、この違いを踏み抜きやすい。
設計の観点: RPC かメッセージングか
「サービスAがBを呼ぶ。RPC とキュー、どっち?」。同期か非同期かで選ぶ:
- RPC(同期): 今すぐ答えが要る、呼び出し側が結果に依存する(在庫確認→注文可否)。速いが、Bが落ちるとAも失敗する(密結合)。タイムアウト・リトライ・サーキットブレーカで守る
- メッセージング(非同期): 結果を今すぐ要らない、確実に処理したい(注文確定→発送指示)。Bが落ちてもキューに残る(疎結合)。ただし結果は後で
- タイムアウト予算: A→B→C と連なるとき、各段のタイムアウトは内側ほど短くする(全体の予算を分け合う)。でないと一番外側が待ちきれずに切って、内側の作業が無駄になる
- リトライ: 必ず冪等キー付きで。指数バックオフ + ジッタで再送の集中(thundering herd)を避ける
メリット・デメリットと実例
| 方式 | 表現 | 速さ・サイズ | 結合 | 実例 |
|---|---|---|---|---|
| gRPC / Thrift | スキーマ(IDL)+ バイナリ | 小さく速い | 同期・密 | Google 内部、多くのマイクロサービス基盤 |
| REST / JSON | テキスト・URL | 冗長だが可読 | 同期・密 | 公開 API の定番 |
| メッセージング | イベント | — 非同期 | 疎 | 注文・通知・ETL(メッセージキュー) |
裏どり:
- gRPC: protobuf でスキーマを定義しコード生成、HTTP/2 上で多重化。この章の Call/Reply・フレーミング・相関は、gRPC が HTTP/2 のフレームとストリームでやっていることの最小版
- Apache Thrift: Facebook 発。IDL + 多言語コード生成 + バイナリプロトコル
- JSON-RPC:
idフィールドで相関する軽量プロトコル。この章の「ID で相関」とまさに同じ発想を JSON でやる - いずれも「スキーマでシリアライズ、境界はフレーミング、多重化は ID で相関」の三点は共通
簡略化したこと
- スキーマ(.proto)もコード生成も無し。Body は生バイト列で、各メソッドが自分で解釈する
- 1接続のみ。コネクションプール・ロードバランシング・再接続は無し
- 再送・サーキットブレーカ・デッドライン伝播は入れていない(呼び出し側の ctx 打ち切りまで)。実務はここに冪等 + 再送 + バックオフを重ねる
- ストリーミング RPC(双方向の連続メッセージ)は扱わない。1要求1応答のみ
参考資料
- gRPC ドキュメント / HTTP/2 の Frames と Streams
- A Note on Distributed Computing (Waldo et al., 1994)。RPC を透過的にする危うさ
- Deutsch & Gosling, The Fallacies of Distributed Computing
- 実装: messaging/rpc