304
你手上那份還是好的:你在請求裡帶了驗證器,伺服器拿去跟自己那份比對,一樣,於是它刻意只回標頭、不回內容。
同樣三個數字,你站在請求的哪一邊,它就是不同的問題。直接讀你自己那一塊。
為什麼會看到它
第一件要知道的事是,304 通常根本不會到你的程式碼手上。瀏覽器自己去重新驗證一份快取回應時,那個 304 會被 HTTP 快取吃掉,交給你的 `fetch()` 的是那份存起來的回應,狀態碼 200——所以在應用程式碼裡看到 `res.status === 304`,代表條件式標頭是「你自己」送的,不是快取送的。網路面板倒是會顯示真正的那一列 304,body 只有幾百位元組,Size 欄位會標成從快取來的。落在這裡的抱怨有兩種,而且方向相反。一種是:你改了檔案、重新整理,拿到的還是舊的——那不是 304 壞了,是根本沒有 304,因為一份還在有效期內的回應會被直接重用,連請求都不會送。另一種是:什麼都不會重新驗證,每次重新整理什麼都重抓一遍,而原因是伺服器那邊沒有驗證器、或驗證器不穩定,跟用戶端做了什麼無關。
該怎麼做
用強制重新整理(Cmd/Ctrl+Shift+R),它會送 `Cache-Control: no-cache`,逼這個問題送到伺服器;或者在 DevTools 裡勾「Disable cache」,讓面板開著的時候每個請求都這樣。接著把出問題的那一列雙向讀:請求上的 `If-None-Match` / `If-Modified-Since` 是你的瀏覽器記住的東西,回應上的 `ETag` / `Last-Modified` 是它下次會送的東西——第二個如果沒有,你重新整理再多次也永遠不會出現 304。當你要量的是伺服器而不是快取,請求時加 `cache: "no-store"`,這樣瀏覽器快取就沒機會搶著回答。還有,部署的形狀一次講定:HTML 用 `no-cache` 送,讓它每次都重新驗證;雜湊過的靜態檔案用 `immutable` 送,讓它永遠不必驗證——兩種抱怨會同時消失。
為什麼會看到它
「我們從來收不到 304」是最常見的回報,而原因幾乎都是:位元組沒變,驗證器卻變了。在兩台以上的節點上,從檔案 mtime 取出來的 `Last-Modified` 每台都不一樣,因為每次部署寫進檔案的秒數不同,所以一個拿著 A 節點給的值去跟 B 節點重新驗證的用戶端,永遠對不上——而 Apache 從 2.3.14 起 `FileETag` 預設是 `MTime Size`,entity tag 也有一模一樣的毛病。壓縮是另外一半:nginx 從 1.7.3 起會把 gzip 過的回應的 entity tag 標成弱的(`W/"..."`),這對 `If-None-Match` 沒問題,因為那個比較本來就是弱比較,但它會讓這個回應失去 `If-Range` 的資格。如果前面還有一層 CDN,那要先問它到底有沒有把 `If-None-Match` 轉給你——一台從來收不到那個標頭的來源機,處理器寫得再對也不可能回出 304。
該怎麼做
給驗證器一個只會跟著內容變的東西:對位元組做雜湊,或者用建置 id,讓每一台送出同一份產物的節點都算出同一個 tag。順手看一下 `Vary`——`Vary: Accept-Encoding` 會把協商出來的編碼放進快取鍵,所以一個 `Accept-Encoding` 跟當初填進去那次不同的用戶端會拿到完整的 200,看起來就像快取失效。也要確認你的處理器真的有在解析收到的東西:`If-None-Match` 可能帶好幾個用逗號分隔的 tag,其中任何一個都可能有 `W/` 前綴,而一個拿原始標頭字串去跟自己的 tag 硬比的處理器,會全部漏掉。比對成功時,回 304、帶同一個 `ETag`、完全不要 body——RFC 9110 規定 304 在第一個空行就結束,所以你在標頭之後寫的任何東西,都會被用戶端當成下一個回應的開頭去讀。
為什麼會看到它
304 不是錯誤,而且你本來就不應該注意到它——它是快速載入安靜的那一半:瀏覽器問「我上次抓完之後這個有變嗎」,網站回「沒有,用你手上那份」。真正會浮上檯面的症狀剛好相反:網站明明更新了,頁面卻一直顯示舊內容,因為你的瀏覽器留著的那份還在被重用。
該怎麼做
按住 Shift 再重新整理,或按 Ctrl+Shift+R(Mac 是 Cmd+Shift+R),這是在叫瀏覽器重新去問伺服器,不要相信手上那份。如果這樣有效、但過一陣子舊版又回來了,就去瀏覽器設定裡把那個網站的快取檔案清掉。無痕視窗是最快的判斷方式:它一開始快取是空的,所以那裡是對的、你平常的視窗是錯的,就代表舊的那份在你機器上,網站本身沒有問題。
對著出錯的那個網址跑一次。它只印狀態碼、不印錯誤頁,所以你看到的是伺服器真正說了什麼,而不是瀏覽器畫出來的東西。
curl -sS --etag-save /tmp/etag -o /dev/null https://example.com/style.css && curl -sS -D - -o /dev/null --etag-compare /tmp/etag https://example.com/style.css第一個請求會把回應帶的 `ETag` 存進一個檔案;第二個把它當成 `If-None-Match` 送回去,而這正是瀏覽器快取在重新驗證時做的事,所以這是一個真的 304,不是模擬出來的。`-D -` 會把第二個回應的標頭印出來,而狀態行就是全部的答案:`304` 代表 tag 對上了。從裡面讀四件事。第一個回應到底有沒有 `ETag`——沒有 tag、也沒有 `Last-Modified`,代表這個網址上的東西永遠無法重新驗證,這就足以解釋一個每次造訪都把自己重抓一遍的網站。這個 304 有沒有照 RFC 9110 的要求把 tag 再送一次?`W/` 前綴是不是只有在協商出壓縮時才出現——把第二行指令分別加上和不加 `-H 'Accept-Encoding: gzip'` 各跑一次,如果一邊拿到 304、另一邊拿到 200,代表驗證器是逐編碼的,而快取必須用 `Vary: Accept-Encoding` 去做鍵。還有,是哪一層回答的:`server:` 和有沒有 `cf-ray` 會告訴你這個 304 是邊緣節點拿自己那份回的,還是你的來源機回的。想走日期那條路就改用 `-z`,curl 的文件裡它叫 `--time-cond`:它會用你指定的日期、或某個本機檔案的時間戳,組出一個 `If-Modified-Since`,而在前面加一個減號會把它反過來變成 `If-Unmodified-Since`。
RFC 9110 §15.4.5 把 304 定義成:一個條件式 GET 或 HEAD 的答案——如果那個前置條件沒有算成假,它本來會回 200。這一節裡有兩條規則在除錯時特別重要。304 不能帶內容,它在標頭欄位後的第一個空行就結束了,所以中介層如果在上面多寫了一個 body,那不只是浪費幾個位元組,而是把訊息的框架直接弄壞。另外,304 必須把「200 本來會帶、而快取需要的」那些標頭欄位再送一次,RFC 9110 點名的是 `Content-Location`、`Date`、`ETag` 和 `Vary`——所以一個身上沒有 `ETag` 的 304,本身就很可疑了。條件本身出自 §13:`If-None-Match` 用弱比較函式去比 entity tag,`If-Modified-Since` 比的是一個 HTTP-date,而 §13.2.2 訂了優先順序——只有在 `If-None-Match` 不存在時才會去看 `If-Modified-Since`,也就是說一個兩者都帶的請求,完全是由 tag 決定的,你的 `Last-Modified` 根本沒有上場。這兩種驗證器的鋒利程度也不一樣:HTTP-date 的解析度只有一秒(§5.6.7),所以同一秒內的兩次修改是分不出來的;而 entity tag 是來源機說了算,只要開頭沒有 `W/` 就是強驗證器。
| 容易搞混的 | 怎麼分 |
|---|---|
200 | 網路面板裡的 304 和你 JavaScript 裡的 200,常常是同一次交換。瀏覽器的 HTTP 快取送出條件式請求、收到 304,然後拿存起來的那個 200 去解決你的 `fetch()`——所以你程式碼觀察到的狀態碼是 200,即使那些位元組根本沒過網路。在應用程式碼裡看到 304,代表那個條件式標頭是你自己送的。 |
412 | 同樣一次前置條件失敗,會因為你想做什麼而回出不同的代碼。GET 上驗證器對不上是 304,因為你手上已經有一份可以用的;換成會改變狀態的方法——PUT 上的 `If-Match` 輸掉了競爭——就是 412(Precondition Failed,RFC 9110 §15.5.13),因為沒有東西可以重用,而且那個寫入絕對不能繼續。 |
no-cache | `no-cache` 正是「製造」304 流量的那個:它允許存起來,但要求重用之前必須重新驗證,所以每個請求都會問,而多數答案是 304。`no-store` 則是連留下這個回應都不准,於是永遠沒有驗證器可送,也永遠不會收到 304。為了修一個「內容太舊」的 bug 就抓 `no-store` 來用,等於把那份舊資料和便宜的重新驗證一起丟掉。 |
ETag | 強弱是真的有差,而且只有某些欄位在意。`If-None-Match` 用的是弱比較函式,所以 `W/"v1"` 和 `"v1"` 算對上,被 gzip 弱化過的 tag 照樣回得出 304。`If-Range` 和 `If-Match` 用的是強比較,所以同一個被弱化的 tag 會安靜地讓範圍請求失效——這就是為什麼在一台會 gzip 的伺服器上,大檔案斷線之後會從頭重抓。 |
這些全都在你的瀏覽器裡跑,不會上傳任何東西。
查 304 的時候,通常也會順手看一下這幾個。
去讀回應標頭,不要讀那個網頁。curl -sS -o /dev/null -D - https://example.com/path 會只印標頭、不印內容,而 server、via 和 cf-ray 三個加起來,就能指出是哪一層回答的。有 cf-ray 的值代表這個回應是 Cloudflare 處理的,那個值也是他們客服會跟你要的編號。想把邊緣節點整個排除掉,就加上 --resolve example.com:443:203.0.113.10 再打一次:它會照樣送出原本的主機名稱和 SNI,但直接連到你指定的來源位址——答案變了,就代表邊緣節點和來源機講的不是同一件事。
不重要,而且絕對不要拿它來做判斷。RFC 9112 要求用戶端忽略原因片語,伺服器可以隨意改它,而 HTTP/2 和 HTTP/3 根本沒有這個欄位——所以在 HTTP/1.1 上是 404 Not Found,到了 HTTP/2 就只剩一個 404。這個網站會寫出登記在案的片語,是因為那是大家會拿去搜尋、也是會出現在 HTTP/1.1 紀錄裡的字串,不是因為有任何軟體依賴它。
5xx 和 429 可以重試,4xx 不要——下一次送過去也不會有任何不同。如果回應帶了 Retry-After 就照它做,RFC 9110 定義它就是為了這件事,值可以是秒數也可以是一個時間點。沒有的話,就用帶抖動的指數退避加上一個硬上限,而且只對冪等的方法這樣做:一個被重試的 POST 可能會刷兩次卡。對一台已經在失敗的伺服器丟出重試風暴,是短暫事故變成長時間事故最常見的那條路。
可以,唯一要守的規則是那個代碼必須是真的。所有會自動讀你回應的東西——搜尋引擎、監控、快取、用戶端的重試邏輯——都只看那個數字,從來不看那一頁寫了什麼。用 200 回一頁錯誤訊息,會把故障從你自己的警報裡藏起來;用 200 回一頁不存在的內容,會讓錯誤被當成內容收進索引;而對一個只是格式不對的請求回 500,會把值班的人送去堆疊裡錯的那一半。
304 還是卡住嗎?看完整的狀態碼對照表,或是回到上面,讀為你這一端寫的那一塊。