Spaces:
Running
Running
| # PDF 导出并发压力测试与性能优化报告 | |
| **日期**: 2026-08-03 | |
| **测试环境**: 本机 Docker(`pdf-test` 容器,16 核 / 32GB,Docker 可见 ~15.5GB) | |
| **对比目标**: Hugging Face 免费版(CPU Basic = 2 vCPU / 16GB) | |
| **工具**: `backend-service/tests/stress/`(README 见 `tests/stress/README.md`) | |
| --- | |
| ## 一、结论摘要 | |
| | 指标 | 优化前 | 优化后 | 提升 | | |
| |------|--------|--------|------| | |
| | 单请求延迟 (medium, 并发1) | 4906ms | **1572ms** | **3.1x** | | |
| | 单请求延迟 (small, 并发1) | 3380ms | **926ms** | **3.7x** | | |
| | 吞吐 (medium, 并发1) | ~12/min | **38/min** | **3.2x** | | |
| | 吞吐峰值 (medium) | ~10/min(并发4) | **83/min**(并发8) | **8x** | | |
| | 并发承载(无失败) | 6 并发即延迟飙升至 8.9s | **8 并发全部成功,延迟 3.6s** | 稳定 | | |
| | 内存峰值 | 每请求新建浏览器(泄漏僵尸进程) | **4 个常驻浏览器,峰值 0.91GiB** | 受控 | | |
| **核心答案**: 优化后本机 docker 上 **4 个用户可同时导出 PDF**(=浏览器池大小),更多并发用户自动排队,不会雪崩。Hugging Face 免费版(2 vCPU)建议 `PDF_POOL_SIZE=2`,即 **2 个用户同时导出**,超出排队。 | |
| --- | |
| ## 二、根因分析(为什么原来并发差) | |
| ### 2.1 每个 PDF 请求启动一个全新 Chromium(最大瓶颈) | |
| `server.js` 原代码在 `/api/generate_pdf` 中**每次请求都 `puppeteer.launch()`**: | |
| - 启动一个 Chromium 耗时 ~0.3–1.5s、占用 ~200–300MB | |
| - 并发 N 个请求 = N 个 Chromium 进程同时竞争 CPU/内存 | |
| - 请求结束 `browser.close()` 还会在容器里留下**僵尸进程**(`chrome_crashpad`/`chromium <defunct>` 累积) | |
| - 实测 6 并发时延迟从 4.9s 飙到 8.9s,吞吐反而下降 | |
| > 行业共识(见参考资料):`page.pdf()` 是 CPU 密集操作,**并发数≈CPU 核数**,永远不要每请求启动浏览器。 | |
| ### 2.2 setContent 的 networkidle0 等待浪费 ~2s | |
| 用临时实验程序(`temp/setcontent-wait-experiment.js`,在容器内实测): | |
| | waitUntil 策略 | 耗时 | | |
| |----------------|------| | |
| | `setContent(load)` | **3–7ms** | | |
| | `setContent(load + networkidle0)` | **~1970ms** | | |
| | `setContent(load) + waitForNetworkIdle(500)` | ~505ms | | |
| | 原代码两者都做 | **~2470ms** | | |
| 原代码既在 `setContent` 里 `networkidle0`,又在后面 `waitForNetworkIdle(500)`,对一个无外链资源的纯本地 HTML 白白等待 ~2.5s。 | |
| ### 2.3 Widget 渲染每个 widget 启动一个浏览器 | |
| `renderWidgetPuppeteer()` / `_renderFullHtml()` 原代码**每个 widget 都 launch 一个新 Chromium**。一次导出 15 个 widget = 15 次浏览器启动。`WidgetRenderer._widgetBrowser` 单例存在但从未被使用(死代码)。 | |
| ### 2.4 事件循环被同步操作阻塞 | |
| - 大 HTML 临时文件的 `fs.writeFileSync` / `fs.unlinkSync` 同步阻塞主线程 | |
| - 优化后改为 `fs.promises` 异步版本 | |
| ### 2.5 响应被错误 JSON 序列化(我引入后立即修复) | |
| `page.pdf()` 在 Puppeteer 24 返回 **`Uint8Array` 而非 Buffer**。`res.send(u8array)` 时 Express 不识别为二进制,会 `JSON.stringify` 成 `{"0":37,"1":80,...}`(约 13 倍体积)。原代码 `Buffer.from(pdfBuffer)` 正是为此。**任何修改都不能去掉这一步**。 | |
| --- | |
| ## 三、实施的优化 | |
| ### 3.1 新增 `browser-pool.js` — PDF 浏览器池 | |
| - 常驻 `PDF_POOL_SIZE` 个 Chromium(默认 4,env 可配) | |
| - 每次请求 `acquire()` 一个浏览器 → 创建 page → 渲染 → 关闭 page → `release()` 归还 | |
| - 浏览器在 N 个任务后回收重建(`PDF_RECYCLE_AFTER=30`),防止 Chromium 长期运行内存膨胀 | |
| - 浏览器崩溃自动重建;池满时请求排队(`PDF_ACQUIRE_TIMEOUT_MS=120s`) | |
| - 池大小 = 最大并发 PDF 数 | |
| ### 3.2 Widget 渲染复用单例浏览器 | |
| `renderWidgetPuppeteer()` 与 `_renderFullHtml()` 改为使用 `getWidgetBrowser()` 共享浏览器(每个 widget 独立 page,渲染后只关 page 不关浏览器),并加了竞态保护与崩溃自动重建。 | |
| ### 3.3 等待策略优化 | |
| - `setContent` / `goto`:`networkidle0` → `'load'`(每个请求省 ~2s) | |
| - `waitForNetworkIdle`:idleTime 500 → 300(保留作为安全网) | |
| - Widget 渲染同样处理 | |
| ### 3.4 其他 | |
| - 大文件读写改异步 `fs.promises` | |
| - 恢复 `res.send(Buffer.from(pdfBuffer))`(关键,见 2.5) | |
| - 补上 `page.on('dialog')` 自动关弹窗(解决方案备忘录 24 的兜底,防止 XSS/异常 HTML 卡死) | |
| - 配置全部 env 化:`PDF_POOL_SIZE` / `PDF_RECYCLE_AFTER` / `WIDGET_MAX_CONCURRENT`(docker-compose.yml 已配置:生产 2,测试 4) | |
| --- | |
| ## 四、压力测试数据 | |
| ### 4.1 medium 负载(375KB 文本 + 3 张 base64 图) | |
| | 并发 | 优化前 avg | 优化后 avg | 优化后 p95 | 优化后吞吐/min | 成功率 | | |
| |------|-----------|-----------|-----------|---------------|--------| | |
| | 1 | 4906ms | **1572ms** | 1843ms | 38 | 100% | | |
| | 2 | 5283ms | **1882ms** | 2131ms | 62 | 100% | | |
| | 4 | 5918ms | **2433ms** | 3752ms | 67 | 100% | | |
| | 6 | 8919ms | **2758ms** | 4163ms | 81 | 100% | | |
| | 8 | 未测 | **3569ms** | 5763ms | 83 | 100% | | |
| ### 4.2 small 负载(62KB,纯文本) | |
| | 并发 | 优化前 avg | 优化后 avg | 优化后吞吐/min | | |
| |------|-----------|-----------|---------------| | |
| | 1 | 3380ms | **926ms** | 65 | | |
| | 4 | — | **1513ms** | 117 | | |
| | 8 | — | **2521ms** | 118 | | |
| ### 4.3 large 负载(1.24MB + 10 图,走临时文件路径) | |
| | 并发 | 优化后 avg | 优化后吞吐/min | 成功率 | | |
| |------|-----------|---------------|--------| | |
| | 1 | 4988ms | 12 | 100% | | |
| | 4 | 7748ms | 20 | 100% | | |
| ### 4.4 资源监控(optimized, medium, 4 浏览器常驻) | |
| - CPU 峰值 ~392%(4 个浏览器同时渲染 ≈ 4 核忙) | |
| - 内存峰值 **0.91GiB**,空闲 0.57GiB(4 个常驻浏览器 + Node) | |
| - 相比优化前每请求新建浏览器、僵尸进程累积,资源完全受控 | |
| ### 4.5 Widget 渲染 | |
| - 5 个 widget(4 chart + 1 mermaid)批量渲染:**7.03s,5/5 成功**,日志确认只启动 **1 次**浏览器(优化前每 widget 1 次) | |
| - 单次 PDF 校验:输出为真实 PDF(`%PDF-1.4` 魔数,~113 页,1.6MB) | |
| --- | |
| ## 五、对 Hugging Face 免费版(2 vCPU)的预测 | |
| 本机 16 核 docker 测出的容量是 **HF 的上界**,不能直接套用。按行业经验(每 page.pdf() 约占满 1 核): | |
| - **HF 建议 `PDF_POOL_SIZE=2`**(docker-compose 已配置)→ 2 个用户同时导出,超出排队 | |
| - 单请求延迟在 HF 上会比本机高(CPU 弱),medium 预计 ~3–4s | |
| - 吞吐预计 ~20–30/min(HF 2 核,CPU 是瓶颈) | |
| - **结论**:当前 1000 用户规模绰绰有余;若未来用户数大幅增长,HF 免费版 2 vCPU 会成为瓶颈,需升级 `CPU Upgrade`(8 vCPU/32GB,$0.03/时)或使用 GPU Space | |
| > ⚠️ **Docker 不能完全模拟 HuggingFace 机器**:CPU 核数(16 vs 2)差异巨大,PDF 渲染是 CPU 密集任务,因此本机测试结果只能作为相对对比(优化前后提升倍数),绝对并发数在 HF 上需按 2 vCPU 重新评估。 | |
| --- | |
| ## 六、验证过的兼容性(避免复杂问题重现) | |
| 对照解决方案备忘录逐项确认优化未破坏: | |
| | 备忘录 | 关注点 | 验证 | | |
| |--------|--------|------| | |
| | 24 (Puppeteer 超时/XSS) | dialog 卡死 setContent | ✅ 补回 `page.on('dialog')` 自动关闭 | | |
| | 32 (Widget 渲染性能) | CDN 本地化失败、缓存方案 | ✅ 未重蹈 `setServerInterception` 覆辙;widget 单例浏览器按备忘录原设计实现 | | |
| | 31 (表格串行 bug) | 前端导出流程 | ✅ 后端响应格式未变 | | |
| | 05/06/26 (PDF/图表) | Shiki 高亮、Mermaid 版本固定 | ✅ 相关逻辑未改动,实测 PDF 与 widget 输出正确 | | |
| --- | |
| ## 七、后续建议(如需进一步优化) | |
| 1. **Shiki 高亮移入 Worker 线程**:大 HTML(>1MB)大量代码块时主线程阻塞;可放入 `worker_threads`。当前 medium/small 负载下 <100ms,非瓶颈。 | |
| 2. **Node.js 多进程(cluster/PM2)**:充分利用多核处理主线程工作。注意每个 worker 需独立浏览器池。 | |
| 3. **外部图片加载超时优化**:非 base64 图片失败时会等 15s 兜底(备忘录 03/04 相关)。可考虑对 http 图片缩短等待或降级。 | |
| 4. **widget 渲染缓存**:备忘录 32 提到 CDN 响应内存缓存可提升 28%(此前实现丢失,本次未恢复,因为 `setServerInterception` 开销曾被证明为负收益;如需恢复需重新验证)。 | |
| 5. **大规模扩展**:超过单机能力后,将 PDF 服务拆分为独立可水平扩展的服务(参考 medium 文章的直接 CDP + 队列架构)。 | |
| --- | |
| ## 八、参考资料(行业最佳实践) | |
| - [Puppeteer PDF: Common Problems and How to Fix](https://blog.pdfloom.com/puppeteer-pdf-problems/) — 浏览器池模式、内存 150-300MB/实例、并发限制 | |
| - [Optimizing Puppeteer PDF generation](https://www.codepasta.com/2024/04/19/optimizing-puppeteer-pdf-generation) — 并发数≈核数,队列限流 | |
| - [Designing a High-Performance HTML-to-PDF Service](https://medium.com/@harishrawat93/designing-a-high-performance-html-to-pdf-service-for-production-1a70099e3ccb) — 浏览器/页面池 + 队列架构 | |
| - [How to Fix Puppeteer Memory Leaks](https://www.grabbit.live/blog/puppeteer-memory-leak) — 浏览器按 N 任务回收(disposable Chromium) | |
| - Hugging Face Spaces 免费版规格:CPU Basic = **2 vCPU / 16GB**([spaces-overview](https://huggingface.co/docs/hub/en/spaces-overview)) | |