Skip to content

RPC

関数を呼ぶように別プロセスの処理を呼ぶ RPC を自作する。型のある要求と応答をバイト列にして戻すシリアライズ(protobuf 風のタグ + varint)、境界の無いバイトストリームから長さを前置きして 1 メッセージを切り出すフレーミング、1 本の接続に多重化した応答を要求 ID で突き合わせる相関、の 3 つで組む。タイムアウトや部分故障が起きるので、再送には冪等な受け手が要る。

この章で作るもの

メッセージキューが「あとで処理する」非同期の繋ぎ方なら、RPC は「今すぐ答えを返して」の同期の繋ぎ方。gRPC のように、別のサーバのメソッドを、あたかもローカル関数のように呼ぶ。だが実体はネットワーク越しのメッセージ交換で、その正体を3つの部品で作る。

順に見ていく。

  1. シリアライズ: 型のある要求/応答をバイト列に変換する。protobuf 風に「[フィールド番号][値]」を並べ、整数は varint(小さい値ほど短い)、文字列は「[長さ][中身]」で書く
  2. フレーミング: TCP はバイトの列であって、メッセージの列ではない。区切りを自分で作らないと、2つの送信がくっついたり途中で切れたりする。長さを前置きして1メッセージを切り出す
  3. 相関(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.ReadmsgA と msgB がくっついた塊を受け取ることがある。TCP はバイトの列で、「1回の Write = 1メッセージ」を保証しない。だから自分で境界を作る。素直な方法が長さ前置きだ。「これから N バイト」と書いてから中身を書く:

go
// 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] に分かれて届いても、長さ基準で切り直せる)
長さ前置きフレーミング。各メッセージの前に長さ(uvarint)を書く。受け手はまず長さを読み、その分だけ読んで1メッセージを切り出す。これで2つの送信がくっついても、途中で分割されても、正しく1件ずつ復元できる

相関: 応答を要求IDで突き合わせる

1本の接続に要求を多重化すると速い(1件ごとに返事を待たなくていい)。だが応答が戻ってきたとき、どの要求への返事かが分からない。サーバは並行に処理するので、応答は送った順に返るとは限らないからだ。

解決は単純。各要求に ID を振り、応答にも同じ ID を載せて返す。クライアントは「ID → 待っている呼び出し」の対応表を持ち、応答が来たら ID で相手を見つけて結果を渡す。

  クライアント          サーバ
    #1 add   ──────▶   (処理中)
    #2 echo  ──────▶   #2 完了 ──┐
    #3 slow  ──────▶   (処理中)   │
                       #1 完了 ─┐ │
        ◀──────────────────────┘ │  応答は #2, #1, #3 の順で返る
        ◀────────────────────────┘  → ID で正しい呼び出しに相関
多重化と相関。クライアントは #1, #2, #3 を続けて送る。サーバは #2 を先に処理し終えて返すこともある。クライアントは応答の ID を見て、正しい呼び出しに結果を届ける。順番ではなく ID が頼り

動かす

下のデモで「echo/add/slow を呼ぶ」と、要求が接続に多重化される(上段のフレーム=長さ前置き)。「サーバが応答(後着から)」を押すと、わざと送信と逆順に応答が返る。それでも各呼び出しは ID で相関して正しい結果を受け取る。「タイムアウト」を押した呼び出しに後から応答が来ると、相関先が無く破棄される。

デモRPC(多重化と相関)呼び出し 0 / 待機中 0
接続を流れるフレーム(長さ前置きで1メッセージを切り出す)
(在中の要求なし)
呼び出しの一覧(ID で応答と突き合わせる)
(まだ呼び出しなし)

メソッドを呼ぶと接続に多重化される。『サーバが応答』は後着から返し、順不同を作る

応答は送信順に返るとは限らない(サーバは並行処理)。だから 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