api-server / PERFORMANCE_STRESS_TEST_2026-08-03.md
XWX-AI's picture
fix(widget): DOCX图表fallback成数据表格根因修复 — widget渲染浏览器池+CDP有限超时+渲染硬超时+队列超时取消+BrowserPool补位bug+body-parser JSON兜底, bump v2.1.11
24a2ddf
|
Raw
History Blame Contribute Delete
9.54 kB

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

原代码既在 setContentnetworkidle0,又在后面 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 而非 Bufferres.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 / gotonetworkidle0'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 + 队列架构)。

八、参考资料(行业最佳实践)