博客 / 詳情

返回

HTTP 緩存最佳實踐和 max-age 帶來的陷阱

正確使用緩存可以帶來巨大的性能優勢,節省寬帶,並降低服務器成本,但許多網站並不重視緩存,造成競爭條件,導致相互依賴的資源不同步。

絕大多數最佳實踐緩存屬於以下兩種模式之一:

  • 模式一:不可變(immutable)內容 + 長 max-age
  • 模式二:可變(mutable)內容,始終由服務器驗證

模式一:不可變內容 + 長 max-age

Cache-Control:max-age=31536000

適用以下情況:

  • 此 URL 上的內容永遠不會改變。
  • 瀏覽器/CDN 可以將此資源緩存一年沒有問題。
  • 可以使用小於 max-age 幾秒的緩存內容,無需諮詢服務器。

image.png

在這個模式下,您永遠不會更改特定 URL 的內容,而是更改 URL:

<script src="/script-f93bca2c.js"></script>
<link rel="stylesheet" href="/styles-a837cb1e.css" />
<img src="/cats-0e9a2ef4.jpg" alt="…" />

每個 URL 包含的信息都會隨之改變,它可以是版本號、修改日期或內容的哈希值。

大多數服務器端框架都自帶工具來簡化這一過程(我使用 Django 的 ManifestStaticFilesStorage),還有一些較小的 Node.js 庫也能實現同樣的功能,例如 gulp-rev。

不過,這種模式不適用於文章和博文等內容,它們的 URL 無法版本化,內容也必須能夠更改。説真的,鑑於我經常會犯一些基本的拼寫和語法錯誤,我需要能夠快速、頻繁地更新內容。

模式二:可變內容,始終由服務器驗證

Cache-Control: no-cache

適用以下情況:

  • 此 URL 上的內容可能會更改
  • 未經服務器許可,任何本地緩存版本都不可信

image.png

注意:no-cache 並不意味着 "不緩存",而是指在使用緩存資源前必須與服務器進行檢驗(或稱為 "重新驗證")。此外,must-revalidate 並不意味着 "必須重新驗證",而是説如果本地資源的時效小於所提供的 max-age,就可以使用,否則就必須重新驗證。

在這種模式下,可以在響應中添加 ETag(你選擇的版本 ID)或 Last-Modified 日期標頭。下一次客户端獲取資源時,就會分別通過 If-None-MatchIf-Modified-Since 回傳已有內容的值,從而允許服務器説 "就用你已有的吧,它是最新的",或者正如它的拼寫那樣 "HTTP 304"。

如果無法發送 ETag/Last-Modified,服務器將始終發送完整內容。

這種模式總是需要通過網絡獲取,因此不如模式一那樣可以完全繞過網絡。

模式一所需的基礎設施讓人望而卻步,而模式二所需的網絡請求又讓人同樣望而卻步,因此,人們往往會選擇介於兩者之間的模式:較小的 max-age 和可變內容,這是一個糟糕的折衷方案。

可變內容的 max-age 通常是錯誤的選擇

遺憾的是,這種情況並不少見,例如在 Github 頁面上就會發生。

想象一下

  • /article/
  • /styles.css
  • /script.js

所有服務:

Cache-Control: must-revalidate, max-age=600

包含以下場景:

  • URL 內容更改
  • 如果瀏覽器有不到 10 分鐘的緩存版本,則使用該版本,無需詢問服務器
  • 否則,進行網絡獲取,如果可用,使用 If-Modified-SinceIf-None-Match

image.png

這種模式在測試中似乎有效,但在實際場景中卻會造成故障,而且很難追查。在上面的例子中,服務器實際上已經更新了 HTML、CSS 和 JS,但頁面最終使用的是緩存中的舊 HTML 和 JS,以及服務器上更新的 CSS。版本不匹配導致了問題的出現。

通常情況下,當我們對 HTML 進行重大修改時,很可能也會修改 CSS 以反映新的結構,並更新 JS 以適應樣式和內容的變化。這些資源是相互依存的,但緩存標頭無法表達這一點。用户最終可能會使用其中一個/兩個資源的新版本,而使用另一個/多個資源的舊版本。

max-age 是相對於響應時間而言的,因此如果上述所有資源都是作為同一導航的一部分被請求的,那麼它們將被設置為在大致相同的時間過期,但仍然存在競爭的可能性。

如果有些頁面不包含 JS,或包含不同的 CSS,過期日期就會不同步。更糟糕的是,瀏覽器經常會從緩存中刪除一些內容,而它並不知道 HTML、CSS 和 JS 是相互依存的,所以它會很樂意刪除其中一個,而不刪除其他的。將這些因素相乘,最終出現這些資源版本不匹配的情況也就不是不可能了。

對於用户來説,這可能會導致佈局和/或功能被破壞,從細微的故障到完全無法使用的內容。

值得慶幸的是,用户有一個逃生通道...

刷新有時可以解決

如果頁面是作為刷新的一部分加載的,瀏覽器總是會與服務器重新驗證,而忽略 max-age。因此,如果用户遇到的問題是由於 max-age 導致的,點擊刷新就能解決一切問題。當然,強迫用户這樣做會降低信任度,因為這會讓人覺得你的網站很不穩定。

Service Worker 線程可以延長這些錯誤的壽命

假設您有以下 Service Worker:

const version = '2';

self.addEventListener('install', (event) => {
  event.waitUntil(
      caches.open(`static-${version}`)
        .then((cache) => cache.addAll(['/styles.css', '/script.js']))
  )
})

self.addEventListener('activate', (event) => {
  // delete old caches...
})

self.addEventListener('fetch', (event) => {
  event.respondWith(
      caches.match(event.request)
        .then((response) => response || fetch(event.request))
  )
})

這個 Service Worker 線程...

  • 預先緩存腳本和樣式
  • 如果匹配,則從緩存中提供服務,否則通過網絡提供服務

如果我們更改了 CSS/JS,我們就會提升 version,使 Service Worker 的字節不同,從而觸發更新。不過,由於 addAll 是通過 HTTP 緩存獲取的(幾乎所有的獲取都是這樣),我們可能會遇到 max-age 競爭條件,並緩存到不兼容的 CSS 和 JS 版本。

一旦它們被緩存,在下次更新 Service Worker 之前,我們將一直提供不兼容的 CSS 和 JS。

您可以繞過 Service Worker 中的緩存:

self.addEventListener('install', (event) => {
  event.waitUntil(
      caches.open(`static-${version}`)
    .then((catch) => {
      cache.addAll([
        new Request('/styles.css', { cache: 'no-cache' }),
        new Request('/script.js', { cache: 'no-cache' })
      ])
    })
  )
})

遺憾的是,Chrome/Opera 尚不支持緩存選項,而 Firefox Nightly 最近才支持緩存選項,不過你也可以自己嘗試一下:

self.addEventListener('install', (event) => {
  event.waitUntil(
      caches.open(`static-${version}`).then((cache) => {
      return Promise.all(
          ['/styles.css', '/script.js'].map(() => {
          // cache-bust using a random query string
          return fetch(`${url}?${Math.random()}`).then((response) => {
            // fail on 404, 500 etc
            if(!response.ok) throw Error('Not ok');
            return cache.put(url, response);
          })
        })
      )
    })
  )
})

在上文中,我使用隨機數來破壞緩存,但您可以更進一步,使用構建步驟來添加內容的哈希值(類似於 sw-precache 的做法)。這有點像在 JavaScript 中重新實現模式一(不可變內容),但只是為了 Service Worker 用户的利益,而不是所有瀏覽器和 CDN 的利益。

Service Worker 和 HTTP 緩存可以很好地合作,不要讓它們打架!

正如您所看到的,您可以解決 Service Worker 中的糟糕的緩存問題,但最好還是解決問題的根源。正確設置緩存可以換 Service Worker 領域的工作變得更輕鬆,而且也有利於不支持 Service Worker 的瀏覽器(Safari、IE/Edge)受益,並讓您最大限度地利用 CDN。

正確的緩存標頭意味着您還可以大幅簡化 Service Worker 的更新:

const version = '23';

self.addEventListener('install', (event) => {
  event.waitUntil(
      caches.open(`static-${version}`)
        .then((cache) => {
        cache.addAll([
          '/',
          '/script-f93bca2c.js',
          '/styles-a837cb1e.css',
          '/cats-0e9a2ef4.jpg'
        ])
      })
  )
})

在這裏,我會使用模式二(服務器重新驗證)緩存根頁面,使用模式一(不可變內容)緩存其他資源。每次 Service Worker 更新都會觸發對根頁面的請求,但其他資源只有在 URL 發生變化時才會被下載。這樣做非常好,因為無論從上一版本還是 10 個版本更新,都能節省帶寬並提高性能。

與本地程序相比,這是一個巨大的優勢,在本地程序中,即使是很小的改動也要下載整個二進制文件,或者涉及複雜的二進制差異,在這裏,我們只需相對較少的下載就能更新一個大型網絡應用程序。

Service Worker 的最佳工作方式是增強而不是變通,因此與其與緩存對抗,不如與它合作!

謹慎使用 max-age 和可變內容可帶來益處

在可變內容上使用 max-age 通常是錯誤的選擇,但並非總是如此。

例如,本頁面的 max-age 為三分鐘,這裏並不存在競爭條件的問題,因為該頁面沒有任何依賴項遵循相同的緩存模式(我的 CSS、JS 和圖片 URL 都遵循模式一 ——不可變內容),而且該頁面的任何依賴項都不遵循相同的模式。

這種模式意味着,如果我有幸寫了一篇受歡迎的文章,我的 CDN(Cloudflare)可以為我的服務器分擔熱量,只要我可以忍受文章更新需要三分鐘才能被用户看到,而我現在就是這樣。

這種模式不能隨便使用,如果我在一篇文章中添加了一個新的部分,並在另一篇文章中進行了鏈接,那麼我就創建了一個可能會發生競爭的依賴關係。用户點擊鏈接後,可能會進入一篇沒有引用部分的文章。如果我想避免這種情況,我會更新第一篇文章,使用 Cloudflare 的用户界面刷新 Cloudflare 的緩存副本,等待三分鐘,然後在另一篇文章中添加鏈接。是的......使用這種模式必須非常小心。

正確使用緩存可以大大提高性能和節省帶寬。對於任何容易改變的 URL,最好使用不可變內容,否則就使用服務器重新驗證。只有當你覺得自己很勇敢,並且確信你的內容沒有依賴關係或可能不同步的依賴關係時,才會混合使用 max-age 和可變內容。

推薦閲讀:徹底理解瀏覽器緩存機制

在 GitHub 上查看此頁面

user avatar peter-wilson 頭像 yaofly 頭像 huishou 頭像 dujing_5b7edb9db0b1c 頭像 hightopo 頭像 flymon 頭像 shaochuancs 頭像 201926 頭像 yilezhiming 頭像 liyl1993 頭像 79px 頭像 musicfe 頭像
33 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.