☰
Spring异步开发实战:@Async大文件上传的线程池与分片方案
2026/10/6 4:22:53 网站建设 项目流程

大文件上传这需求,放在任何一个业务系统里都算不上新奇。但你真亲手做一遍,再被线上流量打一轮,就会明白“文件收下来了”和“文件上传体验做对了”是两码事。最初我接这个需求时,第一版天真地把上传文件直接丢给Service同步处理,几十MB的包还好说,换成一两个GB的媒体文件,Tomcat的默认线程池直接成了停车场:一个上传请求占一个线程,线程数被吃满,其他接口跟着遭殃。后来我改成Spring的@Async异步处理,把上传任务的执行从请求线程里摘出去,才算把这个场景真正理顺。

这篇东西围绕“Spring中使用Async进行异步功能开发实战-以大文件上传为例”展开,讲的是我实际落地这套方案时的完整思路:为什么大文件上传要走异步、@Async运行的底层机制、分片上传怎么和异步方法结合、状态跟踪与重试怎么做、线程池和异常处理有哪些隐蔽的坑。适合手里拿着“文件上传”需求、准备从Controller里一坨同步代码升级成异步架构的Spring开发同学参考。

1. 大文件上传改异步,先弄清同步痛点在哪里

1.1 上传链路里真正消耗资源的环节

一张上传请求从浏览器发出,要经过网络传输、Web容器接收、Multipart解析、业务校验、磁盘写入这几个环节。很多人默认“慢在网速”,上了异步之后才发现瓶颈根本不是带宽,而是后面几段:

  • MultipartResolver解析multipart/form-data时会把上传的内容先转存为临时文件,这个动作吃的是磁盘IO;
  • 业务代码里如果做了InputStream.readAllBytes(),那就是把整个文件加载进堆内存,几百MB的文件分分钟把堆挤爆;
  • 文件写目标盘的过程同样属于IO密集操作,同步执行时请求线程从头到尾被占用。

异步化之前,我测过一个1.2GB的文件在本地环境的耗时分布:网络传输大概占40%,Multipart解析和转存占30%,业务处理和磁盘写占剩下30%。如果你把“上传”从请求线程里整体解耦,至少能释放掉60%以上的线程占用时间,代价仅仅是接收方晚一点看到最终文件。

1.2 同步处理模型下服务端线程的真实占用

Tomcat默认maxThreads一般在200左右,每个线程同一时间只能处理一个请求。当大文件上传占住线程时,请求线程不是在“干活”,而是在等磁盘、等网络。这就像你在银行排队办业务,前面的客户正在填写一张超长的表单,柜员只能干等,后面所有人都在陪着耗。同步模型最要命的就是这种“看起来忙、其实在等”的资源浪费。

实测数据更直观。我压测过一个2GB文件同步上传的场景,同时进来30个请求,Tomcat的activeThreads直接飙到满,平均响应时间从300ms涨到12秒以上,连健康检查接口都开始超时。而上线异步方案后,同样是30个大文件并发上传,请求线程只保留到“任务已接收”的程度,平均响应时间稳定在200ms上下。

1.3 异步化的正确边界

不是所有环节都适合异步。异步应该解决的是“耗时且不需要立即返回结果”的部分,比如大文件的分片合并、格式转换、对象存储转存。而那些用户必须立刻感知的结果,比如“分片已接收”“任务已创建”,还是应该同步返回。

很多初稿设计者犯的错误是,连基础的元数据校验也丢到异步里,结果用户传完文件界面迟迟没有反馈,体验反而更差。我在项目里坚持的原则是:请求入口同步返回“任务已创建/分片已接收”,文件组装、合并、落库这些重操作全部交给异步任务。用户侧看到的反馈快,服务端的线程占用降下来,一举两得。

2. @Async的完整运行链路:代理、线程切换与返回值

2.1 为什么只有Spring管理的Bean才能用@Async

这是我最常被问到的问题,也是排查异步不生效时第一个要检查的点。@Async之所以能生效,靠的是Spring AOP的代理机制。启动类或配置类上加了@EnableAsync后,Spring会对标注了@Async的方法所在Bean创建动态代理。调用方持有的其实是代理对象,代理对象在方法执行前会去匹配AsyncExecutionInterceptor,匹配成功就把真实方法执行提交给线程池。

所以三个硬性条件缺一不可:

  1. Bean必须交给Spring容器管理;
  2. 调用必须从外部进入,即通过代理对象调用;
  3. @Async标注的方法不能是private,因为JDK动态代理和CGLIB都只能拦截public和非final方法。

2.2 方法执行的线程切换模型

这里有一个类比能帮你快速建立直觉:请求线程是前台接待员,只负责登记客户信息,然后把重活交给后台搬运工(线程池里的线程),前台立刻接待下一位客户。线程切换的位置就在AsyncExecutionInterceptor的invoke方法里,它会把方法调用包装成一个Callable提交给TaskExecutor。

Spring异步方法分两种:

  • “发后即忘”型:方法返回void,调用方不等结果,适合文件合并、转码这类后台任务;
  • “等待结果”型:方法返回Future或CompletableFuture,调用方后续get()或whenComplete()获取结果,适合需要知道执行成败的场景,比如异步分片合并后判断是否成功。

2.3 返回值设计常见的坑

之前有同事把异步方法写成返回普通对象String,一运行发现拿到的是null,排查了半天。原因很简单:@Async方法的返回值必须符合代理拦截器的约定,普通对象会被当成null处理。如果你需要结果,用CompletableFuture 包装,并在实际代码里用completedFuture显式完成Future。

@Async("uploadMergeExecutor") public CompletableFuture<Boolean> mergeChunksAsync(String taskId) { boolean success = doMerge(taskId); return CompletableFuture.completedFuture(success); }

还有一个细节:返回Future时,调用方不要直接调用.get()无限期阻塞,否则单机场景下等于把异步又变回了同步。正确做法是超时获取,或者直接用whenComplete回调。

3. 分片上传与异步结合的项目结构设计

3.1 大文件不能整体上传的核心原因

大文件不能整体上传的核心原因有两个:一是浏览器或网关对单个请求体有大小限制;二是网络中途断开后,整个文件都要重传。所以常规做法是分片:前端把文件切成多个切片分别上传,后端收齐后按序号合并。这和你把一本厚书拆成几十页快递寄出,只有全部页数到齐才能装订成一个道理。

异步在这个场景里的角色,主要是把“接收分片”之后的合并、校验、转存等工作从请求线程中剥离,让接口能快速返回“第N片已收”。

3.2 异步分片上传的接口设计

实践中我把上传链路拆成三个接口:

  1. 创建上传任务
@PostMapping("/upload/task") public UploadTaskDTO createUploadTask(@RequestBody CreateTaskRequest req) { return uploadService.createTask(req); }
  1. 上传分片
@PostMapping("/upload/chunk") public ChunkResponse uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("taskId") String taskId, @RequestParam("chunkIndex") Integer chunkIndex) { // 同步保存分片,记录状态到DB return uploadService.receiveChunk(taskId, chunkIndex, file); }
  1. 异步合并触发入口
@PostMapping("/upload/merge") public ApiResponse triggerMerge(@RequestParam String taskId) { uploadService.startMergeAsync(taskId); return ApiResponse.ok("合并任务已提交,可通过轮询查询状态"); }

分片上传本身我保持同步。分片数量多的时候,比如一个2GB文件分200片,每片都做异步化会牵扯到并发写盘的顺序问题,代价远大于收益。分片接收保持同步,合并阶段异步化,这是实操下来最稳的组合。

3.3 合并阶段的关键实现

合并的异步方法核心逻辑:

@Async("uploadMergeExecutor") public void startMergeAsync(String taskId) { UploadTask task = taskMapper.selectByTaskId(taskId); List<ChunkRecord> chunks = chunkMapper.selectByTaskIdOrderByIndex(taskId); try (FileOutputStream fos = new FileOutputStream(task.getTargetPath())) { for (ChunkRecord chunk : chunks) { try (FileInputStream fis = new FileInputStream(chunk.getPath())) { byte[] buffer = new byte[8192]; int len; while ((len = fis.read(buffer)) != -1) { fos.write(buffer, 0, len); } } } } taskMapper.updateStatus(taskId, "SUCCESS"); }

写合并逻辑时,三个坑最典型:

  • 合并时一定要按chunkIndex排序,不依赖数据库默认返回顺序;
  • 每个分片用完必须关闭流,否则Windows上临时文件删不掉,Linux上文件描述符也会被耗尽;
  • 如果目标文件已存在,需要先判断是否需要覆盖,否则重复合并会产生脏数据。

在动手改异步之前,先把合并逻辑做成一个可以被普通同步调用、也能被异步线程调用的纯业务方法,这样测试时可以直接跑单元方法验证,不用每次都走异步链路。

4. 任务状态、失败重试与前端轮询的配合实现

4.1 状态驱动的异步任务模型

异步任务一多,光靠日志排查问题会让人崩溃。我的做法是在MySQL建一张upload_task表,核心字段包括:task_id、file_name、file_size、total_chunks、received_chunks、status(INIT/UPLOADING/MERGING/SUCCESS/FAILED)、fail_reason、create_time、update_time。

这张表至少发挥三个价值:

  • 给前端轮询提供真实依据;
  • 任务中途失败后能从DB恢复现场,手动触发重试;
  • 运营或客服侧排查问题时,直接查库定位到具体阶段。

任何异步动作执行前后都要更新状态字段。比如进入MERGING后立刻写一行update,合并成功再置SUCCESS。这不是为了写代码而写,而是让系统所有参与者对同一个事实说话。

4.2 失败重试的具体做法

异步合并失败,常见原因包括:临时目录空间不足、分片文件被误删、合并时磁盘写入异常。我习惯在异步方法里做三层防御:

第一层,执行前校验分片齐备,逐个检查文件是否存在且大小大于0; 第二层,合并过程中捕获IOException,记录失败阶段和已经写入的字节偏移量; 第三层,失败后不是直接丢异常,而是把task状态更新为FAILED,并写入fail_reason字段,为重试留依据。

重试机制我采用两个方案叠加。简单场景用Spring的@Retryable注解,直接对合并方法做重试声明;复杂场景用一个定时扫描任务,把状态为FAILED且失败次数小于3的任务重新触发。

@Scheduled(fixedDelay = 30000) public void retryFailedTasks() { List<UploadTask> failedTasks = taskMapper.selectFailedTasks(3); for (UploadTask task : failedTasks) { taskMapper.updateStatus(task.getTaskId(), "MERGING"); uploadService.startMergeAsync(task.getTaskId()); } }

这里有个小细节:定时任务重新触发前,一定要先把状态从FAILED改回MERGING,否则异步线程和定时扫描器之间会产生状态竞争,同一批任务被并发处理两次。

4.3 前端轮询接口的注意项

轮询接口不必每次都查全部分片记录,那样SQL压力会很大。数据量到几十万之后,全表count已经有肉眼可见的延迟。我给的查询接口只返回聚合统计:

@GetMapping("/upload/task/{taskId}") public UploadTaskDTO queryTaskStatus(@PathVariable String taskId) { UploadTask task = taskMapper.selectByTaskId(taskId); UploadTaskDTO dto = new UploadTaskDTO(); dto.setStatus(task.getStatus()); dto.setReceivedChunks(task.getReceivedChunks()); dto.setTotalChunks(task.getTotalChunks()); dto.setFailReason(task.getFailReason()); return dto; }

前端轮询节奏建议:UPLOADING阶段可以1秒一次;一旦进入MERGING阶段,轮询间隔拉长到2-3秒,减少无意义请求。合并大文件通常几秒到几十秒,1秒一次和3秒一次对用户感知几乎没有区别,但对服务端的压力差别很大。

5. 线程池配置、异常兜底与资源回收

5.1 自定义线程池的参数经验值

Spring默认的SimpleAsyncTaskExecutor是“每次调用都new一个新线程”的实现,没有线程复用,并发一高就狂开线程,最终被系统拒绝。我的建议永远是显式配置线程池,并且每个业务场景给一个独立线程池,避免互相干扰。

@Configuration @EnableAsync public class AsyncConfig { @Bean("uploadMergeExecutor") public ThreadPoolTaskExecutor uploadMergeExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix("upload-merge-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }

各参数的经验参考:

参数推荐值说明
corePoolSize2文件合并属IO密集型,不必按CPU核数翻倍
maxPoolSize4给突发流量留余地,但不宜过大
queueCapacity100太小容易触发拒绝策略,太大任务堆积延迟高
keepAliveSeconds60空闲线程保留时间
rejectedExecutionHandlerCallerRunsPolicy队列满时由调用线程兜底执行,保证任务不丢

CallerRunsPolicy值得多说一句:合并任务被前端提交后,即使线程池满了,它也会把任务交回调用线程执行。代价是阻塞一次前端请求,但换来了“任务一定被执行”的保证。对上传合并这种不需要秒级响应的场景,我认为值得。

5.2 异步异常处理的一个隐蔽坑

@Async方法的异常不会出现在调用方线程里,除非显式捕获。如果方法返回void且没有配置AsyncUncaughtExceptionHandler,异常会被吞掉,日志里什么都看不到。这是排查问题时的重灾区,表现形式往往是“任务没完成,但没有报错”。

我的做法是单独实现一个异常处理器:

@Configuration public class AsyncExceptionConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) -> { log.error("异步任务执行异常,方法:{},参数:{}", method.getName(), params, throwable); }; } }

5.3 临时文件与磁盘容量控制

异步任务跑起来之后,最容易忽略的是上传临时文件的清理。Multipart解析生成的临时文件,Tomcat默认在请求结束后清理,但合并过程中我们为了校验会额外copy一份分片文件,这部分必须安排清理。

我在项目里建了一个定时任务,每20分钟扫描一次临时目录,删除创建时间超过2小时且没有被任何进行中任务引用的文件。关键判断条件是“没有被进行中任务引用”,这要求任务在启动合并时把涉及的文件路径登记到内存缓存或DB里,扫描器对比后决定是否删除。

这个策略在绝大多数场景都够用,既不会误删正在合并的文件,也能避免磁盘被野生文件占满。磁盘告警这种事,一旦发生就是线上事故级别,别指望事后再清理。

6. 后续演进建议与个人实践心得

6.1 @Async的适用边界

@Async在单机应用内确实好用,但服务实例一多,本地线程池就变成“每台机器各管各的”。此时“任务在哪个节点执行”从不可控变成需要管理,调度、重试、状态可见性都在跨节点断裂。继续硬怼@Async就是给自己埋雷。

我判断是否该从@Async迁出的信号,其实很朴素:

  • 需要跨节点保证同一任务不重复执行;
  • 需要暂停、恢复、取消进行中的任务;
  • 需要更精细的并发控制和失败重试语义。

目前在文件上传场景,我坚持“单服务实例能覆盖业务量”就用@Async+DB状态表;一旦确认要横向扩展,我会把分片合并和文件转存任务迁移到消息队列,由独立Worker消费。接口层保持不变,前端无感,任务处理的可靠性提高一大截。

6.2 从本地异步迁移到消息队列的实践路线

迁移时保留原接口,只在触发合并处把“直接调用异步方法”改成“往队列发一条消息”,Worker端再调用同一套合并逻辑。这个改造风险小,回滚也容易,因为业务逻辑没有动,只是触达方式变了。

队列选型不需要一上来就上太重的分布式调度框架。先选团队熟悉的中间件,把任务投递、消费、失败重新入队跑通,能解决当前问题再谈扩展性。我见过不少团队在单机阶段就上了分布式调度,结果运维成本比业务代码还高。

6.3 我踩过几次坑后沉淀下来的几条硬建议

  • 先从DB状态设计入手,再写异步代码。状态字段没设计好,后面做轮询、重试、对账都很痛苦;
  • 用自定义线程池,不要偷懒用默认的,给每个业务场景独立线程池;
  • 异步方法一定加异常兜底,至少把异常打到日志,否则问题会藏到你找不到;
  • 文件合并前先核对分片数量和总大小,能提前发现文件不完整,避免往IO设备写了一大半才发现写不了;
  • 上线前做并发上传脚本,同时开10个线程各传一个1GB文件,观察线程池队列深度和响应时间变化,这比任何纸面推演都直观。

大文件上传的异步化改造,本质上是重新思考请求、线程、任务三者的关系。别急着把代码改异步,先把数据模型和线程模型想清楚,再用@Async落地,你会省下数不清的调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询