為什麼服務之間需要一份編譯期契約
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 節)。
看中間那一跳:應用程式不自己組 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 節)。
三種風格的資源模型、查詢形狀與版本化策略在 API 設計風格與演進,建議先讀那篇。這裡要搞清楚的是編碼、相容性、串流生命週期,以及長連線如何搞砸負載均衡。
本篇路線:為什麼先講位元組
| 節 | 回答的問題 | 為什麼排在這裡 |
|---|---|---|
| 02 位元組怎麼排 | 欄位在線上長什麼樣 | 線上沒有欄位名、只有編號,後面所有相容性規則都從這裡推出來 |
| 03 欄位編號才是身分 | schema 怎麼改才不會弄壞舊 client | 有了 02,「改名安全、改編號危險」是推得出來的結論,不用硬背 |
| 04 四種 RPC | 編好的訊息怎麼走在 HTTP/2 上 | 訊息之外的那一層:stream、串流、背壓 |
| 05 Deadline 與狀態碼 | 呼叫鏈上的逾時與錯誤怎麼傳 | 單次呼叫懂了,才談多跳之間的預算與重試 |
| 06 長連線與瀏覽器 | 上線後最常踩的雷 | 負載均衡失效與瀏覽器限制,都是前面機制的副作用 |
第 7 節把這些收成面試時的回答。編碼那一節最細,但它是整篇的地基:第 3 節的相容性規則只在你知道「名稱不在線上」之後才講得通。
位元組怎麼排
一個欄位是 tag、wire type、然後才是值。負的 int32 會膨脹,zigzag 就是為了這件事。
第 1 節 Page 裡等號右邊的 1、2、3 就是欄位編號;limit、cursor 這些名稱只留在生成的程式碼裡,不會上線。每個欄位在線上是 tag-length-value,但 length 只在某些 wire type 出現。Tag 本身是一個 varint,它的整數值由欄位編號與 wire type 拼出來:
最低 3 bit 是 wire type,其餘是欄位編號。編號 1 到 15 配上任何 wire type 都還塞得進一個 byte(最大 15<<3|7 = 127);編號 16 起 tag 變成兩個 byte。所以熱路徑上的欄位要把小編號留給它,不是依著想法的順序從 1 往下編就算了。
| Wire type | 線上形狀 | 典型型別 |
|---|---|---|
| 0 Varint | 7 bit 一組,最高 bit 表示後面還有沒有 | int32 / int64 / uint / sint / bool / enum |
| 1 64-bit | 固定 8 byte | fixed64 / sfixed64 / double |
| 2 Length-delimited | 先一個 varint 長度,再跟那一截位元組 | string / bytes / 嵌套訊息 / packed repeated |
| 5 32-bit | 固定 4 byte | fixed32 / 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:
-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 比較貴。
每一格是一個 byte,寬度與下方 JSON 長條共用同一比例尺。編號 15 點到 16,tag 多一格;數值點到 -1,int32 的值區拉成 10 格,sint32 仍是 1 格。第二個欄位固定是 sint32 的 -1(編號 5,若主欄位已是 5 則改 6),用來對照「負數不該用 int32」。
欄位編號才是身分
改名稱不會改線上格式,改編號等於刪掉舊欄位再新增一個。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 | 不相容 | 舊資料缺欄位,新讀者直接拒絕 |
安全的演進是「只加編號、刪了就預約」。型別若要換編碼方式,加一個新欄位,不要原位改。
required 讓缺欄位的訊息無法解析。一旦有舊資料、或有還沒升級的發送端,這個檢查就變成滾動升級的障礙。proto3 拿掉了 required:純量看零值,真的要區分「沒送」與「送了零」,用 proto3 的 optional 拿回 presence。商業上的必填放到應用層驗證,不要放到線上格式的語法裡。
轉送型的服務(閘道、異步橋)若在解碼後用自己的 schema 再編碼,不認識的新欄位會被剝掉。那等於替上游做了一次不相容的刪欄。現行 Protobuf 會保留 unknown fields;早期 proto3 曾經丟棄它們,轉送層不能靠那個舊行為。留住這些欄位,才允許對方先升級。
嵌套訊息與 map 沒有新的 wire type:嵌套訊息是 wire type 2,長度蓋住整個子訊息;map 是重複的 key/value 訊息。子訊息裡的 unknown fields 一樣要留。
一條 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 篇。
5 byte 前綴讓同一條 stream 能接連續多則訊息;一個 DATA frame 可以切在訊息中間。壓縮旗標是 0 或 1,和 protobuf 自己的 length-delimited 欄位不是同一層。
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 能裝下整個檔案。
t3 是背壓生效,不是資料壞掉。t4 的 half-close 只代表「我不再寫」,對端還可以繼續寫回應。t6 的取消是呼叫端放棄這次 RPC,伺服器應停止後續工作並回 CANCELLED,而不是把行程殺掉。
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 再原樣發給下游。
t2 那格是正確的扣減。若 A 每次轉呼都重設成 200 ms,三跳串起來可以變成 600 ms,Caller 的 deadline 名存實亡。t3 的逾時是機制生效,不是訊息毀損。
所有嘗試必須共用這次 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。選一種,寫進服務契約,不要混用。
長連線、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 LB | TCP 連線 | 長連線黏住單一後端 |
| 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 終結掉再轉出去。
面試時把取捨說完
面試官要聽的是編號、負數編碼、trailer、deadline 與連線黏著,不是把 proto 語法背完。
| 他丟出的問題 | 先給的答案 | 緊接著的代價 |
|---|---|---|
| 為什麼比 JSON 小又快? | 線上沒有欄位名,小整數是 varint,依 schema 順序解碼 | 不能自我描述;未壓縮小訊息才穩定贏,gzip 後的大 JSON 會追近 |
| 負數為什麼特別? | int32 負值 10 byte;sint32 用 zigzag | sint 的大正數少 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。