微服務架構把一個系統拆成多個獨立部署的服務,服務之間勢必要跨網路呼叫彼此。**RPC(remote procedure call,遠端程序呼叫)**要解決的問題,是讓這種跨網路呼叫寫起來盡量像呼叫一個本地函式——呼叫端不需要手動處理連線、序列化、封包格式,只要像呼叫函式一樣傳參數、拿回傳值。gRPC 是目前最常見的 RPC 框架之一,這篇文章先說明 RPC 的基本模型,再拆解 gRPC 具體怎麼實作它。

1. RPC 想解決的問題

1.1 用本地呼叫的外觀包住遠端呼叫

一次 RPC 呼叫的基本流程是:呼叫端呼叫一個看起來像本地函式的「stub」,stub 把參數序列化成位元組、透過網路送到伺服器;伺服器端收到後反序列化,呼叫真正的實作函式,把回傳值序列化後送回;呼叫端的 stub 再把收到的位元組反序列化成回傳值,交給呼叫者。整個序列化、傳輸、反序列化的過程對呼叫者是隱藏的,寫起來就像 result = greeter.SayHello(request) 這樣一行本地呼叫。

1.2 隱藏得了語法,藏不住失敗模式

RPC 能讓語法長得像本地呼叫,卻無法讓失敗模式跟本地呼叫一樣。本地函式呼叫幾乎不會「呼叫到一半失去聯繫」,但網路呼叫可能逾時、可能封包遺失後重傳、可能伺服器收到請求卻在回應送達前當機——呼叫端這時完全無法分辨「請求根本沒送到」還是「請求送到了、也執行了,只是回應遺失了」。這是分散式系統裡著名的**部分失敗(partial failure)**問題:呼叫端只能選擇重試、放棄、或想辦法讓重複執行也安全(幂等),沒有辦法讓網路呼叫的失敗模式完全等同本地呼叫。理解這個限制,是正確使用任何 RPC 框架的前提,而不是框架的缺陷。

2. gRPC 具體怎麼運作

2.1 用 Protocol Buffers 定義介面

gRPC 用 **Protocol Buffers(protobuf)**當作介面定義語言:先寫一份 .proto 檔案,描述有哪些遠端方法、每個方法的請求與回應長什麼樣子。

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

protoc 編譯器會從同一份 .proto 檔案,產生多種語言(Go、Python、Java、C++ 等)各自的用戶端 stub 與伺服器端骨架程式碼。這代表介面定義只需要寫一次,不同語言寫成的服務之間也能對得上:不會有「用戶端以為欄位是字串,伺服器端卻讀成整數」這類手寫序列化程式碼常見的錯誤。訊息裡每個欄位都有一個數字標籤(例如 string name = 1; 裡的 1),這個數字才是欄位在線上格式裡的真正識別碼,之後新增欄位只要用新的數字、不要重複使用已經棄用的欄位號碼,新舊版本的用戶端和伺服器就能繼續互相相容。

2.2 建立在 HTTP/2 之上

gRPC 的傳輸層是 HTTP/2,直接繼承了幾個特性:同一條 TCP 連線可以多工處理多個並行呼叫,不會像 HTTP/1.1 那樣互相排隊;標頭用 HPACK 壓縮,減少重複中繼資料佔用的頻寬;HTTP/2 原生支援雙向的串流,這也是 gRPC 能實作雙向串流呼叫的基礎(見第 3 節)。

3. 四種呼叫模式

gRPC 定義了四種呼叫模式,差別在請求和回應各自是「一個」還是「一串」:Unary 是最基本的一對一呼叫,一個請求換一個回應,跟一般函式呼叫的直覺完全一致。Server streaming 是一個請求換回一串回應,例如訂閱某個資源的更新,伺服器會持續推送新的訊息。Client streaming 反過來,用戶端送出一連串請求(例如分批上傳資料),伺服器等收完之後只回一個總結性的回應。Bidirectional streaming 讓兩邊都能各自持續傳送訊息,不需要一方等另一方講完,適合即時互動的場景,例如聊天室或即時語音轉文字。四種模式共用同一套介面定義語法,差別只在 rpc 宣告裡是否加上 stream 關鍵字標註請求或回應。

4. gRPC 與 REST 的取捨

REST(通常搭配 JSON)的優勢是人類可讀、任何 HTTP 用戶端都能直接呼叫、可以直接利用 HTTP 既有的快取語意(例如 GET 天生可快取);缺點是 JSON 序列化體積較大、解析較慢,也沒有強制的型別契約,用戶端跟伺服器對欄位型別的認知不一致時通常要到執行期才會發現。gRPC 用二進位的 protobuf 編碼,體積小、解析快,且介面契約由 .proto 檔案強制產生程式碼,型別不一致在編譯期就會出錯;但瀏覽器沒辦法直接發出原生的 gRPC 呼叫(需要透過 gRPC-Web 加一層代理轉換),二進位格式也不像 JSON 那樣可以直接用肉眼讀懂封包內容,除錯通常需要額外工具(例如 grpcurl)。實務上,對外的公開 API 常用 REST 或 GraphQL 求取相容性和可讀性,服務之間的內部呼叫則更常用 gRPC 換取效率與型別安全。

5. 常見踩坑

**忘記設定逾時(deadline)。**RPC 呼叫預設不會自動逾時,一旦沒有主動設定 deadline,遇上網路異常或伺服器掛住時,呼叫端會無限期等待,佔用的資源(執行緒、連線)也跟著沒有釋放,這正是第 1.2 節提到的部分失敗問題在實務上最常踩到的地方。

**負載平衡失效。**gRPC 把多個邏輯呼叫多工在同一條長連線上,如果負載平衡器只在 TCP 連線建立時決定要轉發到哪一台後端(傳統的第四層負載平衡常是這樣運作),之後同一條連線上的所有呼叫都會一直打到同一台後端,其他後端實例完全分不到流量。解法是改用能理解 HTTP/2、逐一呼叫層級做負載平衡的代理(例如 Envoy),或者在用戶端本身實作服務發現與負載平衡邏輯。

**改欄位號碼或型別破壞相容性。**protobuf 的相容性設計建立在欄位號碼不變的前提上;重新使用已棄用的欄位號碼,或者把一個已經在使用的欄位型別改掉,會讓新舊版本互相誤讀對方的資料,卻不一定會立即報錯,是特別難排查的一類版本相容性問題。新增欄位、保留舊欄位號碼不重複使用,才是安全的 schema 演進方式。