SSR 服务为什么可能出现内存泄漏?如何排查和预防?
简化版
SSR 服务是长期运行的进程,如果在模块级变量、全局缓存、定时器、事件监听、请求上下文、流对象里保留了本该随请求释放的数据,就可能内存泄漏。排查时要看内存曲线、堆快照、对象保留路径和发布关联;预防时要避免全局存用户数据,给缓存设置容量和 TTL,请求结束后清理资源,并用压测观察多轮请求后的内存是否回落。
详细版
和纯静态站不同,SSR server 会持续处理请求。一次请求里创建的用户信息、接口结果、组件上下文,如果被全局引用保存下来,就无法被垃圾回收。
常见原因包括:为了优化性能做了无上限 Map 缓存;在模块单例中存了 request;每次请求都注册事件监听但不移除;流式渲染异常中断后没有关闭资源;定时器捕获了大对象。
面试中要强调“泄漏不是内存高,而是请求结束后对象仍被引用,并且多轮流量后持续增长”。
完整版教学
一、SSR 内存泄漏的本质
Node.js 进程会复用。
模块级变量会跨请求存在。
如果某个请求对象被全局结构引用,它就不会随请求结束释放。
流量越大,泄漏越快表现为内存上涨、GC 频繁、响应变慢,最后进程 OOM。
二、常见泄漏来源
| 来源 | 例子 | 风险 |
|---|---|---|
| 全局缓存 | new Map() 无 TTL | 数据无限增长 |
| 请求上下文 | 把 user/request 存到单例 | 用户数据串请求 |
| 事件监听 | 每次请求 on 不 off | listener 累积 |
| 定时器 | interval 捕获大对象 | 长期无法释放 |
| 流对象 | 异常时未关闭 | buffer 堆积 |
| 三方库 | 单例内部缓存 | 不易观察 |
三、代码示例
const cache = new Map<string, unknown>();
export async function render(req) {
const user = await getUser(req);
// 问题:如果 key 无限增长,user 会长期留在进程里
cache.set(req.url + user.id, user);
return renderHtml(user);
}
如果确实需要缓存,应该设置容量、TTL 和淘汰策略,并避免存放敏感用户对象。
四、排查步骤
先看内存曲线是否持续上升。
再区分是正常缓存升高还是泄漏。
用压测复现多轮请求,看流量停止后内存是否回落。
抓取 heap snapshot,对比增长对象。
查看 retaining path,找到是谁持有引用。
排查内存泄漏时,不要只看某一刻内存高低,要看“增长趋势”和“是否可回收”。
五、和缓存的边界
SSR 缓存能提升性能,但缓存必须有边界。
页面级缓存可以放 CDN 或外部缓存,减少进程内缓存压力。
进程内缓存只适合小规模、低敏感、可淘汰的数据。
用户级数据不要随意放全局缓存。
六、预防策略
代码评审关注模块级可变状态。
封装 request context,确保只在请求生命周期内使用。
事件监听用完移除。
超时、取消和异常路径都要释放资源。
上线前用压测观察内存平台期。
七、误区和追问
- 误区:Node.js 内存上涨就是泄漏。 V8 可能延迟 GC,关键看停止流量后是否回落和对象是否仍被引用。
- 误区:SSR 页面是前端,不用管服务内存。 SSR 服务就是后端进程,稳定性责任更接近服务端。
- 误区:缓存越多性能越好。 无边界缓存会把性能优化变成稳定性事故。
- 追问:怎么定位是哪个对象泄漏? 对比 heap snapshot,看增长对象和 retaining path。
- 追问:为什么全局变量危险? 它跨请求存在,容易把一次请求的数据保留到进程生命周期。
- 追问:临时重启能解决吗? 只能止血,不能解决泄漏源,流量回来还会复发。
八、面试总结口径
回答时按“现象、原因、排查、预防、兜底”讲。
兜底可以包括进程守护、内存阈值重启、限流和降级,但这些不能替代修复根因。