1. 大文件上传同步阻塞的困境:为什么必须“另起炉灶”
先讲一个我实际遇到过的场景。某次给内部系统做一个资料库模块,用户需要上传几百MB甚至几个GB的工程图纸和视频素材。第一版实现很“朴素”:前端直接把文件POST到Spring Boot接口,Controller里接收 MultipartFile,然后循环字节流写入本地磁盘或者OSS。功能倒是跑通了,结果一上生产就出事。用户传一个1GB的文件,网络再快也要几十秒到几分钟,这段时间里Tomcat的工作线程就一直占着不放。而有上传任务的同时,其他用户查列表、点详情这些轻量请求也在同一个线程池里排队,结果整个系统的响应时间从几十毫秒飙升到几十秒,前端轮询接口全部超时,服务端的监控图上线程池活跃数直接顶到上限。
这其实是同步模型最典型的瓶颈——大文件上传是一个典型的IO密集型长耗时操作,它消耗的不是CPU,而是“线程占用时间”。Tomcat默认的线程池也就200个左右,10个人同时传大文件,线程池就满了,应用基本处于半瘫痪状态。
当时的第一反应是“要不要上消息队列”,但仔细一想,上传这个动作本身并不是一个可以简单丢进MQ就完事的操作,文件数据还要写存储、还要记录元数据、还要通知下游处理。所以本质上要解决的是两件事:第一,让Web请求快速返回,把耗时任务扔给后台去干;第二,让这个后台任务可控、可追踪、可重试。这正是Spring的@Async体系擅长做的事情。
当然,这里要先把概念捋清楚。Spring里的Async异步开发,指的是“方法的异步执行”——调用方发起调用后立刻返回,真正的方法体逻辑由Spring管理的线程池去执行。它解决的是“任务执行时机”的问题,而不是“文件传输通道”的问题。HTTP上传这个通道本身依然是同步的,所以我们真正要设计的是一个组合方案:利用Async把上传完成后的文件处理(转存、合并、校验、入库)从请求链路里剥离出去,同时配合分片等手段减轻单次请求的压力。
本文就以大文件上传这个场景为例,完整走一遍Spring Async的实战落地过程。内容包括:@Async的生效机制和线程池配置、上传任务的状态追踪、分片合并的异步链路设计、异常处理与兜底方案,以及我在生产环境里踩过的几个比较典型的坑。适合正在做文件上传类功能、想优化接口响应时间,或者对Spring异步编程理解还停留在“加个@Async注解”这个层面的同学参考。
2. @Async的生效机制与线程池调参:这块才是重头戏
2.1 为什么你的@Async经常不生效
先说一个老生常谈但杀伤力极大的问题:@Async注解默认情况下形同虚设的情况太多了。好多人写了个异步方法,结果发现调用方还是同步等它执行完,排查半天找不到原因。
@Async的底层是基于Spring AOP动态代理实现的。Spring容器在启动时会为标注了@Async的Bean生成一个代理对象,外部调用这个Bean的方法时,实际上是调用代理对象的方法,代理对象把方法提交给线程池,然后立刻返回。这里有个关键前提——你的调用必须走代理。
最常见的失效场景有三个:
- 同一个类内部的“自调用”。比如ServiceA的方法a()里直接调用了同类的方法b(),而b()上标注了@Async。这种调用绕过了代理对象,直接在this引用上执行,异步自然不生效。
- 没有在配置类或启动类上加@EnableAsync注解。没有这个开关,Spring根本不会去解析@Async注解。
- 调用的Bean没有被Spring管理,或者通过new关键字手动创建了对象。
第三点尤其隐蔽,有时候静态工具类里new了一个Service,然后调它的异步方法,显然是不会生效的。
所以我的建议是:异步方法一定要定义在独立的Bean里,并且通过注入的方式调用,同时启动类上确保有@EnableAsync。如果想做得更严谨一点,可以额外开启@EnableAsync(proxyTargetClass = true),强制走CGLIB代理,避免因为接口代理导致的注解不被识别。
2.2 默认线程池的隐患:SimpleAsyncTaskExecutor真不能直接用
如果你只加了@EnableAsync和@Async,什么都没配置,Spring Boot会使用默认的SimpleAsyncTaskExecutor。这个执行器的行为非常原始:每次执行任务都new一个线程,没有线程复用,也没有最大并发数限制。高并发场景下,线程数会无限制增长,最终导致内存溢出或者频繁上下文切换,系统直接卡死。
这对于上传场景来说是不可接受的。文件上传后的处理任务往往比较重,涉及IO读写、校验、入库,并发量稍微一起来,默认执行器会把应用拖垮。
所以生产级用法必须自定义线程池,并且明确指定给@Async使用。Spring支持在配置类里定义一个名为taskExecutor的Bean,@Async注解默认会优先查找这个名称的执行器。也可以通过@Async("otherExecutor")指定使用某个特定名称的执行器。
下面是我在实际项目里用的一个线程池配置,参数经过压测调过,大家可以参考:
@Configuration public class AsyncConfig { @Bean("fileProcessExecutor") public Executor 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.initialize(); return executor; } }这里有几个参数值得单独说一下。
corePoolSize设为8,是考虑到文件处理任务主要是IO密集型的,不是CPU密集型,线程数不需要太多,8个线程就能把磁盘和网络的IO带宽吃得比较满。maxPoolSize设16,是给上传高峰留一定的弹性空间,但也不能无限大,因为每个线程在处理大文件拷贝时占用的内存和文件句柄都不小。队列容量200,是控制缓冲任务的积压量,如果200个都在排队,说明系统处理能力已经到上限了,这时候再往里塞任务意义不大。
拒绝策略我特意选了CallerRunsPolicy。这个策略的含义是:当线程池和队列都满的时候,任务不会被丢弃,而是由提交任务的线程(也就是Web请求线程)自己来执行。对于文件上传这个场景,任务丢不得,宁可让请求线程多等一会儿,也不能把上传结果弄丢。这个策略在设计上会有个优点:天然实现了“背压”——服务端处理不过来时,请求方会感受到延迟,从而自然降低提交速度。
2.3 线程池参数设计的底层逻辑
很多文章讲线程池就是堆一套计算公式,什么CPU密集用N+1、IO密集用2N。但文件处理这个场景更特殊,它的耗时主体是磁盘IO和网络IO,而且每个任务占用资源差异极大。传一个10MB的文件和传一个2GB的文件,处理时长差了百倍。
所以我在实际调参时,不是照搬公式,而是先看监控数据,核心看三个指标:线程池活跃线程数、队列积压量、任务平均耗时。以这些数据为基准调整参数。
另外要特意提醒一个点:除了文件处理的执行器,我还专门定义了一个“任务状态查询执行器”和一个“临时文件清理执行器”。三者的职责严格分开。为什么?因为不同的任务对线程池的需求不一样。文件处理任务耗时长、资源占用大,适合配置独立的线程池;状态查询任务要求响应快,不能被长任务挤掉;清理任务频率低、不能影响主线。如果全都混用一个默认线程池,一个慢任务就可能拖垮所有异步逻辑。这一点类似数据库里“读写分离”的思路,本质都是隔离不同负载之间的相互影响。
3. 上传任务的状态追踪:让异步链路不再“黑盒”
3.1 任务ID与状态机设计
异步化的第一个直接问题就是:调用方立刻就返回了,但用户怎么知道后台处理到哪一步了?尤其是大文件上传,用户最关心的就是“传完了没有、处理完了没有”。
我这里的做法是设计一个“两步提交”的上传模型。前端先把文件对象的信息通过一个预请求告诉后端,后端生成一个全局唯一的任务ID,并初始化这条上传记录的状态,然后把这个ID返回给前端。前端拿着这个ID去执行真正的文件上传。文件上传完成后,后端在Controller里把这个任务ID标记为“已上传”,同时触发一个@Async的异步方法去做后续处理——比如转存到OSS、做MD5校验、生成缩略图、更新索引等。
这里的核心是引入一个显式的任务状态机。我会定义一个枚举:
public enum UploadTaskStatus { INIT(0, "已创建"), UPLOADING(1, "上传中"), UPLOADED(2, "已上传,待处理"), PROCESSING(3, "处理中"), SUCCESS(4, "处理成功"), FAILED(5, "处理失败"); }状态流转路径是:INIT -> UPLOADING -> UPLOADED -> PROCESSING -> SUCCESS/FAILED。每一步都有动作触发。前端通过轮询或者WebSocket订阅任务ID的状态变化,页面上的进度条就是跟着这个状态机在走。
这一步看起来简单,但它是异步链路能否在生产环境落地的关键——没有状态机,就没有进度反馈,没有进度反馈,用户就会以为系统卡死了,然后疯狂刷新页面,造成重复上传。
3.2 内存存储不可靠,生产环境要用Redis
刚开始做的时候,我图省事,把任务状态放在了一个ConcurrentHashMap里,key是任务ID,value是状态对象。单机演示完全没问题,一旦部署到多节点,问题立刻暴露:用户第一次请求落在A节点,状态写进了A节点的内存;第二次查询被负载均衡转发到了B节点,B节点压根不知道这个任务的存在。
所以生产环境必须用外部存储。我选择用Redis,原因很简单:状态读写频繁,需要极低的延迟;任务状态本身不需要复杂的关系型查询;Redis的TTL机制还能顺便清理过期任务。
Redis里的数据结构我是这样设计的:
- Key: upload:task:{taskId},类型是Hash,存储任务的基本信息,包括文件名、文件大小、分片总数、已上传分片数、状态、创建时间、更新时间等。
- Key: upload:task:{taskId}:progress,类型是String,存储一个百分比的数值,用于前端展示进度条。
- Key: upload:task:{taskId}:shards,类型是Set,记录已经上传成功的分片编号。
每次分片上传成功,就通过Pipeline批量更新这几个Key,避免频繁的网络往返。
很多人会问,为什么不直接更新数据库里的记录?我的回答是:数据库更新是最终一致性的保障,但不是高频写入场景的首选。分片上传过程中,每传一个分片就UPDATE一次数据库,在高并发上传时会带来大量的行锁竞争和binlog写入压力。Redis做实时状态,数据库做最终落库,两者配合,各司其职。
3.3 数据库端的最终一致性落库
异步任务处理完成后,会把Redis里的状态同步回数据库。这里要注意,数据库里的记录才是“权威数据”,Redis里的状态可以允许短时间的延迟或丢失,但数据库里不能丢。
我用的表结构大致是:
CREATE TABLE file_upload_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id VARCHAR(64) NOT NULL, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, file_md5 VARCHAR(64), storage_path VARCHAR(512), status TINYINT NOT NULL, progress INT DEFAULT 0, error_msg VARCHAR(500), created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_task_id (task_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;taskId是业务侧的唯一标识,数据库唯一索引能防止同一个上传任务被重复插入。状态更新时用乐观锁,也就是UPDATE语句带status条件,防止并发更新导致状态回退。比如:
UPDATE file_upload_task SET status = 3, updated_at = NOW() WHERE task_id = #{taskId} AND status = 2;这种写法保证了一个任务只能从“已上传”流转到“处理中”,不会被两个线程同时推进到下一个状态。
3.4 进度查询接口的优化思路
前端轮询进度时,如果每秒每个用户都来查一次Redis,连接开销也不小。我的做法是在上传页面里把轮询间隔做成动态的:上传期间每2秒查一次,状态变成“处理中”后每5秒查一次,超过2分钟没变化就直接提示用户联系管理员。另外,查询接口要加一个简单的本地缓存,比如Caffeine缓存1秒,避免多个用户同时查同一个任务时打到Redis。
这些细节看起来琐碎,但大文件上传这种功能,用户在一次上传流程里会和后端交互几十次甚至上百次,任何一个环节的设计不当都会在大量用户同时使用时放大成性能问题。
4. 分片上传 + 异步合并:完整链路这样搭才靠谱
4.1 分片是前提,异步是手段
真正面对GB级文件时,单个HTTP请求把整个文件传完是不现实的。一方面是网络抖动会导致整个文件传失败,另一方面是Web容器对请求体大小通常有限制。所以大文件上传必须做分片。
前端把文件切成固定大小(比如5MB一片)的多个分片,每个分片通过独立的请求上传。后端收到所有分片后,再合并成完整的文件。分片上传本身和异步没有必然联系,但“合并”这个动作往往耗时较长,特别是当分片数量很多、文件总大小上GB时,合并过程可能长达几十秒。如果合并放在请求线程里同步执行,用户请求迟迟不返回,体验很差,还会占用Web容器线程。所以合并动作要交给Async线程池去处理。
4.2 分片上传的临时目录与幂等设计
每个上传任务在服务端分配一个独立的临时目录,命名规则是taskId。目录结构如下:
/tmp/upload/{taskId}/ ├── 0.part ├── 1.part ├── 2.part └── ...前端每次上传分片时,请求参数带上taskId、shardIndex、totalShards、fileMd5。后端根据taskId找到对应目录,把分片内容写入shardIndex.part文件。由于HTTP请求可能重试,同一个分片可能被提交多次,所以写入时要做幂等处理:如果对应分片文件已存在且大小匹配,直接返回成功,不重复写入。
这里有个性能细节:分片写入不推荐用MultipartFile.transferTo()直接落到临时文件,而是先用FileOutputStream写入,然后立即调用flush和fsync吗?其实不一定,fsync是一个重操作,每个分片都fsync会拖慢速度。我的做法是在合并阶段统一做完整性校验(通过MD5),分片落盘阶段只保证数据安全落到了操作系统的Page Cache,不强制fsync。如果进程崩溃,本次上传任务直接标记失败,客户端重新上传即可,不需要做到分片级别的崩溃恢复——因为大文件上传的常态就是失败重来,代价相对可控。
分片全部传完后,前端调用一个“通知合并”的接口。这个接口很重要,它标记当前任务的分片已齐全,触发合并任务。合并接口要做两个校验:一是检查已上传分片数量是否等于totalShards,二是校验每个分片文件的MD5是否等于前端上报的对应MD5。都通过后,才把合并任务丢给异步线程池。
4.3 异步合并的核心逻辑
合并方法定义在独立的FileMergeService中,用@Async("fileProcessExecutor")标注。核心逻辑首先是创建一个足够大的输出文件,然后按照分片序号从小到大循环读取分片文件,写入输出流。这里要注意写入缓冲的大小,我一般设置为8MB的byte[],避免频繁的小块IO。
合并完成后,计算整个文件的MD5,与前端上报的文件MD5做比对。不一致说明数据在传输或落盘过程中出了问题,任务标记FAILED,通知前端重新上传。一致则继续执行后续业务,比如把文件从临时目录转移到正式存储目录或者上传到OSS。
整体代码结构大致如下:
@Service public class FileMergeService { @Autowired private UploadTaskRepository taskRepository; @Async("fileProcessExecutor") public void mergeAsync(String taskId) { UploadTask task = taskRepository.findByTaskId(taskId); if (task == null || task.getStatus() != UploadTaskStatus.UPLOADED) { return; } taskRepository.updateStatus(taskId, UploadTaskStatus.PROCESSING); try { // 1. 合并分片 File mergedFile = mergeShards(task); // 2. MD5校验 String md5 = FileUtils.calcMd5(mergedFile); if (!md5.equalsIgnoreCase(task.getFileMd5())) { throw new BusinessException("文件MD5校验失败"); } // 3. 转存正式目录 String storagePath = moveToStorage(mergedFile, task); // 4. 更新记录 taskRepository.updateSuccess(taskId, storagePath); } catch (Exception e) { log.error("merge task failed, taskId={}", taskId, e); taskRepository.updateFailed(taskId, e.getMessage()); } } }这里有个非常重要的教训:@Async方法的方法体内,一定要自己try-catch所有异常。因为异步方法的异常不会像同步调用那样直接抛给调用方,如果不在方法内部捕获,异常就会丢失,任务状态永远卡在“处理中”,而且日志里什么都看不到。后面专门有一节讲异常处理,这里先记住这个原则。
4.4 合并失败的重试与补偿机制
合并是一个相对脆弱的操作,磁盘满了、文件被清理了、权限不对,都可能导致失败。我设计了最多3次自动重试,每次重试间隔10秒、30秒、60秒,呈指数退避。
重试机制的实现不是用简单的for循环,而是借助Spring的@Retryable注解配合@Recover方法,或者自己用定时任务扫描“处理中”状态超过5分钟的任务,重新触发合并。我推荐后者,因为它在应用重启后依然能执行(定时任务启动时会扫描一遍脏数据),而@Retryable在应用重启后就会丢失重试状态。
定时任务这里有个细节需要注意:补偿任务一定不能和主要异步任务共用同一个线程池,否则会因为排队拥挤导致补偿不及时。我单独配置了一个核心线程数为2的定时任务线程池,专门做扫描和补偿。
4.5 临时文件的清理策略
分片合并完成后,临时目录里的分片文件就没用了。如果不能及时清理,磁盘很快就会满,尤其是大文件场景,一个任务1GB,100个任务就是100GB。这个坑我实打实踩过,当时生产磁盘告警,排查后发现全是/tmp/upload下的残留分片。
我现在的做法是:合并成功后立刻清理当前任务临时目录;对于上传失败、超时未完成的任务,由一个定时任务每天凌晨扫描临时目录,删除文件修改时间超过24小时并且任务状态不是“上传中/处理中”的目录。注意不能粗暴地按时间删,必须结合任务状态判断,避免误删正在上传的文件。
5. 异常处理与兜底方案:异步方法出了问题别让用户背锅
5.1 异步任务是“静默失败”的重灾区
前面提到,@Async方法抛出的异常不会传给调用方。默认情况下,Spring会把这个异常交给SimpleAsyncUncaughtExceptionHandler处理,它只会打一条WARN日志,然后什么都没有。如果业务代码里没有内部try-catch,这个任务就无声无息地失败了,用户看到的是任务状态永远转圈,开发者看到的是日志里一行容易忽略的警告。
所以生产级异步开发,第一件事就是自定义AsyncUncaughtExceptionHandler,把所有异步方法的未捕获异常统一收拢起来,写业务日志、推告警、标记任务失败。
5.2 统一异常处理器的实现
Spring提供了AsyncConfigurer接口,我们可以实现它来指定全局的异步异常处理器。
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix("global-async-"); executor.initialize(); return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) -> { // 这里务必要做两件事: // 1. 打印完整堆栈到错误日志 log.error("异步方法执行异常, method={}, 参数={}", method.getName(), Arrays.toString(params), throwable); // 2. 发送告警通知(钉钉/邮件/监控平台) alertService.sendAlert("异步任务异常", method.getName(), throwable); }; } }注意这里返回的Executor会成为@Async的默认执行器,所以前面提到的那种针对文件处理场景的特化线程池,如果需要在特定方法上使用,就通过@Async("fileProcessExecutor")显式指定。
5.3 使用CompletableFuture承接异步结果
如果你的异步方法需要向调用方返回结果,不要用void,也不要直接返回Future,推荐使用CompletableFuture。它的优势是组合能力极强,可以方便地处理回调、异常、超时。
@Async("fileProcessExecutor") public CompletableFuture<UploadResult> processFileAsync(String taskId) { try { UploadResult result = doProcess(taskId); return CompletableFuture.completedFuture(result); } catch (Exception e) { return CompletableFuture.failedFuture(e); } }调用方通过whenComplete、exceptionally等回调方法拿到结果或异常,比较接近同步代码的体验。在超时控制方面,Future.get(timeout, TimeUnit.SECONDS)是最后一道兜底防线,防止因为下游系统卡死导致任务无限等待。
5.4 应用关闭时的优雅停机
异步任务最大的隐性问题就是线程池里还有任务在跑,应用却被强制关闭,导致任务丢失。Spring Boot生命周期里的@PreDestroy方法可以帮我们做优雅停机。
@Bean("fileProcessExecutor") public ThreadPoolTaskExecutor fileProcessExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // ... 参数配置 ... executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); return executor; }setWaitForTasksToCompleteOnShutdown(true)会保证应用关闭时,线程池不再接收新任务,并等待已提交任务执行完毕;setAwaitTerminationSeconds(60)设置最长等待60秒,超过则强制关闭。对于文件合并这种重任务,60秒通常够用;如果任务特别重,可以适当调大到120秒。
5.5 时间轮与兜底定时任务
上面提到过定时扫描做重试,我再补充一个细节。定时任务扫描的SQL建议写成这样:
SELECT task_id, file_path FROM file_upload_task WHERE status = 3 AND updated_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE) LIMIT 50;status=3是“处理中”,如果5分钟还没结束,大概率是异常了。扫描出来后,先把任务状态重置为“已上传”状态,再重新提交异步合并。注意要限制扫描数量,避免一次性捞太多把线程池塞满。
6. 从这次上传改造中沉淀下来的几个实战判断
6.1 异步不是银弹:先想清楚瓶颈在哪
这次改造完成之后,我复盘了一段时间,最大的体会是:异步化解决的是“请求线程被长任务阻塞”的问题,但它没有减少任务本身的工作量。大文件上传的瓶颈往往在磁盘IO和网络带宽,不是线程数。如果你不做分片、不做断点续传,单纯把合并逻辑丢给异步,那么效果只是“用户请求返回快了”,但服务端实际处理时间并没有缩短,甚至因为线程池排队还可能变慢。
所以异步开发的前提是:你已经找到了真正的瓶颈,并且异步能让系统整体吞吐量提高。大文件上传场景里,异步化的核心收益是释放Web容器线程,让轻量请求不被长任务拖死,同时合并、校验这类动作可以和下一个文件的上传并行推进。
6.2 线程池参数要按“隔离+监控+压测”的套路去调
生产环境里,我见过太多人把线程池参数写成“网上抄的默认值”,然后就不管了。这种做法在文件上传场景很容易出事,因为不同项目的文件大小、并发量、存储介质差异太大了。
我建议的套路是三步:第一步,把执行器独立配置,线程名前缀写好,方便日志检索;第二步,上线后盯一周监控,重点看线程池活跃度、队列堆积时长、任务完成耗时;第三步,根据监控数据做压测,找到线程数和吞吐量的拐点,再回调参数。不要迷信网上的推荐值,每个系统的负载模型都不一样。
6.3 状态机是异步链路的“眼”
没有状态机的异步任务就是个黑盒,出了问题你根本不知道任务卡在哪一步。文件上传场景尤其如此,因为它的链路特别长:上传分片、合并文件、校验MD5、转存、入库、通知回调,每一步都可能失败。状态机配合日志,能让你在用户投诉之前就发现问题。
另外,状态机的设计要控制流转方向,避免出现“处理中”被并发请求改回“已上传”这种状态回退。数据库更新时带上“当前状态”的条件,就是最简单有效的乐观锁。
6.4 关于“前端使用Worker上传”和Spring结合的一点思考
热搜词里有一条“前端使用Worker上传大文件”,这个方向我最近也在研究。前端用Web Worker在后台线程里做分片、算MD5,可以避免主线程卡顿,提升用户体验。但要注意,前端的Worker只解决浏览器侧的UI阻塞问题,服务端的异步设计是一个独立问题。两者不冲突,但也不能互相替代。
从服务端的角度看,无论前端怎么切分,最终到达后端的就是若干个分片请求。后端的核心设计目标始终是:快速接收分片、安全存储、正确合并、及时反馈状态。前端怎么优化UI,后端不用管太多,但接口的幂等性、状态机的健壮性,后端必须把好关。
6.5 高频小细节:日志、监控、告警不能省
最后聊聊一些容易被忽视的细节。异步方法里的日志要打上taskId和线程名,方便追踪。日志级别要用INFO记录任务开始和结束,用ERROR记录异常,不要把所有日志都打在INFO级别,否则出了问题排查成本很高。
监控方面,ThreadPoolTaskExecutor可以暴露到Spring Boot Actuator的metrics里,线程池活跃数、队列大小、任务完成数都能看到。我每次发版后都会先看一眼这个指标,确认异步任务没有异常堆积。告警规则可以设为:队列积压超过阈值持续5分钟,或者任务失败率超过1%,就触发告警推送。
这些细节单独看都不起眼,但它们组合起来,决定了你做的异步功能是“能用的demo”还是“扛得住线上流量的功能”。我从第一次上线时对着日志茫然无措,到现在能通过监控面板快速定位问题,靠的就是把这些细节一点点补齐。
如果你现在正准备改造大文件上传,我建议不要一上来就追求架构大而全。先把分片上传做扎实,再把合并逻辑异步化,配上状态机和异常处理,就已经能解决80%的问题了。剩下的20%,等真正遇到了性能瓶颈或者新的业务场景,再逐步演进也不迟。