Express + Next.js Custom Server 实战:LRU HTML 缓存把 SSR 首屏延迟打下来(含 x-cache 观测)

5906
2026-02-28 15:56
18 小时前

Express + Next.js Custom Server 实战:LRU HTML 缓存把 SSR 首屏延迟打下来(含 x-cache 观测)

这个案例属于“先把闭环跑起来”的类型:不一上来改一堆页面逻辑,而是在 Custom Server(Express)层对 热点 SSR 页面HTML 级别缓存,立刻降低回源渲染次数,并用 x-cache 让结果可验证。

TL;DR(30 秒讲清楚)

  • 问题:热点页面 SSR 计算与 IO 成本高,TTFB 波动大;同时缺少“是否命中缓存”的可观测信号。
  • 方案:在 Express Custom Server 层做 LRU HTML 缓存(TTL 控制失效),并返回 x-cache: HIT|MISS;对预览/爬虫 UA 或不安全请求绕过缓存。
  • 结果:以命中率 + TTFB(p95)+ 错误率验证;用路由维度拆分定位缓存碎片点。

适用读者与前置知识

  • 适合:Next.js Pages Router + SSR,且已使用(或准备使用)Custom Server 承载路由治理的团队。
  • 不适合:页面强依赖登录态/个性化,且你没有明确的隔离或绕过策略的场景。
  • 前置:会写 Express 路由、知道 Next.js renderToHTML/getRequestHandler 的基本用法。

背景与约束

HTML 级缓存的收益很直观,但它的风险也同样直观:

  • 正确性:cache key 设计不当会导致语言错配、跨域串缓存、用户态串扰。
  • 碎片化:URL 上的追踪 query 会把命中率打到很低。
  • 一致性:多实例下 LRU 不共享,命中率会随扩容下降(先接受,再决定是否上共享缓存)。

问题定义(Problem Statement)

对热点 SSR 页面,在不引入错误缓存的前提下,通过服务层 HTML 缓存降低重复渲染成本,并用可观测信号验证命中与收益。

目标与非目标(Goals / Non-goals)

目标

  • 让热点页“第二次请求更快”,并让 TTFB 波动变小。
  • 让命中状态可见(x-cache),便于验收与回归。
  • 把“不安全缓存”的请求显式绕过(宁可不缓存,也不要错缓存)。

非目标

  • 不在本文解决跨实例共享(Redis/边缘 KV)。
  • 不在本文做复杂主动失效(只用 TTL + 版本化 key 的最小策略)。

方案与权衡(Solution & Trade-offs)

方案概览

  1. host + url(或更稳健的规范化 key)作为缓存键。
  2. 可缓存则查 LRU:命中直接返回 HTML,并设置 x-cache: HIT
  3. 未命中则执行 SSR:renderToHTML 生成 HTML,写入 LRU,并设置 x-cache: MISS
  4. 异常时删除 key,避免把错误页面缓存住。

真实链路:Express 路由如何接入缓存边界

“缓存做在 Custom Server 层”最大的好处是:你的路由治理与缓存边界可以集中在一处。路由层只负责把 pagePathquery(必要时还有 locale)交给 renderAndCache,其余逻辑不散落在页面里。

文件:your-project/server/index.ts

符号:server.get(...)renderAndCache(伪代码)

// 伪代码:路由层负责“匹配与映射”,缓存边界在 renderAndCache
server.get('/:locale?/some-tool-slug', (req, res) => {
  const queryParams = { ...req.query };
  // 统一映射到内部页面(示例)
  return renderAndCache(req, res, '/features/some-page', queryParams);
});

为什么选“HTML 级缓存”

  • 收益直观:命中即跳过 SSR(通常最贵)。
  • 落地快:不改页面数据依赖,不改接口调用点。
  • 可验证:x-cache 让优化不是“体感”。

代价是:缓存键与绕过策略必须严谨,否则会引入错误缓存。


实现要点(Implementation Notes)

文件:your-project/server/index.ts

符号:renderAndCache

职责:在服务层统一处理“是否可缓存 / 缓存键 / HIT/MISS / 异常兜底”。

// 伪代码:Express + Next.js Custom Server 的最小缓存骨架
import { LRUCache } from 'lru-cache';
import next from 'next';
import express from 'express';

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

const ssrCache = new LRUCache({ max: 1024, ttl: 60 * 60 * 1000 });

function shouldBypassCache(req) {
  // 预览/爬虫/分享 UA(示例:twitterbot)可绕过,避免抓取到不期望的版本
  const ua = String(req.headers['user-agent'] || '').toLowerCase();
  if (ua.startsWith('twitterbot')) return true;

  // 登录态/实验分组/个性化:更安全的做法是直接绕过
  if (req.isAuthenticated) return true;
  return false;
}

function buildKey(req) {
  const host = req.headers.host || 'unknown-host';
  return `${host}${req.url}`;
}

async function renderAndCache(req, res, pagePath, queryParams = {}) {
  if (shouldBypassCache(req)) return handle(req, res);

  const key = buildKey(req);
  const cached = !dev ? ssrCache.get(key) : null;
  if (cached) {
    res.setHeader('x-cache', 'HIT');
    res.end(cached);
    return;
  }

  res.setHeader('x-cache', 'MISS');
  try {
    const html = await app.renderToHTML(req, res, pagePath, queryParams);
    if (!dev && html) ssrCache.set(key, html);
    res.end(html);
  } catch (e) {
    ssrCache.delete(key);
    throw e;
  }
}

来自真实实现的 6 个细节(建议直接照抄原则)

  1. 只在生产启用缓存:开发环境优先保证“改动立刻生效”,缓存会干扰调试。
  2. 缓存键至少包含 host:多域名部署时避免跨域串缓存。
  3. HIT/MISS 都可观测:无论命中还是未命中,都设置 x-cache,否则无法验收收益。
  4. 预览/爬虫 UA 可绕过:分享预览常带特殊路径/参数,绕过能避免缓存到不期望的预览版本。
  5. 异常必须 delete key:避免把“错误页/半截 HTML”写进缓存。
  6. 多语言要显式传入 locale:如果你的 locale 不是 URL 的一部分,宁可绕过也不要错缓存。

文件:your-project/server/index.ts

符号:renderAndCache(locale 注入,伪代码)

// 伪代码:i18n 场景把 locale 作为渲染输入显式传入(避免错语言)
const locale = req.params?.locale || defaultLocale;
const html = await app.renderToHTML(req, res, pagePath, {
  // 具体实现方式随项目而定:关键是“让渲染输入可解释”
  locale,
  ...queryParams,
});

缓存键:从“能用”到“稳健”

上面的 key 用了 host + url,能跑起来,但你应该很快做一次审计:locale 是否可能从 cookie/header 推断? query 是否带追踪参数?是否存在实验分组?这些都决定你是“进 key”还是“绕过缓存”。

建议把“缓存键风险清单”写进代码评审 checklist(至少包含 host/locale/query/cookie/headers)。

缓存键设计清单:host/path/locale/query/cookie/headers 与 绕过条件
图:缓存键要么覆盖影响输出的维度,要么明确绕过条件。

增强版:缓存键规范化与降噪(提升命中率)

你会很快发现:命中率低不一定是“缓存没用”,也可能是key 被追踪参数打碎URL 规范不统一。 解决方式通常是“规范化 URL”,把不会影响 HTML 的维度从 key 里剔除。

文件:your-project/server/cacheKey.ts

符号:normalizeUrlForCacheKey(伪代码)

// 伪代码:把会碎片化缓存、但不影响 HTML 的部分清理掉
function normalizeUrlForCacheKey(rawUrl) {
  const url = new URL(rawUrl, 'https://placeholder');
  // 1) 去掉 hash(不影响请求)
  url.hash = '';
  // 2) 删除追踪参数(示例)
  for (const k of [...url.searchParams.keys()]) {
    if (k.startsWith('utm_') || k === 'gclid') url.searchParams.delete(k);
  }
  // 3) 可选:路径尾斜杠统一(按你的 canonical 规范)
  url.pathname = url.pathname.replace(/\/+$/, '') || '/';
  // 4) 可选:排序 query(保证稳定 key)
  url.searchParams.sort();
  return `${url.pathname}${url.searchParams.toString() ? '?' + url.searchParams.toString() : ''}`;
}

实践步骤(Step-by-step)

  1. 先加可观测:上线前先把 x-cache 打出来,方便验证与回归。
  2. 只缓存热点页:从访问高度集中的 SSR 页面开始,避免缓存污染范围太大。
  3. 补齐绕过策略:登录态/实验/预览 UA 不确定就绕过。
  4. 规范化 key:删除追踪 query,必要时做 query 白名单化。
  5. 用数据验收:命中率 + p95 TTFB + 错误率三件套。

指标与验证(Metrics & Validation)

  • 命中率:按路由维度统计 HIT/MISS。
  • TTFB:观察 p95 是否下降、波动是否变小。
  • 错误率:SSR 500、渲染异常(异常时务必 delete key)。

通过标准(建议):

  • 命中:热点页面二次请求能稳定命中(例如 x-cache: HIT),且不会命中到错误版本(locale/登录态/实验分组)。
  • 收益:命中率提升,p95 TTFB 明显下降且波动变小。
  • 安全:SSR 错误率不升;异常时能删除 key 并快速回滚。

最小可复现验证(示例):

curl -I https://your-domain.com/some-hot-ssr-page
curl -I https://your-domain.com/some-hot-ssr-page
# 期望第二次出现:x-cache: HIT

预期与判定(建议):

  • 命中可见:第二次请求出现 x-cache: HIT,并且 HTML 与预期页面一致。
  • 不串缓存:切换 locale/host/登录态/实验分组后,不应继续命中同一个 key(不确定就绕过)。
  • 链路安全:一旦 SSR 报错或渲染异常,能删除 key 并快速回滚(避免缓存住错误页)。

不通过先查:cache key 是否被追踪 query 打碎(utm/gclid);locale 是否来自 cookie/header 却没进 key; 是否误缓存了带 Set-Cookie 的响应;以及绕过条件(preview/bot/登录态)是否漏掉关键场景。

常见坑与规避(Pitfalls)

  • 追踪参数碎片化:utm_*/gclid 会把缓存打碎;先删再看命中率。
  • 语言错配:locale 不在 URL 时最危险;不确定就绕过缓存。
  • 错误页被缓存:异常必须 delete key;必要时对 404/重定向绕过缓存。
  • 多实例命中率下降:单机 LRU 不共享是预期行为;先完成闭环,再决定是否引入共享缓存。

FAQ

Q:为什么不用页面级数据缓存,而是直接缓存 HTML?

A:HTML 缓存收益最大、落地最快,适合作为第一阶段把闭环跑起来;当你发现页面强个性化或 key 难以稳定时,再回退到更细粒度的数据缓存。

Q:为什么建议只缓存 GET?POST/带副作用的请求怎么办?

A:HTML 缓存通常只适用于 GET(幂等、可复用)。任何带副作用或返回用户态强相关内容的请求,都不应该走同一个 HTML 缓存通道。

Q:为什么很多实现会绕过预览/爬虫 UA?缓存命中不是更快吗?

A:绕过并不是“为了更快”,而是为了避免缓存到不期望的版本:例如分享预览可能携带特殊参数,或需要实时生成 meta。你也可以选择“为 bot 单独 key”,本质都是隔离。

Q:如何估算 LRU 的 max 和 TTL?

A:先用保守值跑起来:max 约等于“热点 URL 数量 × 关键维度数量(host/locale)”;TTL 先 10–60 分钟观察命中率与正确性。稳定后再逐步放大。

Q:要不要把 cookie 拼进缓存键?

A:通常不建议。cookie 高基数会导致命中率极低,还可能有隐私风险。更安全的策略是:只要 cookie 影响 HTML,就直接绕过 HTML 缓存。

Q:多实例部署导致命中率下降,应该怎么做?

A:单机 LRU 不共享是预期行为:扩容后命中率会被摊薄。正确路径是先用它把“key/绕过/观测闭环”跑通,再按需求引入共享缓存(如 Redis/边缘 KV)与主动失效能力。

Q:TTL 设置多久比较合适?

A:先 10–60 分钟跑起来,观察命中率与正确性;稳定后再拉长或引入“版本化 key”实现快速切换与回滚。