做浏览器自动化最怕的不是脚本写不出来而是脚本跑着跑着内存没了。前几天刚处理完一个Playwright自动化脚本的内存溢出问题从现象出现到最终定位前后折腾了一天多。这个问题的典型程度很高几乎每个做爬虫采集、UI回归测试、批量任务处理的团队都会在某个阶段撞上它。先说结论Playwright脚本的内存溢出绝大多数情况下不是你电脑内存不够而是脚本在长时间运行时把资源“堆”起来了——页面关得不够干净、数据全攒在内存里、事件监听器越挂越多最终Node进程的内存被吃满或者Chromium子进程多到把系统拖垮。问题不难解决但前提是你能系统地定位它而不是靠猜。这篇文章不是教科书式的原理讲解而是基于我这次真实排查过程的一次“手术复盘”。我会把一个完整的Playwright自动化脚本内存溢出问题从现象确认、根因分析、代码修复、监控预防四个环节完整走一遍并给出可以直接抄走的检查和修复方案。适合正在做自动化采集、批量测试、或者用Playwright写常驻任务的开发者参考。1. 先别急着改代码确认问题的真实形态接到这个任务时我的第一反应不是去看代码而是先搞清楚“内存溢出”到底是怎么发生的。因为内存问题的表象经常是类似的但根因可以完全不同——有的属于Node进程堆内存耗尽有的属于浏览器页面堆积有的甚至只是容器里的共享内存目录太小导致浏览器崩溃。1.1 三类最常见的异常表现我习惯把Playwright内存问题分成三类通过观察现象就能快速缩小范围异常表现典型日志或特征大概率指向JS heap out of memory脚本运行一段时间后直接抛“FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory”Node进程侧对象堆积数组、缓存、监听器过多系统整体变卡、进程被杀脚本还在跑但机器或容器内存飙升到90%以上随后进程被OOM Killer杀掉Chromium子进程数量膨胀或浏览器实例未关闭浏览器页面崩溃提示“Renderer process gone”、“Target closed”或直接无响应dev/shm不足、浏览器渲染进程内存超限或页面长期未释放我这次遇到的案例三种现象都有点影子脚本从早上开始采集数据前两个小时一切正常到第三个小时开始明显变慢日志里的每一轮任务耗时从2秒慢慢涨到10秒以上最后Node进程直接抛heap out of memory崩溃退出。系统监控上能看到内存从启动后的500MB左右一路涨到接近4GB然后被容器限制杀掉。1.2 判断是持续泄漏还是任务堆积拿到这个现象后我做的第一步是拉内存趋势图。这里有一个判断技巧如果内存随时间呈线性增长且没有任何回落的迹象那就是典型的“资源泄漏”——每次循环都有对象没释放如果内存呈阶梯式增长每一批任务结束后内存涨一截然后不再回落那多半是“缓存或句柄堆积”例如大量监听器、定时器、历史数据被保留。我这个任务的内存曲线是典型的线性增长每处理100个详情页内存稳定上涨200MB左右。这种特征基本上可以锁定是代码里的某个对象在循环过程中被持续保留不再被垃圾回收。为什么强调先确认形态因为如果一开始就埋头改代码很可能把时间花在调整浏览器参数上却忽略了真正的对象泄漏——那种“参数调一调好像好了跑两天又爆”的反复多半就是根因没找对。2. 用一套严谨的排查流程把泄漏点揪出来确认了是线性泄漏后剩下的问题就是是哪个对象被保留住了刚开始我也试过直接肉眼读代码结果看了半天没看出来。后来老老实实按照下面的流程走才一步步把问题逼到死角。2.1 分清内存涨在Node侧还是浏览器侧这是整个排查流程里最关键的岔路口。我并行开着三个监控视角Node侧process.memoryUsage()可以拿到rss、heapUsed、external三个关键指标写一个定时打点函数每10秒输出一次浏览器侧用系统命令ps -ef | grep chrome或ps -o rss,cmd -p pid观察Chromium子进程的个数和单个进程的内存占用系统侧top或pidstat -r看整体内存变化。排查结果是Node进程的heapUsed和rss同步上涨而Chromium子进程的个数保持稳定每个子进程的内存也没有明显增长。这基本排除了浏览器页面堆积的可能把问题锁定在了Node侧JavaScript堆里。这里分享一个我踩过的坑一开始我只看系统内存总量看到Chromium进程占了不少内存以为是无头浏览器的问题差点把火力和修复方向全放到了浏览器参数上。实际上Chromium进程占用几百MB是正常的真正的异常在Node侧的堆里。所以区分两侧的涨幅必须是第一步。2.2 用GC日志和内存快照定位被保留的对象定位Node侧对象我用的是两个手段配合第一启动时加--trace-gc参数让Node输出垃圾回收的日志。核心看三点GC触发的频率是否越来越密、每次GC后堆内存是否回到接近初始的水平、Mark-sweep标记清除后的堆余量是否在逐步抬高。我这次的日志显示GC次数随时间越来越频繁且每次强制GC之后堆内存仍然缓慢上升说明有对象从GC的视野里“逃”掉了。第二通过--inspect开启调试端口然后在Chrome DevTools的Memory面板分别取两个时间点的堆快照比如刚启动时和运行1小时后用“Comparison”模式对比看哪些构造函数创建的实例数量暴增。这个操作在本地复现时非常有效。结果很快指向了两个构造函数一个是Array一个是EventListener相关的对象。Array实例数量随任务进度线性增加方向上对了。这还没完。真正让我确定代码位置的是我发现Array里保存的全是字符串文本——每一轮网络请求的响应体都被push到了同一个数组里从未清理。结合代码一看果然是采集数据时把每次的响应body都保留了下来说是为了最后统一写文件结果数据量一上来Node堆直接被塞爆。2.3 用二分法验证修复效果定位到怀疑点后不要一次性改多处。我习惯的做法是先注释掉“最可疑”的那段代码跑一个最小复现脚本观察内存曲线是否变得平稳。如果变平稳说明就是这里如果还在涨再恢复这段、注释掉下一段用排除法继续逼近。这个二分法看起来笨但它在面对多个可疑点同时存在时极其可靠。比如我这个项目里除了数据数组还有几处事件监听器也写得不太干净如果一次性把所有地方都改了就算内存恢复正常你也说不清是哪一处起的作用——下次换个场景问题还会再犯。3. 六类最常见的Playwright内存泄漏根源排查完我自己的问题后我又把最近几年见过的Playwright脚本内存问题做了个归类。你会发现大部分问题翻来覆去逃不出下面六个套路。每一项我都附上了代码级的“病根”和“处方”。3.1 页面和浏览器上下文开了不关这是最基础、也最常见的一类。很多人知道要page.close()但很少注意browserContext也需要关闭。Playwright里browser.newContext()会创建一个全新的浏览器上下文它拥有独立的存储、缓存和渲染进程资源。你只关页面不关上下文相当于每次循环都开了一个小型浏览器实例内存自然逐步膨胀。我的建议是每个独立的任务单元都使用独立的Context并且用try/finally保证即使中途报错也能释放// 错误写法 async function fetchPage(browser, url) { const context await browser.newContext(); const page await context.newPage(); await page.goto(url); // 处理逻辑 await page.close(); // 只关了页面context还在 } // 正确写法 async function fetchPage(browser, url) { const context await browser.newContext(); try { const page await context.newPage(); await page.goto(url); // 处理逻辑 } finally { await context.close(); // 会连带关闭页面 } }注意顺序先关页面再关Context可以但直接关Context更稳妥因为它会一并关闭其下的所有页面避免有页面在异常路径中被遗漏。3.2 事件监听器只增不减Playwright的page、context、browser上可以挂很多事件page.on(console)、page.on(request)、page.on(response)等。如果你在循环里创建新页面的同时也挂上了监听器但页面关闭后监听器没有及时解绑这些函数对象就会被外部引用继续保留。这类问题最坑的一点是光看内存快照不容易发现因为单个监听器的体积很小但它会作为闭包把周围的大对象一并带住。我在代码里见过最夸张的情况是循环挂page.on(response)并往全局日志数组里写内容跑了500个页面后监听器数量有两三千个全部指向同一个大数组。修复要点要么用命名函数并在结束时off要么把监听逻辑放到一个独立的生命周期里async function processPage(browser, url) { const context await browser.newContext(); const page await context.newPage(); const onResponse (response) { log([${url}] status: ${response.status()}); }; page.on(response, onResponse); try { await page.goto(url); // 处理逻辑 } finally { page.off(response, onResponse); await context.close(); } }如果你用的是page.once()它会在事件触发一次后自动移除一般不会泄漏但要注意事件可能一直不触发。3.3 数据全攒在内存数组里这就是我这次踩的坑。采集类的脚本很容易写出这样的代码const results []; for (const url of urls) { const page await context.newPage(); await page.goto(url); const text await page.locator(body).innerText(); results.push(text); // 每个页面几百KB文本几千个页面就是GB级 await page.close(); } fs.writeFileSync(output.txt, results.join(\n));看起来逻辑完整但results这个数组把所有的页面文本都保存在堆里直到脚本最后才一次性写盘。数据量小的时候没事数据量一上来Node的堆直接撑爆。修复思路非常简单改造成边采集边落盘或边处理边丢弃const writeStream fs.createWriteStream(output.txt, { flags: a }); for (const url of urls) { const page await context.newPage(); try { await page.goto(url); const text await page.locator(body).innerText(); writeStream.write(text \n); } finally { await page.close(); } } writeStream.end();原则只有一个不要让内存替你暂时保存本来要写入磁盘的数据。想收集统计指标就每批次计算后只保留汇总值不要保留原始明细。3.4 截图和二进制数据没有及时释放另一个高频元凶是截图。page.screenshot()默认返回一个Buffer如果你把它存到数组里等着最后写文件效果和存文本一样Buffer属于Node的external内存同样会涨上去而且涨得更猛。一张全屏截图像素高一点就是几MB甚至十几MB上千张截图全在内存里的画面我见过不止一次。好习惯是截图直接带path参数写到磁盘完全不经过Node堆await page.screenshot({ path: screenshots/${taskId}.png, fullPage: true });如果你确实需要拿Buffer做后续处理比如上传到对象存储用完立刻置空引用不要保留在数组里const buffer await page.screenshot(); await uploadToStorage(buffer); // buffer不再被引用等GC回收还有一类容易被忽略的二进制大对象是response.body()的返回值。如果采集时调用了const body await response.body()然后把body保存下来做后续分析同样会占用大量内存。改成直接用流式方式消费或者只从响应里提取你需要的几个字段不要留整包。3.5 并发打开页面却没有限流Playwright的API设计得很顺手很多人图快直接用Promise.all一次性并发开启几十个页面任务const tasks urls.map(url processPage(browser, url)); await Promise.all(tasks);这个写法如果urls有几百个相当于同时把所有任务都发射了出去每个任务至少会创建一个Context和Page同时还有各自的网络请求、渲染进程。内存和CPU直接被打满脚本还没开始稳定工作系统就已经濒临崩溃。正确的做法是控制并发数。写一个简单的并发调度器或者直接使用p-limit这类库把并发控制在5到10左右。Playwright本身是异步非阻塞的10个并发已经能跑出很不错的吞吐量没有必要一次性全开。3.6 驱动子进程残留前几类都是Node堆的问题还有一类是“系统级”的内存问题Playwright通过Node驱动Chromium进程如果你的脚本经常在异常中断时没有调用browser.close()那么对应的驱动进程和Chromium子进程就会残留在系统里。这类问题的特点是脚本明明已经退出了但ps -ef | grep chrome还能看到一堆进程或者容器内存看着一直在涨可是Node进程并不大。除了在代码里用try/finally确保browser.close()外还可以在进程退出前做一次清理process.on(exit, () { // 打印当前所有相关进程信息或调用清理逻辑 });但注意exit事件里只能放同步代码异步的browser.close()放进去是无效的。更可靠的做法是在主流程里显式管理一个全局的Browser实例并在任务结束后主动close()。如果脚本模式是常驻的可以像下一节说的那样定期重启进程顺便清理所有子进程。4. 一套可直接落地的内存治理方案定位并修掉具体泄漏点还不够。内存治理要想干净必须同时做三件事把资源生命周期管好、给内存设上限、让超限可以被发现和自动恢复。4.1 从架构上隔离任务避免单进程扛全部当你的自动化任务需要长时间运行或处理海量数据时最好的兜底方案不是“把这个脚本修到零泄漏”而是把任务切分成多个独立进程每个进程处理一批跑完就退出内存随之释放。使用Playwright Test Runner时它本身就有worker机制每个worker是独立的Node进程。你只需要配置并发数// playwright.config.js module.exports { workers: 4, // 最多4个worker并行 fullyParallel: true, };每个worker处理完自己的用例文件后进程会退出并回收全部内存worker间的内存完全隔离。这比在单进程里不断优化GC要稳得多。如果你是自研的采集脚本可以考虑用child_process或worker_threads把任务列表分成多个批次每个子进程处理N条后退出由主进程调度下一批。我这里给一个基于child_process.fork的简化示例// master.js const { fork } require(child_process); const ALL_URLS getUrls(); // 全部待处理列表 const BATCH_SIZE 200; function runBatch(urls) { return new Promise((resolve, reject) { const child fork(./worker.js); child.send(urls); child.on(message, (msg) { if (msg.type done) { child.kill(); resolve(msg.result); } }); child.on(error, reject); }); } async function main() { for (let i 0; i ALL_URLS.length; i BATCH_SIZE) { const batch ALL_URLS.slice(i, i BATCH_SIZE); await runBatch(batch); // 一个批次跑完子进程退出内存彻底回收 } }这种“批次化进程隔离”的模式即使某个worker真的泄漏了也只会影响它自己那一批跑完即释放永远不会拖垮整个任务。4.2 内存水位监控与自动熔断光有架构兜底还不够我得能实时看到内存水位。我的做法是在脚本里放一个监控定时器定期检查内存超过阈值就主动退出或重启const CHECK_INTERVAL 10 * 1000; const MAX_RSS_MB 2048; setInterval(() { const mem process.memoryUsage(); const rssMB Math.round(mem.rss / 1024 / 1024); const heapMB Math.round(mem.heapUsed / 1024 / 1024); console.log([mem] rss${rssMB}MB heapUsed${heapMB}MB ext${Math.round(mem.external / 1024 / 1024)}MB); if (rssMB MAX_RSS_MB) { console.error([mem] RSS超过阈值(${MAX_RSS_MB}MB)准备退出); // 记录现场数据然后退出由外层调度器拉起新一轮 process.exit(1); } }, CHECK_INTERVAL);配合上层的批次化调度这个方案可以做到内存正常时长期稳定运行内存异常时自动退出并重启不会让任务在崩溃边缘反复摩擦。4.3 主动给Node和Chromium套上内存缰绳Node默认的堆内存上限在不同版本和环境下并不一致老版本的V8在64位系统上可能只给你不到2GB的堆。如果任务确实需要更大的堆可以在启动命令里显式设置node --max-old-space-size4096 script.js但这只是“上限”不是“优化”。更值得注意的是你可以通过调低上限来让问题更早暴露。比如开发测试时用--max-old-space-size1024跑全量数据一旦内存异常会在小规模时就崩给你看方便快速定位而不是跑到生产环境4GB才爆。Chromium浏览器侧也有类似参数。如果你发现单个页面渲染占用的内存异常高可以在启动时传入V8参数或禁用共享内存限制const browser await chromium.launch({ args: [ --disable-dev-shm-usage, --js-flags--max-old-space-size4096 ], });--disable-dev-shm-usage在容器环境尤其重要因为默认的/dev/shm只有64MBChromium在渲染较大页面时很容易因为写共享内存失败而崩溃。加了这条参数后Chromium会改用/tmp目录存储大部分场景下都能解决。4.4 从设计上减少内存放大最后一层是“设计上不产生大内存”。这句话听起来抽象落实到Playwright脚本上就几条能用locator和evaluate直接提取目标字段的不要先拿innerText或整个页面HTML再自己解析需要遍历大量列表页和详情页时详情页的Context用完即关不要复用在同一个Context里开几百个页面日志、统计、历史记录能写得紧凑就紧凑不要为了事后“复盘”把所有原始数据都放在内存里等如果你跑的是超长任务建议每跑到N条数据就主动重启一次浏览器实例把积累的碎片和缓存彻底清掉。5. 实测案例某数据采集系统从崩溃到稳定最后聊一个完整的例子。我们内部有一个基于Playwright的数据采集项目姑且叫“某跨平台数据采集系统”功能是对某个站点做详情页批量采集。采集中间要处理大量JS渲染的动态页面所以没法用纯HTTP请求必须靠无头浏览器。问题很典型脚本每天早上启动跑大约两到三个小时后崩溃日志末尾是V8的heap out of memory。更麻烦的是这个崩溃时间还会随着数据量增大而越来越提前运维同事一度怀疑是服务器配置不够甚至准备加内存扩容。我接手后先加了监控打点并跑了一轮。现象和预期一致内存从500MB线性涨到4GB后崩溃。Chromium子进程数量约保持在5个左右变化不大。于是把方向锁定在Node堆。用堆快照对比后发现占内存最大的是一批字符串对象引用关系指向全局的一个results数组即所有详情页的正文全文。修复动作拆成三步一、把全量文本保存改为每页解析完成后立即写入本地文件内存里只保留每个页面的状态记录和摘要字段 二、将事件监听器全部改为具名函数并在每轮任务结束时显式off 三、整个采集流程改为批处理模式每200条数据跑完重启一次整个Node进程。改完后的效果很直接内存稳定在400MB到700MB之间波动不再一路爬升脚本可以从早上跑到晚上不再中途崩溃采集吞吐量不仅没下降反而因为不再被GC拖累而略有提升。当然这三步里面第一步是最关键的后两步属于“顺手加固”。但如果没有前期的系统性排查我可能改完第一步就收工了那些潜在隐患会在我下次换一个采集目标时再冒出来。6. 避坑清单与长期预防机制写到这里我想把这次排查过程中沉淀下来的经验浓缩成一份检查清单可直接当做一个“内存体检流程”来用。配合长期监控基本能把Playwright自动化脚本的内存问题控制在萌芽状态。6.1 十个可以背下来的自查要点每创建一个browserContext都要有对应的close()且用try/finally包住优先context.close()而不是只page.close()前者更彻底所有page.on(xx)都改成具名函数并在结束路径上off不要在全局数组里堆页面文本、响应体、截图Buffer需要批量保存数据时用fs.createWriteStream边采边写用Promise.all开并发前先算一下并发量乘单任务内存的乘积是否可控长任务务必做批次化每批次任务完成后重启进程是最后的保险容器环境启动Chromium带上--disable-dev-shm-usage监控里不仅要看Node的rss也要看externalBuffer经常藏在这里遇到崩溃不要先加内存先查泄漏否则你会陷入“加内存—崩溃延迟—再加内存—又崩溃”的死循环。6.2 建立日常监控与告警监控别整太重的方案一个简单的定时打点输出就够用了。我在上一节的示例代码里已经给出了核心逻辑你只需要把日志接到现有的日志平台或直接输出到标准输出由容器平台采集即可。加一个简单的告警规则内存连续3次超过阈值就通知相关人员。对生产环境建议把“内存超标时自动退出”也纳入逻辑。与其让进程带着异常状态硬撑不如让它快速失败由上层调度重新拉起。6.3 顺手升级依赖关注上游修复Playwright本身在持续修复内存相关的问题。比如旧版本里某些Context关闭不彻底、某些事件在页面关闭后没有完全释放都在新版本里有改善。定期升级Playwright到新版本本身就是一种内存治理手段。当然升级前先看发行说明确认没有破坏性变更。我一般会在测试环境跑一轮全量用例再上线避免踩到新版本的行为差异。最后说几句我的体会做浏览器自动化这行碰到内存问题几乎是宿命。但我发现一个规律内存问题的本质通常不是“内存不够”而是资源生命周期管理不够细致。浏览器自动化脚本天然要同时管好Node侧对象、浏览器进程、系统资源这三个层次任何一个层次的管理缺失都会在长时间运行后集中爆发。我的建议是从写第一版脚本时就把资源释放当成核心逻辑的一部分而不是“后面有空再优化”。这个习惯能帮你省掉大量线上救火的痛苦。每次写完一个Playwright脚本跑一轮全量数据、看一遍内存曲线再提交——这个流程如果你能坚持下来内存问题离你会越来越远。