基于计数信号量(Counting Semaphore)的 PDF 渲染并发控制方案

基于计数信号量(Counting Semaphore)的 PDF 渲染并发控制方案

1. 项目背景与问题陈述

1.1 业务现状

当前线上部署了一个 Node.js 服务,对外提供 PDF 预览能力。其核心业务流程如下:

  1. 用户(或前端应用)发起 PDF 预览请求。
  2. 服务端接收请求后,使用 Puppeteer 打开一个无头 Chromium 实例,加载目标 HTML 页面。
  3. 等待页面渲染完成后,调用 page.pdf() 生成 PDF 文件。
  4. 将生成的 PDF 直接作为 HTTP 响应返回给客户端。

该服务采用 「实时生成」 策略------即每次请求都独立触发一次完整的渲染流程,生成一份全新的 PDF。

1.2 问题现象

随着业务量增长,该服务暴露出严重的稳定性问题:

  • 服务器负载持续飙高:CPU 使用率长期维持在 90% 以上,系统 Load Average 远超核心数。
  • 内存溢出(OOM) :Puppeteer 每开启一个页面(page)即占用 150MB ~ 300MB 内存,高并发下内存迅速耗尽,进程被操作系统 OOM Killer 强制终止。
  • 请求超时/服务不可用:大量请求堆积,响应时间从正常的 3~5 秒急剧恶化至 60 秒以上,甚至超时无响应。
  • 级联故障:单机崩溃后,上游 Nginx 将流量转发至其他节点,引发雪崩效应,导致整个集群不可用。

1.3 根因分析

问题的根本原因在于:服务的 PDF 生成能力没有受到任何并发控制

当短时间内涌入大量请求(例如业务高峰、运营批量操作、爬虫攻击)时,系统会无限制地创建 Puppeteer 页面,每个页面都独立占用 CPU 和内存资源。由于 Chromium 本身是资源密集型进程,在无限制并发的场景下,宿主机资源被迅速耗竭,最终导致服务崩溃。

1.4 解决目标

我们需要一套轻量级、高效的并发控制机制,满足以下核心要求:

目标 说明
限制并发数 同一时刻最多允许 N 个 PDF 渲染任务并行执行(N 可根据服务器配置灵活调整)
请求排队 超出并发上限的请求必须排队等待,而非直接拒绝或崩溃
公平调度 排队请求按照先进先出(FIFO) 顺序依次获得执行资格,防止饥饿
异常安全 无论渲染成功或失败,都必须释放并发资源,绝不允许单次异常导致整个服务死锁
零依赖、轻量级 不引入 Redis、RabbitMQ 等外部中间件,避免增加运维复杂度和网络开销

2. 方案选型对比:计数信号量 vs. 完整任务队列

在解决"控制同时渲染 PDF 的任务数量"这一并发问题时,存在两条截然不同的技术路线。为了明确选型依据,我们从本质出发进行对比:

对比维度 本方案(计数信号量) 完整任务队列方案(如 _processNext 或 BullMQ)
队列存储内容 waiters 数组中仅存储 resolve 回调函数(极轻量,内存占用可忽略) 队列中存储完整的 Job 对象 (包含 dataresolverejectattemptsid 等完整元数据)
业务数据载体 依赖 JS 闭包(Closure)url 等参数直接绑定在调用栈上,不进入队列 业务数据显式挂载在 Job 对象的 data 属性上,随队列持久化
调度模式 推送模式(Push):任务自己携带数据向信号量申请许可,获取后立即执行 拉取模式(Pull):Worker 进程主动从队列头部拉取任务消费
失败重试 不支持(渲染报错直接返回 500,由客户端决定是否重试) 原生支持(可配置重试次数、指数退避延迟)
持久化能力 无(进程重启,等待中的回调全部丢失) 支持(可持久化到 Redis/DB,服务重启任务不丢)
内存占用 极低(仅存函数引用,无数据副本) 较高(每个 Job 携带完整业务数据副本)
实现复杂度 ~20 行核心代码,零外部依赖 需维护任务池、递归调度逻辑,或引入第三方库(BullMQ + Redis)

选型结论 :针对 Puppeteer PDF 生成 这一典型的计算/IO 密集型且幂等 的场景,任务失败直接让客户端重试即可,完全不需要持久化和自动重试 。因此,计数信号量以最低的复杂度和内存开销,精准达成了并发控制目标


3. 什么是计数信号量(Counting Semaphore)?

在操作系统经典理论中,信号量(Semaphore)是由 Dijkstra 提出的同步原语,用于控制多个进程/线程对共享资源的访问。

  • 二进制信号量(Binary Semaphore):取值仅为 0 或 1,等同于互斥锁(Mutex),用于实现互斥访问。
  • 计数信号量(Counting Semaphore) :取值可以为任意非负整数,用于控制有限数量的共享资源(如数据库连接、线程池槽位、渲染进程)。

在本方案中 :信号量的计数值 active 代表当前正在执行渲染的页面数量 ,上限 limit 代表系统允许的最大并发渲染数。每个 PDF 任务在开始前执行 P 操作(acquire,荷兰语 proberen,意为"尝试") ,在结束后执行 V 操作(release,荷兰语 verhogen,意为"增加")


4. 核心状态与状态机

4.1 三大核心状态

javascript 复制代码
// 信号量状态
const state = {
  limit: 3,          // 信号量最大值(资源总数,即最大并发渲染数)
  active: 0,         // 当前信号量计数值(正在执行的任务数)
  waiters: []        // 阻塞等待队列(存储的是 resolve 回调函数)
};

4.2 状态机流转逻辑(P / V 操作)

复制代码
┌─────────────────┐
│   新的 PDF 任务   │
└────────┬────────┘
         ▼
┌─────────────────────┐     No      ┌─────────────────────────────┐
│ P 操作: active > 0 ? │ ──────────► │ 将 resolve 推入 waiters    │
│ (尝试获取许可)       │             │ 挂起当前 Promise(阻塞)    │
└────────┬────────────┘             └─────────────────────────────┘
         │ Yes (active > 0)
         ▼
┌─────────────────┐
│ active--        │
│ (消耗一个许可) │
└────────┬────────┘
         ▼
┌─────────────────┐
│ 执行 PDF 渲染    │
│ (Puppeteer)   │
└────────┬────────┘
         │
         ▼ (finally 块)
┌────────────────────────────────────────────────────────────┐
│              V 操作: release() 释放许可                     │
├────────────────────────────────────────────────────────────┤
│  waiters 队列是否为空?                                   │
│  ├─ 非空 ──► 取出队首 resolve() 并执行                   │
│  │           (active 保持不变------实现「许可直接移交」)      │
│  └─ 为空 ──► active++ (许可归还到信号量池中)             │
└────────────────────────────────────────────────────────────┘

5. 核心伪代码实现

5.1 信号量核心模块(semaphore.js

javascript 复制代码
// 从环境变量读取信号量上限,默认为 3
const LIMIT = parseInt(process.env.PDF_CONCURRENCY) || 3;

// 信号量状态
let active = 0;          // 当前可用资源数(此处语义为「已占用资源数」)
const waiters = [];      // 阻塞等待队列(存储 resolve 回调)

/**
 * P 操作(尝试获取许可)
 * 若当前有可用资源,立即返回;否则挂起当前执行流
 * @returns {Promise<void>}
 */
function acquire() {
  if (active < LIMIT) {
    // 有可用资源,占用一个
    active++;
    return Promise.resolve();
  }
  // 资源耗尽,挂起当前任务,将 resolve 存入等待队列
  return new Promise((resolve) => {
    waiters.push(resolve);
  });
}

/**
 * V 操作(释放许可)
 * 优先将许可「直接移交」给等待队列中的任务,避免上下文切换
 */
function release() {
  if (waiters.length > 0) {
    // 【直接移交】取出队首等待者,唤醒它
    // 注意:active 保持不变,因为许可已经无缝转移给了下一个任务
    const nextResolve = waiters.shift();
    nextResolve();
  } else {
    // 无等待者,将许可归还到资源池
    active--;
  }
}

module.exports = { acquire, release };

严谨性说明 :经典信号量的 V 操作通常执行 active++(归还资源),然后唤醒一个等待者(等待者再执行 active--)。本方案将两步合并为一步------直接移交 ,使得 active 始终保持恒定值 LIMIT,避免了计数器的无效波动,且逻辑等价于经典实现。

5.2 集成到 PDF 渲染函数(pdfConvert.js

javascript 复制代码
const { acquire, release } = require('./semaphore');
const puppeteer = require('puppeteer');

let browser = null;

async function pdfConvert(options) {
  // ① P 操作:申请许可(可能阻塞)
  await acquire();

  // ② 执行渲染(借助 try/finally 保证 V 操作必定执行)
  try {
    // 懒加载浏览器(复用单例)
    if (!browser) {
      browser = await puppeteer.launch({
        args: ['--no-sandbox', '--disable-setuid-sandbox']
      });
    }

    const page = await browser.newPage();
    try {
      await page.goto(options.url, { waitUntil: 'networkidle0' });
      await page.waitForFunction('window.renderStatus === 1', { timeout: 30000 });

      const pdfBuffer = await page.pdf({
        printBackground: true,
        width: options.width || '8.26in',
        height: options.height || '11.69in',
        margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' },
        displayHeaderFooter: true,
        footerTemplate: `<div style="width:100%;text-align:center;font-size:10px;">第 <span class="pageNumber"></span> 页 / 共 <span class="totalPages"></span> 页</div>`
      });

      return pdfBuffer;
    } finally {
      await page.close(); // 释放页面内存
    }
  } finally {
    // ③ V 操作:无论成功或失败,释放许可
    // 即使 try 块中包含 return,finally 也必定执行
    release();
  }
}

module.exports = pdfConvert;

6. 关键设计细节与健壮性保障

6.1 关于 finally 的语义保证

命题 :若 try 块执行 return pdfBufferfinally 中的 release() 是否一定执行?

证明 :JavaScript 引擎在执行 return 时,会按以下顺序操作:

  1. 计算 return 表达式(pdfBuffer)的值。
  2. 立即执行 finally 块中的所有代码 (包括 release())。
  3. 将第 1 步计算好的值返回给调用者。

结论 :无论 PDF 生成成功(return)、抛出异常(throw),还是 Puppeteer 内部超时,release() 必定会且仅会执行一次 。信号量的计数值始终保持正确,绝不会出现资源耗尽导致死锁的情况

6.2 信号量计数器的强正确性

  • P 操作(acquire :当且仅当 active < LIMIT 时,任务通过并 active++
  • V 操作(release
    • 有等待者:不修改 active,将许可直接移交给队首等待任务。
    • 无等待者:执行 active--,将许可归还资源池。

该实现确保 0 ≤ active ≤ LIMIT 恒成立,且所有任务按照 FIFO(先进先出) 顺序获取许可,从根本上杜绝了饥饿现象。

6.3 异常安全

所有可能抛出异常的操作(page.gotopage.pdf)均在 try 块内,且 finally 块只包含同步的 release() 调用。即使 Puppeteer 崩溃抛出异常,信号量的 V 操作依然会被执行,保证系统状态可恢复。


7. 运维与调优

7.1 信号量上限调优(基于服务器配置)

服务器配置 推荐 limit 依据
2核 4GB 内存 1 ~ 2 内存有限,避免 Puppeteer 多页面争抢导致 OOM
4核 8GB 内存 3 ~ 4 平衡吞吐量与稳定性,最常用配置
8核 16GB 内存 5 ~ 6 超过 6 个并发,Chromium 内部资源争抢反而增加时延

通过环境变量注入,无需修改代码:

bash 复制代码
PDF_CONCURRENCY=4 node app.js

7.2 监控指标

建议在 P/V 操作处添加日志(生产环境按需开启):

javascript 复制代码
console.log(`[Semaphore] active=${active}, waiters=${waiters.length}`);

waiters.length 持续增长,表明系统负载过高,需考虑扩容或提升 limit


8. 方案总结

维度 说明
设计模式 计数信号量(Counting Semaphore),P/V 操作范式
核心代码量 ~20 行(不含业务逻辑)
外部依赖 零(纯 Node.js 原生实现)
并发控制精度 精确到任务级别,资源利用率 100%(直接移交机制)
异常安全性 借助 try/finally,确保 V 操作必执行,无死锁风险
公平性 阻塞队列 FIFO,无饥饿
适用场景 低频、任务时间可控、允许失败重试的内部渲染服务

本方案严格遵循计算机科学中计数信号量 的理论模型,以最精简的代码实现了对 Puppeteer PDF 渲染并发的精确控制,在正确性、性能与可维护性之间取得了最佳平衡。

相关推荐
工具派4 小时前
从 git commit 到交付 PDF:我的一条在线工具流实录
git·elasticsearch·pdf
Li Ming&18 小时前
Python办公自动化:利用Python批量将PDF转换为图片文件
python·pdf·pip
ji_shuke20 小时前
Vue3 前端批量打印 PDF/图片踩坑记:跨域、合并打印、对话框闪退与按钮一直 loading
前端·pdf·状态模式·pdf打印
Am-Chestnuts20 小时前
Mermaid流程图怎么导出Word/PDF?DS随心转批量保存AI多轮修改与源码
pdf·aigc·word·流程图
FL16238631291 天前
pdf批量转ofd本地离线操作简单支持win10及以上系统
pdf
Metaphor6921 天前
使用 Python 在 PDF 中插入、替换和删除图片
python·pdf
薛定猫AI1 天前
【技术干货】大模型文档结构化提取实战:Python解析PDF发票并批量生成CSV
java·python·pdf
庖丁AI1 天前
PDF 表格提取到 CSV 和 JSON 怎么做?复杂版式要先解决结构化解析
pdf·json