01

契約要對準誰在呼叫

REST、GraphQL、gRPC 不是三個傳輸格式,是三種「呼叫者被允許說什麼」的表面。

選風格之前先問誰拿著客戶端。外部開發者、合作夥伴、瀏覽器裡的前端,和你自己機房裡的下一個服務,要的契約完全不一樣。外部的人需要穩定、可書籤、可被中介層快取的文件;前端需要「這一屏剛好要的形狀」;內部服務需要雙方編譯期就對得上的型別,以及一條 RPC 對應一件工作。

外部客戶端
文件、SDK、共享快取
──資源──▸
REST
URL、方法、狀態碼
前端畫面
一屏聚合多個資源
──查詢──▸
GraphQL
客戶端決定形狀
內部服務
雙方持有同一份 IDL
──RPC──▸
gRPC
方法與訊息

三條箭頭不是同一條管線的先後步驟。它們是三種呼叫者各自的契約:左邊的人不同,中間那一格就該換。

REST 把契約放在資源與 HTTP 語意上。客戶端用 URL 指名要什麼、用方法表明想做的事、用狀態碼判斷整筆結果。中介層(瀏覽器、CDN、反向代理)看得懂 GET,才有機會把公開資源存下來。GraphQL 把契約放在型別系統與一份查詢上:伺服器公布圖,客戶端每次挑自己要的欄位,所以同一份 schema 能餵不同的畫面,不必為每一屏開一個新端點。gRPC 把契約放在 .proto 的方法與訊息上,客戶端與伺服器由同一份 IDL 生成 stub。內部呼叫用它,是因為型別、截止時間與一個 RPC 的成敗都在這條呼叫上,而不是因為「二進位比較快」這一句口號。

邊界這篇不重講的東西

Protobuf 的欄位怎麼編碼、gRPC 一條 stream 上的四種 RPC、deadline 與狀態碼表,都在 gRPC 與 Protocol Buffers 那篇,建議讀完本篇再接著讀。瀏覽器要的即時推送(誰該用長連線、誰該用單向下推)在 WebSocket 與 SSE 那兩篇。這裡只用「內部是強型別 RPC、瀏覽器不是 gRPC 的原生客戶端」當作選型論據。

同一個取捨在別的協定上也看得到。MCP 把模型能碰到的表面收成 tools 與 resources,而不是給模型一門通用查詢語言;A2A 把 agent 之間的契約收成任務的生命週期,而不是共享對方的內部 API。兩者都是「誰在呼叫,表面就長成那樣」。角色、交握與生命週期各有專文,這裡不展開。

一句話答法對外用 REST,因為客戶端、文件與共享快取都說 HTTP 資源;畫面聚合用 GraphQL,因為形狀該由客戶端決定;內部用 gRPC,因為雙方持有同一份型別契約。不要用傳輸格式反推風格。
02

資源、方法與安全語意

URL 是名詞,方法才是動詞。冪等是伺服器狀態的性質,不是方法名字的保證書。

資源建模先問「客戶端想指到的東西有沒有穩定的名字」。訂單是 /orders/18,它的明細可以是巢狀資源 /orders/18/items,商品則是另一個可被許多訂單引用的 /products/9。把商品欄位直接焊進訂單文件裡,客戶端少打一次,但商品不再是一個能單獨快取、單獨授權、單獨演進的資源。第 3 節的帳本就是在算這個決定值多少錢。

HTTP 方法的語意定義在 HTTP 語意規範(HTTP Semantics,RFC 9110)。安全(safe)表示這次呼叫不該改變伺服器狀態;冪等(idempotent)表示同一筆請求做一次與做多次,伺服器狀態相同。客戶端能不能在逾時後重試,看的是冪等,不是「這個方法通常很快」。

方法 安全 冪等 逾時後
GET 是 是 可以重試
PUT 否 是,整份替換 本體相同就可以重試
DELETE 否 是,狀態收到「已刪」 可以重試
POST 否 否 要有冪等鍵,否則可能做第二次
PATCH 否 看這份 patch 能不能重做 JSON Merge Patch 常常可以;「陣列尾端追加」不行

重試欄要看的是「狀態會不會做第二次」,不是狀態碼好不好看。DELETE 第二次回 404 或 204 都可以,只要資源停在已刪。

陷阱把 POST 說成「建立」、把 PUT 說成「更新」就結束

PUT 的契約是「這個 URL 的整份表示變成你送來的這一份」。用 PUT 送一個只含三個欄位的片段,伺服器若照字面替換,其餘欄位會被清掉。部分更新用 PATCH,或用一個更小的子資源。POST 不是「不冪等的 PUT」:它的 URL 常常是集合,伺服器決定新資源的名字。客戶端若需要冪等,鍵要另外帶,見第 8 節。

Richardson 成熟度:這一節停在第幾級

Richardson 成熟度模型(Richardson Maturity Model)是 Leonard Richardson 提出的一把尺,把一個 HTTP API「有多 REST」分成四級。面試問「你的 API 算不算 RESTful」,常常就是在問這把尺。

級 長相 例子
0 所有呼叫打同一個端點,要做什麼寫在本體裡 POST /api,本體 {"action":"getOrder","id":18}
1 拆出資源,每個東西有自己的 URL,但方法不分 POST /orders/18,本體 {"action":"cancel"}
2 用 HTTP 方法表達意圖、用狀態碼表達結果 GET /orders/18 → 200;DELETE /orders/18 → 204
3 超媒體控制:回應裡附上「下一步能做什麼」的連結 見下方的訂單回應

本節前半講的資源命名與方法語意,就是第 1 級加第 2 級。第 3 級多的是「由伺服器告訴客戶端下一步」。

第 3 級的意思是,回應不只給資料,也告訴客戶端現在能做哪些事、要打哪個 URL:

{
  "id": 18,
  "status": "pending",
  "links": {
    "self":   { "href": "/orders/18" },
    "pay":    { "href": "/orders/18/payment", "method": "POST" },
    "cancel": { "href": "/orders/18/cancel",  "method": "POST" }
  }
}

訂單出貨之後,伺服器不再附上 cancel,客戶端就不畫「取消」按鈕,「什麼狀態能取消」這條規則只留在伺服器一處。這就是 HATEOAS(Hypermedia as the Engine of Application State):好處是改流程不必改客戶端,代價是客戶端必須照連結走,不能把 URL 與流程寫死在程式裡。瀏覽器讀 HTML 天生就是這樣,但多數 SDK 與 App 仍把路徑寫死,所以公開 API 大多停在第 2 級。面試時承認你做的是第 2 級,並說明為什麼不強求客戶端解譯連結,比背誦四個等級有用。

一句話答法資源用名詞 URL 指到,方法表達意圖。GET 可重試,PUT/DELETE 在實作遵守語意時可重試,POST 預設不可。逾時表示你不知道伺服器做完沒有。
03

同一個訂單畫面的四筆帳

畫面要 N 筆訂單、每筆 K 個明細、以及商品上畫面真正用到的欄位。四種契約搬的東西不一樣。

面試裡「REST 比較囉嗦、GraphQL 一次拿完、gRPC 比較小」這三句可以同時為真,也可以同時為假,取決於商品放在哪、快取暖不暖、畫面到底用到幾個欄位。這一節拿同一個畫面把四種契約的帳攤開,下方的模擬器可以直接重算。

場景:你們自家手機 App 的「我的訂單」畫面,背後有一個 BFF(Backend for Frontend,專為這個 App 聚合資料的後端)。畫面要 N 筆訂單、每筆 K 個明細,每個明細指向一個商品,但只用到商品的幾個欄位。選手機 App 是因為它能打 REST、GraphQL,也能直接跑原生 gRPC client;瀏覽器得經過 gRPC-Web 與一層 proxy,見 gRPC 與 Protocol Buffers 篇談瀏覽器的章節。

契約 客戶端打幾次 商品怎麼拿到 商品進得了共享快取嗎
REST 拆開 1 + N + P 次,分三波 每個不重複商品一次 GET /products/{id},完整 8 欄 可以。公開商品 GET 命中時,本體不必再傳
REST 內嵌 1 次 伺服器把完整 8 欄焊進每一筆明細,同一個商品被引用幾次就出現幾次 不行,商品成了私人文件的一部分
GraphQL 1 次 POST 只帶查詢選到的欄位,但仍按明細筆數重複 不行,POST 加私人資料
gRPC 1 次 unary RPC 和 GraphQL 同一個形狀,改用 Protobuf 編碼 不行,中介層不把它當成 GET

P 是不重複的商品數,模型假設約六成明細指向不同商品。拆開的 REST 同一波可以並行,HTTP/2 多路複用省的是排程,不是請求數,也不是源站要處理的 GET。

速讀GraphQL 這一列的幾個詞

客戶端送出的查詢長這樣:

query {
  orders(first: 8) {
    id
    items {
      qty
      product { name price }
    }
  }
}

選取集(selection set)是大括號裡列出的欄位,伺服器只回這些,所以 product 只帶 name 與 price,不會 8 欄全下。Resolver 是伺服器上替一個欄位取值的函式:orders 的 resolver 查訂單,items 的 resolver 對每一筆訂單查明細,product 的 resolver 對每一筆明細拿商品 id 去查商品。照這樣天真地寫,8 筆訂單、24 筆明細就是 1 + 8 + 24 次資料庫查詢,這叫 N+1。DataLoader 把同一次請求裡同型別的查詢收成一批、並記住載過的鍵,三層各一次批次查詢。這些都發生在伺服器裡,不改變客戶端收到的 bytes。伺服器另外設了查詢成本預算,清單太大就直接拒絕;三筆代價在第 4 節展開。

gRPC 這一列是 App 對 BFF 的一次 unary RPC:一問一答,送一則請求訊息、收一則回應訊息,用起來像呼叫一個遠端函式(gRPC 還有串流型的 RPC,見 gRPC 與 Protocol Buffers 篇談四種 RPC 的章節)。回應的形狀和 GraphQL 相同,差在編碼:JSON 每個物件都重寫一次欄位名,"qty":3 是 7 bytes;Protobuf 把欄位名換成 1 byte 的欄位編號,同一個值是 08 03 兩個 bytes。但字串值本身一個 byte 也省不掉,商品名稱只少掉欄位名與引號。所以模型取「Protobuf 本體約為同內容 JSON 的 72%」當教學折算:數字多的訊息縮得更多,字串多的縮得少,實際比例要拿自己的資料量。

sequenceDiagram
    participant C as 客戶端
    participant O as 訂單 API
    participant P as 商品目錄
    C->>O: GET /orders
    O-->>C: N 筆摘要
    C->>O: GET /orders/id/items
    O-->>C: 該筆明細
    C->>P: GET /products/id
    P-->>C: 完整 8 欄
  

圖上每一對箭頭是一波,不是只發生一次。明細那一波實際是 N 次,商品那一波是 P 次。同一波可以並行,瀑布深度仍是 3。內嵌、GraphQL 與這次的 gRPC 都把三波收成客戶端的一次呼叫,商品也就不再是共享快取能單獨認領的對象。

互動 · 訂單畫面的契約帳 拖動參數,四列一起重算
8 3 4 40%

命中率只影響拆開的 REST 那些公開商品 GET。內嵌文件、GraphQL 的 POST、gRPC 的 unary 都不會因為這個滑桿變得可快取。

四種契約這次要傳的位元組 拆開 0 內嵌 0 GraphQL 0 gRPC 0

條的長度是這一次線上要傳的 bytes,四列共用同一把尺。變短不一定是風格贏了:GraphQL 被預算拒絕時,條會縮成一份錯誤文件。

契約 請求 深度 傳輸 可快取 多傳 內部
REST 拆開 23 3 0 0 0 源站
REST 內嵌 1 1 0 0 0 源站
GraphQL 1 1 0 0 0 resolver
gRPC 1 1 0 0 0 RPC 1

多傳是線上多付的商品 bytes:拆開的 REST 是未命中的完整欄位減去畫面用到的部分;內嵌是整份 8 欄乘上每一筆明細,再扣掉每個不重複商品真正需要的那一份;GraphQL 與 gRPC 只重複嵌套裡已經選到的欄位。可快取只計算共享快取省下、不必再傳的商品本體。

拖動滑桿後,這裡用算出來的數字說明四筆帳。

預設 N=8、K=3、用 4 欄、命中 40%、沒有 DataLoader。此時 P=14。拆開傳 9,124 bytes(可快取 2,304、多傳 1,536、源站 17 次),內嵌傳 10,428(多傳 6,528),GraphQL 傳 6,028(多傳 1,920、resolver 33 次、複雜度 96),gRPC 傳 4,064。把命中拉到 0%,拆開變成 11,428,比內嵌更貴;拉到 100%,拆開掉到 6,052,多傳變 0,和 GraphQL 的總量接近,但組成是 23 次請求的標頭,不是商品欄位。

模型模型常數(想自己對帳再展開)▸

常數是教學用的整數,不是生產環境的封包基準;面試要講的是哪一項會變、為什麼變。

  • 訂單摘要 64 bytes、明細 20 bytes、商品每欄 48 bytes,完整商品固定 8 欄。
  • 不重複商品數 P = round(N × K × 3 / 5)。命中率只作用在拆開的 REST 那 P 個商品 GET;訂單與明細是使用者私人資料,不算可快取。
  • 每個 HTTP 請求加回應的標頭 220 bytes(方法、路徑、授權、內容型別),以未壓縮估算。若 App 以 HTTP/2 打 REST,HPACK 會把同一條連線上重複的標頭壓小,拆開那一列的標頭成本在這種情況下被高估。
  • GraphQL 查詢文件 160 bytes,再加每欄 12 bytes。執行期複雜度 = N × K × 欄位數,預算 200;超過就拒絕,線上只回 220 + 96 bytes 的錯誤文件,resolver 不跑。
  • Resolver 次數:沒有 DataLoader 是 1 + N + N × K,有 DataLoader 是 3。
  • gRPC 本體 = 同形狀 JSON × 72%,框架另計 32 bytes:5 bytes 的訊息長度前綴,加上長連線上已被壓縮的少量標頭。內部若再扇出成多個服務,那是伺服器拓樸,不計進這張客戶端帳。
一句話答法往返、位元組、可快取是三筆分開的帳。內嵌省請求,卻把商品焊進私人文件;拆開的 REST 用標頭稅換商品 GET 的共享快取。GraphQL 省的是沒選到的欄位,不自動省 N+1,也不讓這份私人回應進共享快取。
04

GraphQL 多付的三筆帳

選取集解決 over-fetch 與 under-fetch。N+1、查詢成本、HTTP 快取失效要另外設計。

Over-fetch 是伺服器把畫面不會畫的欄位也送了;under-fetch 是客戶端為了補一個欄位再打一輪。GraphQL 的選取集把這兩件事收進同一次查詢,這是它對前端聚合真正的貢獻。第 3 節裡,欄位滑桿會改變 GraphQL 與 gRPC 的本體,卻不會改變內嵌 REST 的傳輸量——內嵌文件始終是 8 欄。這就是 over-fetch 在帳上的樣子。

Resolver N+1

第 3 節查詢裡的 Order.items 與 Item.product 各是一個 resolver。天真的實作對每一筆父物件各打一次資料庫:訂單一查之後,N 次明細、N × K 次商品;同一個商品被三筆明細引用,就查三次。客戶端只看到一次 HTTP 200,延遲與連線池的壓力都在伺服器。DataLoader 這類批次器在同一次請求內把相同型別的鍵收成 WHERE id IN (...),並在這次請求內記住已經載過的鍵,重複的商品只查一次。它不是跨請求的 HTTP 快取,也不能把私人訂單回應存進 CDN。預設參數下,沒有批次時 resolver 是 33 次,比拆開的 REST 的 23 個請求還多,因為 REST 客戶端至少知道同一個商品只要 GET 一次;打開批次後變成 3 次,客戶端 bytes 不動。

複雜度與深度

公開或半公開的 GraphQL 端點必須假設查詢是敵意的。只限制深度會放過「深度很淺、但清單變數很大」的查詢:文件上只看得到幾個欄位,N 與 K 在變數裡。第 3 節的預算吃的是執行期的 N × K × 欄位。把訂單拉到 30、明細拉到 12、欄位拉到 8,複雜度是 2,880,查詢被拒,條形縮成錯誤文件。生產上常用的是持久化查詢(只允許已經登記的文件)加上執行期成本,兩者缺一,持久化擋不住「合法文件配一個巨大的變數」。

快取為什麼失效

共享快取認的是方法、URL 與明確的 Cache-Control。GraphQL 常見的 POST 沒有可共享的 URL 鍵,回應又混進了這個使用者的訂單,整份都必須是私人的。把查詢換成 GET、或用持久化查詢的雜湊當 URL,只讓「回應本身可以公開」的查詢有機會被存下來。訂單畫面不是那種查詢。商品若要被共享快取認領,就得回到有自己 URL 的 GET,也就是拆開的 REST 那一列。這不是 GraphQL 寫錯,是這份回應的身分不適合當共享物件。

陷阱用 GraphQL 當公開 API 的預設

合作夥伴要的是穩定資源、可預期的速率限制與能放進 CDN 的 GET。把一個可任意組合的查詢語言交給他們,等於把第 4 節這三筆帳變成他們的攻擊面,也變成你的支援面。公開邊界用 REST;GraphQL 放在你自己的 BFF 後面,查詢文件由你的前端登記。

一句話答法GraphQL 解決的是欄位級的 over-fetch 與 under-fetch。N+1 用同請求批次,查詢成本要用執行期的清單大小而不只是文件深度,私人回應進不了共享快取。
05

分頁:offset 會滑,cursor 會錨

offset 說「跳過幾列」。表頭一插入,跳過的位置就不再是上一頁的下一列。

清單契約要比風格更早講清楚,因為三種風格最後都會碰到同一個問題。REST 可以做 ?offset=40&limit=20,也可以做 ?page_token=。GraphQL 社群的 Connection 模型就是 cursor,不該在選取集裡再發明一套 offset。gRPC 則是請求訊息上的 page token 欄位,語意仍是 keyset,不是「RPC 就不會漏列」。

Offset 的實作是 ORDER BY 之後再跳過前 offset 列。沒有以鍵定位的索引時,引擎仍要走訪被跳過的列,所以越深越貴。下方 demo 的「引擎走訪」就是把每一頁的 offset 加總:page × 頁數 × (頁數 − 1) / 2。Cursor(keyset)記住上一頁最後一列的排序鍵,下一頁問的是「嚴格排在這把鍵之後的 page 列」。排序鍵必須唯一,時間相同時用 id 打破平手,否則同一把時間鍵會漏列或重列。

先看使用者會遇到的症狀。你在 App 裡往下滑訂單列表,最新的在最上面;滑的同時,又有新訂單進來。用 offset 翻頁,上一頁看過的訂單會在下一頁再出現一次,最舊的幾筆滑到底都沒出現。拿每頁 4 列、原本 10 列(列 0 最新、列 9 最舊)、每翻完一頁表頭就多 2 筆新訂單,逐頁走一遍:

頁
翻頁前表頭多了
offset 這一頁
cursor 這一頁
1
0 筆
offset=0 → 列 0–3
從頭讀 → 列 0–3
2
2 筆新訂單
offset=4,前 4 格現在是 2 筆新單加列 0、1 → 拿到列 2–5,列 2、3 重複
「列 3 之後」→ 列 4–7
3
共 4 筆新訂單
offset=8 → 拿到列 4–7,列 4、5 重複
「列 7 之後」→ 列 8–9
停
客戶端以為共 10 列,打 3 頁就停
列 8、9 從沒出現
原始 10 列各出現一次

offset 記的是「跳過幾格」:表頭每多一筆,同一個格數就往回指到一筆已經看過的舊資料。cursor 記的是「上一頁最後一筆是誰」,表頭長多長都不影響下一頁從哪裡開始。

Cursor 也沒有把新訂單帶進來:掃描期間插在表頭的列不會混進已經翻過去的頁,要等使用者重新從表頭讀才看得到。這不是 cursor 比較完整,是它故意不把新列塞進這一次走訪。下面的 demo 用同一組參數起跑;把插入量調大,offset 的起點甚至會落進新列裡。

互動 · 同一組翻頁參數 插入發生在每頁之後、表頭
4 10 2

格子是 offset 客戶端對原始列的結果。編號是掃描開始時的列號,不是插入之後的位置。

讀到一次 重複讀到 頁數用完仍沒讀到
offset cursor
頁數 3 3
重複讀到 0 0
漏掉的原始列 0 0
混進頁面的新列 0 0
引擎走訪 0 0

兩欄吃的是同一組滑桿。預設每頁 4、共 10 列、每頁之後插入 2 列,客戶端打 3 頁:offset 重複 4、漏掉列 8 與列 9、新插入的 4 列一次都沒進頁面,引擎走訪 12 列。cursor 走訪 10 列,原始列不重複也不漏。

拖動後這裡會改寫走訪結果。
關鍵漏讀是頁數用完,不是 offset 算錯一列

若客戶端一直打到伺服器回空頁,尾巴終究會出現,但中間的重複已經顯示給使用者,而且每一次都在付更深的 offset。契約若寫「共有 T 列、請打 ceil(T/page) 次」,插入發生的當下這個 T 就是錯的。Cursor 的契約改問「還有沒有下一頁」,總數可以另外估,不要拿來當迴圈終點。

一句話答法Offset 是跳過幾列,表頭插入會讓下一頁滑進已經看過的列,並在固定頁數時漏掉尾巴。Cursor 錨在上一頁最後一把唯一鍵上,原始列不重複;新列要不要通知已經翻過去的人,是另一個產品決定。
06

錯誤模型與部分失敗

客戶端要知道「整筆失敗」和「這一份裡有一格失敗」是兩種不同的契約。

REST 用狀態碼描述整份回應。4xx 是客戶端這次的請求不成立,5xx 是伺服器沒有完成自己的契約。回應本體可以用 Problem Details for HTTP APIs(RFC 9457)這份標準格式:type 是穩定的問題識別,title 給人讀,status 與 HTTP 狀態碼一致。機器判斷用 type,不要用 title 的英文句子。

部分失敗要在文件裡明文寫,不要默默回 200 再缺欄。訂單 API 在商品目錄逾時時,可以選擇整筆 502,或回 200 並在該筆明細上放一個問題物件。兩種都合法,客戶端的寫法完全不同。207 Multi-Status 幾乎沒有共享快取與 SDK 會正確處理它,公開 API 少用它來表達「有的列成功、有的列失敗」。

整筆失敗 部分成功 客戶端最容易看錯的地方
REST 4xx/5xx,本體用 Problem Details 必須在文件裡定義,不要默默 200 缺欄 把 200 當成每一格都成功
GraphQL errors 且 data 為 null HTTP 200,data 與 errors 並存,path 指到失敗的欄 只看 HTTP 狀態碼
gRPC 一個 RPC 一個非 OK 狀態 要寫進訊息;狀態若仍是 OK,空欄位就是應用的語意 把 OK 裡的空訊息當成「沒有這筆訂單」

三種風格的失敗粒度不同。GraphQL 的部分成功是規格裡的常態;REST 與 gRPC 若要同樣的粒度,得在應用契約裡自己定義,傳輸不會幫你表達。

GraphQL 的客戶端必須先看 errors,即使狀態碼是 200、即使 data.orders 有內容。某一筆的 items 是 null 且 path 指到它,表示那一格失敗,不是這張訂單沒有明細。把 null 畫成空清單,使用者會以為商品被清空。

gRPC 的一個 RPC 有一個狀態。目錄逾時就讓這次 RPC 失敗,呼叫端不會同時拿到「成功」與「半份訊息」,除非伺服器故意回 OK 並在訊息裡放部分結果——那是應用契約,不是傳輸的預設。狀態碼的名字與何時用哪一個,在 gRPC 與 Protocol Buffers 那篇;這裡只區分「一筆 RPC 一個成敗」和「一份文件裡可以有一格失敗」。

一句話答法HTTP 狀態碼描述整份回應。GraphQL 常以 200 搭配 errors,客戶端只看狀態碼會把部分失敗當成成功。gRPC 預設是一筆 RPC 一個狀態,部分結果要寫進訊息並在文件裡定義空欄位的意思。
07

版本化與棄用

能加欄位就不要改欄位的意思。版本是給無法相容的斷裂用的,不是給每一個迭代用的。

多數演進應該是加選的:新客戶端認得新欄位,舊客戶端忽略它。REST 的 JSON 加一個欄位、GraphQL 的 schema 加一個欄位或加一個可空參數、protobuf 加一個新的欄位編號,三者在這件事上是同一個原則。protobuf 不能重用已經刪除的編號,原因在 gRPC 與 Protocol Buffers 那篇的欄位身分;REST 與 GraphQL 對應的紀律是不重用已經棄用的欄位名字,也不改變舊名字的意思。

策略 客戶端怎麼選 快取 什麼時候才用
URL /v2 換路徑 自然分成兩份 資源的意思斷了,舊路徑會活很久
媒體型別或標頭 Accept 指定版本 必須送 Vary: Accept 同一個 URL,舊型別可以設期限
欄位級 不選;舊客戶端忽略新欄位 仍是同一份回應 預設。加欄位、不改意思

三種策略可以並存,但不能拿 URL 版本代替「加一個欄位」這種相容演進。每切一次 /v2,文件、SDK、權限與快取都要再養一份。

媒體型別版本(例如 application/vnd.orders.v2+json)讓 URL 保持穩定,共享快取才不會把兩種表示存成同一個鍵。Vary: Accept 沒寫,快取就可能把 v2 的回應交給只接受 v1 的客戶端。這是標頭版本比路徑版本更容易出錯的地方,不是它比較先進所以免檢。

棄用是一個有期限的過程,不是註解。回應可以帶 Sunset 標頭規範(The Sunset HTTP Header Field,RFC 8594)定義的 Sunset 標頭,文件寫下舊欄位或舊路徑的最後日期,並用實際呼叫量決定能不能刪。沒有量測的棄用只會在某次發佈把仍在用的客戶端打斷。GraphQL 用 schema 上的 deprecated 標記讓工具在建置期警告,執行期仍要接受舊欄位,直到呼叫量歸零。gRPC 則是保留編號、停止寫入、等舊 stub 消失,而不是把編號讓給新欄位。

陷阱用版本號掩蓋不相容的語意

把 status 從字串改成整數、或把「金額」從元改成分,即使用 /v2 包起來,舊客戶端的資料仍然會被讀錯。版本只隔離已經斷裂的契約,不代替遷移腳本與雙讀。能並行寫入新舊兩種表示、再用呼叫量切換的,不要只靠客戶端自己升級。

一句話答法預設用加欄位演進,不改舊欄位的意思,也不重用棄用過的名字。URL 版本留給已經不相容的斷裂,媒體型別版本必須配 Vary。棄用要有 Sunset 與呼叫量,而不是只有文件裡的一句 deprecated。
08

冪等重試與配額標頭

逾時以後你不知道伺服器做完沒有。沒有鍵的 POST 重試,會把扣款做第二次。

第 2 節的方法表決定「能不能裸重試」。建立訂單是 POST:第一次請求可能已經扣款,回應卻在網路上丟了。客戶端只看得到逾時。沒有額外的鍵,重試就是第二筆訂單。

步驟
客戶端
訂單 API
付款
t1
POST /orders,帶鍵 K
建立訂單,記下 K
扣款一次
t2
逾時,沒看到回應
其實已經提交
已經入帳
t3
同一把 K 再 POST
回放第一次的 201
不再扣款

t2 的紅格是客戶端的未知,不是付款失敗。t3 的綠格是鍵發揮作用:同一把鍵、同一份本體,回放第一次的結果。若 t3 沒有鍵,付款格會變成第二次扣款,那才是資料錯誤。

Idempotency-Key 仍是 IETF HTTPAPI 工作小組的 Idempotency-Key 標頭草案(The Idempotency-Key HTTP Header Field,draft-ietf-httpapi-idempotency-key-header),還不是 RFC,但支付與建立類 API 已經把它當成事實契約。客戶端產生唯一字串,建議是 UUID;伺服器在鍵的有效期內記住第一次的請求指紋與回應。同一把鍵配上不同的本體,草案建議回 422,避免把「重試」和「改了一筆新的扣款」混成同一把鍵。第一次還在處理時又送來同一把鍵,回 409,不要並行跑兩次扣款。鍵過期之後的重試必須在文件裡定義:拒絕,或當成新的一次。gRPC 沒有這個 HTTP 標頭,對等的做法是把請求識別放進 metadata,伺服器用同一套「指紋加回應回放」;不要因為換了 RPC 就假設重試是安全的。

速率、配額,以及客戶端看得到的拒絕

速率是一個窗口裡能打幾次,配額是一段較長時間的預算(例如每月呼叫數)。兩者都該在拒絕時告訴客戶端多久以後再試,否則客戶端的重試會變成放大。公開 HTTP API 用 429 Too Many Requests,並帶 Retry-After(秒數或 HTTP 日期)。只回 429、不回何時恢復,客戶端只能猜。

剩餘額度目前沒有一份已經發布的 RFC。網路上常見的是 X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset,這是廠商慣例,不是標準。同一個工作小組的 RateLimit 標頭草案(RateLimit header fields for HTTP,draft-ietf-httpapi-ratelimit-headers)定義 RateLimit-Policy 與 RateLimit,用參數 r 表示剩餘額度、t 表示這個窗口還剩幾秒。契約裡寫明你回的是哪一套,客戶端只實作那一套。兩套同時回可以,但文件要指定哪一套是權威。

429 的本體仍適合用 Problem Details(RFC 9457),讓機器讀 type 區分「視窗速率用盡」和「每月配額用盡」——兩者的等待策略不同,前者等秒,後者可能要等到下個帳期。授權標頭通常讓共享快取不存這份回應;速率限制標頭放在真正打到源站的回應上,不要讓 CDN 快取一份過期的剩餘額度。

gRPC 的配額用盡通常是 RPC 狀態而不是 429。狀態碼表不在這裡重複;公開給瀏覽器與合作夥伴的邊界若是 HTTP,就用上面這組標頭。內部 RPC 的配額常常在服務網格的 token bucket,呼叫端仍要有明確的可重試信號,而不是把所有失敗都立刻重試。

選型面試時把三個邊界說完

對外與合作夥伴:REST,資源與方法守住 HTTP 語意(RFC 9110),錯誤用 Problem Details(RFC 9457),建立類 POST 配冪等鍵,429 配 Retry-After 與你文件指定的額度標頭,分頁用 keyset。自己的前端聚合:GraphQL 放在 BFF 後,查詢文件要登記,resolver 要批次,成本用執行期清單大小計算。內部服務:gRPC,契約是方法與訊息;重試靠請求識別與冪等的伺服器狀態,不靠「RPC 框架會幫我重試」。

一句話答法逾時代表未知,不是失敗。沒有冪等鍵的 POST 不能重試。429 必須帶何時再試;剩餘額度用你文件寫明的那一套標頭,不要假設客戶端認得每一種 X-RateLimit。