Express + Next.js Custom Server 实战:LRU HTML 缓存把 SSR 首屏延迟打下来(含 x-cache 观测)
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)
方案概览
- 用
host + url(或更稳健的规范化 key)作为缓存键。 - 可缓存则查 LRU:命中直接返回 HTML,并设置
x-cache: HIT。 - 未命中则执行 SSR:
renderToHTML生成 HTML,写入 LRU,并设置x-cache: MISS。 - 异常时删除 key,避免把错误页面缓存住。
真实链路:Express 路由如何接入缓存边界
“缓存做在 Custom Server 层”最大的好处是:你的路由治理与缓存边界可以集中在一处。路由层只负责把 pagePath 与 query(必要时还有 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 个细节(建议直接照抄原则)
- 只在生产启用缓存:开发环境优先保证“改动立刻生效”,缓存会干扰调试。
- 缓存键至少包含 host:多域名部署时避免跨域串缓存。
- HIT/MISS 都可观测:无论命中还是未命中,都设置
x-cache,否则无法验收收益。 - 预览/爬虫 UA 可绕过:分享预览常带特殊路径/参数,绕过能避免缓存到不期望的预览版本。
- 异常必须 delete key:避免把“错误页/半截 HTML”写进缓存。
- 多语言要显式传入 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)。
增强版:缓存键规范化与降噪(提升命中率)
你会很快发现:命中率低不一定是“缓存没用”,也可能是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)
- 先加可观测:上线前先把
x-cache打出来,方便验证与回归。 - 只缓存热点页:从访问高度集中的 SSR 页面开始,避免缓存污染范围太大。
- 补齐绕过策略:登录态/实验/预览 UA 不确定就绕过。
- 规范化 key:删除追踪 query,必要时做 query 白名单化。
- 用数据验收:命中率 + 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”实现快速切换与回滚。