@Async 异步任务的五个坑 前言后台系统里耗时任务批量导入、导出、生成文件几乎都会被甩到后台异步执行前端拿一个任务 ID轮询进度任务在别的线程慢慢跑。在 Spring 里实现这件事通常只需要一个注解——Async。看起来简单到不需要动脑但从加了没生效到把整个系统压垮中间每一步都埋着坑。本文按踩坑的严重程度递进梳理Async最常见的五个坑self-invocationAsync根本没生效默认线程池不是池void 方法的异常被静默吞掉上下文不跨线程传播队列与拒绝策略任务丢失或主线程被拖垮每个坑都配了可运行的 demo 和真实运行日志Spring Boot 2.5 JDK 17。全文只讲Async本身不涉及具体业务。这是异步批量任务系列的一篇可以配合前两篇《线程池与分治合并》《当 Transactional 不再可靠》一起看。坑一self-invocation —— Async 根本没生效现象方法上明明标了Async但日志显示它还是在调用者的线程里跑一点没异步。原理Async和Transactional一样是靠AOP 代理实现的。Spring 在容器里放的其实是你这个类的一个代理对象异步的逻辑把方法丢到线程池加在代理上。只有通过代理对象调用增强才会触发。而同一个类内部的this.asyncTask()调用走的是原始对象自己绕过了代理增强自然不会生效。外部调用走代理生效: 其他 Bean ── [代理对象] ──异步逻辑── 目标方法 内部调用this.失效: 目标类的方法A ──this.── 目标方法B 根本没经过代理代码复现ServicepublicclassPitfall1SelfInvocationService{publicvoidouterCallInner(){log.info(outerCallInner 执行线程 {},Thread.currentThread().getName());// this.asyncTask() —— 绕过代理不会异步asyncTask();}Async(bizExecutor)publicvoidasyncTask(){log.info(asyncTask 执行线程 {}若是 main 说明 Async 没生效,Thread.currentThread().getName());}}运行日志 坑一self-invocation内部调用 Async 失效 主线程 main outerCallInner 执行线程 main asyncTask 执行线程 main若是 main 说明 Async 没生效 ---- 对比外部直接调用 asyncTask走代理生效---- asyncTask 执行线程 biz-async-1若是 main 说明 Async 没生效看得很清楚从outerCallInner内部调用时asyncTask在main执行没生效而从外部直接调用同一个方法时它在biz-async-1线程执行生效。解法拆到另一个 Bean把异步方法放到独立的 Service通过注入调用最推荐最清晰。注入自身代理Autowired private XxxService self;用self.asyncTask()调用。AopContext.currentProxy()需要开启EnableAspectJAutoProxy(exposeProxy true)可读性差不推荐。另外两个附带条件也别忘了Async方法必须是public启动类或配置类要加EnableAsync否则整个异步机制根本不会开启。这个坑和上一篇《当 Transactional 不再可靠》里的事务失效是同一个 AOP 代理机制的两个受害者。凡是靠代理增强的注解Async、Transactional、Cacheable都怕 self-invocation。坑二默认线程池不是池现象不配线程池直接用Async高并发下线程数不断上涨线程名一路递增严重时直接 OOM。原理先纠正一个常见误解。Async解析执行器的顺序是先找容器里类型为TaskExecutor、或名为taskExecutor的唯一 Bean找不到才 fallback 到SimpleAsyncTaskExecutor。而SimpleAsyncTaskExecutor的行为是——每来一个任务就 new 一个新线程不复用、无队列、无上限。它名字里有 “Executor”但根本不是一个线程池。任务量一大线程数就失控。代码复现为了准确还原SimpleAsyncTaskExecutor的行为demo 里显式配了一个// 反例还原 Async 默认执行器的行为Bean(simpleExecutor)publicExecutorsimpleExecutor(){SimpleAsyncTaskExecutorexecutornewSimpleAsyncTaskExecutor(simple-async-);executor.setDaemon(true);returnexecutor;}// 正解自定义线程池Bean(bizExecutor)publicExecutorbizExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();executor.setCorePoolSize(1);executor.setMaxPoolSize(2);executor.setQueueCapacity(2);executor.setKeepAliveSeconds(60);executor.setThreadNamePrefix(biz-async-);executor.setRejectedExecutionHandler(newThreadPoolExecutor.CallerRunsPolicy());executor.initialize();returnexecutor;}运行日志各提交 6 个任务---- SimpleAsyncTaskExecutor观察线程名不断递增不复用 ---- [SimpleAsyncTaskExecutor] 任务 5 执行线程 simple-async-5 [SimpleAsyncTaskExecutor] 任务 3 执行线程 simple-async-3 [SimpleAsyncTaskExecutor] 任务 2 执行线程 simple-async-2 [SimpleAsyncTaskExecutor] 任务 6 执行线程 simple-async-6 [SimpleAsyncTaskExecutor] 任务 1 执行线程 simple-async-1 [SimpleAsyncTaskExecutor] 任务 4 执行线程 simple-async-4 ---- 自定义池观察线程名只在固定几个之间复用 ---- [自定义池] 任务 1 执行线程 biz-async-1 [自定义池] 任务 4 执行线程 biz-async-2 [自定义池] 任务 2 执行线程 biz-async-1 [自定义池] 任务 3 执行线程 biz-async-2 [自定义池] 任务 6 执行线程 biz-async-2对比非常直观SimpleAsyncTaskExecutor开了simple-async-1到simple-async-6六个不同的线程一个任务一个线程自定义池始终只在biz-async-1/biz-async-2之间复用。6 个任务尚且如此成千上万个任务时前者就是一场线程灾难。解法自定义 ThreadPoolTaskExecutor七个核心参数逐个说清参数作用corePoolSize核心线程数常驻不销毁maxPoolSize最大线程数队列满后才会扩容到这个上限queueCapacity任务队列容量核心线程忙不过来时任务先在这里排队keepAliveSeconds超过核心数的线程空闲多久后回收threadNamePrefix线程名前缀排查问题时极其重要rejectedExecutionHandler拒绝策略见坑五allowCoreThreadTimeOut是否允许核心线程也被回收线程数怎么定这里直接接上《线程池与分治合并》那篇的结论IO 密集型任务的线程数可以远超 CPU 核数经验值是CPU 核数 × (1 等待时间/计算时间)CPU 密集型才用核数 1。坑三void 方法的异常被静默吞掉现象异步任务里抛了异常调用方毫不知情任务看起来成功了但数据其实不对。这是最危险的一个坑——它不报错只是悄悄地错。原理Async方法返回void时方法内部抛出的异常会交给AsyncUncaughtExceptionHandler处理默认实现SimpleAsyncUncaughtExceptionHandler只打一行错误日志不会向上抛。调用方那条线程早就往下走了根本感知不到。只有返回Future/CompletableFuture时异常才会被封进 future调用方get()时才能拿到。代码复现// 反例void 直接抛异常被吞Async(bizExecutor)publicvoidvoidTaskThrow(){thrownewRuntimeException(任务内部出错了但这个异常会被静默吞掉);}// 推荐void 内部自己 try-catch把失败状态落地Async(bizExecutor)publicvoidvoidTaskSelfHandle(TaskStatestate){try{thrownewRuntimeException(任务内部出错了);}catch(Exceptione){state.markError(e.getMessage());// 回写任务状态表}}// 可选返回 CompletableFuture异常随 future 传播Async(bizExecutor)publicCompletableFutureStringfutureTask(){CompletableFutureStringfuturenewCompletableFuture();future.completeExceptionally(newRuntimeException(任务内部出错了通过 future 暴露));returnfuture;}运行日志---- 反例void 直接抛主线程感知不到 ---- voidTaskThrow 开始线程 biz-async-1 ERROR o.s.a.i.SimpleAsyncUncaughtExceptionHandler - Unexpected exception occurred invoking async method: ...voidTaskThrow() 主线程继续往下走完全不知道上面的任务失败了 ---- 推荐void 内部 try-catch 回写状态 ---- voidTaskSelfHandle 开始线程 biz-async-1 捕获异常并回写任务状态任务内部出错了 主线程查询任务状态stateERROR, message任务内部出错了 ---- 可选返回 CompletableFuture异常可感知 ---- futureTask 开始线程 biz-async-1 主线程通过 future.get() 感知到异常任务内部出错了通过 future 暴露反例里异常只是被框架打了条日志主线程完全不知道上面的任务失败了而 try-catch 回写方案里主线程随后查到了stateERRORfuture 方案里主线程通过get()拿到了异常。解法与原则推荐异步方法内部自己 try-catch把成功/失败状态落地到任务状态表。这也是真实项目里的标准做法——异步批量任务通常都有一张任务表跑完把taskState置为Finish或Error前端轮询这张表拿结果。想统一兜底可以配一个自定义AsyncUncaughtExceptionHandler实现AsyncConfigurer。需要拿返回值或让调用方感知异常返回CompletableFuture。原则异步任务必须有自己的结果落地渠道状态表 / 回调 / future绝不能指望异常像同步方法那样自然冒泡上来。坑四上下文不跨线程传播现象异步方法里取不到当前登录用户、拿不到请求参数、Transactional不回滚——一到异步线程这些本来好好的东西全没了。原理这些上下文的底层都是ThreadLocal上下文载体事务连接、回滚标记TransactionSynchronizationManagerThreadLocal请求信息RequestContextHolderThreadLocal登录态SecurityContextHolderThreadLocal自定义上下文你自己的ThreadLocalAsync一换线程新线程的ThreadLocal是空的自然什么都取不到。代码复现publicstaticfinalThreadLocalStringUSER_HOLDERnewThreadLocal();// 反例裸 ThreadLocal异步线程里取到 nullAsync(bizExecutor)publicvoidrawThreadLocalTask(){log.info([裸 ThreadLocal] 取到的用户 {},USER_HOLDER.get());}// 正解MDC 上下文经 TaskDecorator 复制过来Async(bizExecutor)publicvoidmdcTask(){log.info([MDC TaskDecorator] 取到的 traceId {},MDC.get(traceId));}复制上下文靠TaskDecorator在任务提交时抓取当前线程的上下文在异步线程执行前放进去executor.setTaskDecorator(runnable-{MapString,StringcontextMDC.getCopyOfContextMap();// 提交线程的上下文return()-{if(context!null)MDC.setContextMap(context);// 放进异步线程try{runnable.run();}finally{MDC.clear();// 用完清理防串味}};});运行日志 坑四上下文不跨线程传播 主线程 main 设置了 USER_HOLDER张三, traceIdtrace-abc-123 [裸 ThreadLocal] 异步线程 biz-async-1取到的用户 nullnull 说明上下文丢了 [MDC TaskDecorator] 异步线程 biz-async-1取到的 traceId trace-abc-123非 null 说明复制成功同一个异步线程里裸ThreadLocal取到null而经过TaskDecorator复制的 MDC 取到了正确的traceId。解法参数显式传递最推荐把需要的上下文用户 ID、traceId 等当方法参数传进去简单可靠不依赖任何魔法。TaskDecorator适合 traceId、MDC 这类横切上下文一次配置全局生效。InheritableThreadLocal只在线程创建时继承一次而线程池的线程是复用的创建时机不确定所以在线程池场景不可靠别用。关于事务AsyncTransactional组合时异步方法里的声明式事务是一个独立的新事务且如果再叠加 self-invocation、循环内 catch 异常等场景声明式事务往往直接失效。这正是上一篇《当 Transactional 不再可靠》里用编程式事务手动管理事务边界的原因。坑五队列与拒绝策略 —— 任务丢失或主线程被拖垮现象两种诡异现象任务突然消失没执行或者一提交异步任务主线程自己反而卡住了异步接口比同步还慢。原理线程池的任务处理流程是核心线程忙 → 进队列 → 队列满则扩容到最大线程 → 还满就触发拒绝策略。不同拒绝策略行为完全不同拒绝策略行为后果AbortPolicy默认直接抛RejectedExecutionException任务丢失 异常CallerRunsPolicy由提交任务的线程亲自执行异步退化为同步主线程被拖慢DiscardPolicy静默丢弃新任务任务悄无声息丢失DiscardOldestPolicy丢弃队列中最老的任务老任务丢失代码复现demo 的池配置是核心1 / 最大2 / 队列2即最多容纳2(执行) 2(排队) 4个任务用的是CallerRunsPolicy。一次提交 6 个慢任务第 5 个开始就会触发拒绝。运行日志 坑五队列满触发拒绝策略CallerRunsPolicy 线程池核心1/最大2/队列2最多容纳 4 个任务。提交 6 个观察后面的任务被 main 执行 提交任务 1提交线程 main 提交任务 2提交线程 main 提交任务 3提交线程 main 提交任务 4提交线程 main 提交任务 5提交线程 main 任务 4 执行线程 biz-async-2 任务 1 执行线程 biz-async-1 任务 5 执行线程 main -- 注意第 5 个任务被 main 亲自执行了 提交任务 6提交线程 main -- main 被任务5阻塞直到它跑完才提交任务6 任务 3 执行线程 biz-async-1 任务 2 执行线程 biz-async-2 任务 6 执行线程 biz-async-1关键在任务 5它的执行线程是main不是biz-async。因为提交第 5 个时队列已满、线程用尽CallerRunsPolicy让提交任务的 main 线程亲自执行了它——异步彻底退化成了同步。更要命的是main 被任务 5 阻塞了 500ms连提交任务 6这个动作都被卡住了。在真实的 Web 场景里这意味着接口线程被业务任务拖住吞吐直接崩塌。解法有界队列 合理拒绝策略队列不要用无界Integer.MAX_VALUE否则任务无限堆积迟早 OOMmaxPoolSize也永远用不上。拒绝策略按语义选任务不能丢如支付、下单→CallerRunsPolicy或落库补偿绝不能 Discard。任务可容忍丢失如非关键日志→ Discard 系列。关键任务落库补偿提交前先落一条待处理记录被拒绝时靠定时任务重新捞起来跑。五个坑串成一条链这五个坑不是孤立的而是异步任务从能用到扛得住必须闯过的五道关卡。任何一关没过任务就会掉进对应的失败结局——而且是从上到下层层递进的连异步都没生效坑一谈后面四关都没意义。用一句话概括每一关要问自己的问题坑一 能异步吗 → 过了 self-invocationAsync 才真正生效 坑二 用对池了吗 → 弃用默认 SimpleAsyncTaskExecutor配自定义线程池 坑三 知道成败吗 → 异常必须落地别被静默吞掉 坑四 上下文在吗 → ThreadLocal 类上下文要显式传递或复制 坑五 扛得住吗 → 有界队列 合理拒绝策略别丢任务、别拖垮主线程一句话记忆坑现象根因解法self-invocation没异步内部调用绕过代理拆 Bean / 注入自身代理默认线程池线程暴涨SimpleAsyncTaskExecutor 不复用自定义 ThreadPoolTaskExecutor异常被吞悄悄错void 异常不上抛try-catch 回写状态 / 返回 future上下文丢失取不到用户/事务ThreadLocal 不跨线程参数传递 / TaskDecorator拒绝策略任务丢/主线程卡队列满触发拒绝有界队列 选对策略常见问题QAsync和Transactional加在同一个方法上会怎样两个都是代理增强能叠加但要注意执行顺序和失效场景。异步方法里的事务是在新线程里独立开启的新事务与调用方不在同一事务。而且一旦这个异步方法又被 self-invocation 调用两个注解会一起失效。批量循环里想精确控制事务用编程式事务更稳见上一篇。Q为什么我的Async方法返回值总是拿到 null如果方法签名返回普通对象不是FutureAsync会立刻返回null因为真正的执行还没开始。要拿返回值必须声明返回FutureT或CompletableFutureT然后get()。QAsync用了自定义线程池还需要手动 shutdown 吗不需要。ThreadPoolTaskExecutor是 Spring 管理的 Bean容器关闭时会自动调用它的destroy()优雅关闭。这和《线程池与分治合并》里手写Executors.newFixedThreadPool必须自己shutdown不同——那是你 new 出来的、脱离容器管理的不关就是线程泄漏。Q一个项目里能配多个线程池吗怎么指定用哪个能而且强烈建议按业务隔离。配多个ExecutorBean用Async(bizExecutor)指定名字即可。不同业务用不同的池避免一个慢任务把所有异步任务都堵死——这也是线程池隔离的基本思想。总结Async把异步做得像加个注解一样轻松但轻松的表象下藏着一整条坑。逐个记住能异步别在同类里 self-invocation别忘EnableAsync和 public用对池永远自定义ThreadPoolTaskExecutor别碰默认的SimpleAsyncTaskExecutor知成败异常在方法内 try-catch 落地别指望它自然冒泡带上下文ThreadLocal类上下文靠参数传递或TaskDecorator复制扛得住有界队列 按语义选拒绝策略别丢任务也别拖垮主线程理解每个坑背后的机制——代理、线程池模型、ThreadLocal、拒绝策略——遇到新的异步问题时才能一眼看穿本质。