大 PDF 处理为什么不能只加线程池:页级拆分、异步编排与断点续传实践
大 PDF 工程包如何做异步治理:从整本串行到页级任务编排
在很多业务里,PDF 上传只是一个很普通的功能。
但工程图纸场景不一样。用户上传的往往不是几页合同,而是一整包工程 PDF,少则几十页,多则上百页。系统不仅要完成拆页,还要对每一页做 OCR、结构化识别,后面还可能继续计算延长米、管件、标高、操作条件等业务数据。
如果整条链路按串行方式执行,问题很快就会暴露出来:
上传接口等待时间很长;
用户迟迟看不到任何结果;
某一页失败后,可能要整本重跑;
PDF 拆分、OCR、业务识别的状态容易互相干扰;
单个大文件可能长期占用线程池,后面的任务只能排队。
这次优化的目标并不是单纯把线程数调大,而是重新拆分任务边界:把“整本 PDF”改造成“页级任务”,让拆页、OCR 和业务识别能够独立执行、独立失败、独立恢复。
一、原来的处理方式有什么问题
优化前的链路可以简单理解为:
上传 PDF ↓ 完整拆分整本 PDF ↓ 逐页执行 OCR ↓ 执行业务识别 ↓ 更新项目状态这种方式在文件页数少时问题不明显,但文件一大,几个问题就会同时出现。
1. 主链路太重
PDF 拆页本身就包含文件读取、页面渲染、图片生成等操作。拆页完成后,还要继续调用 OCR,OCR 后面又有业务识别。
如果这些操作都串在一次请求里,用户上传之后只能一直等。
尤其是 OCR 依赖外部服务或者模型接口时,耗时更加不可控。任何一个环节变慢,都会直接拖长整个请求。
2. 用户很久看不到结果
用户上传几十页 PDF 后,真正关心的往往不是“所有页面是否都处理完”,而是系统有没有开始工作、能不能先看到部分结果。
原来的方式通常要等整本 PDF 拆完,甚至等 OCR 完成后,前端才能展示内容。
从用户体验上看,这段时间系统就像没有响应一样。
3. 一页失败,恢复成本很高
如果把整本 PDF 当成一个任务,那么其中某一页渲染失败、上传失败或者 OCR 失败时,很难单独处理。
常见结果是:
整本任务失败 或者重新从第一页开始执行对于上百页的 PDF 来说,这种恢复方式成本很高,也会重复消耗计算和外部调用资源。
4. 状态容易提前完成
这类异步链路里很容易出现状态竞态。
比如:
PDF 还在后台拆页 OCR 已经处理完当前数据库里已有的页面 系统误以为所有任务都完成了 项目状态被提前更新为 COMPLETED本质上是因为“PDF 是否拆完”和“OCR 是否完成”没有被明确拆成两个阶段。
二、核心思路:把整本 PDF 拆成页级任务
这次优化最核心的变化,是不再把整个 PDF 看成一个不可拆分的处理单元。
新的思路是:
一本 PDF ↓ 拆成多个页面 ↓ 每一页生成独立图纸记录 ↓ 每页分别进入 OCR 和业务识别流程也就是说,系统里的最小任务单位从“文件”变成了“页面”。
这么做以后,每一页都可以独立拥有自己的状态:
PENDING PROCESSING SUCCESS FAILED后续不管是重试、补偿还是断点续传,都可以基于页号进行,不需要整本 PDF 重跑。
三、第一页优先:先让用户看到结果
这条链路里比较重要的一个优化,是“第一页优先”。
传统做法是:
先拆完整本 PDF 再统一返回结果优化后则变成:
上传 PDF ↓ 先处理第一页 ↓ 尽快生成第一页图纸记录 ↓ 让前端先展示首个结果 ↓ 剩余页面在后台继续处理这样做并不会缩短整本 PDF 的总处理时间,但会明显改善用户感知。
用户不再需要等待几十页全部拆分完成,而是可以很快看到第一页已经进入系统,确认任务确实开始执行了。
这和很多页面加载里的“首屏优先”思路类似:先保证最早可见结果,再提升整体吞吐。
四、异步编排怎么做
整个异步链路主要使用了三类能力:
Spring @Async 自定义线程池 CompletableFuture它们承担的职责并不完全一样。
1.@Async:把任务从上传主线程剥离
用户上传 PDF 后,主线程只负责:
创建任务;
保存基础信息;
更新拆页状态;
优先处理第一页;
返回任务标识。
剩余页面的处理交给异步方法执行。
这样可以避免上传请求一直占着 Web 容器线程。
2. 自定义线程池:隔离 PDF 任务资源
不建议直接使用 Spring 默认线程池。
PDF 拆页属于资源消耗比较明显的任务,既有文件 IO,也有图片渲染。如果和普通业务异步任务共用线程池,很容易把其他任务一起拖慢。
因此需要为 PDF 处理配置独立线程池,单独控制:
核心线程数 最大线程数 队列容量 线程名称 拒绝策略这样即使短时间内上传了多个大 PDF,也能限制并发规模,避免把服务器资源一次性打满。
3.CompletableFuture:并发处理页任务
一本 PDF 拆成多页以后,可以将页面按批次提交到线程池,通过CompletableFuture.runAsync()并行处理。
整体结构类似:
加载 PDF ↓ 获取总页数 ↓ 按页生成任务 ↓ 批量提交 CompletableFuture ↓ 等待当前批次完成 ↓ 汇总成功页和失败页这里一般不会一次性把上百页全部扔进线程池,而是按批次控制并发量。
否则容易出现两个问题:
同一时刻渲染太多页面,内存压力过大;
单个大 PDF 占满线程池,其他 PDF 无法得到处理机会。
五、为什么要分两层异步
这条链路可以理解为两层异步。
第一层是:
上传请求 ↓ 后台 PDF 处理任务它解决的是主请求阻塞问题。
第二层是:
后台 PDF 任务 ↓ 多个页面并行处理它解决的是整本 PDF 串行执行效率低的问题。
两层异步结合以后,既能让接口快速返回,也能提升后台处理吞吐。
不过这里也需要注意,异步层级越多,状态管理和异常收集就越重要。否则很容易出现“任务已经提交,但不知道执行到哪一步”的情况。
六、补齐拆页状态,避免项目提前完成
为了避免 PDF 还没拆完,项目就被误判为完成,需要单独维护 PDF 拆分状态。
可以增加类似字段:
pdf_split_status pdf_total_pages pdf_processed_pages pdf_split_start_time pdf_split_end_time拆页状态可以设计为:
PENDING SPLITTING PARTIAL_FAILED COMPLETED FAILED当用户上传文件后:
pdf_split_status = SPLITTING每完成一页:
pdf_processed_pages + 1只有当:
pdf_processed_pages == pdf_total_pages 并且不存在失败页才能把拆页状态更新为COMPLETED。
如果有部分页面失败,则进入PARTIAL_FAILED,而不是直接把整个项目标记为失败。
项目最终状态也不能只看 OCR,还要同时判断:
PDF 是否拆分完成 OCR 是否完成 业务识别是否完成这样可以避免不同异步阶段之间互相误判。
七、失败页不应该拖垮整本 PDF
页级任务带来的一个直接好处,就是可以做到单页失败隔离。
例如某一页在处理过程中出现:
页面渲染异常;
图片上传失败;
OCR 超时;
业务识别失败。
处理方式可以是:
记录当前页失败 更新当前页状态 继续处理其他页面 最终汇总失败页而不是直接抛出异常,终止整本 PDF。
最终状态可以根据处理结果判断:
所有页面成功 -> COMPLETED 部分页面失败 -> PARTIAL_FAILED 所有页面失败 -> FAILED这种方式更符合批处理任务的特点。
大任务里局部失败是正常情况,关键是不能因为一个局部错误丢掉其他已经完成的结果。
八、如何支持失败页重试和断点续传
如果系统只记录“整本 PDF 失败”,后续重试只能从头开始。
页级状态建立以后,可以按页号恢复。
例如:
第 1 到 20 页已成功 第 21 页失败 第 22 到 50 页已成功重试时只需要提交第 21 页。
实现上可以给处理方法增加一个可选参数:
onlyProcessPages正常任务不传时,处理所有未完成页面。
重试任务传入具体页号时,只处理指定页面。
同时,数据库写入需要具备幂等能力。
例如第一页已经通过“第一页优先”处理过,后台全量任务执行时还会再次遍历到第一页。如果没有幂等控制,就可能重复插入图纸记录。
可以通过:
唯一索引 insertIgnore 插入前状态判断来避免重复数据。
这样即使任务重复提交、服务重启后重新分发,也不会污染结果。
九、拆页、OCR 和业务识别不能放在一个大事务里
这条链路看起来像一个完整业务流程,但它并不适合放进一个数据库事务。
因为流程中包含:
文件读取 PDF 页面渲染 图片上传 外部 OCR 调用 模型识别 线程池异步任务这些操作的耗时和结果都不受数据库控制。
如果强行包进一个长事务,会带来:
事务时间过长;
数据库连接长期占用;
锁范围扩大;
中途失败时难以回滚外部操作;
异步线程无法共享原事务上下文。
更合理的方式是分阶段落状态。
第一阶段:PDF 拆页
负责:
读取 PDF;
生成单页图片;
创建页级图纸记录;
更新拆页进度。
第二阶段:OCR
负责:
读取待识别页面;
调用 OCR;
保存文本或结构化结果;
更新单页 OCR 状态。
第三阶段:业务识别
负责:
延长米识别;
管件识别;
标高识别;
操作条件识别;
汇总业务结果。
每个阶段都有自己的事务和状态,通过最终一致性串联起来。
十、线程池不能只追求并发越高越好
PDF 页处理虽然可以并发,但并不是线程越多越快。
因为整个过程可能同时消耗:
CPU:页面渲染、图片转换 内存:同时加载多页图片 磁盘 IO:读取 PDF、写临时文件 网络 IO:上传图片、调用 OCR 外部接口配额:OCR 并发限制如果线程池配置过大,可能出现:
Full GC;
内存溢出;
磁盘 IO 打满;
OCR 接口限流;
大量任务超时;
整体吞吐反而下降。
所以线程池参数应该结合测试结果调整,而不是直接按 CPU 核数机械配置。
通常可以考虑:
拆页渲染线程池 OCR 调用线程池 业务识别线程池分别隔离。
因为这几类任务的资源模型不同,混在一个池里不容易控制。
十一、异步任务里的异常不能只打印日志
异步任务有一个常见问题:方法已经在后台执行,异常不会直接返回给用户。
如果只打印一条错误日志,数据库状态仍然停留在PROCESSING,后续系统会以为任务还在运行。
所以每个异步任务发生异常后,至少要完成三件事:
记录失败原因 更新任务状态 保留重试所需的上下文例如单页处理失败后,可以记录:
projectId pdfId pageNo errorStage errorMessage retryCount后续重试任务就可以准确定位失败页,而不是重新扫描整本 PDF。
十二、完整的优化后链路
优化后的流程可以概括为:
用户上传 PDF ↓ 创建项目和上传任务 ↓ pdf_split_status 更新为 SPLITTING ↓ 优先处理第一页 ↓ 前端开始看到首个结果 ↓ 后台异步加载整本 PDF ↓ 按页拆分并分批并发处理 ↓ 每页生成独立图纸记录 ↓ 更新 pdf_processed_pages ↓ 汇总成功页和失败页 ↓ 拆页完成后启动 OCR ↓ 筛选 PENDING / FAILED 页面 ↓ 按页分发 OCR 任务 ↓ 继续分发业务识别子任务 ↓ 分别更新各阶段状态 ↓ 全部完成后更新项目最终状态和原来的整本串行相比,这条链路最大的变化不是“加了异步”,而是任务粒度、状态边界和恢复方式都发生了变化。
十三、为什么当时没有直接上 MQ
这类任务编排也可以通过消息队列实现:
PDF 上传消息 拆页消息 OCR 消息 业务识别消息但是否使用 MQ,还是要看当前系统规模。
当时这条链路主要运行在单体服务内部,任务量虽然不小,但还没有到必须拆成独立任务平台的程度。
因此先选择:
Spring @Async 自定义线程池 CompletableFuture 数据库状态机这样做的优势是:
接入成本低;
改动范围小;
和现有项目表、图纸表结合方便;
调试链路相对直接;
能优先解决主链路阻塞和失败恢复问题。
如果后续需要支持多实例部署、任务削峰、跨服务消费,或者 PDF 任务规模继续增长,再引入 MQ 会更合适。
十四、优化后的实际效果
优化后,用户不再需要等整本 PDF 处理完,第一页会优先进入系统,剩余页在后台并发执行。
在当时相同测试集下,主链路响应时间和 PDF 整体吞吐都有明显改善。
不过这类性能数据一定要建立在真实压测或对比记录上。如果缺少完整测试报告,写简历或面试时更稳妥的说法是:
主链路响应时间显著下降 PDF 处理吞吐明显提升不要为了数据好看,使用无法解释来源的百分比。
十五、这次优化真正解决了什么
回头看,这次优化并不是简单地把串行循环改成并行循环。
真正解决的是下面几个问题。
1. 主链路不再承担全部计算
上传请求快速返回,耗时任务转入后台。
2. 用户可以更早看到结果
第一页优先处理,改善首屏体验。
3. 大任务被拆成可管理的小任务
每一页可以独立处理、独立失败、独立恢复。
4. 状态边界更加清晰
拆页、OCR、业务识别分别维护状态,避免项目提前完成。
5. 失败恢复成本降低
失败时只需要重试失败页或未完成页,不需要整本重跑。
6. 重复执行不会污染数据
通过唯一约束和幂等写入,避免第一页优先和后台全量任务产生重复记录。
十六、总结
大 PDF 的异步治理,真正困难的地方并不是怎么创建线程,而是怎么划分任务。
如果仍然把整本 PDF 当成一个任务,即使放到线程池里,也只是把“同步阻塞”变成了“后台串行”。
只有把它拆成页级任务以后,才能进一步实现:
并发处理 第一页优先 失败隔离 页级重试 断点续传 状态跟踪 幂等执行这次优化最终形成的是一条分阶段、可恢复的任务链路:
上传只负责创建任务 拆页只负责生成页记录 OCR 只负责识别 业务任务只负责计算 每个阶段分别落状态 最后通过状态汇总实现最终一致性对于文件处理、批量任务、模型调用这类长链路来说,比单纯追求并发数更重要的,是让每个任务都有清晰边界,失败之后知道从哪里继续。
