2026-05-08-exam-sprint-report-pdf-pool-design.md 12 KB

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

背景

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

  • validationprepare_contentrender_htmlstorage_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

private final String template;

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

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

阶段 2:PDF generator 池

新增池化 PDF generator,实现现有领域接口:

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

建议新增实现:

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 示例结构:

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 生命周期:

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

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

每个 worker:

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

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

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

PDF pool

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

核心流程:

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 使用可配置超时,避免请求无限等待:

borrow-timeout

如果池关闭或超时,抛出 IllegalStateException

配置

新增配置段:

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 装配

保持领域层依赖不变:

ExamSprintReportGenerationPipeline(
    ...,
    ExamSprintReportPdfGenerator pdfGenerator,
    ...)

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

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

并发模型

当前模型:

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

优化后模型:

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 构造阶段抛出 IllegalStateExceptionUncheckedIOException。应用启动失败,避免运行时不可预测失败。

worker 初始化失败

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

borrow 超时

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

PDF 生成失败

如果 worker 生成失败:

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

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

应用关闭

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

日志与可观测性

保留现有阶段日志:

  • render_html
  • pdf_generation
  • storage_upload
  • generation_succeeded

新增或增强 PDF 池日志:

  • 池初始化成功:poolSizelaunchTimeoutrenderTimeout
  • worker borrow 超时:poolSizeborrowTimeout
  • 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 并发测试可作为较重集成测试,不放入快速单元测试路径。

压测验证

实施后在测试环境执行:

预热: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. 单元测试覆盖模板缓存、池借还、超时、失败替换和关闭流程。