Spring Boot 项目里做异步任务,大多数人都会选 @Async。简单、好用、跟 Spring 深度集成。但线程池的拒绝策略,很少有人认真想过。
最近 review 一段代码,差点被一个”聪明的”设计坑到。
场景:主请求之外的副作用操作
有一个核心接口,必须在几百毫秒内返回。但每次调用还附带三个副作用操作:写历史记录、写变更日志、写操作记录。这仨操作跟主流程结果无关,但都需要写同一个 MySQL。
直觉方案:异步。让主线程赶紧返回,三个写操作扔到后台线程去做。
实现:@Async + 自定义线程池
于是有了这样的设计:
upgradeCorePoolSize: 6 upgradeMaxPoolSize: 40 upgradeQueueCapacity: 8000core=6,日常流量够了。突发时能扩展到 40。队列 8000 充当缓冲区。
@Bean public TaskExecutor upgradeHandlerExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(6); executor.setMaxPoolSize(40); executor.setQueueCapacity(8000); executor.setKeepAliveSeconds(30); executor.setThreadFactory(new ThreadFactoryBuilder() .setNameFormat("async-pool-%d").build()); // 拒绝策略:阻塞,不放弃 executor.setRejectedExecutionHandler((r, executor) -> { try { executor.getQueue().put(r); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); executor.setWaitForTasksToCompleteOnShutdown(true); return executor; }调用方无感:
@Async("upgradeHandlerExecutor") public void asyncSaveHistory(SomeContext context) { saveHistory(context.getEntity()); saveChangeLog(context.getEntityId(), context.getChangeInfo()); } // 调用方不关心结果 upgradeHandlerService.asyncSaveHistory(context);看起来不错。主线程不阻塞,异步线程池兜住流量,queue.put()保证任务永不丢失。
问题:拒绝策略反压
标准ThreadPoolExecutor有四种拒绝策略:
| 策略 | 行为 | 风险 |
|---|---|---|
| AbortPolicy | 抛异常 | 调用方收到异常 |
| CallerRunsPolicy | 调用方线程自己跑 | 调用方变慢 |
| DiscardPolicy | 静默丢弃 | 消息丢失 |
| DiscardOldestPolicy | 丢弃最老的 | 消息丢失 |
这个项目选了一个自定义的:queue.put()。当线程池满载、队列填满时,提交任务的线程会被阻塞在put()上——直到队列有空位。
问题来了:调用方是谁?
Tomcat 线程 (200 个) → Controller → Service.asyncSaveHistory() → ThreadPoolTaskExecutor.execute() → queue.put() ← 阻塞! // Tomcat 线程卡死了当数据库写入变慢(比如突然 8000 辆车同时升级),40 个异步线程全部卡在 INSERT 上,队列积压到 8000。第 8001 个请求进来,put()阻塞——Tomcat 线程被钉死在等待队列上。一个阻塞,两个阻塞,很快 200 个 Tomcat 线程全堵在这里。
原本用 @Async 就是为了不让数据库写入阻塞 Tomcat,结果拒绝策略反而把阻塞传导回去了。
对比:CallerRunsPolicy 反而更好
// 如果换成这个 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());当队列满了,调用方线程(Tomcat 线程)自己执行异步任务。虽然这只 Tomcat 线程变慢了(它得跑完 INSERT 才能处理下一个请求),但至少:
- 它释放了队列中的一个位置
- 它推进了任务处理
- 它不会让所有 Tomcat 线程同时死锁
“慢”比”死”好一万倍。
还有三个隐藏问题
1. @Async void 的异常会静默丢失
Spring@Async void方法里抛出的异常,默认行为是:log 一下,然后消失。调用方永远感知不到。
这个项目里给每个异步方法加了 try-catch,但如果有未捕获的异常冒出来——比如某个 Service 内部抛了 NPE——没有人知道。
@Async("upgradeHandlerExecutor") public void asyncSaveHistory(SomeContext context) { // 如果这行抛 NPE,调用方不知道 saveHistory(context.getEntity()); // context.getEntity() 可能为 null saveChangeLog(context.getEntityId(), context.getChangeInfo()); }改成CompletableFuture<T>返回值,让调用方可选地感知异步结果,是更好的做法。
2. 线程池负载不可观测
队列当前深度多少?活跃线程数多少?有没有发生过拒绝?没有任何指标暴露出来。
当队列悄悄积压到 7999,距离死锁只差一步,没有任何告警。
3. 新线程拿不到请求上下文
@Async在新线程中执行,ThreadLocal里的东西(比如当前请求的用户信息、traceId)全是空的。
这个项目通过显式传参解决——每个异步方法都仔细列出需要的参数。这能 work,但不优雅。每新增一个异步方法都要梳理一遍参数依赖。漏一个就等着 NPE。
总结
| 维度 | 本方案 | 更好的做法 |
|---|---|---|
| 拒绝策略 | queue.put() 阻塞调用方 | CallerRunsPolicy 或 DiscardPolicy + DLQ |
| 异常处理 | @Async void 隐式丢失 | CompletableFuture<T> + callback |
| 可观测性 | 无指标 | Micrometer 暴露队列深度、活跃线程 |
| 上下文传递 | ThreadLocal = null,手动传参 | 参数显式传递(该方案已在做)或异步上下文传播框架 |
异步任务的核心矛盾是:既要异步(不阻塞调用方),又要可靠(不丢任务、异常可感知)。@Async void天生偏向异步,牺牲了可观测和异常传播。自定义queue.put()想补”可靠”这一端,结果补出了更大的问题。
正确做法:
- 拒绝策略用
CallerRunsPolicy——保底方案,不会死锁 - 暴露线程池指标——在队列积压到 80% 时就发出告警
- 如果要更高的可靠性,换消息队列(Kafka/RabbitMQ)——有磁盘持久化和死信机制,不是线程池能替代的
去看看你项目里的@Async线程池,拒绝策略是什么?