临考词汇突击报告同步生成接口当前热态单请求耗时约 260ms 到 300ms,但 20 并发下存在 PDF 生成阶段排队风险。源码检查显示:
validation、prepare_content、render_html、storage_upload 阶段本地代码没有全局串行锁,基本可并行。PlaywrightExamSprintReportPdfGenerator.generate() 使用 synchronized (playwrightLock) 包住完整 PDF 生成流程,单实例内所有报告共用一个 PDF worker。render() 都从 classpath 读取模板文件;模板不常变,适合在启动时加载到内存。目标是按分阶段方案优化:先降低 HTML 模板加载开销,再通过 PDF generator 池解决 PDF 阶段单 worker 串行瓶颈。
采用两阶段实施:
将以下 renderer 的模板从“每次 render 读取”改为“构造时一次性读取并缓存为 String”:
ClasspathAchievementExamSprintReportRendererClasspathOutlookExamSprintReportRenderer模板字段使用不可变 String:
private final String template;
renderer 是 Spring 单例,String 不可变,因此模板缓存天然线程安全,不需要额外锁。
模板加载失败时应在 Bean 构造阶段抛出异常,让应用启动失败。这样能尽早暴露缺失模板或 classpath 资源错误,避免首次请求才失败。
新增池化 PDF generator,实现现有领域接口:
public interface ExamSprintReportPdfGenerator {
byte[] generate(String htmlContent);
}
建议新增实现:
PooledPlaywrightExamSprintReportPdfGenerator
池化实现内部维护 N 个独立 worker。每个 worker 持有独立的 Playwright 和 Browser 资源,避免多个线程并发访问同一个 Playwright Java 对象。
每次调用 generate(html) 时:
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 读取次数。
新增内部 worker 抽象,职责是封装单个 Playwright/Browser 生命周期:
interface PlaywrightPdfWorker extends AutoCloseable {
byte[] generate(String htmlContent);
}
默认 worker 实现可复用当前 PlaywrightExamSprintReportPdfGenerator 的核心逻辑,但锁的范围仅保留在 worker 内部,池通过“同一时刻一个 worker 只借给一个调用方”保证并发安全。
每个 worker:
BrowserContext 和 Page。BrowserContext。推荐第一版采用启动时初始化所有 worker,理由是:
池使用 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。
保持领域层依赖不变:
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 估算:
pool-size=4:20 个 PDF 的排队窗口约 1s 到 1.5s。实际端到端仍取决于 CPU、内存、GC、Azure 上传、Tomcat 线程池和容器资源限制。
模板加载失败时在 renderer 构造阶段抛出 IllegalStateException 或 UncheckedIOException。应用启动失败,避免运行时不可预测失败。
池启动初始化任一 worker 失败时,关闭已创建 worker,并让应用启动失败。这样可以尽早发现 Playwright 或 Chromium 环境问题。
如果在 borrow-timeout 内无法获得 worker,抛出 IllegalStateException。现有 pipeline 会捕获异常并将报告标记为 FAILED。
如果 worker 生成失败:
第一版不在同一个请求内自动换 worker 重试,避免重复生成导致耗时不可控。
池实现 DisposableBean 或 AutoCloseable,关闭所有 worker。关闭过程应尽量关闭所有资源,并汇总或记录异常。
保留现有阶段日志:
render_htmlpdf_generationstorage_uploadgeneration_succeeded新增或增强 PDF 池日志:
poolSize、launchTimeout、renderTimeoutpoolSize、borrowTimeout不需要在每次请求都输出 worker id,除非压测排查需要。避免高并发下日志过量。
使用 fake worker 和 fake factory,避免真实 Playwright 并发测试过重。
覆盖场景:
pool-size=4 时最多 4 个 worker 同时执行。destroy() 会关闭所有空闲 worker。generate() 会失败。保留现有真实 PDF 生成测试,验证中文文本、SVG、模板报告仍可生成 PDF。真实 Playwright 并发测试可作为较重集成测试,不放入快速单元测试路径。
实施后在测试环境执行:
预热:ACHIEVEMENT / OUTLOOK 各 5 到 10 次
并发:20
持续:3 到 5 分钟
场景:ACHIEVEMENT 单接口、OUTLOOK 单接口、两接口混合
关注指标:
sync_generation_succeeded durationMs P50/P95/P99pdf_generation durationMs P50/P95/P99storage_upload P95/P99 与 429/5xx验收建议:
池化会同时持有多个 Browser,内存和 native 资源占用会上升。
缓解:默认 pool-size=4,通过压测逐步调高。
Playwright Java 对象不适合无保护共享。
缓解:每个 worker 持有独立 Playwright/Browser,池保证同一 worker 同时只被一个线程使用。
sync 接口在 worker 不足时会阻塞等待。
缓解:设置 borrow-timeout,并通过压测调整 pool-size。
启动时初始化多个 worker 会增加启动耗时。
缓解:接受启动期成本换取请求期稳定性;如启动耗时不可接受,再评估 lazy 初始化。
模板缺失会导致应用无法启动。
缓解:这是期望行为,可提前暴露发布包问题。
String。PooledPlaywrightExamSprintReportPdfGenerator。pool-size。render_html 不再每次读取 classpath 模板。