# 临考词汇突击报告生成并发优化设计

## 背景

临考词汇突击报告同步生成接口当前热态单请求耗时约 260ms 到 300ms，但 20 并发下存在 PDF 生成阶段排队风险。源码检查显示：

- `validation`、`prepare_content`、`render_html`、`storage_upload` 阶段本地代码没有全局串行锁，基本可并行。
- `PlaywrightExamSprintReportPdfGenerator.generate()` 使用 `synchronized (playwrightLock)` 包住完整 PDF 生成流程，单实例内所有报告共用一个 PDF worker。
- 两个 HTML renderer 每次 `render()` 都从 classpath 读取模板文件；模板不常变，适合在启动时加载到内存。

目标是按分阶段方案优化：先降低 HTML 模板加载开销，再通过 PDF generator 池解决 PDF 阶段单 worker 串行瓶颈。

## 目标

1. 将报告 HTML 模板加载到内存，避免每次渲染重复读取 classpath 资源。
2. 将 PDF 生成从单 worker 串行改为可配置的有限并行。
3. 支持 20 并发同步报告生成时降低 PDF 排队时间。
4. 保持现有报告生成接口、日志语义和领域接口稳定。
5. 为池大小、等待超时、渲染超时提供配置入口，便于压测后调优。

## 非目标

1. 不改变报告业务内容、HTML 模板结构或 PDF 样式。
2. 不引入模板热更新；模板仍随应用发布变更。
3. 不将同步接口改成异步接口。
4. 不在第一版追求 20 个 PDF 任务完全并行；采用有限并行保护 CPU、内存和 Chromium 资源。
5. 不改变 Azure Blob 存储实现的协议或对象路径规则。

## 方案概览

采用两阶段实施：

### 阶段 1：模板内存缓存

将以下 renderer 的模板从“每次 render 读取”改为“构造时一次性读取并缓存为 `String`”：

- `ClasspathAchievementExamSprintReportRenderer`
- `ClasspathOutlookExamSprintReportRenderer`

模板字段使用不可变 `String`：

```java
private final String template;
```

renderer 是 Spring 单例，`String` 不可变，因此模板缓存天然线程安全，不需要额外锁。

模板加载失败时应在 Bean 构造阶段抛出异常，让应用启动失败。这样能尽早暴露缺失模板或 classpath 资源错误，避免首次请求才失败。

### 阶段 2：PDF generator 池

新增池化 PDF generator，实现现有领域接口：

```java
public interface ExamSprintReportPdfGenerator {
    byte[] generate(String htmlContent);
}
```

建议新增实现：

```text
PooledPlaywrightExamSprintReportPdfGenerator
```

池化实现内部维护 N 个独立 worker。每个 worker 持有独立的 Playwright 和 Browser 资源，避免多个线程并发访问同一个 Playwright Java 对象。

每次调用 `generate(html)` 时：

1. 从阻塞队列中借出一个 worker。
2. 使用该 worker 生成 PDF。
3. 成功后归还 worker。
4. 如果生成失败，关闭该 worker 并尝试创建替换 worker；当前请求失败并交给 pipeline 标记报告失败。
5. 如果借出 worker 超时，抛出运行时异常。

## 组件设计

### Template loading

renderer 内部保留私有加载方法，但调用时机从 `render()` 移到构造函数。

Achievement renderer 示例结构：

```java
public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintReportRenderer {
    private final AchievementExamSprintReportSvgChartBuilder chartBuilder;
    private final String template;

    public ClasspathAchievementExamSprintReportRenderer() {
        this(new AchievementExamSprintReportSvgChartBuilder(), loadTemplateFromClasspath(TEMPLATE_RESOURCE));
    }

    ClasspathAchievementExamSprintReportRenderer(
            AchievementExamSprintReportSvgChartBuilder chartBuilder,
            String template) {
        this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
        this.template = Objects.requireNonNull(template, "template");
    }

    @Override
    public String render(ReportContent content, Instant generatedAt) {
        return renderTemplate(template, placeholders(...));
    }
}
```

Outlook renderer 同理。

为便于单元测试，保留包内可见构造函数允许传入模板字符串，避免测试依赖 classpath 读取次数。

### PDF worker

新增内部 worker 抽象，职责是封装单个 Playwright/Browser 生命周期：

```java
interface PlaywrightPdfWorker extends AutoCloseable {
    byte[] generate(String htmlContent);
}
```

默认 worker 实现可复用当前 `PlaywrightExamSprintReportPdfGenerator` 的核心逻辑，但锁的范围仅保留在 worker 内部，池通过“同一时刻一个 worker 只借给一个调用方”保证并发安全。

每个 worker：

- 懒加载或构造时初始化 Playwright/Browser。
- 每次生成创建新的 `BrowserContext` 和 `Page`。
- 生成后关闭 `BrowserContext`。
- 应用关闭时关闭 Browser 和 Playwright。

推荐第一版采用启动时初始化所有 worker，理由是：

- 避免首次 20 并发同时触发多个 Chromium 初始化。
- 启动期暴露 Playwright 环境问题。
- 和现有 warmup 目标一致，降低冷态抖动。

### PDF pool

池使用 `BlockingQueue<PlaywrightPdfWorker>` 管理可用 worker。

核心流程：

```java
public byte[] generate(String htmlContent) {
    PlaywrightPdfWorker worker = borrowWorker();
    boolean reusable = false;
    try {
        byte[] pdf = worker.generate(htmlContent);
        reusable = true;
        return pdf;
    } finally {
        if (reusable) {
            returnWorker(worker);
        } else {
            replaceWorker(worker);
        }
    }
}
```

借出 worker 使用可配置超时，避免请求无限等待：

```text
borrow-timeout
```

如果池关闭或超时，抛出 `IllegalStateException`。

### 配置

新增配置段：

```yaml
ability:
  exam-sprint:
    report:
      pdf:
        pool-size: 4
        borrow-timeout: 30s
        launch-timeout: 30s
        render-timeout: 30s
```

默认值：

| 配置 | 默认值 | 说明 |
|---|---:|---|
| `pool-size` | `4` | PDF worker 数量 |
| `borrow-timeout` | `30s` | 等待可用 worker 的最长时间 |
| `launch-timeout` | `30s` | Chromium 启动超时 |
| `render-timeout` | `30s` | 页面渲染与 PDF 输出超时 |

`pool-size` 第一版默认 4，与现有异步生成线程池 `max-pool-size=4` 对齐，也能避免一次性拉起过多 Chromium 进程。后续通过压测决定是否提高到 6 或 8。

### Spring Bean 装配

保持领域层依赖不变：

```java
ExamSprintReportGenerationPipeline(
    ...,
    ExamSprintReportPdfGenerator pdfGenerator,
    ...)
```

运行时只注入一个主 `ExamSprintReportPdfGenerator` Bean，即池化实现。

当前单 worker 类可以演进为 worker 实现，或保留为测试/备用实现。为了避免多 Bean 歧义，生产配置下只暴露池化 generator 作为主 Bean。

## 并发模型

当前模型：

```text
20 并发请求 -> 20 个请求线程进入 pipeline -> 1 个 PDF generator 锁 -> 串行生成 PDF
```

优化后模型：

```text
20 并发请求 -> 20 个请求线程进入 pipeline -> 4 个 PDF worker -> 分批并行生成 PDF
```

按单个 PDF 生成 200ms 到 300ms 估算：

- 当前单 worker：20 个 PDF 的排队窗口约 4s 到 6s。
- `pool-size=4`：20 个 PDF 的排队窗口约 1s 到 1.5s。

实际端到端仍取决于 CPU、内存、GC、Azure 上传、Tomcat 线程池和容器资源限制。

## 错误处理

### 模板加载失败

模板加载失败时在 renderer 构造阶段抛出 `IllegalStateException` 或 `UncheckedIOException`。应用启动失败，避免运行时不可预测失败。

### worker 初始化失败

池启动初始化任一 worker 失败时，关闭已创建 worker，并让应用启动失败。这样可以尽早发现 Playwright 或 Chromium 环境问题。

### borrow 超时

如果在 `borrow-timeout` 内无法获得 worker，抛出 `IllegalStateException`。现有 pipeline 会捕获异常并将报告标记为 `FAILED`。

### PDF 生成失败

如果 worker 生成失败：

1. 当前请求失败。
2. 关闭该 worker。
3. 尝试创建替换 worker 并放回池。
4. 替换失败时记录警告，池容量临时降低；后续请求仍可使用其他 worker。

第一版不在同一个请求内自动换 worker 重试，避免重复生成导致耗时不可控。

### 应用关闭

池实现 `DisposableBean` 或 `AutoCloseable`，关闭所有 worker。关闭过程应尽量关闭所有资源，并汇总或记录异常。

## 日志与可观测性

保留现有阶段日志：

- `render_html`
- `pdf_generation`
- `storage_upload`
- `generation_succeeded`

新增或增强 PDF 池日志：

- 池初始化成功：`poolSize`、`launchTimeout`、`renderTimeout`
- worker borrow 超时：`poolSize`、`borrowTimeout`
- worker 替换失败：异常类型

不需要在每次请求都输出 worker id，除非压测排查需要。避免高并发下日志过量。

## 测试策略

### 模板缓存单元测试

1. Achievement renderer 使用构造期模板能够正常渲染。
2. Outlook renderer 使用构造期模板能够正常渲染。
3. 关键占位符替换结果与现有断言保持一致。
4. 模板缺失或读取失败时构造失败。

### PDF pool 单元测试

使用 fake worker 和 fake factory，避免真实 Playwright 并发测试过重。

覆盖场景：

1. `pool-size=4` 时最多 4 个 worker 同时执行。
2. 第 5 个请求会等待 worker 归还。
3. borrow 超时时抛出异常。
4. 生成成功后 worker 归还池。
5. 生成失败后 worker 被关闭并触发替换。
6. `destroy()` 会关闭所有空闲 worker。
7. pool 关闭后再次 `generate()` 会失败。

### Playwright 集成测试

保留现有真实 PDF 生成测试，验证中文文本、SVG、模板报告仍可生成 PDF。真实 Playwright 并发测试可作为较重集成测试，不放入快速单元测试路径。

### 压测验证

实施后在测试环境执行：

```text
预热：ACHIEVEMENT / OUTLOOK 各 5 到 10 次
并发：20
持续：3 到 5 分钟
场景：ACHIEVEMENT 单接口、OUTLOOK 单接口、两接口混合
```

关注指标：

- `sync_generation_succeeded durationMs` P50/P95/P99
- `pdf_generation durationMs` P50/P95/P99
- 错误率
- CPU 使用率
- JVM heap 与 GC pause
- 容器内存与 Chromium 子进程内存
- Azure `storage_upload` P95/P99 与 429/5xx

验收建议：

- 错误率为 0。
- 20 并发下 P95 满足业务 SLA。
- 长时间 CPU 不持续超过 80%。
- 无 OOM、容器重启、明显 Full GC 抖动。

## 风险与缓解

### Chromium 资源占用上升

池化会同时持有多个 Browser，内存和 native 资源占用会上升。

缓解：默认 `pool-size=4`，通过压测逐步调高。

### Playwright 线程安全

Playwright Java 对象不适合无保护共享。

缓解：每个 worker 持有独立 Playwright/Browser，池保证同一 worker 同时只被一个线程使用。

### 请求线程等待 worker

sync 接口在 worker 不足时会阻塞等待。

缓解：设置 `borrow-timeout`，并通过压测调整 `pool-size`。

### 启动时间增加

启动时初始化多个 worker 会增加启动耗时。

缓解：接受启动期成本换取请求期稳定性；如启动耗时不可接受，再评估 lazy 初始化。

### 模板启动加载导致启动失败

模板缺失会导致应用无法启动。

缓解：这是期望行为，可提前暴露发布包问题。

## 实施顺序

1. 将两个 renderer 的模板加载移动到构造阶段并缓存为 `String`。
2. 运行相关 renderer 和报告生成测试。
3. 新增 PDF 配置属性。
4. 抽取 Playwright PDF worker 与 worker factory。
5. 实现 `PooledPlaywrightExamSprintReportPdfGenerator`。
6. 调整 Spring 装配，生产注入池化 PDF generator。
7. 补充 PDF pool 单元测试。
8. 运行现有测试和真实 PDF 集成测试。
9. 在测试环境做 20 并发压测，根据结果调整 `pool-size`。

## 成功标准

1. 两类报告的 HTML 和 PDF 输出保持兼容。
2. `render_html` 不再每次读取 classpath 模板。
3. PDF 生成阶段支持配置化有限并行。
4. 20 并发下 PDF 阶段不再单 worker 串行排队。
5. 现有报告生成日志仍能按阶段定位耗时。
6. 单元测试覆盖模板缓存、池借还、超时、失败替换和关闭流程。
