工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    刷了 CDN 還是舊資料:Next.js 的快取沒有清除按鈕

    Authors
    • avatar
      Name
      Alex Yu

    後端改了 API,上線之後我照慣例去 CDN 後台把快取刷掉(purge),想說使用者這樣就會看到新資料。

    結果沒有。頁面打開還是舊的。

    更奇怪的是,在網址後面隨便加一個 ?qq=qq,畫面立刻就正常了。而且過了一個下午,不加也正常。

    先講架構

    這個站的請求路徑是這樣:

    使用者 → CDN → Next.js Server → 後端 API

    CDN 會把頁面存一份在自己那邊,使用者下次來就不用麻煩後面的伺服器。後面那台是 origin,CDN 手上沒有的時候才會回頭去問它。

    原本的認知是:**要清快取,找 CDN 就對了。**這個認知是錯的,錯在中間那台 Next.js 自己也在存東西。

    三個線索指向同一個地方

    **線索一:刷了 CDN 還是舊的。**代表舊資料不只存在 CDN,後面還有一份。

    線索二:加 ?qq=qq 就好了。沒有任何程式在讀這個參數,它唯一的作用是讓網址變得不一樣。所以那份快取是拿網址當 key 的。換個網址,就找不到舊的那份,只好重新算一次。

    **線索三:那份快取存的是整頁,不是資料。**如果只是把 API 回應存起來,換個 query string 也不會讓程式重新去打 API。會重新打,代表整頁的產出被存起來了。

    三個線索湊起來,在 Next.js App Router 裡只有一個東西符合:Full Route Cache,整頁快取。

    這層快取是怎麼跑出來的

    頁面如果沒有特別宣告,Next.js 會把它當成「內容不太會變、可以先算好」的頁面,於是在第一次請求時把整頁算完的結果存起來。

    之後的請求會走成這樣:

    Request
    
    
    Middleware              ← 最先跑。此時還沒決定要用哪個頁面、也還沒開始算
    
    
    Route Matching          ← 決定這個網址對應到哪支程式
    
    
    整頁快取 ──── 命中 ────→ 直接回上次存好的結果
       │                     (page.tsx 完全不執行,也不會打 API)
      沒命中
    
    Page Handler            ← 這時才真的執行頁面程式、去打 API
    
    
    Response(帶著 middleware 設的 header)

    Middleware 是 Next.js 在每個請求進來時、還沒決定要用哪支程式之前,先跑的一段程式。常見用途是轉址和改 header。

    關鍵在中間那格:快取命中的時候,page.tsx 這支程式根本沒有被執行過。

    所以我在 fetch() 上設多短的過期時間都沒用,那段程式碼壓根沒跑到。這也是為什麼「明明設了 2 分鐘,卻好幾個小時都不更新」。

    真正麻煩的是:這層快取沒有清除按鈕

    CDN 有後台。想清哪個路徑就清哪個,按下去幾秒後生效。

    Next.js 這幾層快取沒有這種東西。沒有介面、沒有按鈕、沒有指令。你唯一能控制的是「在程式碼裡設定它多久之後過期」,想要提早清掉,只能重新部署一次。

    這解釋了那個「過了一個下午就自己好了」:那天下午有一次部署,順便把快取洗掉了。

    也就是說,這個 bug 發生的當下,我手上沒有任何工具可以立刻止血。

    Next.js 有四層快取

    除了整頁快取,還有另外三層,各自的生命週期都不一樣:

    在哪裡存什麼
    Request Memoization伺服器,單次組頁面的過程內同一次組頁面時重複問同一個 API,只問一次
    Data Cache伺服器,跨請求API 回來的資料
    整頁快取(Full Route Cache)伺服器,跨請求整頁算完的結果
    Router Cache使用者的瀏覽器裡這個人剛剛瀏覽過的頁面

    加上前面的 CDN,一共五層。出問題的時候要先猜是哪一層在鬧,而其中四層你都沒辦法手動清。

    疊加還會讓延遲比設定值更長:

    CDN 快取 5 分鐘、Next.js 資料快取 2 分鐘
     
    t=0        後端更新資料
    t=0        Next.js 剛好存了一份舊的(有效到 t=2:00)
    t=1:59     CDN 跟 Next.js 要資料,拿到舊的並存起來(有效到 t=6:59)
    t=2:00     Next.js 那份過期了,但 CDN 還在用舊的
    t=6:59     使用者終於看到新資料

    設定寫著 5 分鐘,實際最壞接近 7 分鐘。

    決定:只留 CDN 那一層

    我最後選了一個粗暴的做法:把 Next.js 的四層快取全部關掉,讓 CDN 成為唯一的快取層

    理由跟效能無關:出事的時候救不救得回來。CDN 那層我按一個按鈕就能清,Next.js 那層我只能重新部署。把快取集中到有按鈕的那一層,等於把「線上出包」的處理時間從一次部署縮短成幾秒鐘。

    附帶的好處是行為變得可預測:「為什麼還是舊資料」只剩一個地方要查。

    代價是回 origin 的量會上升。

    關掉整頁快取

    這是根因所在,先處理:

    // app/layout.tsx
    export const dynamic = 'force-dynamic'

    force-dynamic 的意思是「這個頁面每次都要重新算,不要存」。官方文件寫得很明白:它會同時跳過整頁快取和資料快取。放在最外層的 layout 就是全站生效。

    同時要把各頁面自己設的過期時間清掉,它們會蓋掉全域設定:

    // 這類散落在各頁面的設定要一起移除
    export const revalidate = 3600

    關掉資料快取

    上一步已經涵蓋了,但我還是在 API client 上寫明確,避免之後有人從別的地方繞過去:

    export const fetchApi = async <T>(options: FetchOptions): Promise<T> => {
      const { path, queryString } = options
      const endpoint = `/api/${path}${queryString ? `?${queryString}` : ''}`
     
      return client.fetchJson<T>(endpoint, {
        cache: 'no-store', // ← 不要存這次的結果
      })
    }

    Next.js 15 以後 fetch() 預設就不存了,這行等於把預設行為寫出來;14 以前預設是存的,一定要手動關掉。

    關掉瀏覽器那層

    // next.config.js
    const nextConfig = {
      experimental: {
        staleTimes: {
          dynamic: 0,
          static: 0,
        },
      },
    }

    這層在使用者的瀏覽器裡,跟前面兩層是獨立的開關。只關伺服器端的話,同一個使用者在站內來回切頁,還是會看到自己剛才那份舊畫面。

    用 middleware 統一設 Cache-Control

    Cache-Control 是回應裡的一個 header,用來告訴瀏覽器和 CDN「這份東西可以放多久」。

    // middleware.ts
    export const middleware = (req: NextRequest) => {
      const response = NextResponse.next()
     
      // 錯誤頁不要存,否則使用者會被鎖在錯誤畫面裡
      if (
        req.nextUrl.pathname.includes('/404') ||
        req.nextUrl.pathname.includes('/error')
      ) {
        response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate')
        return response
      }
     
      response.headers.set(
        'Cache-Control',
        'public, max-age=120, s-maxage=300, stale-while-revalidate=60'
      )
     
      return response
    }
    • max-age=120:瀏覽器存 2 分鐘。設短一點,使用者重新整理就能看到較新的
    • s-maxage=300:CDN 存 5 分鐘。設長一點,減少回 origin
    • stale-while-revalidate=60:過期後的 60 秒內先回舊的給使用者,同時在背景偷偷更新

    為什麼設在 middleware 就會生效:Next.js 在送出回應前會先檢查「這個回應有沒有人設過 Cache-Control」,只有在沒人設的時候才填自己的值。而 middleware 跑在最前面,所以它設的會一路保留到最後,就算後面命中了快取、頁面程式根本沒執行,也一樣。

    驗證:CDN 有沒有照你的設定做事

    這節是我原本以為最簡單、結果花最多時間的地方。

    curl 一下,看實際回了什麼:

    curl -sI https://example.com/some-page | grep -i cache

    然後發現兩件事跟預期不一樣。

    發現一:有一段設定被 CDN 吃掉了

    伺服器這端回的是:

    cache-control: public, max-age=120, s-maxage=300, stale-while-revalidate=60

    但經過 CDN 之後,瀏覽器收到的只剩:

    cache-control: public, max-age=120, s-maxage=300

    stale-while-revalidate 不見了。

    原因是 Akamai 的快取設定開了 Enhanced RFC support。開啟之後,它會自己把這個指令用掉,不再往下傳給瀏覽器。這是它的設計,不是 bug。但如果你預期瀏覽器也吃得到這個行為,就會落空。而我們的方案沒有可以手動加回 header 的選項。

    你在程式裡設的,跟使用者實際收到的,可能不是同一份。

    發現二:明明命中了,卻不知道存多久了

    回應裡有 akamai-cache-status: Hit from childx-cache: TCP_MEM_HIT,就是沒有 Age

    Age 這個 header 的作用是告訴你「這份快取已經放多久了」。沒有它,你分不出來一個命中是三秒前存的、還是四分鐘前存的,debug 的時候很痛。

    那快取到底放多久

    CDN 的 cache key 上顯示的是 1h,而我設的是 300 秒。

    我原本的假設是:cache key 上的 1h 蓋掉了我的設定,所以實際是一小時。

    **這個假設是錯的。**驗證方法很笨,等就好了:

    1. 先打一次,確認是命中狀態(x-cache: TCP_MEM_HIT),記下時間
    2. 等 390 秒,超過 s-maxage(300) + stale-while-revalidate(60) = 360 秒
    3. 再打一次

    結果是沒命中。所以:

    • cache key 上的 1h 只是這個方案的預設上限值,不是實際生效的時間
    • s-maxage=300 有生效
    • stale-while-revalidate=60 也有作用,只是作用在 CDN 內部:5 分鐘後那份東西變成「過期但還能用」,在後續 60 秒內仍然回命中,超過才真的回 origin
    • 實際能命中的窗口是 360 秒

    我原本打算把「為什麼顯示 1h 而不是 300s」列進要問 CDN 廠商的問題清單。等 6 分半就能自己回答的事,不用去排隊等人回信。

    踩過的坑

    useSearchParams 要包 Suspense

    關掉快取後,某些用了 useSearchParams 的元件竟然開始出錯。

    原因是頁面從「先算好」變成「每次現算」之後,這個 hook 必須包在 Suspense 裡(Suspense 是 React 用來標示「這塊要等一下才會有內容」的界線):

    // ❌ 直接用會出錯
    export default function Popup({ articleId }: PopupProps) {
      const searchParams = useSearchParams()
      // ...
    }
     
    // ✅ 拆一層出來包起來
    export default function Popup(props: PopupProps) {
      return (
        <Suspense fallback={null}>
          <PopupContent {...props} />
        </Suspense>
      )
    }

    上面 middleware 的 matcher 是 '/:path*',對所有路徑都蓋上 public。當時只想到要把錯誤頁排除,沒想到幾個月後這個決定咬了我一口。

    登入用的 /api/auth/* 也被蓋成 public,CDN 就把它當成可以存起來的東西。而 CDN 在命中時會把 Set-Cookie 拿掉。一份大家共用的快取回應,本來就不該夾帶某個人的 cookie。結果登入流程要種的 cookie 種不進去,部分使用者直接登入失敗。

    export const config = {
      // 排除 /api/*:API 回應(特別是會發 Set-Cookie 的)不該被 CDN public 快取
      // (?:/|$) 是為了連裸的 /api 也一起排除,只寫 api/ 會漏掉
      matcher: ['/((?!api(?:/|$)).*)'],
    }

    「讓 CDN 全權控制」的前提,是這個路徑的回應可以被所有人共用。會發個人化 Set-Cookie 的登入 API 不屬於這類。

    同一個網域下有多個專案的話,要一起改

    網站底下還有另一個獨立部署的 Next.js 專案。快取策略只改一邊,兩邊行為就會不一致,而且這種不一致很難察覺,因為單獨看每個專案都是「正常的」。

    框架給的工具,形狀不對

    寫到這裡要平反一下:Next.js 不是完全沒給你清快取的方法。給了,只是給的東西跟你要的不一樣。

    一、有 API,但那是程式碼、不是介面

    App Router 有 revalidatePath

    // app/api/revalidate/route.ts
    import { revalidatePath } from 'next/cache'
    import type { NextRequest } from 'next/server'
     
    export async function GET(request: NextRequest) {
      const path = request.nextUrl.searchParams.get('path')
      if (!path) return Response.json({ revalidated: false })
     
      revalidatePath(path)
      return Response.json({ revalidated: true })
    }

    App Router 之前的 Pages Router 也有,只是叫別的名字:

    // pages/api/revalidate.ts
    export default async function handler(req, res) {
      await res.revalidate('/path-to-revalidate')
      return res.json({ revalidated: true })
    }

    有 API,但你得先自己蓋一個後台:寫一支 route、加上驗證(不然任何人都能拿它打爆你的伺服器)、再包一個介面或至少一份操作說明,給不寫程式的人用。

    這些東西在 CDN 那邊是現成的。在這裡是你的待辦事項。

    二、多台機器的話,只清得到其中一台

    這是自架最容易中的一條,官方文件寫得很直白:

    If you are hosting Next.js using a container orchestration platform like Kubernetes, each pod will have a copy of the cache.

    也就是說,你打一次 revalidatePath,只有接到那個請求的那一台會清掉。其他幾台繼續回舊的,直到各自過期。

    要跨機器生效,得自己接一份共用儲存:

    // next.config.js
    module.exports = {
      cacheHandler: require.resolve('./cache-handler.js'),
      cacheMaxMemorySize: 0, // 關掉預設的記憶體快取
    }

    然後在 cache-handler.js 自己實作 get / set / revalidateTag,把資料存到 Redis 之類的地方。

    16 之後另外有 refreshTags() 可以實作:它會在每個請求之前被呼叫,讓每台機器主動從共用儲存把 tag 的失效狀態同步回來。少了它,其他機器要等自己發現才會更新,官方文件的說法是「continue serving stale content until they independently discover the invalidation」。

    換句話說:「清快取」這件事在 CDN 上是一個按鈕,在這裡是一個要自己寫、自己維運的元件

    三、沒有任何辦法看一眼

    前面兩個至少有解。這個沒有。

    沒有 API 可以查快取現在存了哪些頁、某一頁是什麼時候存的、還剩多久過期。CDN 至少會在回應裡給你 x-cache: HIT(雖然這次連 Age 都沒有);Next.js 這層是純黑盒。

    當時我很想要的是這種東西:

    // ⚠️ 以下 API 都不存在,只是我當下很希望有
    cache.list()                     // 現在存了哪些頁?
    cache.inspect('/article/123')    // 這頁什麼時候存的、還剩多久?
    cache.purge('/article/123')      // 立刻清掉,而且是所有機器一起

    沒有這些,線上出現「有人看到舊的、有人看到新的」的時候,你只能推理,不能觀測。

    版本換過幾輪,看不見這件事沒變

    我踩到這坑的時候是 14。後來兩個大版本,Next.js 一路往同一個方向走:

    版本快取的預設值
    14幾乎什麼都幫你快取,要關得自己關
    15fetch()GET Route Handler、client 端導航預設不快取
    16Cache Components:快取變成完全 opt-in,沒明講就是每次重新執行

    也就是說,我當時手動關掉一整排快取的決定,兩年後變成了框架的預設值。

    15 還順手補了一件跟這篇直接相關的事:它不再用預設值覆蓋你自訂的 Cache-Control。我在 14 靠的是「Next.js 只在你沒設的時候才填自己的值」這個實作行為,15 之後這變成明文保證。

    失效用的 API 也一直在長:

    // 14:一個函式打天下
    revalidateTag('posts')
     
    // 16:要帶 cacheLife profile 才有 stale-while-revalidate
    //     單參數形式已標記為 deprecated
    revalidateTag('posts', 'max')
     
    // 16 新增:只能在 Server Actions 用,讀得到自己剛寫的
    updateTag('posts')
     
    // 16 新增:只刷沒被快取的資料,不碰快取
    refresh()

    順帶一提,16 把 middleware.ts 改名成 proxy.ts。這篇的解法整個蓋在 middleware 上,升上去的時候要一起改。

    但把這幾個 API 排在一起看就會發現:補的全部都是**「怎麼讓它失效」**。從 14 到 16,沒有任何一個新 API 是「讓你看到快取現在長什麼樣子」。

    所以最後那個決定其實沒什麼玄機:把快取放在有按鈕、有 log、出事時有人可以問的那一層。

    結果,和一個查錯方向的下午

    上線後 CDN 的命中率提升到 90% 以上

    關掉 Next.js 的快取確實讓後端 API 流量上升,但因為前面還有 CDN 擋著,真正回到伺服器的量在可接受範圍。

    同一天還有一件事當場沒解決。

    「踩過的坑」提到的那個獨立專案,max-age 沒有跟著改成 120 秒,還是 300 秒。

    第一個念頭是 CDN 又動了手腳,畢竟前面才剛遇過 stale-while-revalidate 被吃掉。請 SRE 幫抓了一次,結果 origin 自己回的就是 300 秒。東西在送出去以前就已經是錯的,這次 CDN 清白。

    答案在那個專案自己的 git log 裡。同一天有一個 commit 把 max-age 從 120 調成 300,訊息大意是「快取拉長到 5 分鐘」;改回 120 的 commit 排在它後面。我拿到的 300 秒,就是中間那一版的值。

    我在 CDN 那一側找了一個下午,其實 git log 一行就有。查快取問題的順序我後來換過來了:先看自己這邊有沒有人動過,再懷疑 CDN。

    重點整理

    • 刷了 CDN 還是舊資料,代表後面還有人在存
    • 加一個沒意義的 query string 就能繞過的問題,八成是拿網址當 key 的整頁快取
    • 整頁快取命中時,頁面程式完全不執行,所以在 fetch() 上設再短的過期時間都沒有用
    • 你在程式裡設的 Cache-Control,跟使用者實際收到的,可能不是同一份
    • Next.js 的快取只能用程式清、而且看不見裡面;多台機器時預設還各清各的

    心得

    改完回頭看,花最多時間的是搞清楚每一層到底誰說了算,關快取本身反而很快。

    而選型的時候我以前不太會問「這東西出事的時候我要怎麼手動救」,這次之後會了。

    延伸閱讀

    參考資料