news 2026/7/8 9:04:18

大 PDF 处理为什么不能只加线程池:页级拆分、异步编排与断点续传实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大 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 只负责识别 业务任务只负责计算 每个阶段分别落状态 最后通过状态汇总实现最终一致性

对于文件处理、批量任务、模型调用这类长链路来说,比单纯追求并发数更重要的,是让每个任务都有清晰边界,失败之后知道从哪里继续。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/8 9:02:42

【Agent智能体】21Coze实战案例集

章节二十一:Coze实战案例集(视频生成 卖点提炼) 一、宣传视频生成 1. 概述 宣传视频生成是一个AI自动化的视频创作流程:用户输入商品信息后,AI自动生成视频脚本、分镜描述,再调用视频生成工具制作宣传视频…

作者头像 李华
网站建设 2026/7/8 9:00:58

Grype:容器镜像漏洞扫描,一条命令搞定

文章目录Grype:容器镜像漏洞扫描,一条命令搞定这东西能扫什么用法有多简单凭什么它有优先级适合谁用Grype:容器镜像漏洞扫描,一条命令搞定 做容器开发的同学应该都有体会:镜像拉下来跑得挺欢,但里面有没有…

作者头像 李华
网站建设 2026/7/8 8:58:48

基于vLLM-Ascend的Qwen3.5-122B模型Atlas 800I A3单机混部部署实践

​作者​:昇腾实战派 ​知识地图​:https://blog.csdn.net/Lumos_Lovegood/article/details/161601003 背景概述 本文档将介绍基于vLLM-Ascend的Qwen3.5-122B模型在Atlas 800I A3上的单机混部部署实践,包括支持的特性、特性配置、环境信息以…

作者头像 李华
网站建设 2026/7/8 8:56:45

Java毕设选题推荐:基于 SpringBoot 的公务员备考数据统计分析系统的设计与实现 基于前后端分离的公考试题录入与维护系统【附源码、mysql、文档、调试+代码讲解+全bao等】

博主介绍:✌️码农一枚 ,专注于大学生项目实战开发、讲解和毕业🚢文撰写修改等。全栈领域优质创作者,博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围:&am…

作者头像 李华