CDN 的快取失效設計:內容平台場景

CDN 的快取失效設計:內容平台場景

Phil Karlton 有句名言:「計算機科學中只有兩件難事:快取失效和命名。」

想像你在管理網站,因為傳輸速度與伺服器效能問題,網站讀取速度很差,特別是當你的使用者來自地球另外一端,常常需要等待幾秒才能看到畫面,這讓他們的使用體驗大打折扣。身為一名重視使用體驗的開發者,你肯定知道該如何解決這問題,沒錯,答案就是 CDN(內容傳遞網路)。

CDN 可以看成是服務商在全球各地建置伺服器,當你的網站內容(例如圖片、CSS、JavaScript、影片等)流經這些伺服器時,它會保留一份複本(稱為快取),等到下次有人讀取同樣的內容,CDN 會拿出複本給使用者。因為全球各地都有 CDN 節點,美國的使用者可以由美國節點提供,日本的使用者可以由日本節點提供。這樣既加速網路傳遞效率,也降低來源伺服器的效能壓力,可謂一舉兩得。

當然這是有條件的。CDN 會使用網址來判斷快取是否是相同檔案,假設你的內容以圖片為主,通常來說,當你更換圖片,新舊兩張圖片會有不同網址,被當成兩個不同的檔案,新圖使用新快取,舊快取留著也沒差;但如果你的內容是文字,新舊版文字很可能有相同網址,被當成是同樣檔案,這時會發生來源伺服器的內容修改了,而 CDN 仍傳送舊版內容的狀況。

CDN 的快取機制

CDN 是如何知道檔案是否要快取,以及快取保留的時間呢?這還是要來源伺服器告訴 CDN。在 HTTP 協議中,請求和回應都帶有一系列稱為標頭(Header)的鍵值對,其中 Cache-Control 負責傳遞當前的快取策略。Cache-Control 由一串指令組成,例如

cache-control: public, max-age=14400

底下是一些常用的指令介紹

public

該資源是公開資源,可以由任何快取(例如 CDN)儲存。

private

該資源是私有資源,只能由客戶端快取,不能由中間層(如 CDN 或代理)快取。這些通常是包含私人資料的資源,例如顯示使用者個人資訊的網頁。舉例來說,網頁可能顯示「Alice 你好,這是你第 100 次登入」,如果 CDN 將這個資訊快取起來,Bob 在登入時也會看到同樣的文字,最糟的情況還會導致個資洩漏。

no-store

該資源無法在任何地方快取。這意味著每次使用者請求此資料時,都必須向來源伺服器傳送請求以獲取新複本。你可以當成這是在告訴 CDN 跟瀏覽器不要快取資源。

no-cache

該資源可以快取,但在使用該資源的快取版本前,需要先確認是否有更新的版本,否則不能使用已快取的複本。版本資訊通常會用另一個 HTTP 標頭 ETag 來表示,它是個對應到資源版本的字串,每當資源更新時,這個字串也會跟著變更。要確認版本是否更新,只要確認最新的 ETag 值是否跟快取中的 ETag 值相同即可。如果不同,意味著資源已更新,客戶端需要重新下載新版本來使用。

max-age

該資源的存留時間,單位是秒。例如 max-age 是 1800,代表從拿到資源後的 1800 秒(30 分鐘)內,都可以使用該資源的快取複本。如果超過 1800 秒,則需要重新跟來源伺服器拿取。

must-revalidate

該資源的存留時間一旦超過 max-age,必須重新跟來源伺服器驗證,否則不可以使用快取資源。看到這你可能會有點納悶,超過 max-age 不是本來就要重新拿取嗎?原則上是,但 RFC 7234 的 4.2.4 有列出特例:「快取不得傳送過期的回應,除非它已斷線或明確允許這麼做。」加上 must-revalidate,代表無視這些特例,在任何情況下,都需要進行重新驗證。

CDN 會依照這些指令來決定快取行為,然而,不是說來源伺服器要 CDN 快取,它就一定會快取。想想看,CDN 雖然在全世界都有建置伺服器,但資源有限,如果將每個資源都快取起來,它的儲存空間一樣無法負荷。因此 CDN 通常會有一套機制,只快取有在用的熱門資源。以 Cloudflare 為例,為了讓使用者知道資源是否來自於 CDN 快取,它有提供另一個標頭 CF-Cache-Status ,常見的狀態有

HIT

該資源在 Cloudflare CDN 中發現並提供。

MISS

該資源在 Cloudflare CDN 中找不到,是由來源伺服器提供。

DYNAMIC

這個有點複雜,依照說明是

Cloudflare does not consider the asset eligible to cache and your Cloudflare settings do not explicitly instruct Cloudflare to cache the asset. Instead, the asset was requested from the origin web server.

Cloudflare 不會將該資產視為可快取,且您的 Cloudflare 設定未明確指示 Cloudflare 快取該資產。因此,該資產是從原始網頁伺服器請求的。

通常是 Cache-Control 告訴 Cloudflare 該資源不可快取,因此 Cloudflare 也沒快取它。對一個不能快取的資源,總不能說是 MISS 吧。Cloudflare 稱呼這類資源為「動態資源」,便在 CF-Cache-Status 中設為 DYNAMIC。

內容平台快取策略

介紹完前面這些技術名詞,就可以來看實際案例了。通常來說,內容平台會採用什麼快取策略跟平台定位有關,如果平台流量來自全世界各地,又很依賴搜尋引擎,可能就會用較積極的快取策略,這裡來看看幾個常見的平台

Substack

cache-control: no-cache
cf-cache-status: DYNAMIC
etag: W/"55e20-zjq7GOol6LsXrbX8O2TPyzt0THM"

資源也許可以被 CDN 快取,但取用時需要用 ETag 驗證資源版本。在我的例子中,該資源版本沒有符合最新版本,因此資源直接來自來源伺服器。

照理講,這是常見的配置,怪是怪在我測試好幾次,即使文件沒有修改,回應的 ETag 都會改變,等於每次都是跟來源伺服器重新拿取。這樣設 no-cache 沒有意義,只是浪費儲存空間而已。那如果 ETag 只有修改時才會改變呢?會好一些,能節省傳輸的資料量,可是每次請求時,仍然要跟來源伺服器確認,還是有往返時間成本在。

Substack 的設計可能有個關鍵因素,它是由電子報平台起家,主要流量來源是 Email,搜尋引擎流量對它而言不是特別重要,因此優化程度有限,讓它顯得有點不上不下。

Patreon

cache-control: private, no-cache, no-store, max-age=0, must-revalidate
cf-cache-status: DYNAMIC

資源不可以被 CDN 快取,也不可以被瀏覽器快取,每次都要從來源伺服器拿。

沒什麼好說的,立場很明確:不要快取任何資源。Patreon 主要的流量是來自直接連結跟 Youtube,我猜實際應用中,Patreon 收款的用途應該大於 CMS 的用途。即使在 CMS 的場景中,它也不負責內容發現,只在創作者私有社群內流通。在這情況下,不快取任何資源的確是最簡單明確的設定。

Medium

cache-control: no-cache, no-store, max-age=0, must-revalidate
cf-cache-status: HIT
expires: Mon, 19 May 2025 13:01:25 GMT

資源不可以被快取,但 CF-Cache-Status 是 HIT,明顯有被 CDN 快取,為什麼會有這種矛盾呢?

這是因為 CDN 可以覆寫 Cache-Control,舉個例子,你可能希望 CDN 快取資源,卻不希望瀏覽器快取,以免舊版資源保存在你無法控制的地方。這時你可以到 CDN 中修改設定,假設來源伺服器給 CDN 的 Cache-Control 是 max-age=86400 ,設定好後,CDN 依照指令將資源快取,另外加上 no-store ,並把 max-age 改成 0,這樣一來瀏覽器就不會快取任何資源。這是為什麼 Cloudflare 快取有命中,Cache-Control 卻顯示不要快取的原因。

在我的例子中,可以看到還有個標頭 Expires, 這是用來說明快取過期的時間,從測試結果來看,預設為請求後的 24 小時。綜合以上,還原 Medium 的設定,應該是「該資源可以被 CDN 快取,但不能被瀏覽器快取,同時,快取的時效為 24 小時,超過就要重新拿取。」

Medium 的主要流量來自搜尋引擎,也在搜尋引擎最佳化上下了不少功夫。這種分段快取策略,可以保證 Medium 有良好使用者體驗與 SEO 成效的同時,也保有對內容的掌握度。如果 Medium 需要讓某篇文章的快取失效,只要到 Cloudflare 上輸入該篇文章網址就行了。

作為 CMS 工具的 Ghost

Medium 的設計已經很接近我理想中內容平台該有的設計,硬要挑剔的話,大約是內容所有權仍由平台控制。舉例來說,你剛發布一篇文章,發現自己有個錯字要修改,於是你快速修正,更新了該篇文章。在 Medium 的設計中,CDN 會快取原本有錯字的那篇,修正後的版本要等一天後才會出現。錯字還只是小事,頂多讓你看著不舒服而已,可要是文章中帶有個資忘記修改呢?要是涉及對他人的言語攻擊呢?這時你會發現,你的內容不是你的內容,是平台的。

在講解法前,先來看 Ghost 的設計

cache-control: public, max-age=0
cf-cache-status: DYNAMIC

資源可以被 CDN 快取,但預設時間為 0,因此每次都需要跟來源伺服器拿取。

聽起來跟 Substack 有點像,但還是有些不同,最大的差異,在於 Ghost 是個 CMS,而不是平台。在 Ghost 的假設中,它不會猜測你跟瀏覽器間是否存在 CDN 或代理,快取策略取決於使用者配置,而不是平台營運需求。如果你的網站設為公開網站,Cache-Control 中會有 public,否則就是 private ;你也可以自行在 Ghost 配置檔中設定快取的保留時間,例如

"caching": {
    "contentAPI": {
        "maxAge": 10
    }
}

Ghost 的設計是,它讓創作者自己決定快取策略。

當然這會引入一些複雜度,像是你可能想跟 Medium 同樣,讓 CDN 快取資源,而不要讓瀏覽器快取,於是你得到 CDN 中設定快取規則,或者你跟 CDN 中間還有其他的代理存在,你也得去確認這些代理是否會影響快取策略。但不管如何,你還是可以自己搞定這些事情。

那麼,要如何清除被 CDN 快取的資源呢?最簡單的方式是到 CDN 的控制台,跟它講要清除哪個網址的快取。只是如果希望訪客總是能看到最新的資源,每次編輯後都要手動清除,未免有點麻煩。從設計觀點,我們希望當內容改變時,能自動打 CDN 的清除快取 API,這種「發生事件時通知我」就是經典的 webhook 應用。

Ghost 有開放 webhook 設定,每當已發布文章重新編輯時,會觸發 post.published.edited 事件。設定完成後,指定的接收端(通常是類似 n8n 這類應用整合服務)會在文章修改時,收到以下資訊

{
    "post": {
        "current": {
            "id": "6776f0c2c5d5cb000000000",
            "uuid": "68ce93c3-dfeb-4484-8936-00000000000",
            "title": "CDN Invalidate",
            "slug": "cdn-invalidate",
            "mobiledoc": null,
            //...
        }
    }
}  

從訊息中取出 slug,組成資源網址,再打給 CDN 的清除快取端點,資源就能保持在最新狀態了。

到目前為止是我認為 CMS 應該要有的功能。但在逛 Ghost Forum 時,發現它又再稍稍向前一步。Ghost 修改文章時,前端會呼叫對應的 admin API,例如

// PUT admin/posts/5b7ada404f87d200b5b1f9c8/
{
    "posts": [
        {
            "title": "My new title",
            "updated_at": "2022-06-05T20:52:37.000Z"
        }
    ]
}

而後端的回應中,會帶有 X-Cache-Invalidate 標頭

x-cache-invalidate: /*

依照 Ghost GitHub 在 2013 年的提案,這個標頭的用途是

為了讓我們的主機平台,以及任何想要使用大量快取的人,能夠在新增內容時正確清除快取,我們需要確保 API 從任何會造成變更的請求中回傳快取失效標頭。

這些標頭應該被命名為 X-Cache-Invalidate ,內容應該是以逗號分隔的清單,列出因變更而失效的所有資源。

這正是我們要的!只要在 Ghost 伺服器前加一個代理,讓它確認回應中是否帶有 X-Cache-Invalidate,就能依照 X-Cache-Invalidate 的指示清除對應的快取資源。在設計上,它更像是元資料(metadata),告訴你呼叫的副作用是什麼,由你自己決定是否需要採取額外的動作。

這項設計好在哪?

webhook 跟 X-Cache-Invalidate 都能達到快取失效的目的,為什麼我會說 X-Cache-Invalidate 是「稍稍向前一步」呢?

讓我們借用 Roy Fielding 的觀點來思考。在〈架構風格與基於網路的軟體架構設計〉中,Fielding 用幾種架構屬性來討論各種設計風格,從前後文來看,也可以說他認為這是架構品質所在。我們可以從這些面向來比較兩種方案的差別

可擴展性(Scalability)

Scalability refers to the ability of the architecture to support large numbers of components, or interactions among components, within an active configuration.

可擴展性表示在一個主動的配置中,架構支持大量的組件或大量的組件之間交互的能力。

我對這句話的解讀是,你的架構是否能支撐元件大量增加或大量互動。舉例來說,你不只是網站經營者,還是 Ghost 託管服務的供應商,你有許多伺服器,上面跑滿了 Ghost,這時你如何透過一個配置,讓所有 Ghost 都能具備消除快取的能力呢?

在 webhook 的方案中,你需要先啟動 Ghost,改寫它 post.published.edited 事件目標端點,這需要一個額外的配置與確認。而在 X-Cache-Invalidate 的方案中,只要在所有 Ghost 前部署一個代理,由代理維護快取失效,你就能在無需修改任何設定的情況下,擴展 Ghost 的服務。

簡單性(Simplicity)

Fielding 沒有給出簡單性的定義,但他提到

If functionality can be allocated such that the individual components are substantially less complex, then they will be easier to understand and implement.

如果功能分配使得單獨的組件足夠簡單,那麼它們就更容易被理解和實現。

我們假設簡單性是指軟體功能容易被理解與實現的能力。

webhook 是一個事件驅動風格的設計,不單純用來服務快取失效場景。在使用 webhook 時,你需要 (1) 找出對應的事件;(2) 理解事件的訊息格式;(3) 重新組織成資源網址。X-Cache-Invalidate 相較起來簡單得多,只需要取用標頭中的值。

可修改性(Modifiability)

Modifiability is about the ease with which a change can be made to an application architecture.

可修改性是對於應用的架構所作的修改的容易程度。

webhook 是個高可修改性的設計,因為它將事件與動作分離,我們無需修改 Ghost 既有邏輯,就能讓它得到快取失效的能力;但 X-Cache-Invalidate 也是個高可修改性的設計,甚至更為出色。舉個例子,假設想替留言加入快取失效,用 webhook 方案,需要制定 comment.edited 事件,再由接受端解析後清除快取;而採用 X-Cache-Invalidate ,只需要在 API 端點處加入中間層,讓回應帶上標頭就能辦到,後者的改動成本較前者更低。

小結:設計存在於細節

從 CDN 快取失效的設計上,可以看到服務供應商各種不同的設計,深入理解後覺得非常有意思。我特別欣賞 Ghost 設計上的創意,在見識到它們的方案前,webhook 是我標準(但不情願)的解法,主要是 webhook 對可擴展性多少有點架構上的妥協,而可擴展性跟可維護性又是我一直很在意的議題……

Dieter Rams 曾說:

我的心思都在細節上。實際上,我一直認為細節比大局更重要。細節決定成敗,細節是質量的基石,細節就是一切。

確實在這個案例中,我們可以察覺到細節的力量,以及它如何讓某種功能成為可能。但這不是說我們一定要用 Ghost 的方式來實作快取失效,像 Patreon 堅決不要 CDN 快取,我也覺得沒問題。重點是當需要實作某項功能時,手邊有哪些工具可以幫助你,而我們又該如何評估這些工具的適切性。

Reference


Read more

收拾行李搬家去:從 Medium 到 Ghost

收拾行李搬家去:從 Medium 到 Ghost

想搬家想很久,連身邊的朋友都搬完了,我還沒動工。 原因是我懶,我討厭麻煩,每次有人問我吃什麼,我都回答麥當勞。搬家是一件麻煩事,我已經有一份很讚的工作了,全副精神都放在工作上,偶爾才會想起來,反正家什麼時候都能搬,一點也不急,有什麼好急的呢對吧。這樣一拖,就拖到現在。 繼續用 Medium 不好嗎? 跟男女朋友分手一樣,通常被問到:「對方不好嗎?」得到回答是:「也沒有不好啦,只是……(以下開放填空)。」 從優點開始講吧!Medium 的編輯器很棒,它是 WYSIWYG(所見即所得)類型的編輯器,能讓創作者快速發佈內容,也因為它讓內容發佈更容易了,它開始吸引一批優秀的創作者,這批創作者持續創作內容,又吸引來更多讀者,更多讀者激勵創作者產出內容,內容又再吸引讀者……這形成一個增強迴圈。Medium 還能支援多人協作,拜它時尚簡約的風格所賜,科技公司會使用 Medium 來打造品牌形象,例如我前公司的 Tech Blog

By Ken Chen
OpenTelemetry 的可觀察性工程:以 Sentry 為例

OpenTelemetry 的可觀察性工程:以 Sentry 為例

點進 OpenTelemetry 的官方文件,它最先映入眼中的句子是「什麼是 OpenTelemetry」。例如,它是套可觀察性框架,用於檢測、蒐集與導出遙測數據;它是開源且供應商中立,能搭配其他的開源工具,像 Jaeger 或 Prometheus;它能將應用程式與系統儀表化,無關是用 Go 還是 .NET 開發,也無關部署在 AWS 還是 GCP 上。 但是身為一名開發者,當下我們想的是:「公司常開發一些沒人要用的功能,聽說 OpenTelemetry 可以提高可觀察性,也許我們應該放棄開發功能,轉頭建立更好的開發環境。」「AWS 常常要不到需要的數據,也許我們應該改用另一套工具,像是 OpenTelemetry,來解決這件事。」我們想像 OpenTelemetry 「應該」要能解決目前面臨到的一些問題,就像在技術的鏡像中尋找願望一樣。 如果已經有在用 Sentry,還需要導入 OpenTelemetry

By Ken Chen
標準化之路:Go 1.23 中的迭代器

標準化之路:Go 1.23 中的迭代器

Ian Lance Taylor 在 "Range Over Function Types" 這篇文章聊到 iterator 誕生的原因。如果我們有兩個容器,稱為集合(Set),想要取得這兩個集合中的不重複元素,加到新的集合中形成聯集,我們可以寫個 Union 函式來執行 // Set holds a set of elements. type Set[E comparable] struct { m map[E]struct{} } // Union returns the union of two sets. func Union[E comparable](s1, s2 *Set[

By Ken Chen
OAuth 2.0 的身份認證:OpenID Connect

OAuth 2.0 的身份認證:OpenID Connect

OAuth 2 讓網路服務可以存取第三方的受保護資源,因此,有些開發者會進一步利用 OAuth 2 來進行使用者認證。但這中間存在著一些語義落差,因為 OAuth 2 當初設計目的是「授權」而不是「認證」,兩者關注的焦點會有些不同。OpenID Connect 是基於 OAuth 2 的一套身份認證協定,讓開發者可以在 OAuth 2 授權的基礎上,再加入標準的認證流程。在這篇文章中,我會說明授權跟認證的場景有何差異,並講解 OpenID Connect 如何滿足認證需求。 因為 OpenID Connect 是建構在 OAuth 2 的基礎上,我會假設這篇文章的讀者已經知道 OAuth 2 的組件與流程,如果你不熟悉,可以先閱讀另外兩篇文章 * OAuth 2.0:

By Ken Chen