上个月底发薪日,财务部门搞了个对账大促,结果刚过十点,系统监控就开始疯狂报警:核心业务网关接口大量超时,Node 进程的 CPU 占用率直接飙升到 100% 挂起不动。紧急排查了日志,原因让人哭笑不得:原来是运营发起了一个针对几万名商户的月度对账单生成任务,而之前的开发同学直接在 Node 主线程里通过 puppeteer.launch() 打开无头浏览器去渲染 HTML 并生成 PDF。
Node.js 虽然是单线程事件循环机制,但 Puppeteer 底层拉起的是一个个货真价实的 Chromium 物理进程。高并发下几百个浏览器进程同时启动,CPU 瞬间被打满,内存直接爆掉,Node 主线程的事件循环被完全阻塞,导致其它轻量级的 API 请求根本得不到响应。
踩过这个大坑后,我们痛定思痛,决定将 PDF 生成这种典型的 CPU 密集型任务从主服务中彻底剥离,构建一套基于 Redis Queue (BullMQ) + Puppeteer 物理进程池的异步解耦架构。
主服务只负责接收生成请求并向 Redis 队列投放任务,具体的 PDF 渲染则由独立的 Worker 集群去消费。为了防止内存泄露和僵尸进程占用资源,我们引入了池化管理,限制单个 Worker 同时运行的 Puppeteer 实例上限。
下面是我们在生产环境 Worker 节点运行的 BullMQ 核心处理器代码:
import { Worker, Job } from 'bullmq';
import puppeteer, { Browser } from 'puppeteer';
import * as fs from 'fs-extra';
import * as path from 'path';
const REDIS_CONFIG = {
host: process.env.REDIS_HOST || '127.0.0.1',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
};
interface PDFJobData {
billId: string;
htmlContent: string;
outputPath: string;
}
// 维持一个重用的浏览器实例,避免频繁 launch 损耗性能
let globalBrowser: Browser | null = null;
let useCount = 0;
const MAX_USE_COUNT = 50; // 累计运行 50 次后强行重启浏览器,防止 Chromium 内存泄露
async function getBrowserInstance(): Promise<Browser> {
if (globalBrowser && useCount < MAX_USE_COUNT) {
useCount++;
return globalBrowser;
}
if (globalBrowser) {
await globalBrowser.close().catch(() => {});
}
globalBrowser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage', // 防止在 Docker 容器中共享内存不足崩溃
'--disable-gpu'
]
});
useCount = 1;
return globalBrowser;
}
const pdfWorker = new Worker<PDFJobData>(
'pdf-generation-queue',
async (job: Job<PDFJobData>) => {
const { billId, htmlContent, outputPath } = job.data;
console.log(`开始处理对账单 PDF 生成任务, JobID: ${job.id}, BillID: ${billId}`);
let browser: Browser | null = null;
let page = null;
try {
browser = await getBrowserInstance();
page = await browser.newPage();
// 设置合理的视口与超时时间
await page.setViewport({ width: 1200, height: 800 });
// 直接将 HTML 写入页面,避免额外的网络请求耗时
await page.setContent(htmlContent, {
waitUntil: 'networkidle0',
timeout: 30000,
});
// 确保输出目录存在
await fs.ensureDir(path.dirname(outputPath));
// 生成 PDF,针对对账单格式进行打印参数调优
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
});
console.log(`账单 PDF 生成成功: ${outputPath}`);
return { success: true, path: outputPath };
} catch (error) {
console.error(`Job ${job.id} 执行失败:`, error);
// 如果报错可能与浏览器实例崩塌有关,强行清空实例
if (globalBrowser) {
await globalBrowser.close().catch(() => {});
globalBrowser = null;
}
throw error;
} finally {
if (page) {
await page.close().catch(() => {});
}
}
},
{
connection: REDIS_CONFIG,
concurrency: 4, // 限制每台物理机 Worker 仅并行处理 4 个 PDF 任务,防止 CPU 崩塌
limiter: {
max: 10,
duration: 1000, // 限制消费速率
}
}
);
pdfWorker.on('completed', (job) => {
console.log(`Job ${job.id} 完成`);
});
pdfWorker.on('failed', (job, err) => {
console.error(`Job ${job?.id} 彻底失败: ${err.message}`);
});
把这套架构部署到独立的 Docker 容器集群后,即使运营在后台一次性导出几万个账单,Node 主站的响应延迟也依然稳稳控制在 50ms 以内,顶多也就是 Redis 里的队列挤压一点时间罢了。
我们在折腾这个方案时,最恶心的是在 Alpine Linux 镜像里跑 Puppeteer 经常缺各种 .so 依赖库,搞得容器直接跑不起来。最后没办法,只能放弃极简镜像,改用基于 Debian 的完整版 Node 基础镜像,虽然体积大了点,但好歹稳定跑通了。
你们项目里有生成 PDF 或者图片的场景吗?是用这类 headless 浏览器排版,还是用底层的 canvas/pdfkit 库纯硬编码去绘制?