1. 异步操作到底解决了什么问题
1.1 我先讲一个现实的崩溃场景
一次线上压测,订单接口P95时间从800ms一路涨到2500ms。排查下来发现主流程里拦了一堆“顺手做”的事:发邮件、写审计日志、调用第三方通知接口、给存储上传一份附件。这些任务每个单独看都不算慢,但加起来就把用户的核心请求给拖垮了。改进方案很简单——把非核心逻辑全部移到 springboot 异步操作 里执行,让接口只处理真正对用户有价值的数据操作。
这种场景在真实业务里太常见了。我见过不少团队一开始图省事,把旁路操作直接写在Controller/Service里,等用户量上来才发现问题。Spring Boot提供的@Async异步能力,就是专门解决这类问题的:把耗时但不影响主流程的任务放到独立线程池中执行,主线程快速返回,响应时间自然就降下来了。适合正在做接口优化、消息通知、日志收集、批量导入导出的同学参考,尤其是那些已经上了Spring Boot但还在用同步思路写业务的开发者。
1.2 典型业务场景:哪些任务适合扔进线程池
从实用的角度列举几个常见的异步场景:
- 邮件、短信、站内信通知:注册成功、下单成功、订单状态变更,末尾都要发邮件。邮件服务响应慢或临时故障不能影响主流程。
- 文件解析与缩略图生成:上传Excel、图片、视频之后做格式解析、压缩、截图,耗时可能几秒到几十秒,绝对不能堵在请求线程里。
- 审计日志和埋点上报:每次操作都要记录日志,但这些日志用户看不见,失败也不影响业务,异步处理非常合适。
- 批量数据导入导出:后台管理界面导入上万条数据时,后台生成模板、写文件、推送下载链接,用户可以先去干别的事。
- 第三方接口调用:调用外部API做风控校验、发票验真、汇率查询,网络波动不可控,异步隔离能减少故障传染。
我个人的判断标准很简单:只要这个操作用户不直接关心结果,而且耗时可能超过100ms,就值得变成异步任务。两个条件缺一不可。如果用户必须立刻看到结果,比如提交订单必须扣库存、校验优惠券,那就不应该异步,否则数据一致性风险和体验问题都会冒出来。
2. 核心方案:@Async 与线程池
2.1 先搞懂线程池,再谈异步
很多新手第一次接触异步,会想“不就是 new Thread 吗?”。我不建议你这么干。直接创建线程有三个问题:一是线程数量不受控,高并发下瞬间把系统资源打满;二是线程复用率低,频繁创建销毁成本高;三是缺少统一的任务队列和拒绝策略,系统过载时连降级手段都没有。
拿银行柜台打个比方:每个业务请求就是一个客户,每次 new Thread 相当于银行临时在路边支个小桌子接待客户,客户多了路边全是桌子,反而把交通堵死。线程池就是一个管理完善的营业厅:固定的柜台数、等候区座位数,人满的时候有明确的处理规则。Spring Boot里的ThreadPoolTaskExecutor就是这样一个营业厅,它帮我们管理核心线程数、最大线程数、任务队列、拒绝策略,我们只需要配置参数。
另外要明确,@Async只是声明这个方法是异步的,真正执行任务的其实是线程池。所以用@Async的前提是先准备好一个合适的ExecutorBean,否则Spring Boot会使用默认的SimpleAsyncTaskExecutor,它每次新建线程执行任务,完全不推荐在生产环境使用。这一点很多人容易忽略,后面我会专门演示配置。
2.2 核心参数怎么定:不是拍脑袋填数字
先来看一组我自己项目里常用的线程池配置,然后逐项说明为什么这么设:
@Bean("taskExecutor") public ThreadPoolTaskExecutor taskExecutor() { 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.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; }核心线程数 corePoolSize:我建议先按“机器 CPU 核心数 + 1”起步,后续根据压测调。因为异步任务大多是IO密集型(调API、读写文件、查数据库),IO等待时CPU是空闲的,核心线程多一点能更充分利用资源。你的服务器如果是4核,可以先定5,再慢慢加。
最大线程数 maxPoolSize:这是系统能承受的上限。一般我会控制在 corePoolSize 的2倍以内,避免过度创建线程导致上下文切换开销超过收益。比如8核机器,MAX设16比较合适。
队列容量 queueCapacity:这是任务在内存里的排队区。队列太大,任务会积压延迟;队列太小,任务会频繁触发拒绝策略。我一般先用“预估每秒任务量 * 任务平均耗时(秒)”来估算,然后留个2到3倍余量。比如每秒进来100个异步任务,平均耗时0.5秒,那队列容量至少是 100 * 0.5 * 2 = 100。
拒绝策略:我强烈推荐CallerRunsPolicy,这样当线程池满、队列也满时,任务会回退到调用主线程执行,而不是直接丢弃。这个策略牺牲了一点主线程响应时间,但保证了任务不丢失。生产环境最怕静默丢掉数据,比如发邮件、记日志的任务丢了很麻烦。
优雅关闭:setWaitForTasksToCompleteOnShutdown(true)配合setAwaitTerminationSeconds(30),让应用停机时能等待任务完成,避免正在执行的任务被强制中断。这个配置很容易被忽略,但线上发版时如果经常看到“任务中断”异常,多半是这里没设。
3. 实操:从零搭建一个异步模块
3.1 启动类开启异步支持
异步功能不会自动生效,必须先给配置类或启动类标记@EnableAsync:
@SpringBootApplication @EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }@EnableAsync的作用是让Spring容器扫描@Async注解,并为这些方法创建代理机制。如果你的项目里有多处需要异步,我建议在启动类上加一次全局生效就好。如果项目里有多个自定义线程池,@Async后面带上线程池Bean名称,比如@Async("taskExecutor"),否则Spring会查找默认的executor,找不到就用SimpleAsyncTaskExecutor,这是很常见的坑。
3.2 定义异步业务方法
假设我们要把邮件发送改造成异步。先创建一个独立的服务类,不能直接在当前业务类里写私有异步方法,原因后面章节讲自调用时会详细说。
@Service public class NotifyService { private static final Logger log = LoggerFactory.getLogger(NotifyService.class); @Async("taskExecutor") public void sendEmail(Long userId, String templateCode, Map<String, Object> params) { // 这里是真正执行邮件发送的耗时逻辑 log.info("开始发送邮件: userId={}, templateCode={}", userId, templateCode); // 模拟调用邮件服务 sendMailRequest(userId, templateCode, params); log.info("邮件发送完成: userId={}", userId); } }然后在订单服务里调用:
@Override public void createOrder(OrderDTO orderDTO) { // 1. 核心业务逻辑,保存订单,必须同步 orderRepository.save(orderDTO); // 2. 旁路操作,异步执行 notifyService.sendEmail(orderDTO.getUserId(), "ORDER_CREATED", orderDTO.getParams()); // 3. 直接返回,这里不会等待邮件发送完成 }这段代码看起来简单,但有两个关键点。第一,sendEmail的返回值是void,调用方不需要也不能拿到执行结果,所以适合纯通知类任务。第二,@Async只对Spring代理生效,NotifyService必须通过注入进来的对象调用,不能内部this.sendEmail()直接调,否则注解不生效,任务还是在调用方线程里同步执行。
3.3 需要返回结果:用 CompletableFuture
有些异步任务需要把结果带回来。比如批量生成报表,需要同时跑多个模块,最后合并结果。此时不能用void,要用CompletableFuture:
@Async("taskExecutor") public CompletableFuture<List<String>> parseExcel(MultipartFile file) { List<String> errors = new ArrayList<>(); // 解析Excel的逻辑 return CompletableFuture.completedFuture(errors); }调用方可以并行触发多个异步任务,然后合并等待:
CompletableFuture<List<String>> f1 = reportService.parseExcel(fileA); CompletableFuture<List<String>> f2 = reportService.parseExcel(fileB); CompletableFuture.allOf(f1, f2).join(); List<String> errors = new ArrayList<>(); errors.addAll(f1.get()); errors.addAll(f2.get());注意这里join()会阻塞主线程等待所有异步任务完成。如果完全不需要结果,就别用CompletableFuture,否则兜了一圈又退回同步等待,接口性能还是上不去。这个“异步转同步”的场景只适合内部并发加速,不适合接口响应优化。
3.4 完整示例:日志型异步任务的落地方案
为了让你能直接抄作业,再给一个更完整的组合示例。假设我们有个“用户行为审计”模块,用户操作完后要把操作行为写入审计表,同时可能还要调用风险控制接口。这个链路不需要前端等待,用异步最合适。
@Async("taskExecutor") public void writeAuditLog(AuditLogDTO dto) { try { // 1. 写数据库 auditLogRepository.insert(dto); // 2. 调用风控接口,异常不影响记录 riskControlService.check(dto.getUserId()); } catch (Exception e) { // 这里的异常处理很重要 log.error("异步审计任务执行失败", e); } }在controller里正常调用auditService.writeAuditLog(dto);就完事了。注意我在异步方法内部做了异常捕获,这是生产环境的必备经验。异步方法的异常是回不到调用方的,不捕获就会丢失,甚至会导致后台任务线程直接挂掉,影响后续任务执行。
4. 进阶:异步事件与复杂场景
4.1 用异步事件彻底解耦业务
如果你的项目里通知逻辑很强,推荐换一种更优雅的写法:结合Spring的ApplicationEventPublisher和@EventListener实现异步事件。好处是核心业务完全不知道有哪些下游逻辑,以后新增一个通知渠道,只需要加一个监听器。
先定义一个事件对象:
public class UserRegisteredEvent { private Long userId; private String email; // getter / setter }业务入口只发布事件:
@Autowired private ApplicationEventPublisher eventPublisher; public void register(UserDTO userDTO) { // 注册逻辑 userService.create(userDTO); // 发布注册事件 eventPublisher.publishEvent(new UserRegisteredEvent(userDTO.getId(), userDTO.getEmail())); }监听端使用@Async加@EventListener:
@Component public class UserRegisteredListener { @Async("taskExecutor") @EventListener public void handleUserRegister(UserRegisteredEvent event) { sendWelcomeMail(event.getEmail()); sendSms(event.getUserId()); initUserCoupon(event.getUserId()); } }这个方案最大的优点是把“事件源”和“事件处理”彻底拆开了。后续要增加积分、推送、风控标记,核心注册代码一行都不用改。我实际维护过一个通知中心项目,就是靠这种事件驱动方式把一个越来越臃肿的注册接口瘦身下来的。要注意的是,监听器内部依然要做好异常隔离,一个监听器抛异常不能影响其他监听器。
4.2 异步方法的事务,别想当然
异步和事务碰到一起是重灾区。很多新人会在一个方法上同时写@Async和@Transactional,然后发现数据没写入,或者事务边界完全不受控。
先说结论:@Transactional默认是基于Spring AOP的,事务和异步都通过代理实现,但它们的执行顺序和时间线完全不同。@Transactional的事务是绑定在当前线程上的;@Async会把方法扔到另一个线程执行,事务管理器感知不到新线程里的上下文,所以两个注解叠加时,很可能事务根本不会开启,或者逻辑混乱。
我在项目里的做法是:把事务和异步拆开。要么把耗时且需要保证一致性的逻辑放在没有@Async的事务方法里面,由主线程同步执行;要么让异步方法内部自己调用一个带事务的方法(通过注入另一个Service),让事务在新线程里独立开启:
@Service public class OrderNotifyService { @Autowired private OrderDao orderDao; @Async("taskExecutor") public void asyncUpdateStatus(Long orderId, String status) { // 此时进入新线程,在方法内开启独立事务 orderDao.updateStatus(orderId, status); } }如果业务场景是“用户注册后异步发邮件,但发送失败不能回滚注册主事务”,那这种独立事务其实是合理的。如果非要跨线程事务联动,那就需要引入消息事务等重量级方案了,一般业务用不上,别轻易碰。
4.3 与消息队列、云存储怎么配合
很多项目不止一个中间件,比如已经集成了Activemq、Kafka、MinIO、TiDB/TDengine等组件。异步线程池往往能成为连接这些组件的“缓冲层”。
举一个我实际做过的例子:文件上传到MinIO之前,先接收MultipartFile,主线程快速把文件流交给异步任务上传,然后继续处理其他参数校验。异步线程内部再去做大文件上传、生成缩略图、提取元数据、写入数据库。这样上传接口的响应时间就不再被大文件传输拖垮了。
@Async("taskExecutor") public void uploadFileToMinio(MultipartFile file, String fileKey) { minioClient.putObject( PutObjectArgs.builder() .bucket("my-bucket") .object(fileKey) .stream(file.getInputStream(), file.getSize(), -1) .build()); // 上传完成后发送消息到ActiveMQ jmsTemplate.convertAndSend("file.process.queue", new FileProcessMessage(fileKey)); }这套模式的精髓是“主线程浅处理,后台线程深处理”。Controller层只做参数校验和返回任务编号,后台线程池负责所有耗时IO。包括批量数据库写入、调用Flink分析任务、往TDengine写入时序数据,都可以套用同样的姿势。需要注意的是,外部组件的连接池和异步线程池之间的容量要配套,否则异步线程数翻倍,数据库或外呼接口被压垮,反而拖垮整个应用。
5. 常见问题与排查技巧实录
5.1 自调用导致异步失效
这是个老生常谈但一直有人踩的坑。同一个类里,一个方法调用另一个带@Async的方法,注解不会生效,因为Spring的AOP代理拦截不到内部this调用。解决办法有三个:
- 把异步方法抽到另一个
Service类,通过注入对象调用。 - 在类内部注入自身代理
@Autowired private CurrentService self;,然后调用self.asyncMethod()。 - 使用
AopContext.currentProxy(),但这需要配置@EnableAspectJAutoProxy(exposeProxy = true),我一般不推荐,容易让代码变得难读。
我最常用的是第一种,语义清晰,也好测试。如果你发现异步方法没有在新的biz-async-线程中执行,线程名还停留在http-nio-上,十有八九就是自调用问题。
5.2 异步任务异常被静默吞掉
异步方法void返回时,如果内部不处理异常,异常会传到Spring的异步异常处理器,默认记录日志就不管了。尤其是UncaughtExceptionHandler没有配置时,你可能连日志都看不到。
处理方式有两个层面。第一,在异步方法内部用try/catch把核心逻辑包住,记录结构化日志。我倾向于记录任务标识、参数、耗时和异常详情,方便后续排查。第二,实现AsyncConfigurer,自定义全局异常处理器:
@Configuration public class AsyncExceptionConfig implements AsyncConfigurer { @Bean("taskExecutor") public Executor taskExecutor() { return ...; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> log.error("异步执行异常: {}", method.getName(), ex); } }两个方案可以同时用。全局兜底保证不丢异常,方法内精确处理业务逻辑的补偿动作。
5.3 拒绝策略和队列积压怎么判断
当你发现接口开始变慢,而自己确实没少设线程池参数,就要怀疑是不是任务冲到了队列里堆积。推荐在测试环境模拟高并发任务,打点观察几个指标:
- 线程池活跃线程数:通过
ThreadPoolTaskExecutor的getActiveCount()或JMX监控查看。 - 队列积压数:
getQueue().size()要纳入监控。 - 拒绝次数:自定义拒绝策略里打点计数,一旦出现拒绝就预警。
我在项目里加过一个简易探针:定时打印线程池状态到日志里,发布前看一眼数据再估算是否扩容线程池。判断规则很简单:活跃线程长期等于corePoolSize,且队列持续增长,说明线程池偏小,可以考虑提高 corePoolSize;如果队列一直是0,但接口还是慢,那瓶颈就不在线程池,可能是下游依赖,别盲目扩线程数。
5.4 上下文信息丢失:MDC 和不透传 SecurityContext
异步切线程后,常见的坑还有日志链路追踪ID丢失、登录用户信息丢失、请求Header丢失。比如你用Logback的MDC记录traceId,主线程里设置的MDC.put("traceId", ...)在异步线程里是拿不到的。
解决思路是:提交任务前手动传递上下文,提交后恢复。可以基于装饰器包装Runnable,或者使用TaskDecorator:
executor.setTaskDecorator(task -> () -> { Map<String, String> contextMap = MDC.getCopyOfContextMap(); try { MDC.setContextMap(contextMap); task.run(); } finally { MDC.clear(); } });这样异步线程里的日志也能串到同一个traceId。对于SecurityContext、RequestAttributes等Winter上下文,同样可以用一个工具类实现传递。这个步骤不是必须的,但没有它,排查线上问题会让你怀疑人生。
5.5 如何确认异步真的生效
最后分享一个最直接的验证方式。在异步任务里打印当前线程名:
log.info("当前执行业务的线程名: {}", Thread.currentThread().getName());如果线程名是biz-async-1、biz-async-2这种带前缀的,说明异步配置成功。如果还是http-nio-8080-exec-1,说明任务还在Tomcat工作线程上同步执行。这个方法快速粗暴,比看配置文件靠谱得多。我做代码评审时就经常让同事加一行日志验证,避免改完之后根本没有达到预期效果。
最后再分享一个小技巧
如果你现在维护的项目里异步逻辑分散在各个Service里,我建议统一抽出@Async的入口层,比如AsyncOptService,里面只放异步方法,业务类全部通过它来触发异步任务。这样后续想加统一的重试、告警、埋点、降级都很方便,排查问题也只需要看一个地方。
异步不是银弹,不要把核心链路强行异步化。判断标准我一直很明确:主流程需要强一致的结果,必须同步;旁路通知、日志、文件处理这些“做了更好,不做也没大事”的任务,才值得用异步。我实际踩过几次坑之后,现在做任何新功能前都会先画一遍请求链路,标出哪些是用户等待路径,哪些可以后台执行,然后再决定在哪里加线程池。这个习惯比具体配置重要得多,建议你也试试。