内容产品的页面需要搜索引擎收录,也需要在 App 内稳定打开。原有服务把模板、数据请求和页面逻辑混在一起,访问量上升后,服务端渲染逐渐成为瓶颈。最直观的现象是响应变慢、CPU 抖动,严重时连不需要 SSR 的页面也被拖累。
把系统迁移到 Nuxt.js 只是重构的起点。框架可以提供同构渲染能力,却不会自动回答哪些页面应该在服务端生成、哪些数据可以缓存、失败时如何退回客户端,以及一台机器到底能承担多少并发。
图 1:缓存、阶段计时和降级都挂在同一条请求链路上。只盯模板渲染时间,会漏掉接口长尾、击穿和激活失败。
先画出完整链路
优化前,我们先把一次页面访问拆开:请求进入 Node 服务,匹配路由,获取多个接口数据,创建 Vue 实例,执行服务端渲染,生成 HTML,再返回浏览器完成激活。静态资源由 CDN 提供,但 HTML 和接口聚合仍然集中在服务端。
只看总响应时间无法知道问题在哪里。于是为关键阶段增加计时:路由匹配、远程接口、模板渲染、序列化和整体耗时。同时记录缓存命中率、进程内存与事件循环延迟。
我们把阶段耗时写进 Server-Timing,这样浏览器、日志和压测结果能使用同一组名字:
export default async function renderPage(req, res) {
const marks = new Map<string, number>();
const measure = async <T>(name: string, run: () => Promise<T>) => {
const started = performance.now();
try { return await run(); }
finally { marks.set(name, performance.now() - started); }
};
const data = await measure("data", () => loadPageData(req));
const html = await measure("render", () => renderer.renderRoute(req.url, { data }));
const serialized = await measure("serialize", async () => injectState(html, data));
res.setHeader("Server-Timing", [...marks].map(([name, ms]) => `${name};dur=${ms.toFixed(1)}`).join(","));
res.end(serialized);
}计时本身也要克制。只记录稳定阶段,不为每个函数打点;否则监控维度快速膨胀,真正需要比较的链路反而看不见。
测量很快推翻了一些直觉。模板渲染并非始终最慢,远程接口的长尾延迟和重复请求占用了大量时间;部分页面每次请求都计算相同数据,热门页面又持续挤压冷门页面的缓存空间。
缓存不是一个开关
“加缓存”很容易,“定义缓存语义”更难。我们把缓存分成三层:CDN 负责稳定静态资源;页面级缓存保存变化不频繁、用户无关的完整 HTML;数据级缓存保存多个页面共享的接口结果。
每层都必须明确键、生命周期和失效方式。页面缓存如果包含登录状态,会造成数据串用;只按路径缓存而忽略查询参数,会返回错误内容;缓存时间过长,又会让运营更新无法及时出现。
LRU 能限制进程内缓存占用,却不能替代业务规则。我们根据页面流量和内容时效性设置不同策略,并为主动失效保留入口。缓存命中时要记录,未命中时也要知道原因,否则缓存只是不可解释的黑盒。
还要防止缓存击穿。热门页面过期瞬间,如果所有请求同时回源,压力会集中爆发。更稳妥的方式是让第一个请求负责刷新,其余请求短暂等待或继续使用可接受的旧值。
进程内可以先用 Promise 合并相同回源请求:
const refreshes = new Map<string, Promise<CachedPage>>();
async function getOrRefresh(key: string) {
const cached = pageCache.get(key);
if (cached?.fresh) return cached.value;
if (cached?.stale && !refreshes.has(key)) {
refreshes.set(key, refreshPage(key).finally(() => refreshes.delete(key)));
return cached.value;
}
if (refreshes.has(key)) return refreshes.get(key)!;
const request = refreshPage(key).finally(() => refreshes.delete(key));
refreshes.set(key, request);
return request;
}这只能合并单进程请求;多实例还需要共享锁或由 CDN 承担页面缓存。我们没有因为示例代码简单就假装问题已经全局解决,而是把适用范围写进缓存策略。
SSR 不是所有页面的默认答案
搜索引擎真正关心的是公开内容页,登录后的互动页面并不需要承担同样成本。我们按业务价值划分渲染策略:核心落地页保持完整 SSR;更新频率低的页面尽量静态化;强交互且无需索引的部分采用 CSR;某些区域可以先输出骨架,再由客户端加载。
这不是技术上的妥协,而是资源分配。SSR 提升首屏内容可见性,也增加服务端计算、状态同步和故障面。只有收益高于成本的页面才值得使用。
对同一个页面,也要区分首次访问与后续导航。首次请求需要完整 HTML,客户端接管之后可以按单页应用方式加载数据。重复执行同一请求不仅浪费资源,还可能造成界面闪动和状态覆盖。
激活失败比白屏更隐蔽
服务端 HTML 与客户端首次渲染不一致时,页面可能看起来正常,却无法正确绑定事件。时间、随机数、浏览器专属 API 和依赖屏幕尺寸的逻辑,都会造成不一致。
我们把环境相关代码集中隔离,服务端不执行依赖 window 的逻辑;需要客户端决定的内容使用稳定占位;接口数据通过明确的初始状态传递,避免客户端再次猜测。
对错误也做分类:数据接口失败时,公开页面可以返回保留主体结构的降级内容;模板渲染失败时,回退到客户端壳;服务过载时,优先保护核心路由。降级不是一个统一的错误页,而是根据页面价值保留尽可能多的可用能力。
| 页面 | 正常策略 | 数据超时 | 渲染失败 | 过载 |
|---|---|---|---|---|
| 公开内容页 | SSR + 页面缓存 | 旧缓存并标记时间 | CSR 壳 | 优先保留缓存命中 |
| 搜索页 | SSR 首屏 | 空结果 + 可重试 | CSR | 限制复杂查询 |
| 登录后工作台 | CSR | 局部错误 | CSR | 返回明确维护状态 |
如果没有按页面价值区分,团队很容易给所有错误返回同一张 500 页面,或者让所有路由竞争同一份 SSR 资源。
容量来自压测,也来自边界
单次请求变快并不等于系统稳定。我们使用接近真实流量分布的压测,而不是只压一个最简单路由;观察吞吐量的同时,关注 P95 延迟、内存增长、错误率和事件循环阻塞。
Node 进程擅长 I/O,但昂贵的同步计算仍会阻塞所有请求。大对象序列化、复杂字符串处理和日志输出都可能成为隐藏成本。对于可以预计算的内容,尽量移出请求链路;对于无法避免的计算,限制输入规模并考虑拆分。
进程管理和健康检查也属于架构。服务要能识别自己是否失去响应,实例重启不能造成全部容量同时下降,发布过程要保留旧版本承接流量。前端团队一旦拥有 SSR 服务,就必须承担服务端运行责任。
优化后的真正变化
最终的提升不只来自某个缓存库或 Nuxt 配置,而是来自一组彼此配合的选择:缩小 SSR 范围,建立分层缓存,减少重复数据请求,隔离环境差异,补齐监控和降级,再用压测验证容量。
这次重构改变了我对前端性能的理解。页面变慢可能表现为浏览器问题,根因却可能在接口、渲染策略、缓存语义或发布系统。性能从来不是最后阶段的“优化项”,它是系统如何使用资源的结果。
当问题跨越浏览器与服务端后,最有价值的能力不是熟悉更多参数,而是能画出链路、找到主要矛盾,并让每个优化都拥有可验证的证据。
SSR 评审时我会要求一张容量表
每类路由列出峰值 QPS、P95 服务端渲染时间、缓存命中率、上游调用数、单实例内存和降级策略。没有容量数字的“开启 SSR”只是功能选择,还不是运行设计。
压测也按真实路由占比混合,不只压最简单页面。最终验收看峰值下错误率、事件循环延迟、缓存击穿时的上游 QPS,以及回滚到 CSR 壳是否仍能完成核心动作。