大 PDF 工程包如何做异步治理从整本串行到页级任务编排在很多业务里PDF 上传只是一个很普通的功能。但工程图纸场景不一样。用户上传的往往不是几页合同而是一整包工程 PDF少则几十页多则上百页。系统不仅要完成拆页还要对每一页做 OCR、结构化识别后面还可能继续计算延长米、管件、标高、操作条件等业务数据。如果整条链路按串行方式执行问题很快就会暴露出来上传接口等待时间很长用户迟迟看不到任何结果某一页失败后可能要整本重跑PDF 拆分、OCR、业务识别的状态容易互相干扰单个大文件可能长期占用线程池后面的任务只能排队。这次优化的目标并不是单纯把线程数调大而是重新拆分任务边界把“整本 PDF”改造成“页级任务”让拆页、OCR 和业务识别能够独立执行、独立失败、独立恢复。一、原来的处理方式有什么问题优化前的链路可以简单理解为上传 PDF ↓ 完整拆分整本 PDF ↓ 逐页执行 OCR ↓ 执行业务识别 ↓ 更新项目状态这种方式在文件页数少时问题不明显但文件一大几个问题就会同时出现。1. 主链路太重PDF 拆页本身就包含文件读取、页面渲染、图片生成等操作。拆页完成后还要继续调用 OCROCR 后面又有业务识别。如果这些操作都串在一次请求里用户上传之后只能一直等。尤其是 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 只负责识别 业务任务只负责计算 每个阶段分别落状态 最后通过状态汇总实现最终一致性对于文件处理、批量任务、模型调用这类长链路来说比单纯追求并发数更重要的是让每个任务都有清晰边界失败之后知道从哪里继续。