# 临考词汇突击报告生成并发优化设计 ## 背景 临考词汇突击报告同步生成接口当前热态单请求耗时约 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` 管理可用 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. 单元测试覆盖模板缓存、池借还、超时、失败替换和关闭流程。