页面越用越卡、最后白屏崩掉:vibe 项目的内存泄漏排查实战
越用越慢、重启就好,十有八九是内存泄漏:三快照定位法找到引用链,七类藏身处对号入座修掉,再用同一套操作验证一遍,最后把内存预算和检查清单留下来防复发。

页面越用越卡,最后白屏崩掉:这就是内存泄漏
一个 vibe 项目最常见的死法,不是上线第一天就报错,而是慢慢变慢。刚打开时一切正常,用半小时后开始卡顿,滚动掉帧,输入延迟,最后标签页白屏,或者 Node 服务进程被系统杀掉,日志里只剩一行 Out of Memory。重启能好几个小时,然后老毛病又回来。这种“越用越糟、重启就好”的症状,十有八九是内存泄漏:程序不断向内存里放东西,却再也不把它们还回去。
本文讲的是怎么找到泄漏、确认泄漏、修掉泄漏,以及怎么让它不再回来。范围划在前面:本站已有的《错误监控与崩溃上报实战》讲的是“崩了之后怎么第一时间知道”,《性能优化实战》讲的是“怎么让页面跑得快”,《缓存策略实战》讲的是“缓存怎么做才划算”。本篇只讲一件事:内存只增不减的问题从哪里来、怎么定位到某一行代码、修完怎么证明真的修好了。以下示例均为虚构的教学示例,应用名与代码片段均为为讲解而构造的简化版本。

一、先分清:泄漏、膨胀与正常占用是三回事
动手之前先把三件事分开,否则很容易修错方向。正常占用是指程序工作时本来就需要的那部分内存,比如打开一个大表格要加载几万行数据,内存涨上去是应该的,关掉表格就应该降下来。膨胀是指数据结构越长越大,但程序本身还认得这些数据,比如一个只增不减的日志数组,问题出在产品设计上。泄漏是指程序已经不需要某些东西了,却因为某个引用还抓着它们不放,垃圾回收器想收也收不走。
判断是不是泄漏,只问三个问题。第一,内存是不是随着使用时间单调上涨,而不是围绕一个水平线上下波动?第二,同样的操作重复做十次,内存是不是涨十次,而不是第一次涨、后面持平?第三,回到初始状态——比如关掉弹窗、回到首页、登出再登录——内存是不是回不到出发点?三个问题有两个以上答“是”,基本可以按泄漏查。如果内存涨上去就稳住了,那是缓存或者预加载,先别往泄漏的方向查。
还有一个容易误判的点:浏览器的内存数字本身会上下浮动,垃圾回收不是实时发生的。正确的做法是看趋势而不是看某一秒的数值,强制触发几次垃圾回收后再读数。Chrome 开发者工具的 Memory 面板里那个小垃圾桶图标,就是用来干这个的。
二、泄漏最爱藏的七个地方
泄漏的原因千奇百怪,但藏身处高度集中。先记住这张表,排查时按表逐项过,八成泄漏都在里面。
| 藏身处 | 典型症状 | 为什么收不走 |
|---|---|---|
| 没移除的事件监听器 | 组件反复挂载卸载后内存上涨 | 监听器抓着组件实例,DOM 走了,实例还在 |
| 没清理的定时器与订阅 | 切走页面后网络请求还在跑 | 定时器回调持有闭包,闭包持有旧状态 |
| 闭包意外捕获大对象 | 打开一次大文件,内存永久多一块 | 小函数顺手抓住了整个大对象 |
| 游离的 DOM 节点 | 列表反复刷新后页面越来越卡 | JS 变量还指着已经从页面上摘掉的节点 |
| 没有上限的缓存 | 跑得越久内存越大,重启才好 | 缓存键只增不减,从不淘汰 |
| 意外的全局变量 | 内存阶梯式上涨,每次操作涨一截 | 漏写声明的变量挂到了全局对象上 |
| 第三方脚本与 iframe | 接了某个 SDK 后内存开始爬坡 | 外部脚本自己建了监听器和定时器 |
其中最容易被 AI 生成代码带出来的,是前两项。AI 写组件时很爱在 useEffect 里加监听器和定时器,却常常漏掉清理函数;写 Node 服务时很爱搞一个模块级对象当缓存,却从不给它设上限。审 AI 代码时,这两处要优先看。
三、浏览器端:三快照定位法
前端泄漏的标准查法是“三快照定位法”,全程只用 Chrome 开发者工具,不用装任何东西。先打开 Memory 面板,点一下垃圾桶图标强制回收,拍第一张堆快照。接着把怀疑的操作重复做十次,比如打开关闭同一个弹窗十次,或者切换十次列表页,每做完一次可以顺手点一下垃圾桶,但关键是操作要完全相同。最后再强制回收一次,拍第二张快照。对照两张快照,看哪些对象的数量涨了十倍左右,那就是泄漏的候选。
光看数量还不够,要看“是谁抓着它”。在第二张快照里点开一个涨得最多的对象,看它的 Retainers(引用链),顺着链条往上找,找到第一个属于你自己代码的函数名或变量名,泄漏点基本就在那里。常见的链条是这样的:某个已卸载组件的实例,被一个全局事件监听器抓着;或者一个已经关闭的弹窗,被一个没清理的 setInterval 回调抓着。找到链条后,第三张快照是用来验证修复的:修完再重复同样的操作,数量不再涨,才算真的修好。
还有两个辅助工具值得用。Performance Monitor 面板可以实时看 JS 堆大小、DOM 节点数和监听器数量的曲线,如果监听器数量那条线一直在往上走,泄漏点就在监听器上。Allocation Timeline 可以记录一段时间内新分配的内存都来自哪些函数,适合抓“每次操作都新建大对象”的问题。注意一个细节:做快照之前,先把页面上无关的扩展程序关掉,扩展自己的内存也会进快照,容易干扰判断。
四、服务端:Node 进程的泄漏排查
Node 服务的泄漏更隐蔽,因为没有页面可以刷新。典型的信号是进程的常驻内存 RSS 持续爬坡,重启后从低点重新开始爬,最后被系统 OOM Killer 杀掉。第一步是确认爬坡真的存在:用 process.memoryUsage 的 heapUsed,每分钟记一个点,画一条线。光看 RSS 不够,RSS 里包含了很多非堆内存,真正要盯的是 heapUsed 和 heapTotal 的趋势。
// 怀疑泄漏时,先在服务里加一个最简单的内存日志
setInterval(() => {
const m = process.memoryUsage();
console.log(JSON.stringify({
time: new Date().toISOString(),
heapUsedMB: Math.round(m.heapUsed / 1024 / 1024),
heapTotalMB: Math.round(m.heapTotal / 1024 / 1024),
rssMB: Math.round(m.rss / 1024 / 1024),
}));
}, 60 * 1000);
确认爬坡之后,用堆快照找元凶。启动时加上 --inspect,用 Chrome 连上去拍快照,方法与浏览器端一样;或者在进程里集成 heapdump,在内存超过阈值时自动写一份快照文件。拿到两份间隔一段时间的快照后做对比,重点看新增的对象里有没有你自己的业务对象,比如某个“会话”“任务”“连接”类的实例数量在涨。数量涨、且引用链指向一个模块级 Map 或数组,基本就是缓存没设上限,或者事件订阅没取消。
还有一类服务端泄漏来自连接和流:数据库连接池泄漏、没关闭的文件流、没消费完的响应体。它们的共同特征是堆内存不一定涨,但句柄数和连接数在涨,最后表现为“服务还能跑,但新请求全部超时”。排查时顺手看一眼 open handles 和数据库的活跃连接数,别只盯着堆。
五、七类泄漏的标准修法
定位到泄漏点之后,修法基本是固定的,按藏身处对号入座。
事件监听器:谁添加谁移除。组件卸载时移除监听器,addEventListener 和 removeEventListener 必须用同一个函数引用,匿名函数是移除不掉的。React 里把清理写进 useEffect 的返回函数,不要依赖组件“应该会被销毁”这种假设。
// 泄漏写法:匿名函数,永远移除不掉
window.addEventListener('resize', () => setWidth(window.innerWidth));
// 修法:具名函数 + 清理
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
定时器与订阅:setInterval、setTimeout、WebSocket、事件总线订阅,全部要在不需要时清理。容易漏的是“组件卸载了,但定时器回调里还在 setState”,修掉的标志是切走页面后网络请求和定时器都停了。
闭包:只捕获需要的东西。回调函数里如果只需要一个 id,就只传 id,不要把整个大对象传进去。处理大文件时,读完就把引用置空,让垃圾回收器能收走。
游离 DOM:不要把 DOM 节点长期存在 JS 变量或 Map 里。一定要缓存节点引用时,用 WeakMap 或者 WeakRef,让节点从页面上摘掉后可以被回收。列表渲染用 key 保证复用,而不是每次刷新都新建一整棵子树。
无界缓存:任何缓存都必须有上限和淘汰策略。最简单的是 LRU:超过 N 条就淘汰最久没用的那条;或者按时间过期,超过 TTL 就失效。给缓存加一个 size 指标,上线后能看到它在涨还是稳住了。
全局变量:严格模式下漏写声明会直接报错,开发环境就把严格模式打开。审代码时搜一搜模块顶层那些“顺手放一下”的对象,它们是最容易被遗忘的引用。
第三方脚本:先隔离再定罪。关掉可疑的 SDK 跑一轮三快照,确认是它的问题后,限制它的加载范围,或者在不需要时销毁它的实例。不要一上来就改自己的代码,外部脚本的泄漏改自己的代码是修不好的。
六、怎么证明真的修好了
泄漏修复最容易犯的错,是凭感觉说“应该好了”。正确的做法是把排查时用的操作写成一个可重复的验证脚本:同样的操作重复十次,修之前内存涨多少,修之后内存涨多少,两组数字贴在一起才算证据。浏览器端可以用同一套三快照流程复核;Node 端可以写一个压测循环,在固定 QPS 下跑十分钟,看 heapUsed 的曲线是不是平了。
还有两个容易漏的验证点。第一,修完之后要验证功能没坏:监听器移除得太激进,会导致按钮点一次就失灵;缓存加了上限之后,要确认热点数据没有被频繁淘汰。第二,要在接近真实数据的量级上验证:用十条数据测不出来的泄漏,用一万条数据可能一次就复现。验证用的数据量要和线上真实量级差不多。
七、防复发:让泄漏进不了下一版
修一次泄漏不难,难的是别让下一版又写出新的。三个机制最管用。第一是内存预算:给关键页面和关键接口定一个上限,比如“列表页来回切换二十次,堆内存增长不超过 10MB”,写进验收清单。第二是回归测试:把三快照流程里最关键的操作写成自动化测试,跑在 CI 里,超预算就拦截合并。第三是代码审查清单:审 AI 生成的代码时,专门检查监听器有没有清理、定时器有没有清理、缓存有没有上限这三项,十秒钟就能扫完。
另外给线上加两道保险。浏览器端可以接一个轻量的性能上报,定期采样堆大小,发现异常爬坡就告警。Node 端可以在 heapUsed 超过阈值时自动写堆快照并告警,留一份“案发现场”供事后分析,而不是等进程被杀掉之后什么证据都没有。告警的阈值要按真实业务量来定,定得太低会天天误报,定得太高等于没有。
八、两则排查手记:从现象到根因的完整链条
光讲方法不够,这里把两次完整的排查过程写下来,重点看思路是怎么走的。以下两个案例均为虚构的教学示例,应用名、数字均为讲解而构造。
案例一:聊天室页面越聊越卡。示例应用“闲聊岛”是一个网页聊天室,用户反馈聊半小时后输入框开始延迟。排查时先看 Performance Monitor:DOM 节点数在缓慢上涨,监听器数量也在涨。拍三快照对比,发现 MessageList 组件实例的数量涨了十几倍,引用链指向一个全局消息总线的订阅。根因是每次进入聊天室都订阅一次消息事件,离开时没有取消订阅,旧组件实例被订阅回调一直抓着。修法是在组件卸载时取消订阅,并把订阅逻辑收敛到一个统一的 Hook 里。验证时重复进出聊天室二十次,组件实例数回到个位数,内存曲线平了。
案例二:导出服务每天凌晨被杀。示例应用“报表工坊”有一个 Node 导出服务,每天凌晨跑批量导出任务时进程内存一路爬坡,最后被系统杀掉。内存日志显示 heapUsed 每跑一批任务涨一截,任务结束后不回落。两份堆快照对比,涨得最多的是一个模块级对象里的任务记录,每条记录还挂着整份导出文件的内容。根因是任务完成后只改了状态字段,没有删记录,也没有释放文件内容。修法是任务完成后删除记录、释放文件引用,并给这个对象加上条数上限和过期清理。验证时连续跑三批任务,heapUsed 在任务结束后回到基线,服务连续跑一周没有再被杀。
两个案例的共同点是:现象都发生在“重复操作”上,根因都是“用完的东西还有引用抓着”,定位都靠“对比两份快照、顺着引用链找自己的代码”。记住这个链条,下次遇到类似症状就不用从零开始想。
最后说一句关于 AI 生成代码的提醒。让 AI 写功能代码很快,但 AI 对“生命周期”没有直觉:它不知道这个组件会被反复挂载,不知道这个服务要连续跑三个月,也不知道这个缓存会攒下多少条记录。所以 AI 交出来的代码,功能常常是对的,泄漏常常是顺手埋下的。审 AI 代码时,不用逐行读,先搜三个东西:addEventListener 有没有对应的 removeEventListener,setInterval 和订阅有没有清理入口,模块级对象有没有上限。这三处扫完,九成隐患就现形了。把这三条写进团队的代码审查清单,比事后排查要便宜得多。
九、上线前检查清单
按这张清单逐项过一遍,再发布下一版。第一,重复操作十次后内存回到起点附近。第二,所有监听器、定时器、订阅都有对应的清理代码。第三,所有缓存都有上限和淘汰策略。第四,大对象用完后引用及时置空。第五,第三方脚本做过开关对比,确认没有它带来的爬坡。第六,验证脚本跑过,修前修后的内存数字有记录。第七,线上内存告警和自动快照已配置。八,验证用的数据量接近线上真实量级。
内存泄漏不可怕,可怕的是它悄悄发生、慢慢变坏,等到用户先发现的时候,口碑已经丢了。把它当成一个固定流程来查:先看趋势确认是泄漏,再用快照定位到引用链,按七类修法修掉,最后用同样的操作验证一遍。流程跑顺了之后,一次泄漏从发现到修好,通常就是半天时间。更重要的是,修完之后把预算和检查清单留下来,让下一个泄漏在合并之前就被拦住,而不是等用户在评论区告诉你页面又卡死了。
评论 (0)
相关文章

2GB 视频传到 99% 断了不该从头再传:分片协议怎么定、前端怎么切怎么传、服务端怎么合、秒传怎么做、三层失败处理怎么做,附上线前检查清单。

多轮对话拼的不是模型记性,而是状态、澄清、打断、回指和修复五件事:状态机先画好、澄清只问会办错的事、改主意一句就够、听错了分级修,聊完还能留下一份摘要。

日志是产品的飞行记录仪,但前提是按规矩写。结构、级别、关联 ID、字段、脱敏、采样、留存:一个一人团队今天就能落地的七件事,出问题时才不会靠猜。