next 精选推荐

缓存策略深度解析

HTMLPAGE 团队
22 分钟阅读

从 HTTP/CDN 到 Next.js App Router 的缓存语义,系统拆解缓存的分层模型、失效策略与常见踩坑,给出可直接落地的工程化方案与检查清单。

#缓存 #CDN #Next.js #App Router #revalidateTag #Cache-Control

缓存策略深度解析

缓存不是“开关”,而是一套分层一致性工程

做得好,缓存能同时提升:

  • TTFB / LCP(更快)
  • 稳定性(抗突发流量、抗抖动)
  • 成本(更少的源站请求)

做得不好,缓存会制造更可怕的问题:

  • 用户看到过期数据,且你无法复现
  • A 用户的内容被 B 用户读到(严重安全事故)
  • 缓存命中率很低,反而更慢、更贵

这篇文章的目标是:让你用一套统一的心智模型,设计并落地“可控、可观测、可回滚”的缓存系统。并以 Next.js App Router 为例,把框架缓存语义纳入整套设计。


1. 先建立缓存的“分层模型”

真实系统的缓存几乎总是分层的:

  1. 浏览器缓存(Browser Cache)
  2. CDN/边缘缓存(Edge Cache)
  3. 应用侧缓存(App Cache:SSR/RSC payload、fetch cache)
  4. 数据侧缓存(Redis/DB buffer)

你需要明确:

  • 每一层缓存什么?(HTML / JSON / 静态资源 / RSC payload)
  • 每一层的 TTL 多久?
  • 写操作发生后,哪几层需要失效?

1.1 缓存的两类问题:性能与一致性

  • 性能问题:命中率、压缩、复用、减少源站
  • 一致性问题:失效、隔离、权限、版本

很多团队“缓存做不好”,本质是只解决了性能,忽略了一致性。


2. HTTP 缓存基础:Cache-Control 的正确用法

HTTP 缓存是地基。你至少要掌握这些指令:

  • public / private
  • max-age(强缓存 TTL)
  • s-maxage(CDN 侧 TTL)
  • stale-while-revalidate(过期可先用旧的,再后台更新)
  • no-store(不要存储)

2.1 一个常用的 CDN 缓存头模板

对“公共页面(无用户态)”:

Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400

含义:

  • 浏览器不强缓存(max-age=0)
  • CDN 缓存 10 分钟
  • CDN 过期后仍可先用旧内容 1 天,同时异步更新

2.2 用户态页面:默认 private 或 no-store

一旦响应与用户身份有关:

  • 默认:Cache-Control: private, no-store
  • 必须缓存时:缓存 key 必须包含用户维度(例如 userId/session)且只在应用侧缓存

结论:宁可慢一点,也不要把用户态响应交给 CDN 缓存。


3. CDN 缓存:命中率与 Key 设计

CDN 缓存的核心是:

  • 你希望命中(hit),就要让相同内容的请求尽可能“看起来一样”
  • 你希望隔离(safe),就要让不同内容的请求“必须不一样”

这两个目标天然冲突。

3.1 Cache Key 里哪些维度必须考虑?

最常见维度:

  • host
  • path
  • query(是否纳入 key?)
  • accept-encoding(gzip/br)
  • accept-language(多语言站点)
  • device(移动/桌面,如果你做了 UA vary)

反例:把“跟内容无关的 query”放进 key(例如 utm_*),会把命中率直接打碎。

3.2 解决 UTM 污染:规范化 URL

建议:

  • 进入应用时把 utm_* 归一化并 301 到干净 URL
  • 或在 CDN 层将 utm_* 从 cache key 里剔除

4. 应用侧缓存:Next.js App Router 的缓存语义

App Router 的“缓存”不是一个地方,而是一套语义:

  • fetch 结果缓存
  • RSC payload 缓存
  • route segment 缓存
  • revalidate 机制(path/tag)

4.1 你必须区分:静态、动态、按需再验证

在 App Router 体系里,一个页面/段可以是:

  • 静态(长 TTL)
  • 动态(每次都算)
  • ISR/按需再验证(写操作后失效)

不要把“页面慢”简单归因给 SSR;很多时候是缓存策略没设计好。

4.2 两个最重要的失效 API:revalidatePath / revalidateTag

  • revalidatePath('/xxx'):按路由失效(粗粒度)
  • revalidateTag('tag'):按数据域失效(细粒度)

经验法则:

  • 数据只影响单页:用 path
  • 数据影响多页(列表、详情、侧栏统计):用 tag

5. 写操作后的失效策略:从“拍脑袋”到“可证明”

写操作发生后,你要回答:

  • 哪些页面会受影响?
  • 哪些数据域会受影响?
  • 这些受影响对象是否有统一 tag?

5.1 建议采用“域驱动 tag”

例子:电商商品

  • product:{id}
  • product:list
  • inventory:{id}

当你更新库存时:

  • 失效 inventory:{id}
  • 可能也失效 product:{id}(如果详情页展示库存)

这样你可以把失效做成一套规则,而不是散落在业务代码里。

5.2 不要用“全站失效”当默认

revalidatePath('/')、清 CDN 全站缓存,短期看省事,长期一定出问题:

  • 命中率暴跌
  • 源站被打穿
  • 回归定位困难

6. 缓存与权限:最危险的组合

6.1 三条硬规则

  • 用户态响应不要进 CDN
  • 缓存 key 必须包含权限维度(userId/role)
  • SSR 页面要明确区分“公共数据”与“私有数据”

6.2 常见事故模式

  • 详情页同时展示公共内容 + 用户私有信息(例如是否收藏)
  • 你把整页缓存了
  • 结果:用户私有信息被缓存并泄漏

正确做法:

  • 公共内容可缓存
  • 私有信息走客户端请求或单独的私有 API(不缓存)

7. 可观测性:让缓存变成“可量化系统”

你至少需要这些指标:

  • CDN 命中率(hit/miss)
  • 源站 QPS
  • TTFB 分布(p50/p95)
  • revalidate 调用量与耗时
  • 失效失败率

日志建议:

  • 给每个请求注入 cacheStatus(HIT/MISS/BYPASS)
  • 记录 cacheKey 的关键维度(注意脱敏)

8. 一份可直接执行的缓存策略模板

8.1 公共内容页(文章、落地页)

  • CDN:s-maxage=600, stale-while-revalidate=86400
  • App:允许 fetch cache,tag 以 content:* 组织
  • 写操作:发布文章时 revalidateTag('content:article')

8.2 列表 + 详情(强一致性要求不高)

  • 详情:tag entity:{id}
  • 列表:tag entity:list
  • 更新:失效 entity:{id} + entity:list

8.3 强一致性(库存、余额)

  • 默认不缓存
  • 或只做极短 TTL(1~3s)
  • 必须有幂等与容错

9. 检查清单(上线前必过)

  • 哪些路由是 public/private?是否明确
  • CDN cache key 是否会被无关 query 污染
  • 用户态响应是否有 no-store
  • 写操作是否有明确的 tag/path 失效策略
  • 是否有命中率与回源监控
  • 回滚策略是什么(关缓存、降级)

总结

缓存策略的本质是:

  • 把“快”变成可控的工程系统
  • 把“一致性”变成可证明的规则
  • 把“失效”变成可观测、可回滚的流程