01

為什麼服務之間需要一份編譯期契約

Protobuf 把契約寫成一份 .proto,並決定位元組怎麼排;gRPC 把一次函式呼叫對上一條 HTTP/2 stream。這篇講這筆交易換到什麼、要付什麼。

內部服務用 JSON 互打,痛在哪

訂單服務要向目錄服務查商品,兩邊由不同團隊、用不同語言維護。用 JSON over HTTP 時,契約通常活在文件、慣例與「對方應該會接受這個欄位」的默契裡。欄位名拼錯或型別被悄悄改掉,要到執行期才發現;每種語言各手寫一份 client 與序列化;每一則訊息都重複帶著欄位名,解析前還要先把文字 tokenize。服務數一多、呼叫量一大,這三件事會一起變貴。

RPC(Remote Procedure Call)的想法是讓遠端呼叫看起來像本地函式:你呼叫一個方法,框架負責把參數編碼、送過網路、在對端解碼並呼叫真正的實作。gRPC 是這個想法的一種實作,由兩塊拼成。第一塊是 Protobuf:一份 .proto 檔同時描述訊息與方法,是雙方共用的契約,也是線上的編碼格式。

syntax = "proto3";

message Page {
  int32 limit = 1;    // 等號右邊是欄位編號,不是預設值
  string cursor = 2;
  sint32 delta = 3;
}

service Catalog {
  rpc Get (Page) returns (Page);
}

protoc 或 buf 這類工具鏈讀這份檔案,替每種語言生成 stub:替你處理編碼與網路的那層程式碼。呼叫端拿到的就是一個普通函式,型別不對在編譯期就會被擋下:

// Go:生成出來的 client,呼叫起來像本地函式
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
next, err := catalog.Get(ctx, &pb.Page{Limit: 20, Cursor: "c_81"})

第二塊是 gRPC 本身:它規定這份訊息如何走在網路上。一個 RPC 佔一條 HTTP/2 stream,不是「把 JSON 換成二進位再 POST 到某個 URL」這麼薄。上面那個 200 ms 的 timeout 也不是只在本機計時,它會跟著呼叫送到對端(第 5 節)。

.proto
編號與型別是契約
▸
Generated stub
呼叫像本地函式
▸
HTTP/2 stream
一 RPC 一條
▸
Server stub
還原成同型訊息

看中間那一跳:應用程式不自己組 HTTP 語意,stub 把一次呼叫對上一條 stream。Frame、HPACK、多路復用的原理由 HTTP/1.1 與 HTTP/2 篇負責,本篇只講 gRPC 怎麼使用它們。

體積與速度的來源不是「二進位比較酷」。線上沒有欄位名稱,小整數用 varint 壓進一個 byte,解碼是依 schema 往前掃,不必先做 JSON tokenize。代價是訊息不能自我描述:沒有 schema 就讀不出欄位。除錯要靠 protoc --decode 或同等工具,不能對著一截 tcpdump 用眼睛讀。

它在 API 風格裡的位置

gRPC 擅長的範圍很窄:雙方都由你控制、都能吃同一份 .proto 的呼叫,典型是機房裡的服務對服務,或你自己發佈的手機 App。給合作夥伴的公開 API、需要 CDN 快取 GET 的資源,REST 比較合適;前端一屏要聚合多個資源,GraphQL 比較合適;瀏覽器也不是原生 gRPC 的客戶端(第 6 節)。

邊界選型論證在前一篇,本篇從「已經決定走 RPC」開始

三種風格的資源模型、查詢形狀與版本化策略在 API 設計風格與演進,建議先讀那篇。這裡要搞清楚的是編碼、相容性、串流生命週期,以及長連線如何搞砸負載均衡。

本篇路線:為什麼先講位元組

節回答的問題為什麼排在這裡
02 位元組怎麼排欄位在線上長什麼樣線上沒有欄位名、只有編號,後面所有相容性規則都從這裡推出來
03 欄位編號才是身分schema 怎麼改才不會弄壞舊 client有了 02,「改名安全、改編號危險」是推得出來的結論,不用硬背
04 四種 RPC編好的訊息怎麼走在 HTTP/2 上訊息之外的那一層:stream、串流、背壓
05 Deadline 與狀態碼呼叫鏈上的逾時與錯誤怎麼傳單次呼叫懂了,才談多跳之間的預算與重試
06 長連線與瀏覽器上線後最常踩的雷負載均衡失效與瀏覽器限制,都是前面機制的副作用

第 7 節把這些收成面試時的回答。編碼那一節最細,但它是整篇的地基:第 3 節的相容性規則只在你知道「名稱不在線上」之後才講得通。

一句話答法Protobuf 是帶編號的二進位契約,gRPC 把每次呼叫映射到一條 HTTP/2 stream;省下的是欄位名與解析成本,不是 API 風格的選擇題。
02

位元組怎麼排

一個欄位是 tag、wire type、然後才是值。負的 int32 會膨脹,zigzag 就是為了這件事。

第 1 節 Page 裡等號右邊的 1、2、3 就是欄位編號;limit、cursor 這些名稱只留在生成的程式碼裡,不會上線。每個欄位在線上是 tag-length-value,但 length 只在某些 wire type 出現。Tag 本身是一個 varint,它的整數值由欄位編號與 wire type 拼出來:

tag = (field_number << 3) | wire_type

最低 3 bit 是 wire type,其餘是欄位編號。編號 1 到 15 配上任何 wire type 都還塞得進一個 byte(最大 15<<3|7 = 127);編號 16 起 tag 變成兩個 byte。所以熱路徑上的欄位要把小編號留給它,不是依著想法的順序從 1 往下編就算了。

Wire type線上形狀典型型別
0 Varint7 bit 一組,最高 bit 表示後面還有沒有int32 / int64 / uint / sint / bool / enum
1 64-bit固定 8 bytefixed64 / sfixed64 / double
2 Length-delimited先一個 varint 長度,再跟那一截位元組string / bytes / 嵌套訊息 / packed repeated
5 32-bit固定 4 bytefixed32 / sfixed32 / float

3 與 4 是已廢棄的 group,新契約不要用。面試時能說出 0、2、5 的差別就夠:變長、帶長度、固定 4 byte。

Varint 與兩種「負數」

Varint 把整數拆成 7 bit 一組。0 到 127 是一個 byte;128 要兩個 byte(80 01)。它偏愛小的非負整數。int32 的負數不是小整數:規格把負的 int32 / int64 sign-extend 成 64 bit再編碼,於是 -1 變成十個 byte,ff ff ff ff ff ff ff ff ff 01。這時候 protobuf 可以比 JSON 還大。

sint32 / sint64 先做 zigzag,把小的負數折回小的非負數,再走 varint:

zigzag(n) = (n << 1) XOR (n >> 31)

-1 變成 1,一個 byte;1 變成 2;-64 變成 127,仍是一個 byte。代價是正數少了一個 bit:sint32 的 127 會 zigzag 成 254,反而要兩個 byte,而 int32 的 127 只要一個。正數很大、又幾乎不會是負的(識別碼、長度、計數),用 int32 或乾脆 fixed32;會在零附近擺盪的差值,用 sint32。

fixed32 永遠 4 byte,沒有 varint 的迴圈。數字經常接近 231 時,varint 要 5 byte,fixed 更短也更好預測。小計數則相反。

關鍵名稱不在線上,編號在

二進位 protobuf 的 JSON 對照物長得像 {"id":1},鍵是 IDL 裡的名稱。把欄位從 1 改到 16,JSON 長度不變,protobuf 的 tag 多一個 byte。體積差要在未壓縮的前提下比;很大又很重複的 JSON 加上 gzip 可以追近 protobuf,但小訊息的 gzip 標頭常常先輸,解析 CPU 也仍是 JSON 比較貴。

互動 · 編碼位址空間 點選編號與數值座標,必要時放上第二個欄位
tag(第一格標 wire type) varint 值 長度前綴 原始位元組
點選編號軸與數值軸,位址空間會依編碼規則重算。

每一格是一個 byte,寬度與下方 JSON 長條共用同一比例尺。編號 15 點到 16,tag 多一格;數值點到 -1,int32 的值區拉成 10 格,sint32 仍是 1 格。第二個欄位固定是 sint32 的 -1(編號 5,若主欄位已是 5 則改 6),用來對照「負數不該用 int32」。

一句話答法Tag 是欄位編號與 wire type 拼成的 varint;int32 的負數會變成 10 byte,零附近的差值改用 sint32 的 zigzag。
03

欄位編號才是身分

改名稱不會改線上格式,改編號等於刪掉舊欄位再新增一個。required 是用相容性換檢查的歷史錯誤。

回到第 1 節那份 Page。生成的 stub 不要手改;欄位或方法改名只影響生成出來的程式碼,線上身分是等號右邊的編號。舊 client 仍把 cursor 送在編號 2 的時候,新 server 若把 2 的意義換掉,解出來的是安靜的錯資料,不是編譯錯誤。

向前相容與向後相容

向後相容:新程式讀得懂舊資料。向前相容:舊程式讀得懂新資料(至少能安全忽略)。Protobuf 的預設能力來自三條規則——沒出現的欄位用型別的零值;不認識的欄位編號要保留,轉送時不能丟;新增欄位使用新編號。

變更線上你實際會踩到的事
新增欄位(新編號)相容舊讀者忽略它;新讀者對舊資料看到零值
刪除欄位必須 reserved編號與名稱都保留,禁止以後的人重用
改名稱、不改編號二進位相容生成程式與 JSON 映射會斷;線上 protobuf 還在
重用編號或改編號不相容舊值被當成新意義,沒有解析錯誤
int32 改 sint32負數會錯同一串 varint,zigzag 與 sign-extend 解出來不同
單一欄位改成 repeated通常不相容proto3 的純量 repeated 預設 packed(wire type 2),原本是 varint(wire type 0)
proto2 新增 required不相容舊資料缺欄位,新讀者直接拒絕

安全的演進是「只加編號、刪了就預約」。型別若要換編碼方式,加一個新欄位,不要原位改。

陷阱proto2 的 required 檢查的是部署,不是商業規則

required 讓缺欄位的訊息無法解析。一旦有舊資料、或有還沒升級的發送端,這個檢查就變成滾動升級的障礙。proto3 拿掉了 required:純量看零值,真的要區分「沒送」與「送了零」,用 proto3 的 optional 拿回 presence。商業上的必填放到應用層驗證,不要放到線上格式的語法裡。

轉送型的服務(閘道、異步橋)若在解碼後用自己的 schema 再編碼,不認識的新欄位會被剝掉。那等於替上游做了一次不相容的刪欄。現行 Protobuf 會保留 unknown fields;早期 proto3 曾經丟棄它們,轉送層不能靠那個舊行為。留住這些欄位,才允許對方先升級。

嵌套訊息與 map 沒有新的 wire type:嵌套訊息是 wire type 2,長度蓋住整個子訊息;map 是重複的 key/value 訊息。子訊息裡的 unknown fields 一樣要留。

一句話答法相容性看欄位編號與 wire type,不看名稱;編號用過就 reserved,required 已經用部署事故證明它不該當必填檢查。
04

一條 stream 上的四種 RPC

Unary、server streaming、client streaming、bidi 差在誰寫、誰先 half-close。背壓是 stream 視窗,不是再造一個佇列。

原生 gRPC 要求 HTTP/2。TLS 時 ALPN(TLS 握手時順便協商「接下來講哪個應用層協定」的擴充)要協商到 h2。一個 RPC 的路徑是 /package.Service/Method,POST,content-type: application/grpc,並帶 te: trailers,宣告自己收得了 trailer。Trailer 是 HTTP 在回應本體之後才送出的一組標頭;gRPC 要等最後一則訊息寫完才知道成敗,所以把結果狀態放在這裡。這些是 gRPC 怎麼使用 HTTP/2;frame 怎麼切、HPACK 怎麼壓標頭、一條連線如何多路復用,見 HTTP/1.1 與 HTTP/2 篇。

Protobuf message
第 2 節那些 tag 與 varint
gRPC length prefix · 5 byte
1 byte 壓縮旗標 + 4 byte 大端長度 + 訊息本體。訊息邊界在這裡,不在 HTTP/2 frame 邊界
HTTP/2 stream
HEADERS 帶 metadata,DATA 載上面那一截,結束時用 trailer 送 grpc-status
TCP 連線
長連線、多條 stream 共用。這一層看不見 RPC

5 byte 前綴讓同一條 stream 能接連續多則訊息;一個 DATA frame 可以切在訊息中間。壓縮旗標是 0 或 1,和 protobuf 自己的 length-delimited 欄位不是同一層。

[compressed:1 byte][length:4 byte big-endian][protobuf bytes]

Unary:狀態在最後的 trailer

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: HEADERS + timeout
  C->>S: DATA 一則請求
  S-->>C: DATA 一則回應
  S-->>C: trailer grpc-status

成功與失敗都在 trailer 的 grpc-status,不在 HTTP status line。串流因此可以等最後一則訊息寫完才宣布結果。HTTP/1.1 對 trailer 的支援不夠,所以沒有「gRPC over HTTP/1.1」這條路。

四種模式的差別幾乎都落在 half-close:一端宣告「我不會再寫了」,但仍繼續讀對方的回應。在 HTTP/2 上,就是送出帶 END_STREAM 旗標的最後一個 frame。

模式誰持續寫請求結束的信號典型用途
Unary各一則請求訊息之後就 half-close查詢、命令
Server streaming伺服器客戶端送來的那一則請求訂閱一個會結束的結果集
Client streaming客戶端客戶端 half-close上傳、彙總
Bidirectional兩邊各自的 half-close,彼此獨立服務間的雙向工作階段

四種模式不是四種協定。差別只在同一條 stream 上,每一端什麼時候停止寫入。瀏覽器要推播給人看,選型在 WebSocket 即時雙向通訊篇與 Server-Sent Events 篇,不在這裡。

背壓與取消

發送端能寫多快,取決於對端有沒有把 HTTP/2 stream 視窗還回來。應用讀得慢,視窗不還,下一個 Write 就停住。這是背壓,不是故障。視窗的預設大小與調法見 HTTP/1.1 與 HTTP/2 篇;gRPC 大量傳輸時,那個預設視窗常常是第一個要動的旋鈕。訊息還有實作層的大小上限(常見預設約 4 MiB),超過要改上限或改成串流,不要假設一則 message 能裝下整個檔案。

步驟
Client
Stream 視窗
Server
t1
HEADERS,帶 timeout
初始額度
接受 RPC
t2
連續 Write
每則 DATA 扣額度
應用慢慢 Read
t3
Write 停住
額度歸零
還沒讀完,機制生效
t4
half-close
—
知道請求已結束
t5
讀到 grpc-status
—
trailer OK
t6
cancel
stream 拆掉
CANCELLED

t3 是背壓生效,不是資料壞掉。t4 的 half-close 只代表「我不再寫」,對端還可以繼續寫回應。t6 的取消是呼叫端放棄這次 RPC,伺服器應停止後續工作並回 CANCELLED,而不是把行程殺掉。

一句話答法四種 RPC 共用「一 stream、5 byte 前綴、狀態在 trailer」;慢的讀者用視窗把發送端停住,取消是 CANCELLED 這條生命週期,不是連線層的意外。
05

Deadline、Metadata 與狀態碼

逾時要沿呼叫鏈扣時間。機器讀狀態碼,人讀 message,商業錯誤不一定是非 OK。

Metadata 是 RPC 的側頻道:認證、追蹤編號、租戶、grpc-timeout。它不是第二份 protobuf。標頭有大小上限(許多實作的入站預設在數 KB 到十來 KB),二進位鍵以 -bin 結尾。把 payload 塞進 metadata,會撞上標頭表與延遲,也繞過你為訊息設計的 schema。

Deadline 是剩餘時間,不是每一跳的新配額

grpc-timeout 的形狀像 200m(200 毫秒)、1S、5M。它寫在協定上,不寫進訊息欄位——interceptor 與伺服器 runtime 看的是 call context。每一跳轉呼時要寫入剩下的時間,不是把上游給的 200 ms 再原樣發給下游。

步驟
Caller
Service A
Service B
t1
deadline 200 ms
context 剩餘 200 ms
—
t2
—
自己花了 50 ms,轉呼寫 150 ms
context 是 150 ms,不是新的 200
t3
整體到點則放棄
—
再花 140 ms 後只剩 10 ms,做完或 DEADLINE_EXCEEDED

t2 那格是正確的扣減。若 A 每次轉呼都重設成 200 ms,三跳串起來可以變成 600 ms,Caller 的 deadline 名存實亡。t3 的逾時是機制生效,不是訊息毀損。

陷阱重試若各自領一個新的超時,deadline 會被放大

所有嘗試必須共用這次 RPC 剩下的時間。外層重試、內層才設定 timeout 的 interceptor 順序,會讓每一試都拿到滿額。重試也只適用於冪等、或帶冪等鍵的方法;UNAVAILABLE 上對非冪等寫入重試,等於幫使用者做了兩次。

狀態碼是契約的一部分

gRPC 的結果是狀態碼、給人看的 message、以及選用的 details(常見載體是 google.rpc.Status 裡的 Any)。程式判斷用狀態碼,不要解析 message 的英文。HTTP 狀態只是這條 stream 的外殼:應用錯誤多數仍是 HTTP 200,真正的碼在 trailer。

狀態碼意思重試
INVALID_ARGUMENT / NOT_FOUND / ALREADY_EXISTS / PERMISSION_DENIED / UNAUTHENTICATED / UNIMPLEMENTED請求或部署就不對不要
FAILED_PRECONDITION系統狀態還不允許先改狀態,盲目重試通常沒用
UNAVAILABLE暫時連不上、或連線被拆冪等才重試,並退避
DEADLINE_EXCEEDED剩餘時間用完只有上游還有預算、且冪等才有意義
RESOURCE_EXHAUSTED配額或本地背壓限流可退避;配額用盡則不要
ABORTED並發衝突,交易可重做冪等前提下可以
INTERNAL / UNKNOWN / DATA_LOSS伺服器沒把錯誤分類好,或資料受損不要當暫時性故障

CANCELLED 多半是呼叫端自己取消的,伺服器不要把它記成下游故障去報警。OK 只表示 RPC 協定層成功。

庫存不足、餘額不夠這類領域結果有兩種放法。放進非 OK 狀態碼,interceptor、指標與負載卸載看得到,但客戶端必須把「商業失敗」從「傳輸失敗」裡分出來。放進 OK 回應的欄位,型別清楚,卻容易被中介層當成成功而計入 SLO。選一種,寫進服務契約,不要混用。

一句話答法Deadline 沿路扣剩餘時間並讓重試共用它;機器看 grpc-status,人看 message,領域錯誤要事先決定放狀態碼還是放 OK 本體。
06

長連線、Interceptor 與瀏覽器

L4 均衡器只看見連線。一條 HTTP/2 連線上的所有 RPC 會黏在同一台後端,直到你改成 per-RPC 選路。

Interceptor 是包在真實呼叫外面的一層函式,客戶端與伺服器各有一串。外層先看到出去的呼叫、最後看到回來的狀態。驗證、計量、填 deadline、重試都放這裡,但順序就是語意:重試必須看得到原始 deadline(第 5 節),驗證不該放在會把失敗吞掉的重試內層之下。各語言註冊時「第一個還是最後一個先執行」並不一致,以該實作的鏈是誰在最外層為準,不要背一個通用的陣列順序。

為什麼 L4 負載均衡會失效

gRPC 的連線是長生命週期的 HTTP/2。L4 均衡器按連線轉發:客戶端握手套到後端 A 之後,這條連線上後來開的每一條 stream 都在 A。B 與 C 要等新的 TCP 連線。一個行程常常只維持少數幾條連線,流量就黏死了。連線層的 keepalive 只讓中間盒不要砍掉閒置連線,它不會重新分配 RPC。

下表會用到幾個 client 端的詞。Channel 是應用拿到的那個邏輯連線物件;subchannel 是 channel 底下對單一後端的實際連線。LB policy 決定每個 RPC 走哪條 subchannel:pick_first 挑第一條連得上的就一直用,round_robin 輪流使用每條健康的 subchannel。

作法選路單位代價
L4 LBTCP 連線長連線黏住單一後端
Client-side LB每個 RPC 選一條 subchannel客戶端要有後端清單與健康狀態;pick_first 仍會黏住第一條 READY 連線
L7 proxy每一條 stream多一跳,proxy 必須轉送 trailer 與 timeout,自己變成故障域

round_robin 這類政策是 per RPC 的;pick_first 在連線健康時行為看起來像 L4。後端要下線時,對端應停止在舊連線上開新 stream(GOAWAY 的使用方式見 HTTP/1.1 與 HTTP/2 篇),進行中的 RPC 可能以 UNAVAILABLE 結束,是否重試回到第 5 節的冪等條件。

Lookaside LB(另外問一個服務「這次該打哪台」)或 xDS(源自 Envoy 的控制平面 API,gRPC client 也能直接訂閱)這類控制平面解決的是「客戶端怎麼知道後端名單與權重」,不是另一種編碼。名單若只在啟動時抓一次,自動擴縮就會把新機器閒置。

瀏覽器講的不是原生 gRPC

瀏覽器的 fetch / XHR 拿不到原生 gRPC 需要的 HTTP/2 控制能力,trailer 也不是一般網頁 API 能穩定讀到的東西。gRPC-Web 是另一份協定:基本規格支援 unary 與 server streaming,要一層 proxy 把它翻成原生 gRPC。Client streaming 與 bidirectional 不在這份基本規格裡。若產品要的是瀏覽器裡的雙向通道,那是 WebSocket 即時雙向通訊篇的問題,不要在 gRPC-Web proxy 裡順便發明一套推播。

關鍵長連線同時是優點與負載均衡的單位錯誤

少握手、多路復用、串流背壓,都建立在「連線活很久」上。均衡與摘除機器則要求「下一個 RPC 可以換後端」。兩件事要分層:連線可以長,選路必須 per RPC,或者乾脆在 L7 把 stream 終結掉再轉出去。

一句話答法L4 只會把整條 HTTP/2 連線釘在一台機器上;要嘛客戶端按 RPC 選 subchannel,要嘛用懂 trailer 的 L7 proxy。瀏覽器走 gRPC-Web,不是原生 gRPC。
07

面試時把取捨說完

面試官要聽的是編號、負數編碼、trailer、deadline 與連線黏著,不是把 proto 語法背完。

他丟出的問題先給的答案緊接著的代價
為什麼比 JSON 小又快?線上沒有欄位名,小整數是 varint,依 schema 順序解碼不能自我描述;未壓縮小訊息才穩定贏,gzip 後的大 JSON 會追近
負數為什麼特別?int32 負值 10 byte;sint32 用 zigzagsint 的大正數少 1 bit,127 就會變成兩個 byte
怎麼演進 schema?只加新編號,刪除就 reserved,不改 wire type轉送層若丟掉 unknown fields,新欄位還是會消失
四種 RPC 差在哪?誰在寫、何時 half-close;狀態在 trailer慢消費者會把視窗吃完,Write 阻塞是背壓
逾時怎麼設?每一跳傳遞剩餘 deadline,重試共用它每跳重設滿額,或重試各自計時,都會把上游預算放大
負載均衡怎麼做?Per-RPC 的 client LB,或 L7 按 stream 轉發pick_first 與純 L4 都會讓長連線黏住一台

每一列都是「機制 + 它破壞什麼」。只答優點的那一半,聽起來像文件複述。

若被問到與 REST 或 GraphQL 怎麼選、URL 版本號怎麼編,停在邊界:那是 API 設計風格與演進篇的題。本篇的契約單位是欄位編號與 RPC 方法,不是資源 URL。

一句話答法Protobuf 用編號與 wire type 換掉自我描述,gRPC 用一條長連線上的 stream 換掉每請求一條 TCP;相容性、deadline 與 per-RPC 選路是這筆交易真正的維護成本。