- Published on
刷了 CDN 還是舊資料:Next.js 的快取沒有清除按鈕
- Authors
-
-
- Name
- Alex Yu
-
後端改了 API,上線之後我照慣例去 CDN 後台把快取刷掉(purge),想說使用者這樣就會看到新資料。
結果沒有。頁面打開還是舊的。
更奇怪的是,在網址後面隨便加一個 ?qq=qq,畫面立刻就正常了。而且過了一個下午,不加也正常。
先講架構
這個站的請求路徑是這樣:
使用者 → CDN → Next.js Server → 後端 APICDN 會把頁面存一份在自己那邊,使用者下次來就不用麻煩後面的伺服器。後面那台是 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 分鐘。設長一點,減少回 originstale-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=300stale-while-revalidate 不見了。
原因是 Akamai 的快取設定開了 Enhanced RFC support。開啟之後,它會自己把這個指令用掉,不再往下傳給瀏覽器。這是它的設計,不是 bug。但如果你預期瀏覽器也吃得到這個行為,就會落空。而我們的方案沒有可以手動加回 header 的選項。
你在程式裡設的,跟使用者實際收到的,可能不是同一份。
發現二:明明命中了,卻不知道存多久了
回應裡有 akamai-cache-status: Hit from child、x-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>
)
}會發 Set-Cookie 的路徑不能套這個 blanket 快取
上面 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 | 幾乎什麼都幫你快取,要關得自己關 |
| 15 | fetch()、GET Route Handler、client 端導航預設不快取 |
| 16 | Cache 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 的快取只能用程式清、而且看不見裡面;多台機器時預設還各清各的
心得
改完回頭看,花最多時間的是搞清楚每一層到底誰說了算,關快取本身反而很快。
而選型的時候我以前不太會問「這東西出事的時候我要怎麼手動救」,這次之後會了。
延伸閱讀
- 6,401 個 IP、3 分鐘、7,262 個錯誤:一隻普通的爬蟲怎麼打掛我的 search 頁:快取失效的另一面:當請求全部打回 origin 會發生什麼事
- ALB 又回 502 了:先別急著加機器,log 會告訴你是哪一種:同一套架構下的另一種 5xx
參考資料
- Next.js - Caching
- Next.js - Route Segment Config
- Next.js - staleTimes
- Next.js - revalidatePath
- Next.js - updateTag
- Next.js - Self-hosting(Configuring Caching 一節)
- Next.js 15 發佈公告,Caching Semantics 一節
- Next.js 16 發佈公告,Cache Components 與 Improved Caching APIs
- MDN - Cache-Control
- Akamai - Enhanced RFC support