Spaces:
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 输出正确 |
七、后续建议(如需进一步优化)
- Shiki 高亮移入 Worker 线程:大 HTML(>1MB)大量代码块时主线程阻塞;可放入
worker_threads。当前 medium/small 负载下 <100ms,非瓶颈。 - Node.js 多进程(cluster/PM2):充分利用多核处理主线程工作。注意每个 worker 需独立浏览器池。
- 外部图片加载超时优化:非 base64 图片失败时会等 15s 兜底(备忘录 03/04 相关)。可考虑对 http 图片缩短等待或降级。
- widget 渲染缓存:备忘录 32 提到 CDN 响应内存缓存可提升 28%(此前实现丢失,本次未恢复,因为
setServerInterception开销曾被证明为负收益;如需恢复需重新验证)。 - 大规模扩展:超过单机能力后,将 PDF 服务拆分为独立可水平扩展的服务(参考 medium 文章的直接 CDP + 队列架构)。
八、参考资料(行业最佳实践)
- Puppeteer PDF: Common Problems and How to Fix — 浏览器池模式、内存 150-300MB/实例、并发限制
- Optimizing Puppeteer PDF generation — 并发数≈核数,队列限流
- Designing a High-Performance HTML-to-PDF Service — 浏览器/页面池 + 队列架构
- How to Fix Puppeteer Memory Leaks — 浏览器按 N 任务回收(disposable Chromium)
- Hugging Face Spaces 免费版规格:CPU Basic = 2 vCPU / 16GB(spaces-overview)