← Tất cả bài viết

[ Blog ]

16 tháng 7, 2026

8 phút đọc

Chuyên mục: Góc nhìn

unstable_cache Next.js Cache null Vĩnh Viễn? Cách Sửa

DB timeout đúng một giây mà trang Next.js trống vĩnh viễn? unstable_cache đã cache cái null mà catch trả về. Hai dòng sửa để cache tự lành, không cần re-save.

Next.jsCachingunstable_cacheApp RouterPrismaNeon
unstable_cache Next.js Cache null Vĩnh Viễn? Cách Sửa

Một trang trên dự án profile cá nha — CMS tự host chạy Next.js với Neon Postgres phía sau — render trống trên production và cứ trống mãi. Không phải 500, không error boundary: trang vẫn ship bình thường, chỉ thiếu đúng phần nội dung, vô thời hạn. Local dev chạy tốt, database khỏe, dữ liệu nằm ngay đó trong bảng. Nếu bạn gặp cảnh unstable_cache của Next.js cache null vĩnh viễn — một trang hoặc cả một collection đột nhiên trắng trơn và chỉ hồi lại khi ai đó re-save trong CMS — bạn đang dính đúng con bug này. Nguyên nhân gốc là một dòng code "phòng thủ" đặt sai chỗ, và cách sửa chỉ là dời nó hai dòng, ra ngoài.

Triệu chứng: trang trống không bao giờ tự lành

Hình dạng con bug này chính là thứ khiến nó khó chịu:

  • Production render empty state của trang (hoặc collection rỗng) mà không có lỗi ở đâu cả — log sạch, build xanh.
  • Nó kéo dài hàng giờ, hàng ngày. Redeploy cũng không chắc hết.
  • Nó chỉ lành khi một editor tình cờ re-save đúng item đó trong CMS — trông như phép màu, và bị chẩn đoán nhầm thành "CMS publish không ăn."
  • Ở local thì không tài nào tái hiện được.

Chẳng có gì trong đó gào lên "caching" cả. Nó gào "bug dữ liệu", nên bạn đi soi query, soi CMS, soi từng row — đều ổn. Vấn đề nằm ở thời điểm query fail, đúng một lần, và code của bạn trả về cái gì trong khoảnh khắc đó.

Nguyên nhân gốc: unstable_cache cache cái fallback, không phải cái lỗi

Đây là reader, đúng nguyên trạng lúc tôi tìm ra. Trông nó rất có trách nhiệm — thậm chí là cẩn thận:

// Reader nuốt lỗi và trả về fallback…
async function getPage(slug) {
  try { return await prisma.page.findUnique({ where: { slug } }) }
  catch { return null }              // ← timeout cold-start của Neon rơi vào đây
}
// …và CHÍNH cái null đó bị cache — với revalidate: false, tức là vĩnh viễn.
const getCachedPage = (slug) =>
  unstable_cache(() => getPage(slug), ['cms-page', slug], {
    tags: cacheTags.page(slug),
    revalidate: false,               // không bao giờ tự chạy lại
  })()

unstable_cache cache giá trị trả về của hàm. Nó không có cách nào biết null nghĩa là "database timeout" chứ không phải "trang này không tồn tại." Một lỗi bị nuốt trả về một cái null hoàn toàn cache được (hoặc [] với reader collection), thế là một cú chớp DB thoáng qua được thăng cấp thành trang trống vĩnh viễn.

revalidate: false là thứ khiến nó thành vĩnh viễn: hàm đã cache không bao giờ tự chạy lại. Thứ duy nhất xóa được entry là một cú revalidateTag trên đúng tag đó — mà trong một site chạy CMS, nó chỉ bắn khi editor mutate đúng item đó. Đó là lý do re-save trong CMS "sửa" được trang. Editor không hề republish nội dung — họ đang vô tình xả một cache entry bị nhiễm độc.

Vị trí của catchMột lỗi DB thoáng qua trở thành
Trong hàm được cachenull/[] bị cache dưới tag — trang trống cho đến lần mutation kế tiếp trên tag đó
Ngoài cache wrapperHàm throw → không cache gì → request kế tiếp thử lại và tự lành

Postgres serverless biến chuyện này thành tất yếu, không phải lý thuyết

Trên Postgres always-on truyền thống, "query ngẫu nhiên timeout một lần" hiếm tới mức bug này có thể trốn nhiều năm. Trên Postgres serverless thì đó là tính năng thiết kế: Neon scale về zero, và cold start ở query đầu tiên sau khi ngủ có thể timeout. Chuỗi sự kiện rất đời thường: site vắng → compute suspend → một crawler gõ vào trang ít ai xem → query đánh thức bị timeout → catch trả nullunstable_cache đóng băng nó. Những trang lặng lẽ nhất — ít khả năng được editor re-save nhất — lại chính là những trang dễ bị gõ lúc lạnh và nhiễm độc lâu nhất.

Tôi từng viết về kiểu hỏng hàng xóm trên cùng nền móng: vì sao Neon hết giới hạn compute dù site ít traffic. Cùng gốc cold-start, triệu chứng ngược nhau — bài kia đốt quota, bài này đóng băng sự trống rỗng.

Cách sửa: dời catch ra ngoài cache wrapper

Tính chất bạn thực sự muốn là: nếu hàm được cache throw, không gì bị cache, và request kế tiếp thử lại. Vậy nên reader bên trong phải được phép throw, còn fallback dời ra mặt ngoài của wrapper:

const queryPage = (slug) => prisma.page.findUnique({ where: { slug } })  // throw khi lỗi
const getCachedPage = (slug) =>
  unstable_cache(() => queryPage(slug), ['cms-page', slug], {
    tags: cacheTags.page(slug), revalidate: false,
  })().catch(() => null)             // ← caller vẫn nhận fallback y cũ, không gì nhiễm độc

Phía gọi thấy API giống hệt — vẫn nhận page | null và render đúng empty state khi request hỏng. Thứ duy nhất thay đổi là cache được phép ghi nhớ cái gì. Một lỗi thoáng qua giờ chỉ tốn đúng một request hiển thị fallback, thay vì một trang trống vô thời hạn.

Toàn bộ cách sửa là vậy. Hai dòng tái cấu trúc, không dependency mới, không thư viện retry.

Audit mọi reader — con bug này thay nhiều bộ quần áo

Chỗ bạn vừa tìm ra hiếm khi là chỗ duy nhất. Với revalidate: false, cách xử lý lỗi của một hàm được cache chính là chính sách nhiễm độc cache của nó: bất cứ thứ gì nó trả về thay vì throw đều bị đóng băng. getPage → catch return nullgetCollection → catch return [] là cùng một con bug mặc hai bộ đồ, và mọi cú ?? defaultValue có thể nuốt failure bên trong wrapper cũng vậy.

Một vòng quét nhanh luôn bắt đủ cho tôi:

# mọi call site của unstable_cache…
grep -rn "unstable_cache" src/
# …rồi soi từng hàm được bọc xem có catch/fallback NẰM TRONG wrapper không
grep -rn -B2 -A6 "catch" src/lib/readers/

Với mỗi hit, hỏi đúng một câu: nếu DB chết giữa request, hàm truyền vào unstable_cache sẽ throw hay return? Nếu nó return, giá trị đó chỉ cách "nội dung vĩnh viễn" đúng một request xấu.

Đây là cái "mặc định sai trong im lặng" thứ ba tôi gặp trong cùng một tầng dữ liệu — bên cạnh chuyện Next.js không cache fetch tới CMS như bạn tưởng. Pattern chung của cả đám: không gì báo lỗi, platform làm đúng như tài liệu, và thiệt hại chỉ lộ ra dưới pattern traffic production mà local không có.

FAQ

Vì sao trang Next.js hồi lại sau khi re-save trong CMS?

Vì cú re-save bắn revalidateTag trên tag của item đó, xả cache entry nhiễm độc, và request kế tiếp chạy lại query thành công. Cú save trong CMS không sửa nội dung — nó vô tình xóa một cache đang lưu cái null sinh ra từ một lỗi DB thoáng qua.

Có khi nào được phép return null trong hàm bọc unstable_cache không?

Có — khi nullcâu trả lời thật, ví dụ row đích thực không tồn tại và bạn muốn cache luôn cái 404 đó. Quy tắc nằm ở đường lỗi: "not found" thật thì được return null; còn lỗi (timeout, connection refused) phải throw để không gì bị cache. Đừng để một cú catch trộn lẫn hai thứ đó.

Đổi revalidate: false thành revalidate: 3600 có sửa được không?

Chỉ khoanh vùng được. Entry nhiễm độc sẽ lành ở cửa sổ revalidate kế tiếp, nên trang trống kéo dài tối đa một giờ thay vì mãi mãi. Đó là giảm thiệt hại, không phải sửa — bạn vẫn serve trang trắng tới một giờ chỉ vì một cú chớp một giây. Catch ngoài wrapper cho bạn cả hai: cache sống lâu lỗi tự lành.

Chuyện này có áp dụng cho fetch caching và "use cache" không?

Nguyên tắc thì tổng quát: mọi cơ chế cache lưu giá trị trả về đều sẽ đóng băng bất cứ thứ gì code bạn trả về trong lúc failure, nên giữ việc nuốt lỗi ở ngoài đơn vị được cache, bất kể API nào. Ngữ nghĩa lỗi cụ thể của từng API thì đối chiếu tài liệu caching của Next.js thay vì đoán — nhưng "bên trong throw khi lỗi, bên ngoài mới fallback" là hình dạng an toàn ở mọi nơi.


Một dòng mang về: với revalidate: false, bất cứ thứ gì hàm được cache trả về trong lúc lỗi sẽ thành nội dung vĩnh viễn — nên cứ để nó throw, và catch ở ngoài wrapper.

Tôi xây và debug tầng dữ liệu CMS như thế này từ đầu tới cuối — caching, Postgres, và đám ống nước resilience — trong các dự án làm site full-stack.

Bài liên quan