
我做了几年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 CompletableFutureProcessResult processFileAsync(Long fileId) { ProcessResult result doProcess(fileId); return CompletableFuture.completedFuture(result); }调用方可以这样编排CompletableFutureProcessResult 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类型的BeanAsync注解会因无法确定目标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会直接抛RejectedExecutionExceptionCallerRunsPolicy会让提交任务的线程通常是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_idvarchar(64)全局唯一任务编号upload_idvarchar(64)本次上传会话编号file_namevarchar(255)原始文件名total_sizebigint文件总大小chunk_totalint总分片数chunk_uploadedint已上传分片数statusvarchar(20)状态INIT/UPLOADING/MERGING/SUCCESS/FAILEDretry_countint重试次数create_timedatetime创建时间update_timedatetime更新时间状态流转很简单INIT表示客户端申请了上传但还没传分片UPLOADING表示正在传分片所有分片完成后状态变为MERGING表示异步合并任务正在执行合并成功为SUCCESS失败则为FAILED。这个状态机是幂等设计的基础。4.3 异步合并任务的代码实现以下是关键代码的骨架你可以直接参考落地。Service public class ChunkMergeService { Async(fileProcessExecutor) public CompletableFutureMergeResult 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 ResultUploadTaskVO 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 ResultBoolean 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容器中存在多个ThreadPoolTaskExecutorAsync(beanName)显式指定异步方法里的事务不生效事务和异步都是代理跨线程后事务上下文丢失将事务逻辑下沉到独立ServiceREQUIRES_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从提交到完成的完整链路一目了然比对着时间戳大海捞针有效得多。这个习惯我从做上传方案的第一天就养成了强烈建议你也有意识地用起来。