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

这里至少存在四种不同的状态:

  1. 请求状态:URL、Cookie、Authorization、语言、地区、设备等;
  2. 数据状态:数据库或后端 API 当前返回的内容;
  3. HTML 表示:Vue SSR 根据数据生成的字符串;
  4. 客户端状态:hydration 后,浏览器中的响应式状态和后续请求结果。

缓存的基本问题可以形式化为:

R=F(K,S)R = F(K, S)

其中:

  • RR 是最终响应,例如 HTML、JSON 和响应头;
  • KK 是缓存键,通常来自 URL 以及部分请求头、Cookie 或用户身份;
  • SS 是渲染所读取的数据和配置;
  • FF 是“读取数据并渲染”的过程。

只有当两个请求在缓存键 KK 相同的情况下,确实允许共享同一个响应,缓存才是正确的。更严格地说,如果请求 q1q_1q2q_2 被映射到同一个缓存键:

key(q1)=key(q2)key(q_1) = key(q_2)

那么必须满足:

F(q1)=F(q2)F(q_1) = F(q_2)

或者至少满足:返回相同响应不会违反安全、隐私和业务语义。

例如,商品详情页通常可以按 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 会假设:

  1. HTML 中的结构来自同一份初始状态;
  2. 客户端首次渲染结果与服务端结果一致;
  3. 序列化到页面中的数据与 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-ages-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 可以复用这份数据,避免同一个页面在启动时无条件重复请求。

这里要区分三件事:

  1. useAsyncData 的键用于 Nuxt 数据复用和去重;
  2. $fetch 请求的 HTTP 缓存由服务端、浏览器或 CDN 的响应头决定;
  3. 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-ControlETagVary 等标准语义;具体的预取、标签清除、后台刷新和缓存键配置,则属于 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 种。

理论上最多需要:

4×6×3=724 \times 6 \times 3 = 72

个页面版本。若再把用户 ID 加入键,则变成:

72×N72 \times N

其中 NN 是用户数,缓存几乎失去共享意义,并产生更高的泄露风险。

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,观察到的旧内容时间可能更长。

可以用近似上界表示:

TstaleTbrowser+Tedge+Torigin-cache+TpropagationT_{\text{stale}} \leq T_{\text{browser}} + T_{\text{edge}} + T_{\text{origin-cache}} + T_{\text{propagation}}

这不是所有平台的严格保证,因为不同缓存可能并行工作,且 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

请求过程如下:

  1. 用户访问 /products/42
  2. CDN 命中则直接返回公共 HTML;
  3. 未命中时,SSR 服务读取 product:42
  4. SSR 生成不含用户收藏状态的 HTML;
  5. 浏览器 hydration;
  6. 客户端请求库存和收藏状态;
  7. 商品后台更新时,删除 product:42/products/42 的缓存;
  8. 库存变化不需要清除页面缓存,因为库存从公共 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-CacheVia 等头由具体代理或 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 旧、数据旧和客户端旧

可以按顺序检查:

  1. curl 直接请求源站,确认源站 HTML;
  2. 请求 CDN 地址,比较边缘返回的 HTML;
  3. 查看页面中序列化的 SSR payload;
  4. 打开浏览器 Network,查看 hydration 后的数据请求;
  5. 检查 Service Worker 和浏览器磁盘缓存;
  6. 检查数据缓存键和底层数据库版本。

例如:

源站 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

后者才表示不应存储。对于隐私响应,应明确使用 privateno-store,而不是依赖对 no-cache 的模糊理解。


十四、如何选择缓存层

可以按数据和页面的共享范围选择:

内容 页面共享 数据共享 典型策略
公开文档 CDN 页面缓存 + 数据缓存
公开商品描述 页面短 TTL,更新时主动失效
实时库存 否或弱 通常否 客户端请求,避免公共 HTML
用户订单 SSR 或客户端私有请求,private, no-store
推荐列表 视算法而定 按用户或分组 明确用户/分组键,限制共享范围
静态 JS/CSS 不适用 内容哈希文件名,长时间缓存

最重要的不是“缓存越多越快”,而是先回答两个问题:

  1. 这个响应能否被另一个请求者安全复用?
  2. 内容更新后,允许旧值存在多久?

第一个问题决定缓存是否可以共享,第二个问题决定 TTL、SWR 和主动失效方式。


十五、生产设计中的最小验证闭环

一个 SSR 缓存方案至少应验证以下路径:

冷请求
→ 页面是否回源
→ 数据是否回源
→ 是否写入正确缓存键
→ 响应头是否允许预期的缓存层存储

热请求
→ 命中的是哪一层
→ 是否跳过了 SSR
→ 是否返回相同版本

内容更新
→ 数据缓存是否失效
→ 页面缓存是否失效
→ CDN 是否完成清除
→ 新请求是否得到新版本

个性化请求
→ 不同用户是否隔离
→ 是否包含 private/no-store
→ hydration 前后状态是否一致

故障
→ 缓存不可用时是否回源或降级
→ 回源失败时是否错误共享旧值
→ 是否存在并发击穿

Vue SSR 缓存的核心并不是给渲染函数外面套一个 Map,而是明确表示的来源、共享边界、缓存键、版本和失效路径。页面缓存决定是否重复生成 HTML,数据缓存决定是否重复读取数据,边缘缓存决定请求是否需要回源,个性化决定响应能否被共享,失效机制决定旧内容的生命周期。只有这几者的条件同时成立,缓存才既能提高性能,又不会改变页面的安全性和业务语义。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。