
大文件上传这需求放在任何一个业务系统里都算不上新奇。但你真亲手做一遍再被线上流量打一轮就会明白“文件收下来了”和“文件上传体验做对了”是两码事。最初我接这个需求时第一版天真地把上传文件直接丢给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匹配成功就把真实方法执行提交给线程池。所以三个硬性条件缺一不可Bean必须交给Spring容器管理调用必须从外部进入即通过代理对象调用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 CompletableFutureBoolean mergeChunksAsync(String taskId) { boolean success doMerge(taskId); return CompletableFuture.completedFuture(success); }还有一个细节返回Future时调用方不要直接调用.get()无限期阻塞否则单机场景下等于把异步又变回了同步。正确做法是超时获取或者直接用whenComplete回调。3. 分片上传与异步结合的项目结构设计3.1 大文件不能整体上传的核心原因大文件不能整体上传的核心原因有两个一是浏览器或网关对单个请求体有大小限制二是网络中途断开后整个文件都要重传。所以常规做法是分片前端把文件切成多个切片分别上传后端收齐后按序号合并。这和你把一本厚书拆成几十页快递寄出只有全部页数到齐才能装订成一个道理。异步在这个场景里的角色主要是把“接收分片”之后的合并、校验、转存等工作从请求线程中剥离让接口能快速返回“第N片已收”。3.2 异步分片上传的接口设计实践中我把上传链路拆成三个接口创建上传任务PostMapping(/upload/task) public UploadTaskDTO createUploadTask(RequestBody CreateTaskRequest req) { return uploadService.createTask(req); }上传分片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); }异步合并触发入口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); ListChunkRecord 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、statusINIT/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() { ListUploadTask 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迁出的信号其实很朴素需要跨节点保证同一任务不重复执行需要暂停、恢复、取消进行中的任务需要更精细的并发控制和失败重试语义。目前在文件上传场景我坚持“单服务实例能覆盖业务量”就用AsyncDB状态表一旦确认要横向扩展我会把分片合并和文件转存任务迁移到消息队列由独立Worker消费。接口层保持不变前端无感任务处理的可靠性提高一大截。6.2 从本地异步迁移到消息队列的实践路线迁移时保留原接口只在触发合并处把“直接调用异步方法”改成“往队列发一条消息”Worker端再调用同一套合并逻辑。这个改造风险小回滚也容易因为业务逻辑没有动只是触达方式变了。队列选型不需要一上来就上太重的分布式调度框架。先选团队熟悉的中间件把任务投递、消费、失败重新入队跑通能解决当前问题再谈扩展性。我见过不少团队在单机阶段就上了分布式调度结果运维成本比业务代码还高。6.3 我踩过几次坑后沉淀下来的几条硬建议先从DB状态设计入手再写异步代码。状态字段没设计好后面做轮询、重试、对账都很痛苦用自定义线程池不要偷懒用默认的给每个业务场景独立线程池异步方法一定加异常兜底至少把异常打到日志否则问题会藏到你找不到文件合并前先核对分片数量和总大小能提前发现文件不完整避免往IO设备写了一大半才发现写不了上线前做并发上传脚本同时开10个线程各传一个1GB文件观察线程池队列深度和响应时间变化这比任何纸面推演都直观。大文件上传的异步化改造本质上是重新思考请求、线程、任务三者的关系。别急着把代码改异步先把数据模型和线程模型想清楚再用Async落地你会省下数不清的调试时间。