☰
Spring @Async 分片上传异步合并:大文件上传的完整实践
2026/10/1 3:18:49 网站建设 项目流程

分片上传加异步合并,这个组合我实际用下来确实省了很多事。大文件上传那种几十秒甚至几分钟的同步等待,用户等得起,服务器扛不住。Spring的@Async注解看着简单,真正落地的时候线程池怎么配、任务状态怎么追踪、异常怎么处理,全都有讲究。我把这套方案完整拆开讲清楚,从线程池参数设计到前端轮询联动,每一步都附上可以直接抄的代码。

1. 项目背景与整体设计思路

1.1 大文件上传的真实现状

很多人对“大文件”没概念,觉得手机拍个视频也就几百兆,传到服务器能有多慢?在一个实际的业务系统里,大文件早就不是几百兆这么简单。视频素材动辄几个GB,压缩包甚至十几个GB,设计文档加上高清配图也有几百MB。真正让后端头疼的不是文件有多大,而是用户上传这个文件的过程中,HTTP连接要保持多久。

前端把文件读进内存,然后通过HTTP请求往服务器写,这个操作是同步阻塞的。一个1GB的文件,就算用户带宽给力,传完也要几十秒。如果用户带宽一般,五分钟、十分钟都是常态。这个过程中,后端Servlet线程一直被占用着,连接不释放,线程不放回连接池。最致命的是,Tomcat的默认线程池就那么大,200个线程用完了,其他人的请求全部排队,整个服务的接口响应时间一落千丈。

更麻烦的是,文件传到一半断网了、浏览器崩溃了,整包作废,用户还得从头再传。这种体验在移动端尤其明显,电梯进一下、地铁隧道走一段,上传就断了。我见过一个真实的线上事故,某个运营后台导入一个800MB的Excel,同步接口直接阻塞了40秒,请求方超时重试,又挤占了更多线程,最后整个Web服务雪崩,首页都打不开了。

1.2 为什么选异步化这个方案

解决大文件上传,业界有三条路:一是改HTTP协议层,用WebSocket或者HTTP/2的Streaming方式;二是搞分片上传,大文件切成多个小块逐步传;三是把文件校验、转码、入库这些费时的操作从请求线程里摘出去,放到后台异步执行。

三条路不冲突,但我的判断是:分片上传解决的是“网络不稳定怎么断点续传”的问题,而同步阻塞的痛点,必须靠异步化来解决。就拿上面的场景来说,文件上传完成后,后端要做的事情可不止是落盘,还要算MD5校验完整性、生成缩略图、把文件的元数据写入数据库、有视频的话还得触发转码。这些操作加在一起,耗时很可能又吃掉几秒钟甚至更久。如果都放在上传接口的回执里同步执行,用户明明已经把文件发到服务器了,前端还在转圈等待,这种体验是最折磨人的。

@Async的意义不在于它有多炫技,而在于它把“用户触发的请求”和“系统内部的处理任务”彻底解耦。前端拿到“文件已收到,正在后台处理”的响应,用户该干嘛干嘛去,后台任务完成后再通知。这个模式,放到任何业务场景里都成立,架构上也干净。

1.3 整体架构分层与核心流程

我采用的实现方案是“前端分片 + 后端异步合并处理”,整体流程拆成五步:

  1. 前端将大文件按固定大小分片(比如每片5MB),逐片上传到后端临时存储区。
  2. 后端收到每个分片后,立即落盘并返回“分片已接收”的ACK,请求线程快速释放。
  3. 前端所有分片上传完成后,调用一个“合并文件”的接口,通知后端可以开始拼装了。
  4. 合并接口收到请求后,验证分片完整性,然后通过@Async注解的方法,启用新线程去执行实际的合并、校验、元数据入库等耗时操作。
  5. 合并接口本身立即返回一个任务ID,前端通过轮询查询任务的执行状态,直到任务完成或失败。

整个链路中,耗时最长的合并操作完全脱离了HTTP请求线程,这个就是核心思路。项目代码结构分成四层:Controller层只做参数接收和响应;Service层负责业务逻辑编排,异步方法也在这里;异步任务层专门跑@Async标注的处理方法;线程池配置层独立成一个配置类,方便后续调优。

工具链上,我用了Spring Boot 2.7.x,构建工具是Maven,文件临时存储用的本地磁盘,如果想上生产建议替换成MinIO或阿里云OSS,接口风格统一走RESTful。

2. 异步开发前的必修课:@Async原理与线程池配置

2.1 @Async注解的底层机制

@Async用起来就是一行注解的事,可它背后的机制值得先搞清楚。Spring的@Async依赖AOP的拦截器机制。当你在一个Bean的方法上加上@Async,Spring容器在启动时就会为这个Bean创建一个代理对象。外部调用这个Bean的方法时,实际上走进的是代理对象的拦截器逻辑,拦截器会去匹配有没有@Async注解,有的话就把这个方法的调用任务提交给线程池,然后立即返回。

关键在于:代理只在外部调用时生效。如果你在同一个类的另一个方法里直接调用了这个带@Async的方法,因为走的是this引用,根本不经过代理对象,异步就完全失效了,相当于普通同步调用。这是新手最容易踩的坑,后面我专门讲排查方法。

@Async还支持返回值类型,可以用void、Future、CompletableFuture。void适合那种“只管提交,不关心结果”的任务,比如发通知;CompletableFuture适合需要拿到任务返回结果做后续拼接的场景。我的大文件合并任务用的是void加任务状态记录的方式,因为合并这个动作没必要阻塞等待,但业务方需要知道合并是否成功,所以额外维护了一个任务状态表。

@Async注解不指定线程池的话,Spring会使用默认的SimpleAsyncTaskExecutor,这是最坑的地方。这个执行器的名字叫“Simple”,实际是每次调用都新建一个线程,不复用、不限制并发数。生产环境用这个,并发一起来,线程数直接爆炸,内存迟早撑不住。所以实际项目中,无论如何都要自定义线程池。

2.2 线程池参数设计计算

线程池配置里,核心线程数、最大线程数、队列容量怎么定?很多人直接抄网上的配置,corePoolSize=5,maxPoolSize=10,queueCapacity=100,抄完也不思考合不合适。线程池的参数必须结合自己的业务量来算。

大文件上传这个场景,单位时间内的合并请求量不大,但每个任务可能持续几十秒,是典型的CPU密集型和IO密集型混合任务(文件合并涉及磁盘IO,转码涉及CPU)。CPU密集型任务的线程数参考公式是CPU核心数 + 1,IO密集型的参考公式是CPU核心数 * 2。文件合并更适合按IO密集型来算,因为耗时的主要在磁盘读写和网络交互上。

我举个例子,假设生产机器是4核8线程,核心线程数取8,最大线程数取16,队列容量取100。那么当并发合并任务不超过8个时,任务全部由核心线程处理;超过8个时,新的任务进入队列排队;队列满了还没处理,才创建新的线程直到最大16个。如果16个线程都在忙且队列已满,新任务触发拒绝策略。

队列容量到底配多大?这个要看任务的等待容忍度。大文件合并任务本身不要求毫秒级响应,但它也不能无限排队,否则用户提了合并请求很久没反馈,体验也不好。100到200是比较合理的区间。注意一点:千万不要把队列配成LinkedBlockingQueue的无界队列,一旦任务积压,线程池里的线程数永远不会超过核心线程数,所谓“最大线程数”就形同虚设了,这是一个非常容易被忽略的隐患。

2.3 自定义线程池配置代码

配合@Async使用的线程池,建议单独写一个配置类,代码如下:

@Configuration @EnableAsync public class AsyncThreadPoolConfig { @Bean("fileMergeExecutor") public ThreadPoolTaskExecutor fileMergeExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 核心线程数:IO密集型任务,按CPU核心数x2估算 executor.setCorePoolSize(8); // 最大线程数:核心线程数x2 executor.setMaxPoolSize(16); // 队列容量:根据任务积压容忍度设置 executor.setQueueCapacity(200); // 线程名称前缀:排查问题时一眼定位是哪个线程池 executor.setThreadNamePrefix("file-merge-"); // 拒绝策略:任务满时由调用者线程执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 等待所有任务结束后再关闭线程池 executor.setWaitForTasksToCompleteOnShutdown(true); // 关闭前的等待时间,单位秒 executor.setAwaitTerminationSeconds(60); executor.initialize(); return executor; } // 可以再定义其他业务线程池,不同业务分离 @Bean("notifyExecutor") public ThreadPoolTaskExecutor notifyExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix("notify-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }

代码里有两个容易被忽略但很重要的点。第一,setWaitForTasksToCompleteOnShutdown(true)的意思是在Spring容器关闭前,先等待所有已提交任务执行完毕,避免应用停机时正在合并的文件被强制中断,造成文件损坏。第二,拒绝策略选CallerRunsPolicy,即任务队列满了之后,新的任务不让它丢失,而是由提交任务的线程自己去执行合并。虽然这样会让某个请求线程阻塞,但总比任务莫名其妙丢弃要好,尤其合并任务丢一个,用户整个文件就废了。

另外,@EnableAsync注解放在这个配置类上也是可以的,作用是让Spring扫描到@Async注解并创建代理。

2.4 异步方法的调用注意事项

异步方法写在Service类里,调用方是Controller或者另外一个Service。这里有一个“伪异步”的经典误区,我直接给出正确的调用写法:

@Service public class FileMergeService { @Async("fileMergeExecutor") public void asyncMergeFile(String fileId, String mergeId) { // 真正耗时的合并逻辑 } }

调用方这样写:

@RestController @RequestMapping("/file") public class FileController { @Resource private FileMergeService fileMergeService; @PostMapping("/merge") public Result<String> merge(@RequestParam String fileId) { String mergeId = UUID.randomUUID().toString(); // 记录任务初始状态 taskStateService.init(mergeId); // 异步执行合并 fileMergeService.asyncMergeFile(fileId, mergeId); // 立即返回,不等待合并结果 return Result.success("文件合并任务已提交", mergeId); } }

这里的关键是fileMergeService要通过@Resource或@Autowired注入,而不是new出来。注入进来的才是代理对象,new出来的直接调方法就是同步执行。实际的耗时操作放在独立的Service类里,和调用方不在同一个类中,这样代理机制才能正常生效。

3. 大文件异步上传核心实现

3.1 分片上传接口设计

分片上传的接口设计,核心要解决的是“怎么把一整块文件拆开传”。前端负责把文件切成固定大小的分片,每个分片有自己独立的编号,后端只管收、只管存。

我设计的分片上传接口,接收参数如下:

  • fileId:文件的唯一标识,前端在上传前先调用初始化接口生成。
  • chunkIndex:当前分片的序号,从0开始。
  • totalChunks:总分片数。
  • chunkSize:分片大小,用于后端校验长度。
  • file:分片的二进制流。

Controller接口示例:

@PostMapping("/upload/chunk") public Result<Void> uploadChunk( @RequestParam String fileId, @RequestParam Integer chunkIndex, @RequestParam Integer totalChunks, @RequestParam(value = "file") MultipartFile file) { // 将分片保存到临时目录 chunkStorageService.saveChunk(fileId, chunkIndex, file); // 这里不检查所有分片是否齐全,由前端明确触发合并 return Result.success(); }

分片大小的选择,需要平衡网络传输效率和失败重试成本。分片太大,单片出现问题重传代价高;分片太小,请求数量暴增,服务端压力大。实测下来5MB到10MB是一个比较合理的区间,考虑到移动互联网网络的抖动情况,我最终选了5MB,一个1GB文件也就是约200个分片,不会太多,单片上传失败重传的成本也可控。

存分片的目录结构按fileId建子目录,分片文件按序号命名,比如chunk_0.bin、chunk_1.bin。注意一点,分片接口返回速度要快,文件写入操作做好之后就直接返回,不需要等所有分片都齐了再做什么处理。

3.2 合并接口的交互流程

所有分片上传完成后,前端调用合并接口。合并接口做的事情很简单:验证参数,创建任务记录,提交异步任务,然后立即返回。

@PostMapping("/merge") public Result<String> merge(@RequestParam String fileId, @RequestParam String fileName) { // 校验分片完整性:检查分片数量和大小是否匹配 boolean checkResult = chunkStorageService.checkChunks(fileId); if (!checkResult) { return Result.fail("分片不完整,请检查缺失分片"); } String mergeTaskId = UUID.randomUUID().toString(); // 初始化任务状态为"处理中" mergeTaskStateService.init(mergeTaskId, fileName); // 提交异步合并任务 fileMergeService.asyncMergeFile(fileId, mergeTaskId); // 立即返回任务ID return Result.success("合并任务已提交", mergeTaskId); }

为什么校验分片完整性要放在异步任务之前?因为分片都不齐,直接提交异步任务也是白跑一趟,合并线程被白占用一次。在同步阶段做一次轻量校验,成本很低,却能拦住大部分无效请求。更细致的校验还得在异步任务里再执行一遍,比如实际读取文件长度和预期长度比对,防止分片内容是坏的。

这里重点聊一下mergeTaskStateService。任务状态是整个异步方案里最容易被忽略的部分,业务方如果永远不知道后台任务到底跑得怎么样了,异步就没有意义。我用一个简单的内存Map来存任务状态,生产环境可以换成Redis或者数据库。

@Service public class MergeTaskStateService { private final Map<String, MergeTaskState> stateMap = new ConcurrentHashMap<>(); public void init(String taskId, String fileName) { MergeTaskState state = new MergeTaskState(); state.setTaskId(taskId); state.setFileName(fileName); state.setStatus(0); // 0-处理中,1-成功,2-失败 state.setProgress(0); state.setUpdateTime(new Date()); stateMap.put(taskId, state); } public void updateProgress(String taskId, int progress) { MergeTaskState state = stateMap.get(taskId); if (state != null) { state.setProgress(progress); state.setUpdateTime(new Date()); } } public void finish(String taskId, boolean success, String msg) { MergeTaskState state = stateMap.get(taskId); if (state != null) { state.setStatus(success ? 1 : 2); state.setMessage(msg); state.setUpdateTime(new Date()); } } public MergeTaskState query(String taskId) { return stateMap.get(taskId); } }

状态对象里除了常规的状态码和进度,我还加了一个message字段,失败的时候记录失败原因。前端轮询拿到失败状态后,可以直接把失败原因展示给用户,比如“合并失败:分片文件名不一致”,这对排查问题有很大帮助。

3.3 异步合并方法的实现

异步合并方法是整个流程的核心。它接收fileId和taskId,在独立的线程上执行真正的文件合并逻辑。

@Async("fileMergeExecutor") public void asyncMergeFile(String fileId, String mergeTaskId) { // 提交任务时的状态已经是"处理中",这里更新进度为1% mergeTaskStateService.updateProgress(mergeTaskId, 1); try { // 获取分片列表并按序号排序 List<File> chunks = chunkStorageService.listChunks(fileId); // 目标文件路径 File targetFile = new File(uploadDir + "/" + fileId + "_merged.bin"); // 合并:按顺序将分片字节流写入目标文件 try (FileOutputStream fos = new FileOutputStream(targetFile); BufferedOutputStream bos = new BufferedOutputStream(fos)) { int chunkCount = chunks.size(); for (int i = 0; i < chunkCount; i++) { try (FileInputStream fis = new FileInputStream(chunks.get(i)); BufferedInputStream bis = new BufferedInputStream(fis)) { byte[] buffer = new byte[8192]; int len; while ((len = bis.read(buffer)) != -1) { bos.write(buffer, 0, len); } } // 每合并一个分片,更新一次进度 int progress = (int) ((i + 1) * 100.0 / chunkCount); if (progress >= 100) { progress = 99; } mergeTaskStateService.updateProgress(mergeTaskId, progress); } } // 合并完成,继续后续处理:计算MD5、写入数据库记录等 String md5 = FileDigestUtil.md5(targetFile); fileMetadataService.saveFileInfo(fileId, targetFile, md5); // 清理临时分片文件 chunkStorageService.cleanChunks(fileId); // 最终标记任务成功 mergeTaskStateService.finish(mergeTaskId, true, "合并成功"); } catch (Exception e) { log.error("文件合并失败, fileId={}, taskId={}", fileId, mergeTaskId, e); mergeTaskStateService.finish(mergeTaskId, false, e.getMessage()); } }

这里有几个细节值得展开:

第一,进度更新策略我故意没有直接更新到100%,而是先更新到99%,等MD5校验、元数据入库这些操作全部成功之后再一次性标记成功。这样做的好处是,只要任务状态不是“成功”,前端就知道还没彻底结束,不会出现“都到100%了文件却不可用”的矛盾状态。

第二,分片合并时用缓冲流,缓冲区大小8KB是比较稳妥的选择,测试下来既能保证传输效率,又不会占用过多内存。如果要追求更高的合并效率,可以考虑用FileChannel.transferTo做零拷贝合并,但代码复杂度会上升,这里就不展开了。

第三,合并过程中如果抛异常,必须把任务状态置为失败,同时记录异常信息。很多人在异步方法里忘了catch异常,任务挂了连个日志都没有,排查起来等于大海捞针。异步方法不像同步接口,异常了框架还能帮你返回500,异步执行异常如果不自己处理,线程池的线程可能会被异常打断,后续任务也受影响。

3.4 前端轮询与下载联动

异步任务的最终效果要体现在前端体验上。前端在收到合并接口返回的任务ID后,启动一个轮询定时器,每两秒查一次任务状态。

async function triggerMerge(fileId, fileName) { const resp = await axios.post('/file/merge', { fileId, fileName }); const taskId = resp.data.data; // 启动轮询 const timer = setInterval(async () => { const stateResp = await axios.get(`/file/merge/task/${taskId}`); const state = stateResp.data.data; if (state.status === 1) { // 合并成功,跳转到下载页 clearInterval(timer); window.location.href = `/file/download/${fileId}`; } else if (state.status === 2) { // 合并失败,提示用户 clearInterval(timer); alert(`文件合并失败:${state.message}`); } else { // 更新进度条 updateProgressBar(state.progress); } }, 2000); }

轮询接口的后端实现也很简单,从MergeTaskStateService里查一下状态返回即可:

@GetMapping("/merge/task/{taskId}") public Result<MergeTaskState> queryMergeTask(@PathVariable String taskId) { MergeTaskState state = mergeTaskStateService.query(taskId); if (state == null) { return Result.fail("任务不存在"); } return Result.success(state); }

这个方案虽然简单,但完全够用。如果项目里已经接入了WebSocket或者SSE,也可以把轮询替换成服务端主动推送,体验会更好一些,但复杂度会上升。对大多数业务系统来说,轮询配合进度条,已经能达到很好的用户感知了。我个人测试下来,2秒一次的轮询频率对服务器压力极小,查询接口走的是内存Map,开销几乎可以忽略。

3.5 断点续传与秒传的扩展思路

分片上传还有一个天然的额外收益:断点续传和秒传可以顺手做出来。

断点续传的实现逻辑是,前端重新上传之前,先调用一个“查询已上传分片”的接口,后端扫描当前fileId对应的临时目录里已经存在哪些分片,把分片序号列表返回给前端。前端拿到已上传的分片列表,只上传缺失的分片,这样网络断了之后重新上传,不需要从头来。

秒传的实现更简单,前端在上传前计算整个文件的MD5值,调用一个“校验文件是否存在”的接口,后端在数据库里查有没有相同MD5的文件记录,如果有,直接返回“文件已存在,无需上传”,然后秒级生成一条引用记录。这个能力在大文件场景下非常实用,同一个文件多个人传,只有第一个人真正占存储空间,其他人都只是引用同一个文件地址。

这两个能力和分片上传配合起来,整个文件上传模块的功能就完整了,而且都不需要额外引入组件,在现有架构上就能实现。

4. 常见问题与排查技巧实录

4.1 常见问题速查表

实际开发中,@Async相关的坑远比想象中多。我把高频问题整理成一个表格,方便对照排查:

问题现象根本原因解决方案
@Async方法同步执行,没有异步效果同类内部方法调用,绕过代理把异步方法拆到独立Bean,或注入自身代理
异步任务执行无响应,但不报错方法上异常未被捕获,线程池吞掉了异常在异步方法内部try-catch,或配置AsyncUncaughtExceptionHandler
异步方法一直不执行,任务积压线程池队列满了,任务被拒绝检查线程池参数,看日志里的拒绝策略记录
线程数疯狂增长,内存溢出用了默认的SimpleAsyncTaskExecutor一定自定义线程池并指定给@Async
应用停机后,正在合并的文件损坏线程池被强制关闭,任务中断配置waitForTasksToCompleteOnShutdown和awaitTerminationSeconds
多线程并发写同一个文件多个任务共享了同一个文件路径按fileId隔离路径,避免不同文件互相覆盖
任务状态一直停在99%后续校验失败,但没更新状态确保所有后置操作都在try-catch中,且失败时更新状态

4.2 排查技巧:一眼定位异步问题

异步问题最大难点在于“它不报错,但结果不对”。同步代码里出错会在调用栈里留痕迹,异步代码出错,控制台可能静悄悄的。我用的排查方法有三个:

第一个是线程名定位。线程池配置里我特意指定了setThreadNamePrefix("file-merge-"),这样线上日志里只要看到file-merge-前缀的线程,就知道是哪个线程池在执行。如果日志显示异步方法跑在了http-nio前缀的线程上,那基本可以断定是代理失效、同步执行了。

第二个是异常处理器的配置。@Async注解可以用AsyncUncaughtExceptionHandler来接收没有被捕获的异常,虽然我更推荐在异步方法内部自己catch,但这个兜底机制一定要有:

@Configuration public class AsyncExceptionConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, obj) -> { log.error("异步方法执行异常, method={}, params={}", method.getName(), Arrays.toString(obj), throwable); }; } }

这样设置之后,哪怕异步方法里忘了catch,异常也不会被静默吞掉。

第三个是监控线程池运行状态。我写了一个简单的监控接口,可以随时查看线程池活跃线程数、队列积压情况:

@GetMapping("/monitor/executor") public Map<String, Object> monitorExecutor() { ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) applicationContext.getBean("fileMergeExecutor"); ThreadPoolExecutor threadPoolExecutor = executor.getThreadPoolExecutor(); Map<String, Object> result = new HashMap<>(); result.put("activeCount", threadPoolExecutor.getActiveCount()); result.put("queueSize", threadPoolExecutor.getQueue().size()); result.put("completedTaskCount", threadPoolExecutor.getCompletedTaskCount()); result.put("poolSize", threadPoolExecutor.getPoolSize()); return result; }

4.3 事务与异步的组合坑

@Async和@Transactional一起用,也是一个高频大坑。Spring的事务是基于代理机制实现的,异步方法被提交到新线程执行时,事务上下文不会跟着传递。如果你在异步方法上标注@Transactional,事务其实是失效的。

正确的做法有两种:一种是把事务控制放在异步方法内部的业务逻辑中,手动开启事务;另一种是拆成两步,同步方法开启事务做数据入库,提交给异步方法的是“不需要强事务保证”的后续任务。这两个方法我都在项目里实践过,推荐第二种,因为异步任务的前置数据已经在同步阶段落库了,后续操作即使失败,也不会产生脏数据。

4.4 线程池参数调整原则

线程池参数不是一成不变的,要根据线上数据随时调整。我建议在系统上线后,先用默认参数跑一周,每天看一下监控接口的活跃线程数峰值和队列积压量。如果活跃线程数长期等于核心线程数,说明并发请求大于预期,核心线程数可以调大;如果队列积压量长期超过队列容量的50%,说明任务生产速度远大于消费速度,要么调大核心线程数,要么考虑增加消费端的机器。

一个容易忽略的原则是:线程池参数要从任务性质出发,而不是线程池模板。合并任务是IO密集型,可以多开线程;但如果是CPU密集型的计算任务,比如视频转码、图片压缩,线程数一旦超过CPU核心数过多,反而会因为上下文切换导致吞吐量下降。4核的机器跑转码任务,开32个线程不会比开8个线程快,只会更慢。

4.5 异步任务的取消与补偿机制

异步任务跑了很久,用户等得不耐烦了,点了个“取消”,任务真的能停下来吗?答案是:@Async提交的void任务,拿不到Future引用,外部的取消机制很难直接作用到它。但我们可以通过状态位来协作取消。

我在MergeTaskState里加了一个cancelled字段,取消接口把它置为true。异步合并方法在合并每个分片之前,都检查一下cancelled字段,发现为true就提前退出,清理已合并的临时文件,并把任务状态置为“已取消”。这种协作式取消比强杀线程可靠得多,不会留下半截文件。

补偿机制方面,异步任务失败后不能只记录状态就完事了。我加了一个简单的重试策略:失败状态的任务,在前端轮询到失败结果后,允许用户点击“重新合并”,此时前端重新调用合并接口,生成一个新的任务ID,旧任务的数据会被清理掉。这个实现虽然朴素,但比引入消息队列重试机制要务实很多,在中小规模业务场景下性价比很高。

5. 实测效果与优化方向

5.1 上线后的实际表现

我用这套方案做了压测对比。同样是1GB文件,走同步合并的上传接口,从提交合并请求到返回结果,耗时平均在25到35秒之间,期间整个接口一直被占用,Tomcat线程池的活跃线程数持续处于高位。切到异步合并之后,合并接口的响应时间稳定在50毫秒以内,线程池里新增的合并任务在后台并行处理,同一个Tomcat线程可以继续服务其他HTTP请求。

更直观的压力测试:用10个并发用户同时上传1GB文件,同步方案下Tomcat线程池被占满后,第11个用户的所有请求全部排队,接口响应时间飙到十几秒甚至超时。异步方案下,10个合并任务扔到后台异步执行,Tomcat线程池只有前期的分片上传请求在占用,并且分片接口本身很快返回,线程池压力一直很平稳。

需要说明的是,异步化没有让合并本身变快,它只是不占请求线程了。合并一个1GB文件,实际耗时依然是几十秒,但这个耗时不再阻塞前端请求。用户感知到的就是“上传完秒反馈,进度条慢慢走,最后提示成功”,整个体验比之前清爽太多。

5.2 现有方案的三个已知短板

这套方案能解决大多数场景,但我也得诚实地说清楚它有哪些短板。

第一个是单机存储瓶颈。分片和合并后的文件都存在本地磁盘,磁盘满了就完蛋,而且单台机器的磁盘IO带宽终究有限。真要大规模上生产,分片临时存储和最终文件存储都应该切换到对象存储(比如MinIO或者云上的OSS),分片上传直接改为前端直传对象存储的临时目录,再由后端异步任务在服务器侧做对象存储的合并操作。对象存储的合并接口通常有服务端Copy机制,可以把多个分片合成一个大对象,会比本地合并再上传的方案更快。

第二个是任务状态存内存,一旦应用重启,所有进行中的任务状态全部丢失。换成Redis存储任务状态,成本很低,收益却很大。文件合并中的任务,应用重启前如果没执行完,恢复后可以通过状态表感知到“有任务中断了”,再触发补偿重试。

第三个是前端轮询虽然简单,但终究不够实时。2秒的轮询间隔是权衡后的结果,如果项目本身已经有WebSocket基础设施,完全可以换成服务端主动推送进度,体验会再上一个台阶。

5.3 两个值得投入的扩展思路

异步化之后,整个文件上传链路已经比较健康了,再往后有两个方向值得投入。

第一个是引入消息队列做削峰填谷。大文件合并任务的峰值流量如果不确定,可以把“合并请求”先发到消息队列,消费者按自己的消费速率来处理合并任务。这样即使瞬间来了100个合并请求,线程池也不会被打满,只是任务在队列里排队,队列变成了消息中间件,天然支持持久化和重试。代价是多维护一套MQ,小项目慎用。

第二个是分布式任务调度。如果合并任务还要依赖其他服务(比如转码服务、内容审核服务),整个流程其实已经变成一个“工作流”了。这个时候可以考虑引入简单的分布式任务框架,把“合并完成 → 转码 → 生成封面 → 入库 → 通知”这几个步骤编排成一条流水线,每一步的成败都清晰可见。对于大文件上传这种链路长、操作重的场景,这种做法比在Service里堆一堆代码要稳健得多。

我在实际部署这套方案后,最直观的感受是:之前每天都会有几次因为大文件上传导致接口超时的工单,现在这方面的告警基本清零了。异步不是银弹,但用对场景,能把很多“看起来高频但其实不该阻塞主链路”的操作合理地挪走,这个思路本身比代码更值钱。

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

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

立即咨询