File size: 9,540 Bytes
24a2ddf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# 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))