vatt'ghern jaskier's ballads

你的 search 端點接受一個三層巢狀的 filter 物件、一串 facet、一段全文關鍵字。把它塞進 GET 的 URI,編碼後是兩千個字元,還會被 proxy 記進 access log;改用 POST,又有人在 retry 時遲疑——這真的可以安全重送嗎。RFC 10008 給的答案是第三條路。

HTTP QUERY 方法(RFC 10008)——把唯讀查詢從 GET 與 POST 的夾縫裡救出來

完這篇,你會知道一件事:在 HTTP 裡發一個「條件很龐大、但不改變任何狀態」的查詢,過去二十年只有兩個都不對的選項,而 RFC 10008 定義的 QUERY 方法把這個長年的尷尬補上了正式語意。文章從 GET 帶查詢條件會踩到的四個坑講起,再說清楚為什麼退而用 POST 也不令人滿意,接著拆解 QUERY 的三件核心事——safe、idempotent、回應可快取,最後落到你能自己判斷的問題:你手上哪個端點值得換成 QUERY,遷移時相容性與快取層要注意什麼。

把查詢條件塞進 URI——GET 踩到的四個坑

先把場景擺出來。你有一個產品搜尋 API。前端傳來的查詢條件不是一兩個關鍵字,而是一整包:價格區間、十幾個 facet 勾選、地理範圍多邊形、排序權重、分頁游標。這是一個唯讀操作,照 REST 的直覺就該用 GET——GET 是 safe 的、是 idempotent 的、回應可快取,這些性質中間的 proxy 與 CDN 都認得。問題出在「條件要放哪裡」。GET 沒有 request body 的語意位置,所有條件只能編進 URI 的 query string。

RFC 10008 在動機一節把 GET 的困境講得很直白。第一個坑是長度。它寫道,當條件「too voluminous to be encoded in the request's URI」時這個模式就變得有問題,更麻煩的是上限本身不確定:「size limits often are not known ahead of time because a request can pass through many uncoordinated systems」。你的 application server 可能允許 16 KB 的 URI,但中間任何一個 reverse proxy、load balancer、CDN 都可能在更低的門檻把請求砍掉,而你事前無從得知那個門檻是多少。查詢條件一長,請求就可能在某個你看不到的環節靜默失敗。

第二個坑是編碼成本。把結構化資料硬塞進 URI 並不便宜——文件指出「expressing certain kinds of data in the target URI is inefficient because of the overhead of encoding that data」。一個 JSON filter 物件要先序列化、再做 percent-encoding,巢狀的大括號、引號、逗號全都膨脹成 %7B%22 這類三字元序列,原本幾百位元組的條件膨脹成上千字元的字串。

第三個坑是隱私與紀錄。URI 天生是會被到處複製的東西。RFC 的說法是「request URIs are more likely to be logged than request content and may also turn up in bookmarks」。你的查詢條件——可能含有使用者輸入、內部欄位名、商業邏輯——會被寫進每一層的 access log,會留在瀏覽器歷史,會出現在書籤裡。request content 通常不會被這樣到處記,URI 卻會。

第四個坑最隱蔽,也最傷快取。把條件編進 URI,等於替每一種條件組合各自鑄造了一個資源身分。文件的原話是「encoding queries directly into the request URI effectively casts every possible combination of query inputs as distinct resources」。對快取而言,URI 就是 cache key,於是 /search?q=a&sort=x/search?q=a&sort=y 是兩個毫不相干的資源;參數順序換一下、多帶一個 tracking 參數,又是一個新 key。快取命中率被條件的組合爆炸稀釋掉,最終一個本來「同樣的查詢理應命中同樣結果」的場景,在快取層看起來卻像是無數個各跑一次的獨立資源。

下面這個 widget 把第一個坑量化。拖動條件的位元組數,看 GET 把它編進 URI 後的長度怎麼逼近那條「不確定的上限帶」,而 QUERY 把同樣的位元組放進 request content,URI 維持固定短長度,完全不碰那條帶子。

drag the handle to grow the query payload · GET-in-URI vs QUERY-in-body

900 B
原始查詢條件大小(bytes) request 行裡 URI 的字元數 不確定的 URI 上限帶(各家 proxy 不一) GET:條件編進 URI QUERY:條件放 request content
GET 線隨條件大小膨脹(percent-encoding 約 1.4 倍放大),很快撞進那條各家中間系統各自為政、你事前不知道在哪的上限帶;QUERY 線維持端點本身的固定短 URI,因為條件搬到了 content 裡。橫軸是模型化的示意,不是某次實測。

那就用 POST 吧——為什麼這個退路不令人滿意

看到這裡,多數工程師的直覺反應是:那把條件放進 POST 的 body 不就好了。Elasticsearch 的 _search、GraphQL 的 query,業界大量唯讀查詢早就是這樣做的——一個 POST,body 帶 JSON,伺服器回結果。這在功能上完全可行,body 沒有 URI 那些長度、編碼、紀錄的毛病。問題不在能不能跑,在於語意丟失了。

POST 在 HTTP 的語意裡是「請伺服器處理這份 content」,它既不保證 safe,也不保證 idempotent。這不是文件寫法的瑕疵,是 POST 本來就涵蓋建立資源、送出訂單、扣款這類會改狀態的操作。當你拿 POST 去做一個唯讀查詢,你知道它是唯讀的,但 HTTP 協定層不知道。RFC 10008 把這個資訊落差講得很精準:在這種情況下「it is not readily apparent -- without specific knowledge of the resource and server to which the request is being sent -- that a safe, idempotent query is being performed」。也就是說,除非你預先知道這個特定端點的脾氣,否則任何中間系統看到一個 POST,都只能假設它可能改變狀態。

這個落差有兩個具體後果。一個是重試。網路抖一下、連線中途斷掉,client 或 proxy 想自動重送——對 GET 它們敢,對 POST 它們不敢,因為重送一個可能扣款的請求是災難。於是你那個明明唯讀、重送一百次都安全的搜尋,因為穿了 POST 的外衣,享受不到自動重試。另一個後果是快取。POST 的回應預設不可快取,中間的 CDN 與 proxy 不會去碰它,每一次相同的查詢都得一路打到後端重算。你用 POST 換到了乾淨的 body,卻把 GET 原本附帶的 safe、idempotent、可快取三項好處全退了回去。

下面把同一個搜尋端點的三種寫法並排——GET 把條件硬塞 URI、POST 把條件放 body 但語意失聲、QUERY 兩者兼得。切換 tab 看每種寫法的 request 長相,以及它對中間系統「漏掉了哪個信號」。

GET /products/search?filter=%7B%22price%22%3A%7B%22gte%22%3A100%2C
%22lte%22%3A500%7D%2C%22tags%22%3A%5B%22sale%22%2C%22new%22%5D%7D%26
sort=relevance%26cursor=eyJvZmZzZXQiOjQwfQ HTTP/1.1
Host: shop.example

// safe ✓  idempotent ✓  可快取 ✓
// 代價:URI 上千字元、條件進 log、每個組合各自一個 cache key
POST /products/search HTTP/1.1
Host: shop.example
Content-Type: application/json

{ "price": { "gte": 100, "lte": 500 },
  "tags": ["sale", "new"],
  "sort": "relevance", "cursor": "eyJvZmZzZXQiOjQwfQ" }

// body 乾淨 ✓
// 代價:協定層看不出 safe / idempotent,預設不可快取
QUERY /products/search HTTP/1.1
Host: shop.example
Content-Type: application/json

{ "price": { "gte": 100, "lte": 500 },
  "tags": ["sale", "new"],
  "sort": "relevance", "cursor": "eyJvZmZzZXQiOjQwfQ" }

// body 乾淨 ✓  safe ✓  idempotent ✓  回應可快取 ✓
// cache key 涵蓋 request content(見下節)

把三個 tab 連起來看,QUERY 的設計意圖就清楚了:它要的是 POST 的 body 加上 GET 的語意。body 解決長度、編碼、紀錄三個坑;safe 與 idempotent 的明示解決重試與快取的信號缺失。差別只在它是一個新的 method 名字,於是中間系統能用「方法本身」這一個欄位就判斷出意圖,不必預先認識每個端點。

QUERY 的三件核心事——safe、idempotent、回應可快取

QUERY 不複雜,它的全部精神可以濃縮成 abstract 裡的一句話。RFC 10008 的摘要寫道:「A QUERY requests that the request target process the enclosed content in a safe and idempotent manner and then respond with the result of that processing. This is similar to POST requests, but QUERY requests can be automatically repeated or restarted without concern for partial state changes.」拆開看,這句話定義了三件事。

第一件,查詢條件放在 enclosed content,也就是 request body,而不是 URI。既然條件由 content 與它的媒體型別共同定義,伺服器就必須認真對待 Content-Type。文件給了一條強制規則:「Servers MUST fail the request if the Content-Type request field is missing or is inconsistent with the request content」。注意這是 MUST——不是建議,是規範性強制。缺了 Content-Type,或宣告的型別跟實際 content 兜不攏,伺服器必須讓請求失敗,而不是猜。這條讓 QUERY 的語意邊界明確:查詢是什麼,由 content 加上它自己宣告的型別決定,沒有模糊空間。

第二件,QUERY 是 safe 的。文件直接寫「QUERY requests are safe with regard to the target resource」。safe 在 HTTP 的意思是這個方法不請求、也不預期改變目標資源的狀態——它只讀。這正是 POST 缺的那個信號:現在方法名本身就宣告了「我不改東西」。

第三件,QUERY 是 idempotent 的。文件說「Furthermore, QUERY requests are idempotent; they can be retried or repeated when needed」。idempotent 的意思是重送一次、十次、一百次,對伺服器狀態的影響跟送一次一樣。這就是 abstract 裡那句「automatically repeated or restarted without concern for partial state changes」的底氣來源——client 與 proxy 看到 QUERY,可以放心在連線中斷時自動重送,不必擔心重複觸發了什麼副作用。safe 加 idempotent,把 POST 退掉的兩個信號一次補回。

safe 與 idempotent 這兩個性質合起來,才撐得起 abstract 那句「可自動重試或重複執行而不必擔心 partial state change」。把它翻成工程現場:一個查詢在傳輸到一半連線斷了,client 不知道伺服器到底收到沒、處理到哪。對 POST,這是個進退兩難——重送可能造成重複的副作用,不重送可能漏掉一次合法操作。對 QUERY,因為協定保證它不改狀態、且重複等於單次,client 與 proxy 可以直接重送,最壞情況不過是多算一次同樣的查詢。partial state change——做了一半的狀態改動——這個風險對 QUERY 根本不存在,因為它從定義上就不改狀態。這是為什麼同一份 body,掛上 QUERY 比掛上 POST 在不可靠網路下更耐操。

把這兩個標籤放在方法名上、而不是埋在某個端點的文件裡,差別是「全域可讀」。HTTP 的中介層——proxy、快取、client library——只看得到方法、URI 與標頭,看不到你的應用語意;一個 POST 到底改不改狀態,它們無從判斷,只能一律當成不安全、不可自動重試。QUERY 等於把「這是一次唯讀、可重複的查詢」這個保證,寫在整條鏈路都讀得到的位置。abstract 特別在「similar to POST」之後補一句它可以 automatically repeated or restarted,正是要凸顯這個差別:body 一樣、能表達的查詢一樣,真正多出來的,是那一個會被全鏈路一致解讀的語意標籤。對寫服務的人來說,這意味著「這條查詢能不能安全重送」不再是一則只存在於團隊口頭、或藏在註解裡的約定,而是協定層給出的明文承諾——任何認得 QUERY 的環節都能據此行事。

三件事之外,QUERY 真正跟 POST 拉開差距的,是快取。文件先給了允許:「The response to a QUERY method is cacheable; a cache MAY use it to satisfy subsequent QUERY requests」。這裡是 MAY——快取「可以」拿這個回應去服務後續相同的 QUERY,而不是每次都回源。POST 的回應預設不可快取,QUERY 在這一點上站回了 GET 那一邊。

但快取一個 body 在 URI 之外的方法,藏著一個 GET 從來不必煩惱的陷阱。GET 的條件全在 URI,URI 就是天然的 cache key。QUERY 的條件在 body,如果快取還是只看 URI,那 /search 這一個 URI 下成千上萬個不同的查詢會共用同一個 key,快取會把 A 查詢的結果回給 B 查詢——這是會回錯資料的嚴重 bug。RFC 10008 因此下了一條強制規則:「The cache key for a QUERY request MUST incorporate the request content and related metadata」。又是 MUST。cache key 必須涵蓋 request content 與相關 metadata,這是 QUERY 與 POST 在機制上最大、也最容易被實作搞錯的一處差異。下面這張圖把它畫出來。

兩個 QUERY 打同一個 URI,body 不同 QUERY /search body: { q: "靴", color: 紅 } Content-Type: application/json QUERY /search body: { q: "靴", color: 藍 } Content-Type: application/json CACHE KEY A hash( URI + 紅 body + 型別 ) CACHE KEY B hash( URI + 藍 body + 型別 ) 兩個 key 不同 → 兩份結果各自快取,互不污染 若 cache key 只看 URI,兩個查詢會撞 key,B 拿到 A 的結果
MUST 規則的具體後果:QUERY 的 cache key 把 request content 與相關 metadata(含 Content-Type)一起 hash 進去,所以 body 不同的查詢落在不同的 key。GET 不必這樣做,因為它的條件本來就在 URI 裡。

把 GET、POST、QUERY 三個方法在這幾個維度上並排,差異一眼就清楚。

方法 條件放哪 safe idempotent 回應預設可快取
GETURI query string
POSTrequest content
QUERYrequest content是(cache key 須含 content)
QUERY 在「條件放哪」站到 POST 那一欄,在 safe / idempotent / 可快取三欄站回 GET 那一欄——這正是它存在的理由。最右欄的括號是它跟 GET 唯一的機制差別。

給查詢結果命名——Content-Location、Location 與 Accept-Query

QUERY 不只是把查詢送出去、把結果拿回來,它還給了幾個讓查詢結果與查詢端點「有名字」的選項。這些都是 can,是選項而不是強制,但它們讓 QUERY 更貼合 REST 的資源模型。

第一個是 Content-Location。文件說,成功的回應「can include a Content-Location header field containing an identifier for a resource corresponding to the results of the operation」。意思是:這次查詢算出來的結果集,伺服器可以給它一個 URI,放在 Content-Location 裡回給你。等於說「這份結果如果你想直接拿,它的位址在這」——查詢結果被命名成一個可定址的資源。

第二個是 Location。文件寫「A server can assign a URI to the equivalent resource of a QUERY request. If the server does so, the URI of that resource can be included in the Location header field」。這跟 Content-Location 指的不是同一個東西:Content-Location 命名的是「結果」,Location 命名的是「這個 QUERY 的等價資源」——也就是一個你之後可以用其他方法去操作的、代表這個查詢本身的資源。兩個 header 一個指向答案,一個指向問題。

第三個是 Accept-Query,一個發現與協商機制。文件描述它「can be used by a resource to directly signal support for the QUERY method while identifying the specific query format media type(s) that may be used」。一個資源可以用 Accept-Query 這個回應 header 直接告訴 client:我支援 QUERY,而且我吃的查詢格式是這幾種媒體型別。這讓 client 不必靠試錯或文件,就能在執行期得知某個端點能不能 QUERY、要用什麼格式送。下面把一次 QUERY 的完整來回,連同這三個 header 出現的位置畫成時序。

CLIENT RESOURCE 0. 先看資源宣告:Accept-Query: application/json 1. QUERY /search body: { ...條件... } 資源以 safe + idempotent 方式處理 content 2. 200 OK + 結果 body Content-Location: /search/results/9f3 Location: /saved-queries/42
Accept-Query 讓資源預先宣告自己吃哪種查詢格式;回應裡 Content-Location 命名「這次的結果集」,Location 命名「這個查詢的等價資源」。三者都是選項(can),不是每個 QUERY 都會帶。

你該不該換、換的時候要小心什麼

把判斷標準說在前面:QUERY 適合的,是那種「唯讀、但條件龐大到塞不進 URI」的端點。最典型的就是 search 與 report——全文搜尋、複雜 facet 過濾、報表的多維度條件。這些今天多半是 POST,因為條件放不進 GET;換成 QUERY,你拿回 safe、idempotent、可快取,而 body 一個字都不用改。反過來,條件兩三個參數就講完的端點,GET 本來就夠用,沒有換的理由;會改狀態的操作更是 POST 或 PUT 的地盤,跟 QUERY 無關。業界既有的慣例——Elasticsearch 的 _search、GraphQL 用 POST 帶 query——正是 QUERY 想要正名的那批場景:它們功能上早就在用 body 做查詢,缺的只是一個帶正確語意的方法名。

有人會問,HTTP 已經有 safe 又 idempotent 的方法了,為什麼還要再發明一個。這問題 RFC 自己回答了:method registry 裡確實已經有三個同時具備 safe 與 idempotent 性質的方法——文件列出「'PROPFIND,' 'REPORT,' and 'SEARCH'」。但這三個都帶著各自既有的歷史包袱與應用領域:PROPFIND 與 REPORT 來自 WebDAV,SEARCH 也有自己一段曲折的歷史與既定語意。與其把一個帶包袱的舊動詞硬掰成通用查詢方法、冒著跟既有部署衝突的風險,作者選擇用一個乾淨的新名字 QUERY,讓語意從頭定義、不背舊帳。這個取捨本身就提醒你:方法名是 HTTP 裡少數會被整條鏈路解讀的全域識別,挑名字是設計決策,不是隨手。

真要換,相容性是第一個要算的帳。QUERY 是一個新 method,整條鏈路上每一個環節都得認得它:你的 web framework 的 router 要能 dispatch QUERY、reverse proxy 與 load balancer 要願意轉發一個非標準動詞、CDN 要知道怎麼快取它、client 的 HTTP library 要能發出 QUERY。鏈路上任何一個還停在「只認得 GET/POST/PUT/DELETE」的環節,都可能把 QUERY 擋掉或誤處理。務實的遷移路徑通常是並存:保留原本的 POST 端點,同時讓資源支援 QUERY,並用前面講的 Accept-Query 讓懂的 client 自己升級,舊 client 繼續走 POST。

第二個要小心的是快取層自己。前面那條 cache key MUST 涵蓋 request content 的規則,是把雙面刃。RFC 10008 在安全考量裡點出一個風險:「Caches that normalize QUERY content incorrectly or in ways that are significantly different from how the resource processes the content can return an incorrect response」。如果快取對 content 做正規化的方式跟資源實際處理 content 的方式不一致——比方說資源認為 JSON 的鍵順序不影響語意,快取卻把不同鍵順序當成不同 key(或反過來合併了實際不同的查詢)——就會回錯結果。換句話說,部署 QUERY 的快取,必須跟後端對「什麼樣的 content 算同一個查詢」有一致的認知,這比快取一個 URL 難得多。

還有一個瀏覽器端的細節值得先知道。文件提到,在實作 CORS 的 user agent 上,「A QUERY request from user agents implementing Cross-Origin Resource Sharing (CORS) will require a 'preflight' request」。也就是說跨來源的 QUERY 會多一趟 preflight 的 OPTIONS 請求,這在延遲敏感的前端要納入考量。至於 GET 被詬病的 URI 進 log 問題,文件反而把它列為改用 QUERY 的動機之一——「the potential for logging of the URI might motivate the use of QUERY over GET」;不過若伺服器要替查詢結果指派一個臨時 URI,且原始 content 含有不該被記錄的敏感資訊,那個 URI「SHOULD be chosen such that it does not include any sensitive portions of the original request content」,這裡是 SHOULD,是強烈建議而非絕對強制。

最後一點是這份文件的份量。它的官方狀態是 Proposed Standard,作者是 Julian Reschke、James M. Snell 與 Mike Bishop 三人,2026 年發布。Proposed Standard 代表它已經通過 IETF 的共識流程,不是一份個人草案;但它仍處在標準軌的早期階段,生態系的實際支援——framework、proxy、CDN、client library 把 QUERY 認全——還需要時間鋪開。把它放進選型考量是合理的,期待它明天就在你整條鏈路上暢通無阻則言之過早。

Take-away:QUERY 就是「POST 的 body 配上 GET 的語意」——當你的端點唯讀、條件卻塞不進 URI 時,它讓你不必在丟掉可快取與丟掉重試安全之間二選一,代價是 cache key 必須涵蓋 request content,且整條鏈路都得先認得這個新動詞。