Vue 基础体系 · 第 69/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue SSR 缓存:页面、数据、边缘缓存、个性化和失效
Vue SSR(Server-Side Rendering,服务端渲染)会在服务器上执行 Vue 组件,生成初始 HTML,再由浏览器加载 JavaScript 完成 hydration(激活)。缓存可以减少数据库查询、接口请求和 SSR 渲染次数,但缓存的对象并不只有一种:
- 页面缓存缓存一次完整的 SSR HTML 响应;
- 数据缓存缓存页面渲染过程中读取的数据;
- 边缘缓存把页面或数据放到 CDN、边缘节点等距离用户更近的位置;
- 个性化决定响应是否依赖用户、租户、地区、语言等请求上下文;
- 失效决定旧内容何时不能继续被使用,以及内容更新后如何让各层缓存停止返回旧值。
如果没有先区分这些层次,很容易出现“页面已经更新但用户仍看到旧内容”“用户 A 看到用户 B 的页面”“SSR HTML 与客户端 hydration 不一致”等问题。
一、先建立 SSR 缓存的对象模型
一次 SSR 请求通常经过以下路径:
sequenceDiagram
participant B as 浏览器
participant E as CDN/边缘节点
participant S as SSR 应用
participant D as 数据源
B->>E: GET /products/42
alt 边缘缓存命中
E-->>B: 返回已缓存 HTML
else 边缘缓存未命中
E->>S: 转发请求
S->>S: 查页面缓存
alt 页面缓存命中
S-->>E: 返回 HTML
else 页面缓存未命中
S->>D: 读取数据
D-->>S: 商品数据
S->>S: 渲染 Vue SSR HTML
S->>S: 写入页面缓存
S-->>E: 返回 HTML
end
E->>E: 写入边缘缓存
E-->>B: 返回 HTML
end
B->>B: 下载 JS 并 hydration
这里至少存在四种不同的状态:
- 请求状态:URL、Cookie、Authorization、语言、地区、设备等;
- 数据状态:数据库或后端 API 当前返回的内容;
- HTML 表示:Vue SSR 根据数据生成的字符串;
- 客户端状态:hydration 后,浏览器中的响应式状态和后续请求结果。
缓存的基本问题可以形式化为:
其中:
- 是最终响应,例如 HTML、JSON 和响应头;
- 是缓存键,通常来自 URL 以及部分请求头、Cookie 或用户身份;
- 是渲染所读取的数据和配置;
- 是“读取数据并渲染”的过程。
只有当两个请求在缓存键 相同的情况下,确实允许共享同一个响应,缓存才是正确的。更严格地说,如果请求 和 被映射到同一个缓存键:
那么必须满足:
或者至少满足:返回相同响应不会违反安全、隐私和业务语义。
例如,商品详情页通常可以按 URL 共享:
/products/42
但下面这些页面通常不能仅按 URL 共享:
/account
/orders
/dashboard
因为它们的内容还依赖当前用户身份。
二、页面缓存:缓存完整的 SSR HTML
2.1 页面缓存缓存的到底是什么
页面缓存缓存的是一次 SSR 请求生成的 HTTP 响应,通常包括:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=0, s-maxage=60
ETag: "product-42-v18"
<!doctype html>
<html>
...
</html>
它不是 Vue 组件实例,也不是服务端的响应式对象。SSR 渲染完成后,Vue 组件实例通常就不再存在;缓存保存的是字符串化后的 HTML 以及相关响应元数据。
页面缓存可以发生在多个位置:
- SSR 进程内存;
- Redis、KeyDB 等外部缓存;
- 反向代理;
- CDN 边缘节点;
- 浏览器 HTTP 缓存。
这些缓存的生命周期、共享范围和失效方式不同,不能把它们统称为“SSR 缓存”。
2.2 页面缓存的正确条件
对一个页面设置共享缓存,至少需要检查四个条件。
条件一:内容是否具有公共性
公共商品页、公开文章页、公开文档页通常可以共享。用户订单、账户余额、草稿内容则通常不可以。
条件二:缓存键是否覆盖所有影响因素
假设页面根据语言返回不同内容:
/products/42
如果语言通过 Accept-Language 传入,则缓存必须考虑这个请求头:
Vary: Accept-Language
或者将语言显式放入 URL:
/en/products/42
/zh-CN/products/42
后者通常更容易观察、预热和失效。
如果内容根据地区、主题、设备类型变化,也必须把这些维度纳入缓存键,或者放弃共享页面缓存。缓存键遗漏一个影响内容的变量,就可能发生错误复用。
条件三:响应是否包含禁止共享的数据
以下内容是危险信号:
- 用户姓名、头像、订单、余额;
- 根据 Cookie 判断的登录状态;
Authorization对应的用户权限;- 服务端生成的用户专属 CSRF Token;
Set-Cookie;- 仅对某个租户可见的数据。
在这些情况下,通常应使用:
Cache-Control: private, no-store
其中:
private表示响应只允许被私有缓存使用,不应被共享缓存存储;no-store表示缓存不应存储该响应。
对于包含强隐私数据的页面,no-store 比单纯依赖 CDN 的默认行为更明确。
条件四:HTML 与客户端 hydration 是否对应
SSR 页面返回的 HTML 不是静态截图。客户端 hydration 会假设:
- HTML 中的结构来自同一份初始状态;
- 客户端首次渲染结果与服务端结果一致;
- 序列化到页面中的数据与 HTML 内容相匹配。
如果页面缓存返回了旧 HTML,但客户端启动后立即从数据接口读取新数据,就可能出现:
服务端 HTML:库存 3 件
客户端首次请求:库存 0 件
这不一定必然导致 hydration 报错,但会导致页面闪烁、内容替换,或者因为结构不同而出现 hydration mismatch。
因此,页面缓存和数据缓存必须考虑“同一快照”问题,而不是分别设置 TTL 后认为它们天然一致。
三、HTTP 缓存语义:浏览器缓存与共享缓存
页面是否会被缓存,不能只看应用代码,还取决于 HTTP 响应头和 CDN 的实现。
一个常见的公共 SSR 页面响应头是:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30
ETag: "product-42-v18"
Vary: Accept-Encoding
这些指令的含义不同:
public:允许共享缓存存储;max-age=0:浏览器端不直接把它视为一段时间内的新鲜内容,通常会重新验证;s-maxage=60:共享缓存,例如 CDN,可在 60 秒内直接使用;stale-while-revalidate=30:过期后,在最多 30 秒窗口内可以先返回旧响应,同时后台重新验证;ETag:响应的版本标识,客户端或代理可以用它进行条件请求;Vary: Accept-Encoding:压缩算法不同的响应不能混用。
max-age 和 s-maxage 经常被混淆。它们并不是“两个缓存时间都生效”,而是分别面向私有缓存和共享缓存。实际效果还可能受到 CDN 的平台规则覆盖。
3.1 ETag 的条件请求
当缓存对象过期时,客户端可以发送:
GET /products/42
If-None-Match: "product-42-v18"
如果资源没有变化,服务器返回:
HTTP/1.1 304 Not Modified
ETag: "product-42-v18"
304 没有新的响应体,客户端继续使用原来的 HTML。这节省的是传输体积,但不一定节省 SSR 渲染。如果 ETag 由应用计算,应用仍可能需要读取数据才能判断版本是否变化。
更高效的方式是把数据版本或内容版本保存下来:
product:42:version = 18
请求只需比较版本,命中时可以快速返回 304 或缓存结果。
3.2 Vary 不是任意缓存键声明
Vary 是 HTTP 响应头,用于声明响应是否随某些请求头变化,例如:
Vary: Accept-Encoding, Accept-Language
它不能直接表达“响应随某个 Cookie 中的 userId 变化”。如果把:
Vary: Cookie
用于大量用户,可能导致几乎每个 Cookie 组合都形成一个缓存版本,命中率极低;更严重的是,如果应用实际上只关心 Cookie 的一小部分,整个 Cookie 作为维度会制造大量无意义的键。
对于用户身份,通常更明确的做法是:
- 登录页面设置
private, no-store; - 公共页面不在 SSR HTML 中渲染用户信息;
- 用户信息由客户端在 hydration 后通过私有接口读取;
- 或者按经过验证的租户、语言等有限维度建立明确缓存键。
四、数据缓存:缓存 SSR 过程中的读取结果
页面缓存解决的是“整个 HTML 是否重复渲染”。数据缓存解决的是“渲染时读取的数据是否重复获取”。
例如一个商品页可能需要:
商品基本信息
库存
推荐商品
评论摘要
即使页面本身不能共享,某些数据仍然可以缓存。反过来,即使页面可以共享,数据层也可能需要短 TTL 或严格失效。
4.1 数据缓存的缓存键
数据缓存的键必须包含所有影响数据结果的维度:
product:42
product:42:locale:zh-CN
inventory:42:region:cn-east
recommendations:user:918
错误示例:
recommendations
如果推荐结果依赖用户,却使用全局键,那么用户 A 的推荐结果可能返回给用户 B。
一个数据缓存函数可以抽象成:
type CacheEntry<T> = {
value: T
expiresAt: number
version: number
}
const cache = new Map<string, CacheEntry<unknown>>()
async function getCached<T>(
key: string,
ttlMs: number,
loader: () => Promise<T>
): Promise<T> {
const now = Date.now()
const old = cache.get(key) as CacheEntry<T> | undefined
if (old && old.expiresAt > now) {
return old.value
}
const value = await loader()
cache.set(key, {
value,
expiresAt: now + ttlMs,
version: (old?.version ?? 0) + 1
})
return value
}
使用时:
const product = await getCached(
`product:${productId}`,
60_000,
() => productRepository.findById(productId)
)
这个示例适用于说明机制,但生产环境中还要处理:
- 多个 SSR 进程之间的共享;
- 进程重启导致的缓存丢失;
- 读取失败是否覆盖旧值;
- 并发请求是否重复加载;
- 序列化和内存上限;
- 数据更新后的主动失效。
4.2 数据缓存不能自动保证页面一致
假设页面缓存 TTL 是 60 秒,商品数据缓存 TTL 是 300 秒:
t=0 商品数据版本 A,生成页面 A
t=60 页面缓存过期,重新 SSR
t=60 数据缓存仍命中版本 A,生成页面 A
t=300 数据缓存才更新到版本 B
因此,页面虽然重新渲染了,内容仍然是旧的。页面 TTL 只表示“多久重新尝试生成 HTML”,不表示“底层数据一定是新的”。
反过来,如果数据缓存 TTL 是 10 秒,页面缓存 TTL 是 300 秒,那么数据已经更新,用户仍可能从页面缓存得到 300 秒内的旧 HTML。
缓存层之间的时间关系不能替代业务失效关系。
五、SSR 数据获取与 hydration:避免重复请求和状态错位
以 Nuxt 3/4 中常见的 useAsyncData 为例:
<script setup lang="ts">
type Product = {
id: string
name: string
price: number
}
const route = useRoute()
const {
data: product,
error,
status
} = await useAsyncData<Product>(
`product:${route.params.id}`,
() => $fetch(`/api/products/${route.params.id}`)
)
if (error.value) {
throw createError({
statusCode: 404,
statusMessage: 'Product not found'
})
}
</script>
<template>
<main v-if="status === 'success' && product">
<h1>{{ product.name }}</h1>
<p>{{ product.price }}</p>
</main>
</template>
在 Nuxt 的常见实现中,服务端执行 useAsyncData 后,结果会参与 SSR,并序列化到 Nuxt payload;客户端 hydration 可以复用这份数据,避免同一个页面在启动时无条件重复请求。
这里要区分三件事:
useAsyncData的键用于 Nuxt 数据复用和去重;$fetch请求的 HTTP 缓存由服务端、浏览器或 CDN 的响应头决定;useAsyncData本身不等于跨请求、跨用户的公共缓存。
product:${id} 如果只包含公共商品 ID,可以作为公共数据键。如果数据还依赖语言或区域,应明确加入:
const locale = useState('locale')
const { data } = await useAsyncData(
`product:${route.params.id}:${locale.value}`,
() => $fetch(`/api/products/${route.params.id}`, {
headers: {
'Accept-Language': locale.value
}
})
)
如果键写错,可能出现服务端数据复用错误;如果服务端数据被缓存为公共数据但实际包含用户权限字段,则会造成更严重的数据泄露。
5.1 纯 Vue SSR 中的状态隔离
使用 Vue SSR 时,服务器进程会处理多个请求。不能把请求相关状态放在模块级单例中:
// 错误示例
export const currentUser = reactive({
id: '',
name: ''
})
如果请求 A 修改了 currentUser,请求 B 可能读取到 A 的状态。正确做法是每个请求创建新的应用实例和状态容器:
import { createSSRApp } from 'vue'
import { createStore } from './store'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const store = createStore()
app.provide('store', store)
return { app, store }
}
在 SSR 入口中,每次请求都调用 createApp()。这是 SSR 正确性的前提,也直接影响缓存安全:即使页面没有启用公共缓存,状态污染也可能让一个请求直接看到另一个请求的数据。
六、页面缓存与数据缓存的组合方式
常见组合有三种。
6.1 只缓存页面
请求 → 页面缓存命中 → 返回 HTML
请求 → 页面缓存未命中 → SSR 读取实时数据 → 返回 HTML
适合内容不频繁变化的公开页面。优点是命中后不需要执行 Vue SSR;缺点是整个页面的最小更新粒度是页面。
6.2 只缓存数据
请求 → 每次执行 SSR → 数据缓存命中 → 生成新 HTML
适合页面含有不能共享的部分,但底层公共数据查询昂贵的情况。例如页面包含用户私有区域,同时包含公共商品目录。
6.3 页面和数据都缓存
请求 → 页面缓存命中 → 不访问数据缓存
请求 → 页面缓存未命中 → 数据缓存命中 → SSR
请求 → 两者都未命中 → 读取数据库 → SSR → 写入两层缓存
这种组合吞吐量最高,但一致性也最复杂。必须明确哪一层负责什么:
- 页面缓存减少 SSR 计算;
- 数据缓存减少数据源压力;
- 主动失效要同时处理相关页面和数据;
- 页面缓存中的旧 HTML 可能继续遮蔽已经更新的数据缓存。
七、边缘缓存:把页面复制到用户附近
7.1 什么是边缘缓存
边缘缓存通常由 CDN 或边缘平台提供。用户请求先到距离较近的边缘节点:
- 命中时,边缘节点直接返回 HTML 或 JSON;
- 未命中时,请求回源到 SSR 服务;
- 回源响应可以被边缘节点保存一段时间。
它和应用进程内缓存的关键区别是:
| 特性 | 应用内缓存 | 边缘缓存 |
|---|---|---|
| 位置 | SSR 进程所在机器 | 多个边缘节点 |
| 共享范围 | 通常是单进程或单实例 | 多地域、多用户 |
| 失效速度 | 应用可直接控制 | 受 CDN API 和节点传播影响 |
| 主要收益 | 减少数据读取或渲染 | 减少回源和网络延迟 |
| 主要风险 | 多实例不一致 | 错误共享、失效传播延迟 |
边缘缓存通常依赖 HTTP Cache-Control、ETag、Vary 等标准语义;具体的预取、标签清除、后台刷新和缓存键配置,则属于 CDN 或部署平台能力,不能假定所有平台都相同。
7.2 Nuxt 中的边缘缓存配置
Nuxt 使用 Nitro 作为服务端运行时。现代 Nuxt/Nitro 版本通常可以通过 routeRules 为路由配置缓存策略:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/docs/**': {
swr: 300
},
'/api/public/**': {
swr: 60
},
'/account/**': {
ssr: true
}
}
})
这里的 swr 是 Nuxt/Nitro 的路由缓存能力,具体存储后端、部署适配器和失效行为与版本、平台有关。它不是浏览器原生的 Cache-Control 配置,也不能据此推断所有 CDN 节点都会完全按照相同方式工作。
对于严格需要 HTTP 语义可观察性的场景,可以在服务端显式设置响应头:
// server/routes/docs/[slug].get.ts
export default defineEventHandler((event) => {
setHeader(
event,
'Cache-Control',
'public, max-age=0, s-maxage=300, stale-while-revalidate=60'
)
return {
ok: true
}
})
如果同一个路由同时配置了框架缓存和响应头,应确认最终部署平台的实际行为,避免应用层认为 TTL 是 60 秒,而 CDN 因平台规则使用了另一套 TTL。
八、个性化:为什么登录状态会改变缓存策略
“个性化”表示响应内容依赖请求者或请求上下文,而不是仅依赖公共 URL。
常见个性化维度包括:
- 用户 ID;
- 租户 ID;
- 登录状态;
- 权限角色;
- 地区;
- 语言;
- 实验分组;
- 设备类型;
- Cookie 中的偏好。
这些维度的风险不完全相同。
8.1 用户级个性化
如果 HTML 中包含:
<p>你好,张三</p>
那么按 /home 做公共页面缓存是不安全的。第一次生成该页面的用户可能是张三,之后所有用户都可能收到这段 HTML。
正确方案之一是拆分:
公共 SSR HTML:不包含用户姓名
浏览器 hydration:调用 /api/me
用户区域:客户端渲染
/api/me 应使用:
Cache-Control: private, no-store
并在服务端通过 Cookie 或 Authorization 认证。
另一种方案是完全不缓存该页面:
Cache-Control: private, no-store
这会保留 SSR 的首屏和 SEO 优势,但每次请求都需要回源和执行 SSR。
8.2 有限维度个性化
语言、地区和实验分组有时可以共享,但必须限制维度数量。例如:
/en-US/docs/cache
/zh-CN/docs/cache
比按任意 Cookie 直接分裂缓存更容易控制。
假设一个页面有三个维度:
- 语言 4 种;
- 地区 6 种;
- 实验分组 3 种。
理论上最多需要:
个页面版本。若再把用户 ID 加入键,则变成:
其中 是用户数,缓存几乎失去共享意义,并产生更高的泄露风险。
8.3 “页面公共、局部个性化”的边界
可以将页面分成:
公共部分:SSR + CDN 缓存
私有部分:客户端请求 + private/no-store
但必须处理加载期间的状态:
- HTML 中显示登录按钮,客户端读取用户后变成头像;
- 或 HTML 中渲染一个明确的占位骨架;
- 客户端只替换局部节点,不改变服务端初始结构。
如果服务端根据 Cookie 渲染“已登录菜单”,而客户端初始状态却认为“未登录”,就会出现 hydration 不一致。问题不在缓存本身,而在服务端和客户端使用了不同的初始状态来源。
九、失效:TTL 不是唯一的更新机制
缓存失效(invalidation)是让旧缓存停止被使用的过程。常见方法有四类。
9.1 TTL 失效
TTL(Time To Live)表示缓存条目最多可以保持新鲜的时间。
例如页面 TTL 为 60 秒:
t=0 写入版本 A
t=59 仍可直接返回 A
t=60 A 过期,需要重新验证或回源
TTL 的特点是简单,但它不保证发布后立即生效。内容在更新时间后,最多仍可能被缓存 60 秒;如果还经过浏览器缓存、多个 CDN 层或 stale-while-revalidate,观察到的旧内容时间可能更长。
可以用近似上界表示:
这不是所有平台的严格保证,因为不同缓存可能并行工作,且 CDN 可能有额外规则;它表达的是排查旧内容时必须考虑多个层次。
9.2 发布新 URL 或版本键
静态资源通常使用内容哈希:
app.91f3a2.js
app.7bc812.js
内容变化就生成新 URL,因此不需要删除旧文件。对于 SSR 页面,也可以把内容版本放入内部数据键:
page:docs:cache:version:18:/docs/cache
但 HTML URL 通常不能随意改变,否则会影响 SEO、链接和分享。因此页面一般使用 TTL 或主动清除,数据和静态资源则更适合版本化。
9.3 主动清除
内容发布或商品更新后,可以执行:
更新商品 42
→ 删除 data:product:42
→ 删除 page:/products/42
→ 请求 CDN 清除 /products/42
CDN 的“按标签清除”(purge by tag/surrogate key)通常是平台能力,不是所有 CDN 都提供,也不是 HTTP 标准。可以在应用层维护依赖关系:
product:42
├── /products/42
├── /search?q=...
└── /category/phones
如果只清除商品接口而没有清除页面缓存,页面仍可能继续显示旧商品信息。
9.4 SWR 失效
SWR(stale-while-revalidate)允许缓存过期后先返回旧值,再后台更新:
请求 1:返回旧版本 A,后台开始生成 B
请求 2:可能仍返回 A,也可能命中已更新的 B
它降低了回源延迟和缓存击穿风险,但增加了短时间内的版本不确定性。它不适合要求“发布完成后所有用户立即看到新版本”的页面,例如:
- 账户余额;
- 库存扣减结果;
- 管理后台权限;
- 已撤下的敏感内容。
十、缓存击穿、并发和故障路径
10.1 缓存击穿
当某个热门缓存同时过期,大量请求可能一起访问数据库:
1000 个请求同时发现 product:42 过期
→ 1000 次数据库查询
→ 1000 次 SSR 渲染
这称为缓存击穿或请求风暴。
常见解决方法是 single flight(单航班)或请求合并:同一缓存键在同一时间只允许一个加载任务执行,其他请求等待该任务。
const pending = new Map<string, Promise<unknown>>()
async function getWithSingleFlight<T>(
key: string,
loader: () => Promise<T>
): Promise<T> {
const running = pending.get(key) as Promise<T> | undefined
if (running) {
return running
}
const task = loader().finally(() => {
pending.delete(key)
})
pending.set(key, task)
return task
}
这个示例只合并并发任务,没有保存最终结果,因此需要和实际缓存结合:
async function getData<T>(
key: string,
load: () => Promise<T>
): Promise<T> {
const hit = cache.get(key) as CacheEntry<T> | undefined
if (hit && hit.expiresAt > Date.now()) {
return hit.value
}
return getWithSingleFlight(key, async () => {
const secondCheck = cache.get(key) as CacheEntry<T> | undefined
if (secondCheck && secondCheck.expiresAt > Date.now()) {
return secondCheck.value
}
const value = await load()
cache.set(key, {
value,
expiresAt: Date.now() + 60_000,
version: (secondCheck?.version ?? 0) + 1
})
return value
})
}
“二次检查”是必要的:等待期间,另一个请求可能已经填充了缓存。
10.2 缓存源故障
缓存系统不可用时,不能简单地让所有请求失败。通常需要区分数据类型:
- 公共文档:可以短时间返回旧缓存;
- 商品价格:可能必须回源读取;
- 用户余额:不能使用旧值;
- 推荐内容:可以降级为空列表。
对于缓存读取失败,可以设置有限的 fallback;但不能把“缓存异常”误判成“缓存未命中”后无限重试,否则会把故障放大到数据库。
10.3 回源失败与 stale-if-error
HTTP 的 stale-if-error 可以表达“回源失败时允许继续使用旧响应”,例如:
Cache-Control: public, s-maxage=60, stale-if-error=300
但是否支持、旧响应能保留多久以及什么错误算作 error,取决于缓存实现。它适合公开、可接受短时间旧内容的页面,不适合安全和交易状态。
十一、一个完整的缓存策略算例
假设有一个商品详情页:
URL:/products/42
公共数据:商品名称、描述、公开价格
短时数据:库存
私有数据:用户是否收藏
可以设计成:
页面 HTML:
只包含商品名称、描述、公开价格
CDN s-maxage=60
商品公共数据:
数据缓存 TTL=60
商品更新时主动删除
库存:
不写入公共页面,客户端请求 /api/inventory/42
Cache-Control: no-store 或 private
收藏状态:
客户端请求 /api/favorites/42
Cache-Control: private, no-store
请求过程如下:
- 用户访问
/products/42; - CDN 命中则直接返回公共 HTML;
- 未命中时,SSR 服务读取
product:42; - SSR 生成不含用户收藏状态的 HTML;
- 浏览器 hydration;
- 客户端请求库存和收藏状态;
- 商品后台更新时,删除
product:42和/products/42的缓存; - 库存变化不需要清除页面缓存,因为库存从公共 HTML 中拆出。
这样做的因果关系是:
- 页面可以共享,因为 HTML 不依赖用户;
- 收藏不能共享,因为它依赖用户;
- 库存变化频繁,不适合和页面绑定;
- 商品内容变化时,同时清除数据缓存和页面缓存,避免页面继续遮蔽新数据。
如果把库存直接 SSR 到可共享 HTML 中,即使设置了 10 秒 TTL,也可能向用户显示过期库存。TTL 只限制时间,不保证交易状态正确。
十二、诊断缓存问题的方法
缓存故障必须先判断“哪一层返回了旧内容”。
12.1 查看响应头
curl -I https://example.com/products/42
重点观察:
Cache-Control
Age
ETag
Vary
Date
Via
X-Cache
Set-Cookie
Age 常用于表示共享缓存中的存活时间;X-Cache、Via 等头由具体代理或 CDN 定义,不属于统一标准。不能因为没有 X-Cache 就断定没有 CDN。
12.2 验证不同用户是否拿到同一响应
curl -i \
-H 'Cookie: session=user-a' \
https://example.com/account
curl -i \
-H 'Cookie: session=user-b' \
https://example.com/account
如果两个用户收到相同的账户 HTML,首先检查:
- 是否错误设置了
public; - CDN 是否忽略了 Cookie;
- 应用是否在公共缓存键中遗漏了用户身份;
- 是否存在
Set-Cookie但缓存平台仍然缓存; - 是否有中间反向代理改写了响应头。
12.3 区分 HTML 旧、数据旧和客户端旧
可以按顺序检查:
curl直接请求源站,确认源站 HTML;- 请求 CDN 地址,比较边缘返回的 HTML;
- 查看页面中序列化的 SSR payload;
- 打开浏览器 Network,查看 hydration 后的数据请求;
- 检查 Service Worker 和浏览器磁盘缓存;
- 检查数据缓存键和底层数据库版本。
例如:
源站 HTML 已是版本 B
CDN HTML 仍是版本 A
说明问题在边缘缓存或更前面的代理。
如果:
CDN HTML 是版本 B
页面序列化数据是版本 A
说明 SSR 过程使用了旧数据缓存,或页面和数据版本没有绑定。
如果:
HTML 与 payload 都是版本 B
hydration 后变成版本 A
则应检查客户端请求、浏览器缓存、Service Worker 或客户端状态初始化,而不是继续清理 CDN。
十三、常见错误与失败表现
错误一:把所有 SSR 页面都设置为 public
失败表现是用户数据串给其他用户。原因是共享缓存只知道 URL 和配置的键,不理解“这段 HTML 属于谁”。
错误二:只缓存 API,不考虑页面缓存
失败表现是 API 已经返回新数据,但用户打开页面仍看到旧 HTML。页面缓存命中时,浏览器根本不会访问该 API。
错误三:只清除数据缓存
失败表现是后台查询已经得到新结果,但访问页面仍然是旧内容。完整页面缓存需要独立清除或等待其 TTL 到期。
错误四:把用户 ID 放入公共 HTML 缓存键
这虽然可以降低串数据风险,却通常会产生近似“每个用户一份页面”的缓存,失去共享缓存的主要收益,还可能把身份信息写入 CDN 日志和缓存元数据。
错误五:用随机数或当前时间参与 SSR 输出
例如:
const id = Math.random()
const now = new Date().toISOString()
服务端和客户端首次渲染可能得到不同结果,造成 hydration mismatch。若页面还被缓存,随机值或时间可能在缓存 TTL 内被所有用户复用。
需要稳定的首屏值时,应在服务端生成并序列化到 payload,客户端 hydration 使用同一个值。
错误六:认为 no-cache 等于“不缓存”
HTTP 中:
Cache-Control: no-cache
通常表示“可以存储,但使用前必须重新验证”,并不等价于:
Cache-Control: no-store
后者才表示不应存储。对于隐私响应,应明确使用 private 或 no-store,而不是依赖对 no-cache 的模糊理解。
十四、如何选择缓存层
可以按数据和页面的共享范围选择:
| 内容 | 页面共享 | 数据共享 | 典型策略 |
|---|---|---|---|
| 公开文档 | 是 | 是 | CDN 页面缓存 + 数据缓存 |
| 公开商品描述 | 是 | 是 | 页面短 TTL,更新时主动失效 |
| 实时库存 | 否或弱 | 通常否 | 客户端请求,避免公共 HTML |
| 用户订单 | 否 | 否 | SSR 或客户端私有请求,private, no-store |
| 推荐列表 | 视算法而定 | 按用户或分组 | 明确用户/分组键,限制共享范围 |
| 静态 JS/CSS | 是 | 不适用 | 内容哈希文件名,长时间缓存 |
最重要的不是“缓存越多越快”,而是先回答两个问题:
- 这个响应能否被另一个请求者安全复用?
- 内容更新后,允许旧值存在多久?
第一个问题决定缓存是否可以共享,第二个问题决定 TTL、SWR 和主动失效方式。
十五、生产设计中的最小验证闭环
一个 SSR 缓存方案至少应验证以下路径:
冷请求
→ 页面是否回源
→ 数据是否回源
→ 是否写入正确缓存键
→ 响应头是否允许预期的缓存层存储
热请求
→ 命中的是哪一层
→ 是否跳过了 SSR
→ 是否返回相同版本
内容更新
→ 数据缓存是否失效
→ 页面缓存是否失效
→ CDN 是否完成清除
→ 新请求是否得到新版本
个性化请求
→ 不同用户是否隔离
→ 是否包含 private/no-store
→ hydration 前后状态是否一致
故障
→ 缓存不可用时是否回源或降级
→ 回源失败时是否错误共享旧值
→ 是否存在并发击穿
Vue SSR 缓存的核心并不是给渲染函数外面套一个 Map,而是明确表示的来源、共享边界、缓存键、版本和失效路径。页面缓存决定是否重复生成 HTML,数据缓存决定是否重复读取数据,边缘缓存决定请求是否需要回源,个性化决定响应能否被共享,失效机制决定旧内容的生命周期。只有这几者的条件同时成立,缓存才既能提高性能,又不会改变页面的安全性和业务语义。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 与 Nuxt SEO:Meta、结构化数据、Canonical、站点地图和渲染
- 下一篇:Vue Islands 与渐进式水合:交互边界、成本和适用场景
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论