聊聊http缓存中的Stale-While-Revalidate

5 分钟
deepseek/deepseek-chat-v3-0324 Logo

AI 摘要 (由 deepseek/deepseek-chat-v3-0324 生成)

文章探讨了Next.js的缓存机制,重点分析了HTTP缓存头(如Cache-Control、ETag)与SWR(Stale-While-Revalidate)策略的结合应用。通过对比强制缓存与协商缓存的差异,作者展示了如何利用SWR在后台异步更新数据以平衡性能与数据新鲜度。文章还解析了Next.js的unstable_cache服务端缓存配置,并提供了React Query和useSWR客户端库实现SWR的代码示例,帮助开发者优化应用性能。

10.34s
~7261 tokens

起因

最近在使用 nextjs 重构自己的博客,发现nextjs 有个强大的缓存系统,我们先从 网站图标SWR开始研究下。

Building Your Application: Data Fetching
Learn how to fetch, cache, revalidate, and mutate data with Next.js.
网站图标nextjs.org
预览图片

首先查看下我博客的响应头输出,是不是感觉跟常见的响应头有点不一样

shell 示例
shell
// shell 示例
# curl -I -X GET https://www.eavan.dev/
HTTP/2 200 
accept-ch: Sec-CH-Prefers-Color-Scheme
cache-control: s-maxage=21600, stale-while-revalidate=31514400
content-type: text/html; charset=utf-8
critical-ch: Sec-CH-Prefers-Color-Scheme
date: Tue, 25 Mar 2025 09:22:13 GMT
etag: "108p1n79obtz16"
vary: Sec-CH-Prefers-Color-Scheme, RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Accept-Encoding
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 4294967294
x-powered-by: Next.js, Payload

常见的 http 缓存

先聊聊常见的 http 缓存字段吧

强制缓存(Force Cache):

    • Expires:HTTP/1.0的缓存控制字段,设置资源的过期时间点
    • Cache-Control:HTTP/1.1的缓存控制字段,比Expires优先级更高
      • max-age:资源最大有效时间(秒)
      • s-maxage:代理服务器缓存的最大有效时间
      • no-cache:需要与服务器协商验证
      • no-store:不缓存任何响应内容
      • public/private:资源缓存范围控制

协商缓存(Negotiation Cache):

    • Last-Modified/If-Modified-Since:基于资源修改时间的缓存控制
    • ETag/If-None-Match:基于资源内容标识的缓存控制,精确度更高

下图查看交互时序

众所周知,强制缓存,如果设置不当就不会更新,如果过于保守则缓存不起作用,又增加了服务器压力,这其中蕴含着系统设计的哲学,那么我们能否既要又要呢?

SWR的出现

因此,仅使用强制缓存header字段很难满足“ 既希望缓存生效,但又尽量提供最新数据”等需求。因此提出的就是 网站图标Stale-While-Revalidate (SwR) 这一 Cache-Control 的扩展。

简单来说,就是“ 先从缓存中显示内容,但会在后台异步更新缓存 ”的机制。

RFC 5861: HTTP Cache-Control Extensions for Stale Content
This document defines two independent HTTP Cache-Control extensions that allow control over the use of stale responses by caches. This document is not an Internet Standards Track specification; it is published for informational purposes.
网站图标datatracker.ietf.org

假设设置以下缓存控制头

Cache-Control: max-age=60, stale-while-revalidate=3600

这表示:

  • 资源在 60 秒内保持新鲜,浏览器直接使用缓存
  • 60 秒后到 3660 秒内,浏览器仍返回缓存版本,但同时在后台发起请求更新缓存
  • 3660 秒后,浏览器必须等待新请求完成

Stale-While-Revalidate (SWR) 其核心思想是,当资源过期后,缓存会立即返回(stale)的旧数据以保证快速响应,同时在后台(while-revalidate)异步请求新的数据以更新缓存,供后续请求使用。这种策略旨在平衡用户感知的性能和数据的最终一致性。SWR 的主要优势包括:  

  • 提升感知性能:用户能够迅速获得响应,即使数据是旧的,也避免了长时间等待。
  • 减少非关键更新的服务器负载:并非所有数据都需要实时更新,SWR 允许在后台进行更新,减轻了服务器的瞬时压力。
  • 增强用户体验:通过快速加载和后台更新的结合,提供了流畅且数据相对新鲜的用户体验

nextjs 如何利用 SWR 概念的

首先声明,nextjs 只是借助了 SWR 的概念,让 ISR 情况下去降低服务器的压力,

首先来详细梳理一下 Next.js 中 网站图标unstable_cache 的配置项与标准 HTTP 缓存头之间的微妙关系。

首先要明确最重要的一点:

  1. unstable_cache: 主要用于服务器端的数据缓存。它缓存的是函数(通常是数据获取函数,如数据库查询、API 调用)的执行结果。这个缓存存在于 Next.js 服务器(isr)或构建过程(ssg)中,目的是减少重复执行昂贵操作的次数,提高服务器性能和响应速度。
  2. HTTP 缓存头 (Cache-Control, ETag, Last-Modified等): 用于控制客户端(浏览器)和中间缓存(如 CDN)如何缓存整个 HTTP 响应。它们由服务器在 HTTP 响应中发送,告诉下游缓存这个响应可以被缓存多久、如何验证缓存是否仍然有效等。

虽然它们作用于不同层面,但 unstable_cache 的配置(特别是 revalidate)有可能会影响 Next.js 如何决定为包含该缓存数据的页面或路由设置哪些 HTTP 缓存头。

我们来看 unstable_cache 的主要配置项:

typescript
import { unstable_cache } from 'next/cache';

const cachedFn = unstable_cache(
  async (arg1, arg2) => {
    // ... 昂贵的数据获取操作 ...
    return data;
  },
  ['my-unique-key-part'], // keyParts: 用于生成缓存键
  {
    revalidate: 60,         // 缓存有效期(秒)或 false
    tags: ['tag1', 'tag2'], // 缓存标签,用于按需重新验证
  }
);

其中和 SWR 相关的就是revalidate配置项,它定义了服务器端数据缓存(s-maxage)的有效期(以秒为单位),在此期间,对该缓存函数的调用将直接返回缓存数据,也就是类似浏览器端http 的强缓存,只不过把这个作用在了服务端.

超过时间后,下一次调用会重新执行函数,并将新结果存入缓存(这个过程可以称为 Stale-While-Revalidate)。在重新验证完成前,旧的缓存数据可能仍会被短暂返回。


客户端请求如何使用SWR

相对于 nextjs 可以使用 RSC 融合服务端响应头去使用 SWR 的特性,那单纯客户端请求的时候就很难利用浏览器的特性去控制这个缓存的颗粒度,所以基本上需要自己实现,其实也可以通过好用的第三方客户端请求库去更方便的模拟SWR,下面举两个常见的请求库

reactQuery

Overview | TanStack Query React Docs
TanStack Query (formerly known as React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and...
网站图标tanstack.com
预览图片
reactQuery 示例伪代码
typescript
// reactQuery 示例伪代码

// --- API 模拟 ---
// 假设这是一个获取用户数据的 API
let serverData = { id: 1, name: "初始用户", timestamp: Date.now() };
let requestCounter = 0;

async function fetchUserData(userId) {
  requestCounter++;
  console.log(`[React Query] 发起API请求 #${requestCounter} 获取用户 ${userId}...`);
  await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟网络延迟
  serverData.timestamp = Date.now(); // 更新时间戳,表示数据更新了
  return { ...serverData, fetchedBy: `Request ${requestCounter}` };
}

// --- React 组件 ---
import { useQuery, QueryQuery({
    queryKey: ['user', userId], // 查询的唯一键
    queryFn: () => fetchUserData(userId), // 获取数据的函数
    staleTime: staleTimeConfig, // <--- 关键参数:数据被认为是新鲜的时长
    // gcTime: 5 * 60 * 1000, // 缓存数据在非活跃状态下保留的时长 (旧版 cacheTime)
  });

  console.log(`[React Query Component] userId: ${userId}, staleTime: ${staleTimeConfig}ms`);
  console.log(`  isLoading: ${isLoading}, isFetching: ${isFetching}`);
  console.log(`  Data:`, data);

  if (isLoading) return <p>加载中...</p>;
  if (error) return <p>错误: {error.message}</p>;

  return (
    <div>
      <h3>用户 ID: {data?.id} (StaleTime: {staleTimeConfig}ms)</h3>
      <p>名称: {data?.name}</p>
      <p>数据获取时间: {new Date(data?.timestamp).toLocaleTimeString()}</p>
      <p>由 {data?.fetchedBy} 获取</p>
      {isFetching && <p><i>后台正在更新数据...</i></p>}
      <button onClick={() => refetch()}>手动刷新</button>
    </div>
  );
}

function AppReactQueryExample() {
  const [showProfile, setShowProfile] = useState(true);
  const [userId, setUserId] = useState(1);

  return (
    <QueryClientProvider client={queryClient}>
      <h2>React Query `staleTime` 示例</h2>
      <button onClick={() => setShowProfile(!showProfile)}>
        {showProfile ? '隐藏' : '显示'} Profile (staleTime: 0)
      </button>
      <button onClick={() => setShowProfile(!showProfile)}>
        {showProfile ? '隐藏' : '显示'} Profile (staleTime: 30000)
      </button>
       <button onClick={() => setUserId(userId + 1)}>切换用户 ID</button>
      <hr />

      {/* 示例1: staleTime: 0 (默认) */}
      {/* 每次组件挂载或窗口聚焦,如果数据已存在,会立即显示缓存数据,并立即在后台发起请求更新。*/}
      {showProfile && <UserProfile userId={userId} staleTimeConfig={0} />}

      <hr style={{margin: '20px 0'}} />

      {/* 示例2: staleTime: 30000 (30秒) */}
      {/* 数据获取后的30秒内,组件挂载或窗口聚焦,会直接使用缓存中的“新鲜”数据,不会发起后台请求。*/}
      {/* 30秒后,数据变为“陈旧”,行为同 staleTime: 0 */}
      {showProfile && <UserProfile userId={userId} staleTimeConfig={30000} />}

      {/* 
        测试步骤:
        1. 初始加载,两个组件都会发起请求。
        2. 点击 "隐藏 Profile",再点击 "显示 Profile"。
           - staleTime: 0 的组件会显示缓存并立即 isFetching。
           - staleTime: 30000 的组件 (如果在30秒内) 会直接显示缓存,isFetching 为 false。
        3. 等待超过30秒后,再切换显示。
           - staleTime: 30000 的组件现在也会显示缓存并 isFetching。
        4. 切换窗口焦点,观察行为。
        5. 点击 "切换用户 ID",观察两个组件都重新获取数据。
      */}
    </QueryClientProvider>
  );
}

// export default AppReactQueryExample; // 在实际应用中取消注释


useSWR

用于数据请求的 React Hooks 库 – SWR
SWR is a React Hooks library for data fetching. SWR first returns the data from cache (stale), then sends the fetch request (revalidate), and finally comes with the up-to-date data again.
网站图标swr.vercel.app
预览图片

Vercel 的 swr 库本身就是以 Stale-While-Revalidate 命名的,所以它的默认行为就是 SWR。它没有一个直接叫做 staleTime 的选项来定义数据在多久内是 "fresh" 从而阻止自动 revalidation。相反,它通过 revalidateOnFocus, revalidateOnReconnect, refreshInterval 等选项来控制何时触发 "revalidate"。dedupingInterval 则用于防止在短时间内对同一 key 发起重复请求。

useSWR 示例伪代码
typescript
// useSWR 示例伪代码
// --- API 模拟 (可复用上面的 fetchUserData) ---
// async function fetchUserData(userId) { ... }

// --- React 组件 ---
import useSWR, { SWRConfig } from 'swr';
import React, { useState } from 'react';

// fetcher 函数,useSWR 需要一个全局或局部的 fetcher
const globalFetcher = async (url) => {
  const userId = url.split('/').pop(); // 从 URL 中简单提取 userId
  requestCounter++;
  console.log(`[useSWR] 发起API请求 #${requestCounter} 获取用户 ${userId}...`);
  await new Promise(resolve => setTimeout(resolve, 1000));
  serverData.timestamp = Date.now();
  return { ...serverData, id: parseInt(userId), fetchedBy: `Request ${requestCounter}` };
};


function UserProfileSWR({ userId }) {
  const { data, error, isValidating, mutate } = useSWR(
    `/api/user/${userId}`, // key 通常是请求的 URL
    globalFetcher,         // 全局或局部 fetcher
    {
      // useSWR 的选项:
      // revalidateOnFocus: true, (默认) - 窗口聚焦时重新验证
      // revalidateOnReconnect: true, (默认) - 网络重连时重新验证
      // dedupingInterval: 2000, (默认) - 在此时间内对相同 key 的请求只会发起一次
      // refreshInterval: 0, (默认) - 不轮询。如果设为例如 5000,则每5秒重新验证一次
    }
  );

  console.log(`[useSWR Component] userId: ${userId}`);
  console.log(`  isValidating: ${isValidating}`); // 相当于 React Query 的 isFetching
  console.log(`  Data:`, data);

  if (!data && !error && isValidating) return <p>加载中 (SWR)...</p>; // 初始加载
  if (error) return <p>错误 (SWR): {error.message}</p>;
  // 注意:SWR 会在后台更新时,data 仍然是旧数据,直到新数据获取成功
  // 所以我们通常直接显示 data,并通过 isValidating 指示后台活动

  return (
    <div>
      <h3>用户 ID: {data?.id} (useSWR)</h3>
      <p>名称: {data?.name}</p>
      <p>数据获取时间: {data?.timestamp ? new Date(data?.timestamp).toLocaleTimeString() : 'N/A'}</p>
      <p>由 {data?.fetchedBy} 获取</p>
      {isValidating && <p><i>(SWR) 后台正在验证/更新数据...</i></p>}
      <button onClick={() => mutate()}>手动刷新 (mutate)</button>
    </div>
  );
}

function AppSWRExample() {
  const [showProfile, setShowProfile] = useState(true);
  const [userId, setUserId] = useState(1);

  return (
    // SWRConfig 可以提供全局配置
    <SWRConfig value={{ fetcher: globalFetcher }}>
      <h2>Vercel `useSWR` 示例</h2>
      <button onClick={() => setShowProfile(!showProfile)}>
        {showProfile ? '隐藏' : '显示'} Profile (SWR)
      </button>
      <button onClick={() => setUserId(userId + 1)}>切换用户 ID (SWR)</button>
      <hr />

      {/*
        useSWR 默认行为:
        - 组件挂载时,如果缓存中没有数据,则获取数据。
        - 如果缓存中有数据,立即显示缓存数据,并在后台重新验证 (revalidate)。
        - 窗口聚焦、网络重连时也会触发重新验证。
        - `dedupingInterval` (默认2秒) 会阻止在2秒内对同一个key的重复请求。
          这意味着如果快速连续挂载多个使用相同key的组件,只有第一个会真正发起请求。
      */}
      {showProfile && <UserProfileSWR userId={userId} />}
       <hr style={{margin: '20px 0'}} />
      {showProfile && <UserProfileSWR userId={userId + 100} />} {/* 不同 key 会触发不同请求 */}


      {/*
        测试步骤:
        1. 初始加载,组件会发起请求。
        2. 点击 "隐藏 Profile",再点击 "显示 Profile"。
           - 组件会立即显示缓存数据,并且 `isValidating` 通常会是 true (除非被 dedupingInterval 阻止)。
        3. 切换窗口焦点,观察 `isValidating` 变化。
        4. 点击 "切换用户 ID",观察组件获取新用户的数据。
      */}
    </SWRConfig>
  );
}