资讯动态

Spring @Async异步处理实战:从大文件上传到线程池原理与坑点解析

发布时间:2026/9/28 15:20:55 来源:尧图企业网站定制
要讲清楚Spring里的异步开发我觉得用大文件上传这个场景最合适。先说一个我前几年遇到的实际画面在线教育后台老师上传一节课的录像一个MP4动辄几百MB。接口收到文件后要一边写磁盘、一边做格式校验还要调FFmpeg重新转一版压缩视频。同步处理下前端页面要转十几分钟最后往往等来一个超时。Tomcat默认的200个工作线程很快被几个大文件上传占满连登录、课程列表这些轻量接口都跟着变慢健康检查连续失败。排查到最后问题就出在“同步”两个字上——一个请求从头到尾占着一个线程而这个完整的耗时链条可能以分钟计。后来我用Spring的Async把耗时的文件处理拆到独立线程池上传接口只负责接收文件和登记任务立刻返回一个taskId转码、校验、转存对象存储这些重活全部放后台执行。前端拿着taskId轮询进度体验完全不一样。这篇文章就把这套方案从原理到代码完整拆开讲包括线程池参数怎么定、临时文件怎么管理以及几个不跑线上根本遇不到的坑。1. 大文件上传的同步困境问题到底出在哪1.1 一次线上故障的完整排查链路当时我负责的课程后台突然频繁报警最先看到的表象是首页接口响应从几十毫秒涨到几秒。我先看了服务器负载CPU使用率其实不算高但Tomcat线程几乎全部处于忙碌状态。随后用jstack PID thread_dump.txt抓了线程栈这一看就明白了大量线程分两类一类卡在FileOutputStream.writeBytes上正在把二进制流往磁盘刷另一类卡在java.lang.ProcessImpl.waitFor上等FFmpeg进程把视频压完。看到这些调用栈我基本就锁定了问题Tomcat的工作线程被我们自己的业务逻辑拿去做重活而且做一次要几分钟甚至十几分钟。更麻烦的是用户那边页面一直转圈最后超时他不确定文件有没有传上去于是又点了一次上传服务端又多写了一份重复文件。数据库连接、日志线程、缓存客户端这些下游组件也都跟着被拖住因为上游线程不释放下游资源就一直被占用。1.2 同步模型的时间账和线程账算一笔账就清楚了。假设上传一个500MB的视频用户家庭宽带的实际上传速度是5MB/s那网络传输就要100秒落盘加基础校验大概20秒FFmpeg转码压缩可能要200秒。整个同步接口的耗时约320秒。Tomcat默认线程池即使按200个线程算6个并发大文件上传就能吃光线程池。何况生产环境还有其他接口抢线程往往3个大文件上传就能让服务雪崩。处理环节耗时估算资源占用网络传输约100秒读取请求体磁盘落盘约20秒IO写操作FFmpeg转码约200秒等待子进程同步接口总时长约320秒一个Tomcat线程全程占用换成异步后Controller只负责接收文件到临时目录快速登记任务整体同步时间能控制在几百毫秒内剩下的320秒交给后台线程池去跑。用户看到的是“上传成功正在处理中”而不是一个永远打转的进度条。1.3 异步化的边界不是所有环节都要异步有些人看完会说既然网络传输也耗时那把接收文件本身也做成异步不是更好我的建议是不要这样。文件接收之前需要知道权限是否合法、大小是否超限、磁盘空间是否足够这些校验如果全部异步用户可能在几分钟甚至几小时后才发现上传失败反馈链路太长。更合理的拆法是把快速校验和文件落地作为同步步骤控制整个接口在极短时间内返回把转码、格式校验、对象存储上传、后续通知这些重活放进异步线程。换句话说要异步化的是“文件的后处理”不是“文件的接收”。这样既保住了用户体验又避免了请求线程长期被占。2. Spring Async的底层逻辑从注解到线程池任务2.1 Async不是魔法而是代理机制在工作很多人只在方法上加了Async发现没生效第一反应是依赖包不对。实际上Spring在Bean初始化阶段通过BeanPostProcessor扫描注解为带有Async的Bean生成代理对象。项目启动类上没有EnableAsync这个扫描动作根本不会发生方法自然还是同步执行。即使加了EnableAsync你注入的也是代理对象。外部调用方持有的引用是代理代理先将方法调用包装成Task再交给线程池执行。明白了这一点后面很多坑都能解释为什么同类内部调用不异步、为什么异步方法抛出的异常调用方接不到本质上都是因为切入点根本没经过代理或者代理把异常放到了另一个线程里。2.2 线程池参数怎么定这套配置我沿用了很久Spring Boot虽然自动配置了一个applicationTaskExecutor但它的参数偏向通用场景文件处理这种任务用默认线程池非常危险。我建议单独定义一个专用线程池名字最好能体现业务场景。Configuration public class AsyncConfig { Bean(fileProcessExecutor) public ThreadPoolTaskExecutor fileProcessExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(file-process-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } }参数不是拍脑袋定的。核心线程数我按CPU核数的1到2倍取文件处理任务是典型的IO密集型加少量CPU计算线程在等待FFmpeg进程、等待对象存储响应时不会释放所以可以比CPU核数略多一点。队列容量200的意思是高峰期允许200个任务排队超过这个量才创建最大线程数的线程。如果队列用无界队列最大线程数等于白设因为任务永远进不了扩容阶段。Spring的ThreadPoolTaskExecutor底层逻辑是核心线程数满后再来任务先入队列队列满才创建非核心线程达到最大线程数且队列也满了才会触发拒绝策略。这里还要点名批评一个常见配置直接注入SimpleAsyncTaskExecutor。它每来一个任务就new Thread没有队列、没有线程复用、没有并发上限突发流量能直接把系统内存打满。生产环境自定义线程池是必选项不是可选项。2.3 返回值选void还是CompletableFutureAsync方法可以返回void也可以返回Future或CompletableFuture。返回void适合“发完就不管”的场景配合任务状态表记录结果返回CompletableFuture适合需要聚合结果的场景比如一个视频要同时转码和抽帧用allOf等所有任务完成。大文件上传我建议用void加上数据库状态记录因为调用方根本不需要阻塞等待结果如果返回Future又在主线程里get()等于又把异步退化成了同步。3. 大文件上传案例从Controller到异步处理的完整实现3.1 整体流程设计先接收再处理整个链路我设计成五步前端上传文件到ControllerController做轻量校验后把文件写入本地临时目录生成一个taskId返回给前端同时把taskId和文件路径交给异步线程池最后异步线程处理完毕后更新任务状态。前端拿着taskId轮询查询接口拿到SUCCESS结果后展示下载地址。任务状态我用一个简单的状态机管理PENDING、PROCESSING、SUCCESS、FAILED。状态存在数据库表里字段包括taskId、原始文件名、文件大小、当前状态、错误信息、创建时间和完成时间。有了这张表后续做失败补偿、异步重试、任务统计都非常方便。3.2 Controller落地临时文件并登记任务Controller层只做三件事校验、落盘、登记。我特意把MultipartFile在Controller阶段就转成磁盘文件不直接传给异步方法因为MultipartFile依赖当前请求上下文异步线程拿到之后很可能已经无法读写。RestController RequestMapping(/upload) Slf4j public class FileUploadController { private static final SetString ALLOWED_EXTS Set.of(mp4, mov, mkv); PostMapping public ResponseEntityUploadTaskVO upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { throw new BizException(文件不能为空); } String ext StringUtils.getFilenameExtension(file.getOriginalFilename()); if (ext null || !ALLOWED_EXTS.contains(ext.toLowerCase())) { throw new BizException(不支持的文件类型); } String taskId UUID.randomUUID().toString().replace(-, ); String tempDir fileStorageProperties.getTempDir(); String targetPath Paths.get(tempDir, taskId . ext).toString(); file.transferTo(new File(targetPath)); fileUploadService.initTask(taskId, file.getOriginalFilename(), file.getSize()); fileUploadService.asyncProcess(taskId, targetPath); return ResponseEntity.ok(new UploadTaskVO(taskId, PROCESSING)); } }上传大小限制要提前在配置文件里声明spring: servlet: multipart: max-file-size: 1024MB max-request-size: 1100MBmax-file-size限制的是单文件大小max-request-size限制的是整个请求体大小。如果前后端做了分片上传请求体大小一般不会太大但服务端要允许大文件存在。临时文件名我用UUID重新生成不采用用户原始文件名避免路径穿越和文件名脏字符带来的安全问题。3.3 异步任务转码、入库、更新状态异步方法放在独立的FileUploadService里用Async(fileProcessExecutor)显式绑定前面定义的线程池。整个方法体必须自己包一层try-catch把异常写进任务状态表否则异常会在异步线程里被默认吞掉排查问题要多花几个小时。Service Slf4j public class FileUploadService { Async(fileProcessExecutor) public void asyncProcess(String taskId, String sourcePath) { long start System.currentTimeMillis(); updateTaskStatus(taskId, PROCESSING); try { validateFile(sourcePath); String outputPath convertToH264(sourcePath); String remoteUrl minioUploader.upload(outputPath); updateTaskResult(taskId, SUCCESS, remoteUrl); log.info(task{} done, cost{}ms, taskId, System.currentTimeMillis() - start); } catch (Exception e) { log.error(task{} failed, taskId, e); updateTaskResult(taskId, FAILED, null); } } }FFmpeg转码我用ProcessBuilder启动子进程同时做超时控制。这里有个很容易忽略的细节启动子进程后要持续读取标准输出否则子进程输出缓冲区满会卡住。private String convertToH264(String sourcePath) throws IOException, InterruptedException { String outputPath sourcePath.replaceAll(\\.\\w$, _h264.mp4); ProcessBuilder pb new ProcessBuilder(ffmpeg, -i, sourcePath, -c:v, libx264, outputPath); pb.redirectErrorStream(true); Process process pb.start(); try (BufferedReader reader new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line reader.readLine()) ! null) { log.debug(ffmpeg: {}, line); } } if (!process.waitFor(300, TimeUnit.SECONDS)) { process.destroyForcibly(); throw new IllegalStateException(ffmpeg timeout); } if (process.exitValue() ! 0) { throw new IllegalStateException(ffmpeg exit code process.exitValue()); } return outputPath; }转码完成后的文件再转存到MinIO或者OSS最后把远程地址写入任务表。3.4 前端轮询与状态查询查询接口很简单GetMapping(/task/{taskId}) public ResponseEntityTaskVO queryTask(PathVariable String taskId) { return ResponseEntity.ok(taskService.getTask(taskId)); }返回体里包含taskId、status、progress、resultUrl、errorMsg这几个字段。前端每2到5秒轮询一次直到status变成SUCCESS或FAILED再清除定时器。大部分后台管理系统用轮询就足够了没必要一上来就上WebSocket。配合前端async/await做状态流转页面代码会非常清晰。4. 上线之后才会遇到的五个坑4.1 Async自调用失效明明注解了却没异步最容易踩的坑是同类内部调用。比如在同一个Service里普通方法调用了带Async的另一个方法你会发现它还是同步执行。原因前面讲过Async靠代理生效同类内部调用走的是this引用根本没经过代理对象注解自然失效。解决办法有两个。一是把异步逻辑拆到独立的Service类由外部注入后调用二是配置EnableAspectJAutoProxy(exposeProxy true)然后用AopContext.currentProxy()拿代理对象再调用。实际项目里我推荐第一种拆类之后职责清晰也方便单测。下面这种写法就是典型的无效写法Service public class OrderService { Async public void asyncProcess() { } public void doWork() { asyncProcess(); // 自调用不会异步 } }4.2 线程池满了之后文件真的会丢线程池默认的拒绝策略是AbortPolicy满了直接抛TaskRejectedException。如果在Controller里没有catch这个异常文件已经被写入本地目录但任务登记失败这份文件就变成无人认领的孤儿文件既没人处理也不占任务状态后续非常难清理。我更推荐自定义拒绝策略拒绝时做降级记录而不是直接丢弃或抛异常executor.setRejectedExecutionHandler((runnable, threadPoolExecutor) - { log.warn(file process queue full, active{}, queueSize{}, threadPoolExecutor.getActiveCount(), threadPoolExecutor.getQueue().size()); // 这里可以落一条失败记录到任务表稍后补偿 asyncTaskRepository.markRejectedTask(taskId); });如果使用CallerRunsPolicy任务会由提交线程执行接口会变慢但任务不丢。具体选哪种策略看业务对“不丢任务”和“接口响应”哪个更敏感。大文件上传场景我倾向于不丢但会在监控里把任务积压数作为重要告警指标。4.3 异步方法里的异常和事务边界Async方法和Transactional叠加时事务的边界会从调用者线程移到异步线程里。这意味着调用方无法在同一个事务里包含异步操作异步方法抛出的异常也不会导致调用方事务回滚。更常见的问题反而是异常被静默吞掉因为异步方法的调用方根本拿不到结果。全局配置一个AsyncUncaughtExceptionHandler很有必要Configuration public class AsyncConfig implements AsyncConfigurer { Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) - log.error(async method {} error, params{}, method, params, throwable); } }但说实话最稳妥的还是异步方法内部自己catch把异常写进任务状态表不要依赖全局处理器。因为全局处理器能帮你打日志但任务状态表才是业务侧真正能感知和做补偿的入口。4.4 临时文件堆积磁盘被打满只是时间问题每上传一个文件本地临时目录就多一份文件。转码成功之后如果忘记删除源文件或删除时机不对磁盘会在不知不觉中被打满。我的习惯是任务完成后立即删除临时源文件和中间文件只保留最终产物同时配一个定时任务定期扫描临时目录删除超过24小时还未完成的任务文件。Scheduled(cron 0 0 * * * ?) public void cleanTempFiles() { File tempDir new File(fileStorageProperties.getTempDir()); File[] files tempDir.listFiles(); if (files null) return; long now System.currentTimeMillis(); for (File f : files) { if (now - f.lastModified() 24 * 60 * 60 * 1000L) { log.info(cleaning temp file: {}, f.getAbsolutePath()); f.delete(); } } }清理逻辑要看业务保存周期但“必须定期清理”这一点是跑不掉的。4.5 应用重启导致任务状态停留PROCESSING如果你只用内存Map存任务状态重启后所有状态都没了。即使用了数据库状态表也会遇到重启前处理了一半的任务一直停在PROCESSING。解决思路是在应用启动时扫描一下这些半途任务重新提交线程池或者统一标记为FAILED并在错误信息里写上“服务重启导致中断”。开始时大文件上传平台还会遇到服务发布后用户反馈“一直处理中”的问题就是这里没处理干净。5. 从单文件扩展到海量文件的几个思路5.1 批量上传时先给线程池加个闸当业务从单文件上传扩展成批量上传比如老师一次传10个视频常见写法是for循环里一个文件提交一个异步任务。如果批量请求多线程池队列瞬间被打满拒绝策略触发部分文件悄悄失败。更好的做法是分批提交每批控制数量配合CompletableFuture.allOf等待一批完成后继续下一批或者在入口用Semaphore限制同时提交的任务数量。异步化不代表没有背压背压一旦失控整个服务的稳定性都会出问题。5.2 本地转码再转存MinIO链路怎么排最终的存储我推荐这种链路先写到本地临时目录异步完成校验和转码再把产物上传到MinIO上传成功以后删除本地文件。转存过程本身是跨网络的IO操作非常适合放在异步线程里。MinIO客户端配置很轻量Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(https://minio.internal.example.com) .credentials(accessKey, secretKey) .build(); }上传时建议直接用putObjectSDK内部对大对象会自动分段处理。如果文件量特别大可以考虑在本地把产物拆成多个分片再并发上传分片到MinIO最后用完整对象合并。异步线程池的并发度这时要单独评估不能和转码任务混用同一个线程池否则互相影响。5.3 线程池指标监控才是异步化的最后一块拼图异步化之后接口是快了但你看不到后台发生了什么。线程池的活跃线程数、队列长度、完成任务数这些指标必须暴露出来。Spring Boot Actuator可以直接暴露线程池相关信息也可以在自己的监控接口里把数据推给监控平台MapString, Object metrics new HashMap(); metrics.put(activeCount, executor.getActiveCount()); metrics.put(queueSize, executor.getQueue().size()); metrics.put(completedTaskCount, executor.getCompletedTaskCount());最重要的一条告警是队列积压持续上涨其次是拒绝次数0。收到告警再动手扩容或调整参数而不是等用户反馈“文件一直处理中”。6. 实际运行两年后我的一些体会这个方案在我手上跑了两年多上传高峰没有再堵过接口用户反馈的“传文件卡死”问题彻底消失。但我始终觉得异步化解决的是线程占用问题并没有解决任务可靠性问题。线程池的队列本质是内存队列进程一重启里面的东西就没了必须有数据库状态表和定时补偿兜底才算真正做完。还有一点经验是不要一次性把所有重活全部塞进异步。比如转码和对象存储上传可以分开两个线程池转码线程池小一点上传线程池大一点因为两者资源消耗完全不一样。真要在一个线程池里做也要把两类任务命名好方便监控定位。最后分享一个实用小技巧给每个异步任务生成一个全局唯一的taskId在入口和出口都打一行结构化日志。排查问题时顺着taskId就能把整条链路的时间线串起来。这比任何复杂链路追踪都更直接也更容易落地。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑