AWS
缓存(ISR/SSG)

Next 和 OpenNext 中的缓存

在 Vercel 之外使用 Next 时,缓存可能会很快变得棘手。有很多事情需要考虑。

通常,你会在 Next 应用前面部署一个 CDN。这个 CDN 将缓存来自你的 Next 应用的响应并提供给用户。这对性能很有好处,但当你需要使缓存失效时,这也可能成为一个问题。我们在本文档中提供了一些代码示例来帮助处理 CloudFront 缓存失效在 OpenNext 中,只有当你进行按需重新验证(On Demand Revalidation)时才需要这样做

此外,默认的 Next.js 独立输出(或 next start)在无服务器环境中无法工作,因为它试图在后台进行重新验证。

默认的 Next.js 还使用文件系统来缓存文件。你可以通过提供自己的缓存实现来覆盖它。在使用 OpenNext 时,这是自动完成的。

Next.js 设置的默认 Cache-Control 头也存在 2 个问题。 他们默认使用这个头 s-maxage=YOUR_REVALIDATION_TIME, stale-while-revalidate。这有 2 个问题:

  • stale-while-revalidate 不是 Cache-Control 头的正确语法。它应该是 stale-while-revalidate=TIME_WHERE_YOU_SERVE_STALE。他们在最近版本的 Next 中添加了此 未记录的选项 (opens in a new tab) 来补救这个问题
  • 为同一页面的每个请求设置相同的 s-maxage 值可能不是一个好主意。 Next 可以根据你是请求完整 HTML 还是进行客户端导航(页面路由器的 RSC 或 JSON)来提供不同的内容。 这可能导致 ISR 缓存不一致,尤其是当你有很长的重新验证时间时。 例如,假设你使用 app router,你在主导航栏中有一个指向首页的链接,并将 ISR 设置为 1 天。其他每个页面都将有一个不同的 RSC 缓存条目(用于客户端导航)。这将导致你的应用中有多少页面就有多少缓存条目,它们都将具有相同的 1 天 s-maxage 值,但可能是在非常不同的时间被请求的。这可能导致某些页面提供长达 2 天的过时内容。

所有这些问题在 OpenNext 中都会自动为你修复

CloudFront 缓存失效

当你手动重新验证特定页面的 Next.js 缓存时,存储在 S3 上的 ISR 缓存文件将被更新。但是,仍然需要使 CloudFront 缓存失效:

// pages/api/revalidate.js
export default async function handler(req, res) {
  await res.revalidate("/foo");
  await invalidateCloudFrontPaths(["/foo"]);
  // ...
}

如果使用的是 pages router,你还必须使 _next/data/BUILD_ID/foo.json 路径失效。BUILD_ID 的值可以在 .next/BUILD_ID 构建输出中找到,并且可以在运行时通过 process.env.NEXT_BUILD_ID 环境变量访问。

await invalidateCloudFrontPaths(["/foo", `/_next/data/${process.env.NEXT_BUILD_ID}/foo.json`]);

下面是 invalidateCloudFrontPaths() 函数的一个示例:

import { CloudFrontClient, CreateInvalidationCommand } from "@aws-sdk/client-cloudfront";
 
const cloudFront = new CloudFrontClient({});
 
async function invalidateCloudFrontPaths(paths: string[]) {
  await cloudFront.send(
    new CreateInvalidationCommand({
      // 在此处设置 CloudFront 分配 ID
      DistributionId: distributionId,
      InvalidationBatch: {
        CallerReference: `${Date.now()}`,
        Paths: {
          Quantity: paths.length,
          Items: paths,
        },
      },
    })
  );
}

请注意,手动 CloudFront 路径失效会产生费用。根据 AWS CloudFront 定价页面 (opens in a new tab)

每月前 1,000 个请求失效的路径不收取额外费用。此后,每个请求失效的路径收费 0.005 美元。

由于这些成本,如果多个路径需要失效,使通配符路径 /* 失效更经济。例如:

// 在前 1000 个路径之后,这将花费 $0.005 x 3 = $0.015
await invalidateCloudFrontPaths(["/page/a", "/page/b", "/page/c"]);
 
// 这将花费 $0.005,但也会使其他路由(如 "page/d")失效
await invalidateCloudFrontPaths(["/page/*"]);

对于通过 next/cache 模块 (opens in a new tab) 进行的按需重新验证,如果你想检索给定标签的关联路径,你可以使用此函数:

import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb";
 
const client = new DynamoDBClient({ region: process.env.CACHE_BUCKET_REGION });
 
async function getPaths(tag: string) {
  try {
    const { Items } = await client.send(
      new QueryCommand({
        TableName: process.env.CACHE_DYNAMO_TABLE,
        KeyConditionExpression: "#tag = :tag",
        ExpressionAttributeNames: {
          "#tag": "tag",
        },
        ExpressionAttributeValues: {
          ":tag": { S: `${process.env.NEXT_BUILD_ID}/${tag}` },
        },
      })
    );
    return Items?.map((item) => item.path?.S?.replace(`${process.env.NEXT_BUILD_ID}/`, "") ?? "") ?? [];
  } catch (e) {
    console.error("Failed to get by tag", e);
    return [];
  }
}