上个月底发薪日,财务部门搞了个对账大促,结果刚过十点,系统监控就开始疯狂报警:核心业务网关接口大量超时,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 库纯硬编码去绘制?