Next.js Tag-based Revalidation
Next.js 用 cacheTag 为缓存结果建立业务失效身份,并通过 revalidateTag、updateTag 或 revalidatePath 在数据变化后选择后台刷新、立即过期或路径级失效。
[!info] related notes
- 所属 MOC:
- 前置概念: nextjs-cache-components
- 并列概念: nextjs-cache-life
- 易混淆概念:
- 关系笔记: nextjs-cache-vs-tanstack-query、tanstack-query-cache-invalidation-patterns、cache-strategies-and-consistency
Next.js Tag-based Revalidation
一句话定义
Tag-based Revalidation 把“这份缓存代表什么业务数据”显式编码为 tag,然后在业务事实变化时按 tag 重新验证,而不是只依赖时间到期或猜哪些页面碰巧使用了这份数据。
cached result
↓
cacheTag("products")
↓
业务数据变化
↓
revalidateTag / updateTag
cacheTag() 只负责标记身份
import { cacheTag } from "next/cache"
export async function getProducts() {
"use cache"
cacheTag("products")
return productRepository.list()
}
这里:
cacheTag("products")
不会立刻把缓存标记为 stale,也不会主动刷新。
它做的是:
给当前缓存结果增加一个可用于未来 invalidation 的业务标签。
真正发生失效的是后续的 revalidateTag() 或 updateTag()。
这是一个非常重要的语义区别。
为什么 Tag 比 Path 更接近业务身份
假设 Product Catalog 同时出现在:
/home
/products
/search
/category/health
如果围绕页面路径失效:
revalidatePath("/home")
revalidatePath("/products")
revalidatePath("/search")
revalidatePath("/category/health")
会把缓存一致性绑定到 UI 信息架构。
更稳的方式是:
cacheTag("products")
更新后:
revalidateTag("products", "max")
表达的是:
产品数据变了,所有依赖这类缓存数据的结果都需要重新验证。
因此:
Path
→ UI location identity
Tag
→ cached data invalidation identity
revalidateTag():允许短暂 stale
当前推荐用法:
revalidateTag("products", "max")
核心语义:
matching cache entries
↓
旧结果不再被长期信任
↓
仍可以先服务 stale result
↓
后台生成 fresh result
↓
更新 cache
也就是 stale-while-revalidate。
适合:
- Blog / CMS 内容
- Product Catalog
- 公开文档
- 更新后允许短暂延迟的聚合结果
例如 Digital Biome 文章:
文章更新
→ revalidateTag("articles", "max")
→ 用户可能短暂看到旧文章列表
→ 后台刷新
如果几十秒旧内容可接受,这是合理的性能/一致性取舍。
updateTag():立即过期
如果用户刚完成写入并要求下一次读取立刻看到新值:
updateTag("goals")
语义是:
matching cache
↓
immediately expired
↓
下一次读取必须拿 fresh result
典型场景:
User creates Goal
↓
DB / owning service write succeeds
↓
updateTag("goals")
↓
redirect /goals
↓
用户马上看到刚创建的 Goal
这满足:
Read-your-own-writes
即“我刚写进去的东西,我下一次读就应该看到”。
当前 Next.js 中,updateTag() 只能在 Server Actions 中使用。
revalidateTag vs updateTag
允许短暂看到旧数据?
│
├─ Yes
│ → revalidateTag(tag, "max")
│ → stale-while-revalidate
│
└─ No
→ 用户刚写入后必须看到 fresh
→ updateTag(tag)
| 维度 | revalidateTag | updateTag |
|---|---|---|
| 核心语义 | 后台重新验证 | 立即 expire |
| 能否短暂返回 stale | 可以 | 不应继续依赖旧缓存 |
| 典型场景 | CMS、Catalog、公开内容 | read-your-own-writes |
| 使用位置 | Server Action / Route Handler | Server Action |
updateTag() 不是乐观更新
这是最容易和 TanStack Query 混淆的地方。
updateTag
典型时间线:
Mutation 已经成功
↓
Server truth 已经改变
↓
updateTag()
↓
旧 server cache 立即失效
↓
下一次 read 重新拿 fresh truth
它做的是:
confirmed write 之后的 server-cache invalidation。
Optimistic Update
乐观更新则是:
Mutation 还没确认成功
↓
UI 先假设会成功
↓
显示预测结果
↓
Server response
├─ success → reconcile
└─ failure → rollback
因此:
updateTag
≠ optimistic update
如果需要真正的乐观 UI,那属于 React/TanStack Query 的客户端交互策略,而不是 updateTag 本身。
revalidatePath():路径级失效
revalidatePath("/goals")
用于:
我明确知道需要重新验证某个 Route,但没有更合适的业务 tag。
它的粒度通常比 tag 更粗。
revalidatePath("/dashboard")
→ 围绕 Route 失效
revalidateTag("goal:123", "max")
→ 围绕数据身份失效
在能清楚定义 tag 的情况下,优先使用 tag-based invalidation 通常更精确,也更不容易 over-invalidate。
Tag 粒度怎么设计
可以有:
products
product:123
user:456
user:456:goals
goal:789
原则和 TanStack Query query key 很像:
As broad as necessary, as narrow as justified.
太窄:
只失效 goal detail
但 goal list / dashboard summary 也受影响
会留下 stale projection。
太宽:
修改一个 Goal title
→ updateTag("everything")
会制造无意义的重新计算。
Mutation 后应由谁触发失效
理想链路:
User Mutation
↓
Owning Service / DB commit
↓
确定事实已经改变
↓
Next.js invalidation
↓
Next server projection 收敛到新 truth
关键是不应该:
先 invalidate
→ mutation 最后失败
把缓存失效误当成业务事务本身。
缓存只能响应 truth 的变化,不能替代 truth 的写入。
Event-driven Invalidation
对于 CMS / Catalog 这种长时间稳定数据,可以:
long cacheLife
+
业务变化事件
+
revalidateTag
例如:
CMS publish
→ webhook
→ Route Handler
→ revalidateTag("articles", "max")
这比:
每分钟定时重新生成
更贴近真实变化,也能减少无效计算。
BodySense 边界
Active Agent Run:
高频 status / seq / events
→ 不应依赖长 server cache + tag invalidation 来模拟实时性
→ fresh snapshot + SSE
Assessment / User 数据:
如果真的引入 server cache
→ identity 必须包含用户/资源边界
→ mutation 后 invalidation 必须与 Go owning service 的成功写入对齐
缓存策略不能绕过 数据所有权与服务边界。
最短记忆
cacheTag
= 给缓存一个业务失效身份
revalidateTag
= 可以先旧后新
updateTag
= 立即 expire,支持 read-your-own-writes
revalidatePath
= 按 Route 粗粒度失效
updateTag
≠ optimistic update