拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot异步操作实战:@Async、线程池与CompletableFuture避坑指南

Spring Boot异步操作实战:@Async、线程池与CompletableFuture避坑指南

先说一个我实际遇到的线上问题:一个报表导出接口,原来在方法里同步做“查数据、生成Excel、上传OSS、发邮件通知”,用户点一次按钮要等十几秒才看到响应,高峰期Tomcat线程被这种长任务占得死死的,整个应用都跟着变慢。后来我把非核心步骤拆成Spring Boot异步操作,接口从十几秒降到200毫秒返回,任务在后台线程池里慢慢跑。那段时间我把@Async、CompletableFuture、线程池配置、事务传播、上下文传递这些东西重新梳理了一遍,踩了不少坑,也攒了不少能直接用的方案。这篇文章就把我实际的Spring Boot异步操作经验整理出来,适合已经写过Spring Boot、但对异步机制还停留在“加个@Async就行”阶段的开发者。

1. 先从一次接口超时的线上事故说起

1.1 同步调用为什么把Tomcat线程池压垮

先说一下事故的背景。这个导出接口内部有三个步骤:查询报表数据,大概需要300毫秒;用POI生成Excel,耗时1到2秒;把文件传到OSS并发送邮件通知,又需要几百毫秒。单看每一步都不算慢,问题是它们串行执行,一次请求要占住一个Tomcat工作线程3秒左右。

Tomcat默认配置下工作线程有限,常见是200个。你可以想象成一个银行网点只有200个柜台,每个柜台一次只能服务一个人。一旦某个业务需要长时间占用柜台,后面排队的请求就得一直等。线上流量稍微上来一点,200个线程被导出接口全部占住,其他所有接口都开始排队,然后监控里就会出现线程池拒绝、请求超时、CPU突然飙高。

当时我把接口改成:主线程只负责校验参数、把任务丢进线程池、立刻返回。生成Excel、上传OSS、发邮件这些全部放到Spring Boot异步操作里去执行。改造之后,接口耗时从十几秒降到200毫秒左右,而真正耗时的操作在后台异步线程池里继续跑,用户不需要干等着。

这里要理解一个关键点:绝大多数业务系统瓶颈不是CPU,而是IO等待。查数据库要等网络、生成文件要等磁盘、发邮件要等外部服务,这些等待时间里线程其实什么都没干,只是干等。异步操作的核心价值,就是把“等待时间”和“干活时间”分开,让有限的线程去处理更多请求。

1.2 Spring Boot里异步操作的三种常见形态

很多新手以为Spring Boot异步操作就等于一个@Async注解,实际项目里异步有三种常见形态,我列个表说清楚:

形态实现方式适用场景典型问题
方法级异步@Async + 线程池单个方法异步执行,如发邮件、写日志、推送通知自调用失效、异常丢失、事务失效
编程式异步CompletableFuture、ExecutorService多个异步任务编排、汇总、并行调用线程池用错、Future.get阻塞
跨进程异步ActiveMQ、RabbitMQ、Kafka 等消息队列系统解耦、削峰填谷、需要重试引入中间件成本,需要处理消息幂等

同一个业务里,这三种形态经常是组合使用的。比如接口先快速返回给前端,后台用消息队列通知积分服务,积分服务里再用CompletableFuture并行查询多个数据源。没有一种异步方案能包打天下,关键是根据场景决定异步的边界到底在哪里。

我个人的判断标准有两个:第一,这个任务是不是必须同步返回给用户?不是就异步;第二,这个任务能不能容忍延迟?比如发送通知晚几秒没问题,那就丢线程池。如果业务要求非常强的可靠性,比如订单创建后的对账数据,宁可上消息队列,也不要只依赖本地线程池,因为进程一旦重启,线程池里排队中的任务就全丢了。

2. @Async注解:最顺手也最容易踩坑的入口

2.1 三步启用:开启注解、配置Executor、标注方法

使用@Async的第一步是在启动类上加@EnableAsync:

@SpringBootApplication @EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }

第二步是定义线程池Bean。不建议完全不配置就直接用@Async,因为Spring Boot在没有找到自定义线程池时会走默认策略,后面我会单独说这个坑。先看一个我常用的基础配置:

@Configuration public class AsyncConfig { @Bean("bizAsyncExecutor") public ThreadPoolTaskExecutor bizAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix("biz-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }

第三步是在异步方法上指定线程池名字:

@Service public class NotificationService { @Async("bizAsyncExecutor") public void sendEmail(String userId) { // 真正发邮件的逻辑 } }

这样调用方调sendEmail时,方法会立刻返回,真正的方法体在bizAsyncExecutor这个线程池里执行。注意线程名的前缀,排查问题时看日志线程名就知道任务是不是真的跑在异步线程里了。

2.2 同一个类里的自调用会让异步失效

这是@Async最经典的坑。看这段代码:

@Service public class OrderService { @Async("bizAsyncExecutor") public void asyncNotify() { // 发通知 } public void createOrder() { asyncNotify(); // 同类中直接调用 } }

createOrder()执行时,asyncNotify()会异步执行吗?不会。因为@Async是基于Spring AOP代理实现的,只有外部调用这个Bean的方法时,调用才会被代理拦截,然后把方法提交到线程池。而上面这种写法是this.asyncNotify(),属于对象内部直接调用,根本没有经过代理对象,所以结果是同步执行,耗时操作照样卡住主线程。

我自己排查这类问题时的经验是:看代理对象。Spring Boot默认使用CGLIB代理,如果你的类是final的,或者方法被final修饰,CGLIB代理会有问题。但自调用失效和CGLIB还是JDK动态代理没关系,只要是同类内部方法调用,通通绕过了代理。

解决办法有三个:

  • 把异步方法拆到独立的@Service类里,让外部调用;
  • 注入自身代理对象,例如@Autowired private OrderService self;,然后通过self.asyncNotify()调用;
  • 使用ApplicationContext.getBean(OrderService.class).asyncNotify(),不推荐,但排查时能应急。

建议首选第一种,把异步逻辑单独放到一个类里,职责清晰,也方便测试。

2.3 返回值只能选void或者Future/CompletableFuture

有人会写这样的代码:

@Async("bizAsyncExecutor") public User getUser(Long id) { // 耗时查询 return userMapper.findById(id); }

然后发现调用方拿到的是null。为什么?因为代理会把方法提交到线程池,然后立即返回,方法原来的返回值根本来不及产生。对于@Async方法,返回值只支持void、Future、CompletableFuture、ListenableFuture等类型,不能直接返回普通对象。

正确的写法是返回CompletableFuture:

@Async("bizAsyncExecutor") public CompletableFuture<User> getUserAsync(Long id) { User user = userMapper.findById(id); return CompletableFuture.completedFuture(user); }

调用方再通过future.get()或者thenApply拿结果。这里还有个隐含建议:如果主线程只是拿到future后立刻get(),那异步就白做了,因为get()会阻塞。真正合理的用法是交给CompletableFuture继续编排回调,这个我在第4章展开。

2.4 异步方法的异常不能扔给调用方

同步方法如果抛出异常,调用方可以用try-catch捕获。但@Async方法不一样,方法体在线程池线程里执行,主线程早就返回了,根本没有捕获异常的机会。如果你在异步方法里不做任何处理,异常会被吞掉,线上就出现“发了邮件但没发出去、日志里还没有任何异常”的诡异现象。

处理异步异常,我一般做两层:

第一层,方法内部自己try-catch,记录完整业务日志,并把失败任务写入重试表。这是业务兜底。

第二层,实现AsyncUncaughtExceptionHandler,兜住那些没处理好的异常:

@Configuration public class AsyncExceptionConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) -> { log.error("async method error, method: {}, params: {}", method.getName(), params, throwable); // 发告警、写重试表 }; } }

注意:返回值是CompletableFuture的异步方法,异常不会走到AsyncUncaughtExceptionHandler,而是会包装在future里,需要通过exceptionally处理。所以说回来,异步方法一定要清楚自己怎么处理异常,不能指望调用方。

3. 线程池配置:异步不配Executor等于白做

3.1 SimpleAsyncTaskExecutor到底有多不靠谱

如果不自定义线程池,Spring Boot默认情况下会用SimpleAsyncTaskExecutor来执行@Async方法。这个名字看起来人畜无害,实际上它每次执行一个任务都会创建一个新线程,没有线程复用,也没有队列。简单说,你的任务越多,它给你new的线程越多,不设上限。

看到这里你应该能想到后果:高并发下线程数飙升,几万个线程同时跑,CPU上下文切换爆炸,内存也跟着被吃光,最后应用直接宕机。我见过不止一个项目,代码里加了@EnableAsync和@Async,但没配线程池,压测一上来就挂,去掉异步反而没事,根因就是SimpleAsyncTaskExecutor。

所以,使用@Async的第一步不是写注解,而是先写ThreadPoolTaskExecutor配置。Spring Boot里的ThreadPoolTaskExecutor是对java.util.concurrent.ThreadPoolExecutor的封装,适合Spring环境直接注入。

3.2 核心线程、最大线程、队列怎么设

线程池参数没有标准答案,但有决策依据。先看一个相对完整的配置:

@Bean("exportTaskExecutor") public ThreadPoolTaskExecutor exportTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(500); executor.setKeepAliveSeconds(120); executor.setThreadNamePrefix("export-"); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }

这里的参数含义:

  • corePoolSize:核心线程数,即使空闲也保留的线程数量。
  • maxPoolSize:线程池允许的最大线程数。
  • queueCapacity:等待队列容量。
  • keepAliveSeconds:非核心线程空闲多久后回收。
  • waitForTasksToCompleteOnShutdown:应用关闭时等待任务执行完,线上必须开。
  • awaitTerminationSeconds:最多等多少秒。
  • rejectedExecutionHandler:队列满且线程数达到最大值后,新任务怎么处理。

线程池的工作逻辑很多人搞反了:不是核心线程满了就立刻开新线程,而是任务量超过corePoolSize后先放进队列;队列也满了,才会继续创建线程直到maxPoolSize;连maxPoolSize的线程都在忙,才会触发拒绝策略。

那参数怎么定?我通常这样判断:

  • IO密集型任务,比如读写文件、调用外部接口、访问数据库,线程数可以大一点,一般取CPU核心数乘以2到4,因为线程大部分时间在等待。
  • CPU密集型任务,比如复杂的计算、加密解密,线程数不宜超过CPU核心数,否则抢CPU反而更慢。
  • 队列容量要结合任务积压容忍度。例如核心线程4个,一个任务耗时1秒,队列500,意味着最多能积压500秒的工作量。如果你是接口异步写日志,500队列没问题;如果是用户触发的重要操作,队列太大意味着失败感知越慢。

不建议照抄某个固定的“核心4最大8队列500”,要用压测数据去调整。我一般会在测试环境用JMeter模拟真实流量,同时监控线程池活跃数、队列堆积数,再来调参数。

3.3 多个业务不要共用一个线程池

项目里常见的错误是定义了一个Executor,所有@Async方法都往里面丢。邮件服务堵了,会影响报表导出;报表导出爆了,影响短信通知。不同业务的优先级、响应时间要求、任务量都不同,挤在一个池子里,互相拖累。

我的做法是按业务拆分线程池:

线程池Bean场景建议参数参考
logExecutor异步写日志、埋点核心2,最大4,队列大
notifyExecutor发邮件、短信、推送核心4,最大8,队列中等
reportExecutor报表生成、大数据导出核心2,最大4,队列500,CallerRuns
mqExecutor消息消费后的异步处理结合并发消费数和下游能力

拆开之后还有一个好处:每个线程池的监控指标相互独立,看监控时一眼就知道是哪个业务堵住了。

3.4 优雅关闭和监控不能省

很多人在本地开发时没感觉,一到发版就遇到奇怪问题:比如任务只执行了一半,应用就退出了。原因是JVM关闭时线程池里的线程直接被终止,队列里的任务全部丢失。

Spring Boot中ThreadPoolTaskExecutor本身会执行shutdown,但你最好明确设置上面提到的两个参数:

executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60);

意思是应用关闭时先等已有任务跑完,最多等60秒,超时再强制结束。这样可以避免发版重启把正在跑的异步任务掐断。

监控方面如果你用了Spring Boot Actuator,可以自定义一个指标来观察线程池状态:

@Bean public MeterBinder threadPoolMetrics(ThreadPoolTaskExecutor executor) { return registry -> Gauge.builder("thread.pool.active", executor::getActiveCount) .register(registry); }

哪怕暂时不上监控,也建议在日志里定期打印线程池活跃数、队列剩余容量、已完成任务数。不然线程池满了你都不知道,只会发现请求越来越慢。

4. 从Future到CompletableFuture:让异步任务真正编排起来

4.1 Future.get()的阻塞会把异步拉回同步

Java原生的Future接口很早就支持异步结果获取,但有个天然问题:get()方法是阻塞的。你调用future.get()时,当前线程会一直等到结果返回。如果异步任务耗时3秒,主线程在get()那里等于还是等了3秒,异步的意义就打折扣了。

看个例子:

Future<Report> reportFuture = reportService.generateAsync(); Report report = reportFuture.get(); // 这里会阻塞,直到异步任务完成

所以我的经验是:Future只适合那种“你先做点别的事,最后再来拿结果”的场景,比如同时发起两个独立查询,先提交第一个,再提交第二个,最后分别get()。如果你提交完立即get(),那和同步调用没什么两样。

4.2 CompletableFuture怎么编排多个异步任务

真正好用的是CompletableFuture,它最大的价值不是拿结果,而是编排异步任务:任务完成后自动触发下一个动作,不需要主线程傻等。

举个真实场景:一个用户详情接口,需要同时查询用户基本信息、订单列表、优惠券数量,三个数据源相互独立。用同步写法耗时是三者之和,用CompletableFuture并行写耗时是三者中最大的那个。

public UserDetailVO getUserDetail(Long userId) { CompletableFuture<User> userFuture = CompletableFuture.supplyAsync( () -> userService.getUser(userId), reportTaskExecutor); CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync( () -> orderService.getOrders(userId), reportTaskExecutor); CompletableFuture<Integer> couponFuture = CompletableFuture.supplyAsync( () -> couponService.getCouponCount(userId), reportTaskExecutor); CompletableFuture<Void> all = CompletableFuture.allOf(userFuture, orderFuture, couponFuture); all.join(); return new UserDetailVO(userFuture.join(), orderFuture.join(), couponFuture.join()); }

这里用allOf等待所有任务完成,再用join取出结果。注意了,join和get的区别在于join不抛受检异常,在业务代码里更清爽。如果任务之间有依赖关系,比如先查用户,再用用户ID查订单,可以用thenApply把两个CompletableFuture串起来:

CompletableFuture.supplyAsync(() -> userService.getUser(userId), executor) .thenApply(user -> orderService.getOrders(user.getId())) .thenAccept(orders -> sendToClient(orders));

这种写法读起来像流水线,主线程完全不阻塞。还有thenCombine可以合并两个独立的结果,exceptionally可以处理单个任务的异常,handle则可以同时处理结果和异常。在实际项目里,我建议把每个异步任务拆成方法级别的CompletableFuture.supplyAsync,不要嵌套太深,不然可读性会很差。

还有一个坑必须提:CompletableFuture如果不指定线程池,默认用ForkJoinPool.commonPool()。这个是JVM全局共享的线程池,如果任务里有阻塞操作,比如调用远程HTTP接口,是会拖累其他使用commonPool的代码的。所以凡是业务里用CompletableFuture,我都习惯显式传线程池参数。

4.3 虚拟线程时代的新玩法

最近Java生态里讨论很多的是虚拟线程,Spring Boot 3.2之后也做了支持。相关热词里那个newVirtualThreadPerTaskExecutor就是指用虚拟线程实现的一种Executor:每个任务一个线程,但线程很轻,创建和销毁代价远比平台线程小。

Spring Boot开启虚拟线程很简单:

spring.threads.virtual.enabled=true

这样Spring MVC处理请求的线程、@Async的默认执行器等会用虚拟线程。如果你只想在某个线程池里用虚拟线程,可以这样注册:

@Bean public AsyncTaskExecutor virtualTaskExecutor() { return new TaskExecutorAdapter( Executors.newVirtualThreadPerTaskExecutor()); }

然后@Async("virtualTaskExecutor")就可以用。但我不建议大家为了追新而全量切虚拟线程。虚拟线程的价值主要在大量阻塞IO场景,比如同时发起几千个HTTP请求、数据库查询。如果是CPU密集计算,虚拟线程并不能提高吞吐量,反而可能因为调度开销导致性能下降。我目前的做法是:老项目不动,新项目里把轻量IO型任务优先放到虚拟线程上,重量级计算还是走传统线程池。

5. 实战:异步操作在项目里常见的落地场景

5.1 接口快速返回,异步写操作日志和流水

最典型的用法是核心业务接口里,主流程只保留强一致性的操作,非核心操作异步化。比如用户下单后要写操作日志、埋点、发券,这些不该阻塞下单接口:

@PostMapping("/order") public Result createOrder(@RequestBody OrderDTO dto) { Order order = orderService.create(dto); // 同步,必须成功 auditLogService.saveAsync(dto, order); // 异步写日志 couponService.sendAsync(userId); // 异步发券 return Result.ok(order); }

这里有个需要想清楚的边界:如果异步发券失败,用户感知不到,但业务上是否允许?我一般会引入“本地任务表”的补偿机制:异步方法开始先把任务状态置为PENDING,执行成功改为SUCCESS,失败记录错误信息并保留重试次数,由定时任务扫描重试。也就是说,异步不等于放弃可靠性,而是把可靠性从同步调用变成异步补偿。

5.2 批量通知推送:邮件、短信、WebSocket

发送大量通知是异步的经典场景。假设后台要发1万封邮件,同步for循环逐封发,一个线程要跑很久;如果改用线程池分批并行,速度会快很多。但要注意:邮件服务商一般有限流,全量并发很可能触发限流导致大量失败。

我建议用“线程池 + 限流”组合。比如每批50封,每批之间加一个小延迟:

public void sendBatch(List<String> emails, String content) { List<List<String>> partitions = Lists.partition(emails, 50); for (List<String> partition : partitions) { CompletableFuture.runAsync(() -> sendPartition(partition), notifyTaskExecutor); } }

如果对发送顺序有要求,或者不想突然打满下游服务,还可以在消息投递前用Semaphore限流。类似地,WebSocket推送大量在线用户时也要控制并发,避免同时推送导致服务端连接打满。

5.3 定时任务与异步:让报表生成不拖垮调度线程

Spring的@Scheduled默认是同步执行的。比如一个定时任务每5分钟跑一次,但任务本身执行耗时10分钟,我这边的经验是:默认同一个定时任务不会并行执行下一次,但由于调度线程被占住了,其他定时任务也可能受影响。

推荐做法是:@Scheduled方法体里只做“提交任务”,真正耗时逻辑放到@Async线程池:

@Scheduled(cron = "0 */5 * * * ?") public void triggerReport() { CompletableFuture.runAsync(() -> reportService.generateDailyReport(), reportTaskExecutor); }

如果报表生成任务不能并行跑,还需要在任务内部加锁,比如基于任务名的分布式锁,防止定时触发重叠。

5.4 与消息队列整合:从本地异步升级为分布式异步

本地线程池异步有一个天然缺陷:进程宕机或重启,队列里未执行的任务全部丢失;多个实例部署时,每个实例各自为政,任务没有统一负载均衡。当业务要求更高的可靠性时,就要把异步升级成消息队列,比如ActiveMQ、RabbitMQ、Kafka。

很多项目已经通过Spring Boot整合ActiveMQ或RabbitMQ。消息队列的消费端本身就是异步的:生产者把消息发到队列,消费者通过@JmsListener或@RabbitListener异步接收并处理。这样做的好处是削峰填谷、失败重试、多实例水平扩展。

我的选择建议:单机、任务轻、允许少量丢失,选本地线程池异步;涉及资金、对账、核心通知,选消息队列。最好不要一开始就给所有异步逻辑都上MQ,中间件会带来额外运维成本,消息重复消费、顺序消费也都是新的坑。

6. 避坑清单与排查思路

6.1 异步方法没执行的常见原因排查

异步方法不执行、或者看起来像同步执行,这类问题我排查过太多次,给你一条完整的排查链路:

  • 第一步,看启动类上有没有@EnableAsync。漏了这个注解,所有@Async都会静默失效,而且不会报错。
  • 第二步,看异步方法是不是被同一个类里其他方法调用。自调用会绕过代理,这个前面说过。
  • 第三步,看异步方法是不是private或者final。@Async对private方法无效,final方法因为CGLIB无法代理,也不会正确生效。
  • 第四步,看调用方注入的是不是Spring代理对象。如果自己new了一个Service,方法根本没有交给Spring管理,肯定没有异步。
  • 第五步,看异步方法是不是返回了普通对象。返回非Future类型时,代理无法拿到结果,方法返回null。

排查时我一般先在异步方法入口打印一行带线程名的日志。如果线程名是自己配置的前缀,说明异步生效;如果线程名还是Tomcat线程,说明走了同步路径。

6.2 事务+异步:@Transactional为什么失效

这是另一个高频坑。先看代码:

@Async("bizAsyncExecutor") @Transactional public void asyncUpdate() { // 更新订单状态 }

很多人以为这个异步方法里的事务没问题,实际上它和调用方的事务是完全两个事务。原因是Spring事务和异步都是基于代理,但事务上下文绑定的是数据库连接,而数据库连接保存在当前线程的ThreadLocal中。异步方法从线程池里启动,主线程的数据库连接不会传递过来,所以事务传播REQUIRED也传播不到异步线程。

更严重的场景是这样:主方法创建订单并开启事务,然后调用异步方法去更新一个字段。主方法因为后面校验失败回滚了,但异步方法已经在另一个线程执行并提交了事务,数据就出现了不一致。

遇到这种需求,我建议先问一句:真的需要跨线程共享一个事务吗?大多数情况下不需要,更合理的做法是:

  • 异步方法自己管自己的事务,并在业务上接受“独立提交”的现实;
  • 如果主流程和异步流程必须保证一起成功,那就不要用线程池异步,改用本地消息表+消息队列,通过最终一致性解决;
  • 如果只是要事务完成后执行某个操作,可以用TransactionSynchronizationManager.registerSynchronization,在事务提交后再投递异步任务。

6.3 上下文传递:TraceId、用户信息、ThreadLocal

线程池异步会把ThreadLocal里的数据隔离开,这一点常常被忽略。比如你在拦截器里往MDC里放了一个TraceId,进到异步线程后,日志里的TraceId就丢了;再比如你用RequestContextHolder保存了当前登录用户,异步线程里取出来是null。

这里我推荐用TaskDecorator解决。它可以在任务提交到线程池时包装任务,执行前把上下文复制到线程池线程,执行完再清理:

@Bean("contextAwareExecutor") public ThreadPoolTaskExecutor contextAwareExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 省略常规参数 executor.setTaskDecorator(runnable -> { Map<String, String> context = MDC.getCopyOfContextMap(); RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); return () -> { try { MDC.setContextMap(context); RequestContextHolder.setRequestAttributes(requestAttributes); runnable.run(); } finally { MDC.clear(); RequestContextHolder.resetRequestAttributes(); } }; }); return executor; }

要特别注意finally里的清理逻辑。线程池的线程是复用的,如果不清理ThreadLocal,下一次任务复用这个线程时会拿到上一次的用户信息,造成严重的数据串号问题。

6.4 测试异步代码的姿势

写异步代码最怕测不准。单元测试里调用@Async方法,方法经常立刻返回,断言还没执行完就结束了。我的做法分三步:

第一,把异步方法里的核心业务逻辑抽成一个普通方法,单独做单元测试。比如发邮件的模板渲染、报表数据的计算,这些可以同步测,覆盖面也更好。@Async入口只保留“提交线程池”的作用,不值得花太多精力测。

第二,集成测试时用CountDownLatch或者Awaitility等待异步任务真正完成。

@SpringBootTest class AsyncServiceTest { @Autowired private AsyncService asyncService; @Test void testAsyncMethod() { CountDownLatch latch = new CountDownLatch(1); asyncService.doWork(() -> latch.countDown()); latch.await(3, TimeUnit.SECONDS); // 断言异步任务确实执行了 } }

第三,测试结束时要检查线程池有没有泄漏,比如是否还有未执行完的任务,队列里是不是堆了一堆任务。如果每次测试都新建线程池,记得在@AfterEach里调用shutdown。

最后再分享一个小习惯:我现在每写一个异步方法,都会在代码注释里写清楚它用的线程池Bean、超时时间、失败后的重试策略。这个习惯救过我很多次,因为异步代码出问题的时候,最怕的不是问题本身,而是你根本不知道这个任务到底跑在哪个线程、有没有人处理失败。看清线程池边界,理清异常处理和补偿机制,Spring Boot异步操作才能真正成为提高吞吐的利器,而不是埋给未来的雷。

返回列表