我做了几年Spring后端,遇到大文件上传这类需求时,第一反应往往不是考虑并发量有多大,而是担心请求会把应用拖垮。文件传上来之后要校验、要转存、要异步通知业务方,这一串操作如果全放在HTTP请求线程里做完,接口超时和线程池耗尽几乎是必然的。这篇文章就以大文件上传为例,把Spring中@Async异步开发的整套打法拆开讲清楚,从注解用法到线程池配置,再到落地时会踩的坑,一次性说透。
文章适合正在用Spring Boot做业务开发的工程师,尤其是负责文件上传、导入导出、消息通知等耗时操作的场景。如果你已经知道@Async的基本用法但总感觉使不顺手,或者配了线程池后发现异步任务经常丢失、拒绝、不生效,那这篇文章正好对症。
1. 为什么大文件上传必须走异步
不少同学对异步的理解停留在“让接口快一点”这个层面,其实在大文件上传场景里,异步解决的问题远不止响应速度。
1.1 同步处理模式下的三个致命问题
假设你用的是最传统的同步上传接口:客户端把文件以multipart/form-data形式POST到后端,后端Servlet接收完整个请求体之后,Controller方法才开始执行。
第一个问题是连接长期占用。大文件上传动辄几百MB甚至几个GB,在带宽有限的情况下,传输过程可能要持续几十秒甚至几分钟。这个过程中Tomcat的工作线程一直挂在请求上,既不能响应其他请求,也不能被回收。Tomcat默认的max-threads是200,一旦有几十个大文件同时在传,线程池很快就会被占满,紧接着所有新请求都会排队等待,整个应用的吞吐量直接崩掉。
第二个问题是业务处理阻塞请求线程。文件上传完成后,通常还要做内容校验、生成缩略图、转码、写入对象存储、触发下游消息等操作。这些操作里任何一个出现抖动,都会让用户端的请求迟迟收不到响应。而HTTP请求在网关层(比如Nginx的proxy_read_timeout)通常有60秒的超时限制,一旦业务处理超过了这个阈值,网关直接断开连接,用户拿到的是502,但后端其实还在继续处理。
第三个问题是内存压力。同步模式下,Servlet容器接收完整个请求才会交给业务代码,这意味着整个文件要么落在临时目录,要么开辟内存缓冲区。Spring Boot默认的MaxRequestSize是10MB,超过这个大小直接拒绝,你得自己调大限制,但这会带来另一个问题——请求体越大,内存和临时磁盘的开销就越大,同步模式下这部分资源管理非常被动。
1.2 异步能带来什么实质变化
引入异步之后,接口的处理模型变成两段。第一段:接收文件元信息和分片信息,快速返回“上传受理成功”;第二段:后台线程池真正去拉取、合并、处理文件内容。
这样做的好处很明显。用户端不用干等,拿到一个taskId就能继续做自己的事情,前端可以轮询任务状态来展示进度。后端的Tomcat工作线程被快速释放,同一时间能支撑的并发连接数大幅提升。而真正耗时的文件合并、转储、业务回调都在独立的线程池里执行,执行时间和HTTP请求的生命周期彻底解耦,哪怕处理了5分钟,也不会影响网关超时。
说白了,异步就是把“请求受理”和“业务执行”剥离开。用户感知的响应速度只取决于第一段的处理时间,而后端系统能否撑住压力,取决于线程池的设计是否合理。这也是为什么大文件上传这种场景,我始终建议走异步而不是去调大超时时间——调大超时只是延后问题,异步才是从架构上解决问题。
2. Spring中@Async的核心用法与生效前提
Spring从3.0就开始支持@Async注解,但很多项目里用起来仍然会出现“注解没生效”的问题。这部分的坑往往不在注解本身,而在你对Spring代理机制的理解。
2.1 开启异步与最简用法
在Spring Boot项目里启用@Async只需要两步。第一步是在启动类或者任意配置类上加@EnableAsync,第二步是在需要异步执行的方法上标注@Async。
@Configuration @EnableAsync public class AsyncConfig { // 线程池配置见下文 }@Service public class FileProcessService { @Async public void processFile(Long fileId) { // 真正耗时的文件合并、校验、转码逻辑 FileDetail detail = fileRepository.findById(fileId); mergeAndHandle(detail); } }调用方直接注入FileProcessService,调用processFile方法,方法会立即返回,实际逻辑放到线程池里执行。这就是最基础的使用方式,看起来毫无难度,但生效是有条件的。
2.2 两个导致@Async失效的经典坑
第一个坑是同类内部调用。Spring的@Async和@Transactional一样,底层靠AOP代理实现。外部调用方拿到的是代理对象,代理对象在调用目标方法时会先把任务丢进线程池;但如果你在同一个类的另一个方法里直接调用了@Async方法,比如:
@Service public class FileProcessService { public void startProcess(Long fileId) { this.processFile(fileId); // 直接调用,不走代理 } @Async public void processFile(Long fileId) { // ... } }此时this是原始对象而不是代理对象,@Async完全被忽略,方法会同步执行。解决方式有三种:把异步方法拆到独立的Bean里;注入自己(Spring Boot 2.6+支持@Lazy SelfInjection);或者从ApplicationContext里显式获取代理对象。实务中我最推荐第一种,拆独立Bean,结构清晰,也不会被其他坑绕进去。
第二个坑是方法必须是public且不能是static。代理机制只能拦截通过Bean对象发起的public方法调用,private方法和static方法都无法被代理拦截。另外如果方法返回void,异常只能靠AsyncUncaughtExceptionHandler处理;如果返回Future类型,异常会被包装到Future里,调用方可以感知。
2.3 返回值与回调处理
@Async方法可以返回void,也可以返回Future或者CompletableFuture。业务中需要拿到异步执行结果,或者需要在完成之后触发后续动作时,用CompletableFuture会顺手很多。
@Async public CompletableFuture<ProcessResult> processFileAsync(Long fileId) { ProcessResult result = doProcess(fileId); return CompletableFuture.completedFuture(result); }调用方可以这样编排:
CompletableFuture<ProcessResult> future = fileProcessService.processFileAsync(fileId); future.thenAccept(result -> notifyBizSystem(result));Spring对CompletableFuture有特殊适配,@Async方法返回CompletableFuture时,Spring会把它当成真正支持异步返回的类型来处理。它跟Future相比最大的优势是天然支持回调编排,可以串行、并行、组合多个异步任务。大文件上传场景里,你会经常遇到“合并分片完成之后还要触发转码,转码完成之后还要回调业务方”这种链条式需求,用CompletableFuture能把链路写得很优雅。
3. 线程池设计是Async架构的重头戏
@Async只是表象,真正决定异步系统稳定性的,是背后的线程池。默认情况下,@Async使用的是Spring的SimpleAsyncTaskExecutor,这个类名字看着人畜无害,实际用起来就是灾难。
3.1 为什么不能直接用默认线程池
SimpleAsyncTaskExecutor的逻辑是每次提交任务都新建一个线程,完全没有复用。高并发场景下线程数量会直接爆炸,线程切换开销、内存占用都会失控。更麻烦的是它不受Spring Boot在2.1之后为@Async默认配置的ApplicationTaskExecutor管理,很多监控指标看不到,出了问题你也无从排查。
我见过一个项目上线初期没配线程池,直接用默认行为跑异步任务,结果上传高峰期线程数冲到几千个,CPU被打满,整个服务假死。后来一查,就是SimpleAsyncTaskExecutor在“辛勤工作”。所以实践中的第一条军规:使用@Async之前,必须自定义线程池。
3.2 手写一个可靠的文件处理线程池
Spring Boot下最常用的线程池是ThreadPoolTaskExecutor,它是java.util.concurrent.ThreadPoolExecutor的Spring封装,以下配置是实战验证过的。
@Configuration public class AsyncPoolConfig { @Bean("fileProcessExecutor") public ThreadPoolTaskExecutor fileProcessExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 核心线程数 executor.setCorePoolSize(8); // 最大线程数 executor.setMaxPoolSize(20); // 队列容量 executor.setQueueCapacity(200); // 线程名前缀,方便日志排查 executor.setThreadNamePrefix("file-process-"); // 空闲线程存活时间,单位秒 executor.setKeepAliveSeconds(60); // 拒绝策略:由调用者线程执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }注意,在Spring Boot 2.5.5之前的版本中,如果环境中存在多个ThreadPoolTaskExecutor类型的Bean,@Async注解会因无法确定目标Bean而报错。这里我在@Bean注解上指定了名称fileProcessExecutor,就是为了在使用时精确引用。
配置之后,用@Async("fileProcessExecutor")指定这个线程池:
@Service public class FileProcessService { @Async("fileProcessExecutor") public void processFile(Long fileId) { // ... } }如果不写线程池名称,Spring会按类型查找唯一的线程池Bean;如果有多个,就一定要显式指定。
3.3 核心参数怎么定
线程池参数没有绝对公式,但可以根据业务特性估算一个起点。大文件上传处理属于IO密集型任务,阻塞在磁盘读写和网络传输上的时间占比很高,CPU计算时间很少。IO密集型的经验值是核心线程数设为CPU核数的2倍左右,再根据任务耗时和提交速率调整。
假设你的机器是4核,核心线程可以先设为8,最大线程数设为20,队列容量设200。这样的组合能保证日常流量下任务都在队列里排队,由核心线程消费;瞬时流量上来时,队列满了会创建新线程到最大线程数;再满了就走拒绝策略。
参数定好之后,还要关注一个容易被忽略的点:最大线程数和队列容量的关系。ThreadPoolTaskExecutor的执行顺序是:核心线程满 → 丢队列 → 队列满 → 建新线程到最大线程数 → 队列再次满 → 拒绝策略。如果你的队列设得非常大,最大线程数实际上很难触发,因为队列几乎不会满。所以队列大小不能拍脑袋设很大,否则maxPoolSize形同虚设。
3.4 拒绝策略的选择逻辑
四种拒绝策略里,AbortPolicy会直接抛RejectedExecutionException,CallerRunsPolicy会让提交任务的线程(通常是Tomcat线程)自己去执行这个任务,DiscardPolicy和DiscardOldestPolicy则会静默丢弃。
文件处理场景我建议用CallerRunsPolicy。理由很简单:大文件上传的任务不能随便丢,丢了用户文件就找不回来了。CallerRunsPolicy虽然会让提交线程(比如Tomcat线程)阻塞在任务执行上,相当于变相把压力传回给调用方,但至少保证了任务不丢,而且执行完之后Tomcat线程自然释放,系统不会因为任务丢弃引发数据不一致。你还可以在拒绝时记录告警日志,方便后续扩容或调整参数。
我在生产环境里还会给线程池加上监控:
executor.setTaskDecorator(runnable -> { // 记录任务提交时间 return () -> { long start = System.currentTimeMillis(); try { runnable.run(); } finally { // 记录任务执行耗时和线程池状态 } }; });TaskDecorator是Spring提供的一个钩子,能在任务执行前后加入自定义逻辑。这个技巧很少有人用,但排查问题的时候非常有用。
3.5 多线程池隔离
如果你的系统里既有大文件处理,又有邮件通知、消息推送等异步任务,建议按业务拆多个线程池。否则就会出现一种尴尬情况:文件处理任务把线程池占满了,邮件通知这种轻量任务也被堵在后面排队。
拆开之后,每个业务线有独立的线程池、独立的参数、独立的监控告警,互不干扰。这也是大文件上传方案里我会额外强调的一点——线程池隔离和数据库隔离一样重要,只是很多人没意识到。
4. 大文件上传异步方案的完整落地
前面讲的是Async的基础能力和线程池设计,这一节把大文件上传的完整异步方案串起来。方案核心是:前端分片上传 + 后端异步合并 + 任务状态查询 + 失败重试。
4.1 整体链路设计
整个上传流程分成五个阶段,核心思想是所有重活都往异步线程池里推。
第一阶段是客户端初始化上传。客户端调用initUpload接口,上传文件名、文件大小、分片大小等信息,后端返回uploadId和分片数量。第二阶段是分片上传。客户端把文件切成固定大小的分片(比如每片5MB),逐片上传,每片上传完成后端都记录状态。第三阶段是合并任务触发。所有分片都上传完成后,客户端调用completeUpload接口,后端收到请求后,立刻把“文件合并处理”这个任务提交到fileProcessExecutor线程池,然后直接返回“已受理”。第四阶段是后台异步执行。线程池里的任务负责校验分片完整性、合并分片、转储对象存储、更新数据库状态。第五阶段是状态查询。前端通过轮询或者WebSocket接收任务进度,页面展示“合并中”“已完成”“失败重试”等状态。
为什么分片上传和异步合并要搭配使用?单文件整体上传有几个硬伤:网络中断后要重传整个文件;HTTP请求体太大,容器和网关都要调超时;服务端内存压力巨大。分片之后,每个分片都是独立的小请求,失败只需要重传该分片。而合并且转储这个操作天然适合异步——它不需要用户等待,只需要一个任务状态。
4.2 核心表结构与任务状态机
异步方案里,任务状态的设计直接影响代码复杂度。我的做法是维护一张upload_task表,关键字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| task_id | varchar(64) | 全局唯一任务编号 |
| upload_id | varchar(64) | 本次上传会话编号 |
| file_name | varchar(255) | 原始文件名 |
| total_size | bigint | 文件总大小 |
| chunk_total | int | 总分片数 |
| chunk_uploaded | int | 已上传分片数 |
| status | varchar(20) | 状态:INIT/UPLOADING/MERGING/SUCCESS/FAILED |
| retry_count | int | 重试次数 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
状态流转很简单:INIT表示客户端申请了上传但还没传分片;UPLOADING表示正在传分片;所有分片完成后状态变为MERGING,表示异步合并任务正在执行;合并成功为SUCCESS,失败则为FAILED。这个状态机是幂等设计的基础。
4.3 异步合并任务的代码实现
以下是关键代码的骨架,你可以直接参考落地。
@Service public class ChunkMergeService { @Async("fileProcessExecutor") public CompletableFuture<MergeResult> mergeChunks(MergeRequest request) { String taskId = request.getTaskId(); long start = System.currentTimeMillis(); try { // 1. 再次校验所有分片是否已上传 boolean allUploaded = chunkMapper.checkAllUploaded(request.getUploadId()); if (!allUploaded) { // 分片缺失,标记失败并返回 updateTaskStatus(taskId, TaskStatus.FAILED); return CompletableFuture.completedFuture(MergeResult.fail("分片缺失")); } // 2. 按分片序号合并临时文件 File mergedFile = doMerge(request.getUploadId(), request.getFileName()); // 3. 转储到对象存储/分布式存储 String storageUrl = storageService.store(mergedFile); // 4. 更新任务状态 updateTaskStatus(taskId, TaskStatus.SUCCESS, storageUrl); long cost = System.currentTimeMillis() - start; log.info("文件合并完成, taskId={}, cost={}ms", taskId, cost); return CompletableFuture.completedFuture(MergeResult.success(storageUrl)); } catch (Exception e) { log.error("文件合并失败, taskId={}", taskId, e); updateTaskStatus(taskId, TaskStatus.FAILED); return CompletableFuture.completedFuture(MergeResult.fail(e.getMessage())); } } }合并的逻辑很简单:读取该uploadId下的所有分片文件,按序号依次写入同一个输出流,最后得到一个完整的文件。如果之前用的是磁盘临时目录存分片,这一步就是纯IO操作;如果分片已经存在对象存储里,就需要先全部拉回本地再合并,这时的网络IO开销会大不少,这正是我说的为什么合并要异步的另一个原因。
4.4 状态查询与进度展示
状态查询接口是异步方案的标配,因为没有它,前端就永远不知道后端“默默”干完没有。
@GetMapping("/upload/task/{taskId}") public Result<UploadTaskVO> getTaskStatus(@PathVariable String taskId) { UploadTask task = uploadTaskMapper.selectByTaskId(taskId); UploadTaskVO vo = new UploadTaskVO(); vo.setTaskId(task.getTaskId()); vo.setStatus(task.getStatus()); vo.setChunkTotal(task.getChunkTotal()); vo.setChunkUploaded(task.getChunkUploaded()); // 前端可以算出上传百分比 return Result.success(vo); }前端拿到状态后,如果是MERGING就显示“文件合并中”,SUCCESS就跳转下一步。这里有个体验细节:合并阶段虽然没有进度条可看,但你可以把状态文案做得更友好,比如轮询到MERGING状态超过30秒后显示“合并耗时较长,请耐心等待”,避免用户误以为卡住了。
4.5 失败重试与幂等保护
异步任务处理过程中必然会出现失败,网络抖动、存储不可用、数据库异常都可能导致合并失败。文件上传这种业务不能直接放弃,必须有重试机制。
最简单的重试策略是在合并失败后更新失败状态,同时提供手动重试接口,用户点击“重新处理”后重置状态并重新提交任务。也可以做成自动重试:在catch块里判断重试次数小于3时重新提交任务,否则标记失败并告警。
这里要特别注意幂等设计。合并任务必须保证同一个uploadId在同一时间只能被一个任务处理,否则两个线程同时合并同一个文件,轻则重复写入,重则产生脏数据。我的做法是在提交异步任务之前,先用数据库对taskId加状态上的乐观锁:
int updated = uploadTaskMapper.compareAndSetStatus(taskId, TaskStatus.UPLOADING, TaskStatus.MERGING); if (updated == 0) { // 其他线程已经处理过,直接返回 return; }这种方法在分片上传完成、状态从UPLOADING转为MERGING的瞬间生效,保证并发环境下只有一个线程能执行合并逻辑。比分布式锁轻量,而且完全够用。
4.6 分片上传接口与校验细节
虽然文章主题是异步,但完整的方案离不开分片上传的配套实现。后端接口接收分片时,需要校验uploadId是否有效、分片序号是否合法、分片大小是否在预期范围内。
@PostMapping("/upload/chunk") public Result<Boolean> uploadChunk(ChunkUploadRequest request, MultipartFile file) { // 校验请求参数和文件大小 if (file.getSize() > MAX_CHUNK_SIZE) { return Result.fail("分片大小超出限制"); } // 存储分片到临时目录 chunkStorage.store(request.getUploadId(), request.getChunkIndex(), file); // 更新已上传分片计数 uploadTaskMapper.incrementChunkUploaded(request.getUploadId()); return Result.success(true); }分片文件存储时,规范命名规则很重要,比如files/{uploadId}/{chunkIndex}.part。这样合并时可以按序号直接读取,避免扫描目录时还要解析乱七八糟的文件名。临时目录建议挂载在独立磁盘上,因为大文件分片的总大小可能会超过系统盘容量,别把临时文件写满系统盘。
5. 常见问题与排查技巧实录
这章整理的是我在多个项目里实际踩过、帮人排查过的坑。每一项都有人问过我,所以值得单独列出来。
5.1 问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| @Async方法完全没有异步效果,接口仍然阻塞 | 同类内部调用,代理未生效 | 拆独立Bean调用异步方法 |
| 配置了线程池但报错找不到Bean | 容器中存在多个ThreadPoolTaskExecutor | @Async("beanName")显式指定 |
| 异步方法里的事务不生效 | 事务和异步都是代理,跨线程后事务上下文丢失 | 将事务逻辑下沉到独立Service(REQUIRES_NEW) |
| 线程池满了疯狂抛RejectedExecutionException | 队列太小或拒绝策略选错 | 调大队列容量或改用CallerRunsPolicy |
| 任务莫名消失,没有日志 | 用了Discard策略或线程被interrupt | 换拒绝策略,增加提交时告警日志 |
| 异步任务里RequestContextHolder取不到用户信息 | 子线程中没有请求上下文副本 | 提交任务前通过TaskDecorator传递上下文 |
| 服务重启时正在处理的任务丢失 | 内存任务没有持久化 | 任务状态入库,启动时扫描未完成任务重新提交 |
| 大文件合并完成后磁盘空间没释放 | 临时文件未清理 | 合并完成后finally块删除临时目录 |
5.2 异步方法里的事务与上下文丢失
异步线程和请求线程不是同一个线程,所以ThreadLocal里存的数据默认是拿不到的。典型场景是用户在请求里设置了登录用户信息,异步方法里去取,结果取到null。
解决办法是在提交异步任务时把需要的上下文信息作为参数显式传入,或者在自定义TaskDecorator里做上下文拷贝。我强烈推荐前者,简单直接,而后者会遇到各种奇怪的坑,比如跨线程传递HttpServletRequest对象导致序列化问题、线程池复用导致上下文串号等。
事务问题更隐蔽。@Async方法上加@Transactional,如果外部调用方的事务还没提交,异步线程去读数据可能读到旧数据;如果异步线程抛异常,也没有任何机制回滚外部事务。所以在异步任务里操作数据库,我一般采用“先提交后处理”的策略:主流程把必要的状态先入库,异步任务处理完再更新状态,不让事务跨线程传播。
5.3 服务重启时如何补偿未完成任务
异步任务在内存里执行,服务一重启,正在执行或者排队中的任务全部丢失。应对思路是“任务状态持久化”。每次任务提交时,在数据库里记录一条异步任务日志,状态为PENDING;线程池执行完成后更新为SUCCESS/FAILED;服务启动时扫描所有状态为PENDING且未超时的任务,重新提交。
这其实是一个非常简单的“消息表”模式,虽然没有消息中间件,但只要任务状态在库里,重启后起码能捞回来重跑一遍。文件上传这种对一致性要求比较高的场景,这个兜底机制值得做。配合上传任务的分片数据表,甚至可以做到“上传了一半,重启后继续传”的断点续传效果。
5.4 慢任务拖垮线程池的排查思路
如果异步任务执行时间离谱地变长,先看是不是某个任务阻塞在了数据库查询或者外部RPC上。线程池里的线程一旦被慢任务占满,后续任务会大量堆积在队列里,表现出来就是“文件上传完成但一直不合并”,或者“合并进度一直不动”。
排查手段有两把利器。第一是线程栈快照,用jstack抓线程状态,看看file-process-前缀的线程在干嘛,是RUNNABLE正常执行还是WAITING阻塞在锁上。第二是线程池监控指标,线程池活跃线程数、队列积压量、任务执行平均耗时,这些指标做成图表后,慢任务的异常一眼就能看出来。
我见过一个案例:线程池里有几个任务卡在了一个第三方SDK的不合格HTTP调用上,默认的SocketTimeout没设置,导致任务阻塞了几分钟。后面把连接超时和读取超时都配上,线程池立刻恢复正常。很多慢任务问题不在线程池本身,而在你提交的Runnable代码里。
5.5 关于线程池参数调整的几点建议
很多同学喜欢抄网上的参数配置,8核机器配个2000的队列,然后高枕无忧。实际上队列容量2000意味着任务积压时用户要等很久才能看到结果——虽然不报错,但体验已经崩了。我的习惯是:核心线程数设成本机CPU核数的1到2倍,最大线程数是核心线程的2.5倍左右,队列容量根据任务平均耗时和预估峰值提交速率计算。
举个例子,假设单任务平均执行时间300ms,峰值每秒提交50个任务,那1秒内积压的任务数是15个左右。队列容量设为100就够用了,留足两到三倍的缓冲。如果队列满了又不想丢任务,CallerRunsPolicy会自动让Tomcat线程帮忙执行,此时接口响应会变慢,这其实是系统在给你发信号:该扩容了,或者该削峰了。
线程池参数不是配一次就一劳永逸的,需要结合监控数据持续调整。这也是异步开发里最容易被忽略的运维视角。
6. 个人经验总结
做了这么多异步方案,我最深的体会是:@Async只是一个入口,它不是设计核心,真正要花心思的是线程池参数、任务状态管理、失败补偿这三件事。大文件上传场景里尤其如此,文件数据是用户的资产,丢不得,所以每次提交异步任务之前,我都会问自己三个问题:任务失败了怎么办?服务重启了怎么办?并发重复提交了怎么办?这三个问题想清楚了,异步方案基本就稳了。
另外还想分享一个小技巧:给异步任务加一个全局的唯一ID,无论是日志、回调、还是状态表,都用这个ID贯穿全链路。排查问题时log里搜这个ID,从提交到完成的完整链路一目了然,比对着时间戳大海捞针有效得多。这个习惯我从做上传方案的第一天就养成了,强烈建议你也有意识地用起来。