@Async + 自定义线程池,一个”阻塞拒绝策略”差点让 Tomcat 死掉

发布时间:2026/6/25 18:47:09

@Async + 自定义线程池,一个”阻塞拒绝策略”差点让 Tomcat 死掉 Spring Boot 项目里做异步任务大多数人都会选 Async。简单、好用、跟 Spring 深度集成。但线程池的拒绝策略很少有人认真想过。最近 review 一段代码差点被一个”聪明的”设计坑到。场景主请求之外的副作用操作有一个核心接口必须在几百毫秒内返回。但每次调用还附带三个副作用操作写历史记录、写变更日志、写操作记录。这仨操作跟主流程结果无关但都需要写同一个 MySQL。直觉方案异步。让主线程赶紧返回三个写操作扔到后台线程去做。实现Async 自定义线程池于是有了这样的设计upgradeCorePoolSize: 6 upgradeMaxPoolSize: 40 upgradeQueueCapacity: 8000core6日常流量够了。突发时能扩展到 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 的异常会静默丢失SpringAsync 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()); }改成CompletableFutureT返回值让调用方可选地感知异步结果是更好的做法。2. 线程池负载不可观测队列当前深度多少活跃线程数多少有没有发生过拒绝没有任何指标暴露出来。当队列悄悄积压到 7999距离死锁只差一步没有任何告警。3. 新线程拿不到请求上下文Async在新线程中执行ThreadLocal里的东西比如当前请求的用户信息、traceId全是空的。这个项目通过显式传参解决——每个异步方法都仔细列出需要的参数。这能 work但不优雅。每新增一个异步方法都要梳理一遍参数依赖。漏一个就等着 NPE。总结维度本方案更好的做法拒绝策略queue.put() 阻塞调用方CallerRunsPolicy 或 DiscardPolicy DLQ异常处理Async void 隐式丢失CompletableFutureT callback可观测性无指标Micrometer 暴露队列深度、活跃线程上下文传递ThreadLocal null手动传参参数显式传递该方案已在做或异步上下文传播框架异步任务的核心矛盾是既要异步不阻塞调用方又要可靠不丢任务、异常可感知。Async void天生偏向异步牺牲了可观测和异常传播。自定义queue.put()想补”可靠”这一端结果补出了更大的问题。正确做法拒绝策略用CallerRunsPolicy——保底方案不会死锁暴露线程池指标——在队列积压到 80% 时就发出告警如果要更高的可靠性换消息队列Kafka/RabbitMQ——有磁盘持久化和死信机制不是线程池能替代的去看看你项目里的Async线程池拒绝策略是什么

相关新闻