基礎設施

部署後快取失效:該清除什麼,不該清除什麼

每天部署,不會有過時的邊緣快取或來源伺服器超載。將資產分類為永不清除的不可變雜湊金鑰,以及依路徑清除的可變 HTML。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

CDN 邊緣快取是您的網站之所以快速的原因,也是您的部署之所以看不見的原因。能在十毫秒內從鄰近城市提供頁面的同一個快取,在您發布修正後,會繼續提供上週的版本長達一小時。天真的反應是在每次部署時清除所有內容,但這會清空快取,並將下一波流量直接導向您的源站,而這正是快取存在所要吸收的負載。可行的答案並非單一設定。它是一小組規則,針對每種資產類型選擇,其中您大部分的位元組被永久快取且永不清除,只有少數實際變更的檔案會被設為失效。這篇文章闡述了這些規則、支援這些規則的快取標頭,以及執行精確工作的清除呼叫。

為何邊緣快取在部署後會提供過時的內容

CDN 快取回應是因為你告訴它要這麼做,通常是透過帶有 max-ageCache-Control 標頭。一旦邊緣節點有了副本,它就會用該副本回應,直到其年齡到期或你明確清除它為止。邊緣節點不知道你進行了部署。它不會監看你的 git 歷史或你的發布管道。從它的角度來看,什麼事都沒發生,所以它會繼續提供它所擁有的內容。

這個差距就是整個問題所在。兩種失敗模式位於一個旋鈕的兩端。將旋鈕轉向長快取生命週期和高命中率,部署可能需要一個小時才能觸及使用者,如果瀏覽器也持有一份副本,時間會更長。將旋鈕轉向短生命週期和積極清除,每次部署都會將冷流量傾倒到你的源站,命中率下降,而且你越頻繁地發布,你的延遲數字就越差。兩端都不是你想要待的地方。

解決之道是停止將網站視為一個可快取的單一物件。不同的檔案會依不同的時程變更,它們值得擁有不同的規則。

將資產分為不可變與可變

一個內容網站所提供的幾乎每個檔案,都可歸入兩個類別之一,而這種劃分正是讓其餘策略變得簡單的原因。

第一個類別是建置後就永不改變的內容。一個打包後的 JavaScript 檔案、一份樣式表、一個字型、一張處理過的圖片。它們的位元組是固定的。如果你編輯了原始碼,建置過程會產生一個不同的檔案,而你會寧願將其作為新東西提供,而不是就地變動舊有的檔案。

第二個類別是在一個穩定網址下會就地改變的內容。你的 HTML 頁面是最明顯的例子。URL /blog/some-post 必須保持運作,並持續指向該文章的當前版本。當你修正一個錯字時,網址不會改變,但其背後的位元組會改變。

這兩個類別需要相反的快取規則。下方的表格是整個策略的一頁式總覽,而文章的其餘部分將解釋每一列。

資產類型 位址風格 Cache-Control 部署動作
JS、CSS、字型、建置後的圖片 雜湊名稱,app.a1b2c3.js public, immutable, max-age=31536000 無,新的建置是新的名稱
渲染後的 HTML 頁面 穩定路徑,/blog/post public, max-age=60, stale-while-revalidate=86400 僅清除變更的路徑
API 與 JSON 片段 穩定路徑 public, max-age=30, stale-while-revalidate=300 透過代理鍵清除
網站地圖、摘要 穩定路徑 public, max-age=300 發佈時清除
使用者專屬的回應 穩定路徑 private, no-store 絕不在邊緣快取

一句話總結:不可變的內容快取一年且永不清除,而可變的內容則短暫快取並精準地清除。你的流量幾乎都不需要清除快取,因為你絕大多數的位元組都是不可變的。

不可變資產:雜湊檔案名稱並永不清除

第一種情況的訣竅是將檔案內容的雜湊值放入其名稱中。一個建置步驟會讀取 app.js 的最終位元組,計算出一個簡短的摘要,並產出 app.a1b2c3d4.js。載入它的 HTML 會參照該確切的名稱。只要變更一行原始碼,摘要就會改變,所以下一次建置會產出 app.e5f6g7h8.js,而 HTML 現在會改為指向它。

因為名稱是從內容衍生而來,一個給定的名稱永遠只會代表一組確切的位元組。這讓你可以傳送網路上所擁有的最強快取標頭:

Cache-Control: public, immutable, max-age=31536000

max-age=31536000 代表一年。immutable 指令會告訴瀏覽器在重新載入時不用費心重新驗證,因為這個承諾是千真萬確的:只要這個名稱存在,其對應的位元組就不會改變。CDN 會快取檔案一次,並從邊緣節點提供服務,直到檔案因不常使用而被逐出。你的源站幾乎不會看到該檔案的重複流量。

對於部署來說,這樣做的好處是你永遠不需要清除這些檔案。部署並不會覆寫 app.a1b2c3d4.js。它會以新的名稱發布一個新檔案,並更新 HTML 中的引用。新的名稱從未存在於任何快取中,所以沒有過時的內容需要清除,而舊的名稱會繼續為任何仍在請求它們的頁面提供舊的位元組。對於你最大的資產來說,快取失效不再是你需要執行的一個動作。它變成了一種不可能發生的狀態,因為一個名稱的意義永遠不會改變。

可變的 HTML:短 TTL 和目標性清除

HTML 頁面不能使用雜湊名稱,因為它們的位址必須保持穩定,以利於連結、書籤和搜尋引擎。所以它們得到相反的處理:短的快取生命週期,加上對已變更的特定路徑進行明確的清除。

單獨使用短 TTL 是可行的,但它本身會迫使你在新鮮度和來源負載之間做出選擇。stale-while-revalidate 打破了這種緊張關係,下面的章節會涵蓋它。目前,標頭看起來像這樣:

Cache-Control: public, max-age=60, stale-while-revalidate=86400

max-age=60 意指邊緣會將頁面視為有效達一分鐘,這能吸收掉熱門 URL 的突發流量。但要等待一分鐘才能看到實際部署的結果實在太久了,所以你不會依賴過期機制。在部署結束時,你會清除你所變更的確切路徑。

清除是一種對 CDN 的 API 進行的已驗證呼叫,用來丟棄特定的快取物件。在不同供應商之間,其形式都相同:你指定要丟棄的內容。

curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
  -H "Authorization: Bearer $CDN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"files": [
        "https://example.com/blog/some-post",
        "https://example.com/blog/some-post/"
      ]}'

重點在於「針對性」。您清除的是這次部署所觸及的頁面,而不是整個區域。如果某個發布版本變更了三篇文章和索引頁,您就清除四個 URL。其他所有快取頁面都會保持熱門狀態,因此這次部署對您其餘的流量來說是無感的,您的源站也永遠不會注意到它的發生。

困難之處在於,您必須知道哪些路徑已變更。如果您的建置已經會產生一份清單,那麼對比最後兩次建置的輸出,就能得出變更的集合。如果沒有,下一節將提供一種完全無需列舉 URL 即可清除的方法。

透過代理鍵清除,而非 URL

當一個變更擴散到許多頁面時,透過 URL 清除的方式就會失效。編輯一位作者的姓名,你可能需要重新整理該作者的每一篇文章、作者頁面以及一些標籤頁面。手動列出那些 URL 的作法很脆弱且容易出錯。

代理鍵解決了這個問題。當源伺服器渲染一個回應時,它會附加一個標頭,用一個或多個標籤來標記該回應。大多數 CDN 會讀取像 Surrogate-Key 或快取標籤標頭之類的標頭,並根據其標籤為每個快取物件建立索引。

Surrogate-Key: post-1024 author-42 tag-infra

一個貼文頁面帶有它自己的 id、其作者的 id,以及它的標籤。之後,當作者變更時,你清除一個鍵,CDN 就會捨棄所有帶有它的物件,無論有多少個:

curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
  -H "Authorization: Bearer $CDN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["author-42"]}'

您不再需要追蹤哪些 URL 受到影響。您根據回應所依賴的內容來標記回應,並根據已變更的依賴項進行清除。即使影響範圍很廣,這也能讓清除作業保持精確,並且讓部署步驟無需推斷您的 URL 結構。源伺服器(origin)已經知道每個頁面中包含哪些資料,因此是宣告這些依賴項的正確位置。

在部署結束時自動化清除

需要人為記住的清除,就是會被跳過的清除,而被跳過的清除會導致網站過時,看起來就像是部署失敗。解決方法是將清除設為管線的最後一個步驟,在新版本上線後自動執行。

順序很重要。先部署新的源站,確認其正在提供服務,然後再清除。如果您在新版本上線前清除,邊緣會從源站重新擷取舊的位元組並重新快取它們,而您除了短暫的額外負載之外,什麼也沒完成。在之後清除,每個已清除路徑的下一個請求將會錯過邊緣、命中新的源站,並重新快取新版本。

# 1. deploy and wait for the new revision to receive traffic
deploy_release

# 2. compute the changed paths from the build manifest diff
CHANGED=$(diff_manifest build/prev build/current)

# 3. purge only those paths, at the very end
purge_paths "$CHANGED"

將清除作業整合到部署流程中,也提供了一個誠實可靠的地方,讓您查看哪些內容已被設為無效。記錄下被清除的路徑或金鑰。當有人詢問頁面為何更新、或為何沒有更新時,答案就在部署日誌中,而不是在某人的記憶裡。

在重新驗證時提供過期內容

stale-while-revalidate 能讓您維持較短的 TTL,而無需付出延遲的代價。它出現在上方的 HTML 標頭中,且其本身就值得理解,因為它消除了害怕頻繁到期的最後一個理由。

Cache-Control: public, max-age=60, stale-while-revalidate=86400

在最初的 60 秒內,邊緣會將快取頁面視為新鮮的來提供。在那之後的一天內,邊緣被允許立即提供過期的副本,同時在背景中從源站擷取新的副本。觸發重新整理的使用者無需等待源站。他們會以邊緣速度取得稍微舊一點的頁面,而下一位訪客則會取得更新後的頁面。新鮮度僅落後一個請求,而非整個源站的來回行程。

這改變了短 max-age 的感受。若沒有它,60 秒的 TTL 意味著每個 URL 每分鐘都有一位不幸的使用者需要等待源站。有了它,該使用者會得到即時回應,而源站回填則在頻外進行。您可以同時獲得近乎即時的新鮮度和近乎零的使用者端源站延遲。當您需要時,明確的清除仍然會強制立即重新整理,因此兩者可以協同工作:清除處理部署,而 stale-while-revalidate 則處理部署之間的緩慢漂移。

權衡取捨,以及何時可以進行全面清除

這一切都不是沒有代價的。經過雜湊的不可變名稱意味著您的建置管線必須計算摘要,重寫 HTML 中的每個參照,並將舊檔案保留足夠長的時間,以確保仍在載入舊版本的頁面不會損壞。這是實實在在的複雜性,而且它會永遠存在於您的建置工具中。

針對性清除也有其成本:您必須知道哪些內容發生了變更。您的建置要麼會發出一個您可以進行差異比對的資訊清單,要麼您的源站會用其正確維護的代理鍵來標記回應。一個過時的標籤或一個遺失的資訊清單條目,意味著頁面會悄悄地不刷新,這比整個網站一致地落後更難以察覺。

與這些成本相權衡的是全面清除,它完全不需要上述任何東西。一次呼叫即可清除整個區域,並保證所有內容都是最新的。在某些情況下,這確實是正確的答案。一個流量低的小型網站可以承受回源的冷啟動衝擊而不會有人注意到。緊急情況,例如下架一個絕不能再提供的頁面,值得使用這種簡單粗暴的工具。而一個真正觸及每個頁面的遷移,也無法從精確清除中獲得任何好處。

一個經得起考驗的規則是:讓您大部分的位元組都成為不可變的,這樣它們就永遠不需要清除,對於可變的剩餘部分,按路徑或按鍵進行清除,並將全面清除保留為一種工具,在情況小到無需在意或緊急到來不及思考時才使用。您的日常部署應該只觸及少數路徑,並讓其餘的快取保持與您發布前一秒完全相同的熱度。