Spring Boot @Async 实现异步的最佳实践

2025年11月13日星期四·5535 字·28 分钟·
-浏览量
纯文本
学习文档Spring Boot异步编程线程池并发编程
AI 摘要- DeepSeek

从代理机制与默认执行器行为出发,整理 @Async 的适用边界、线程池显式配置、异常闭环、上下文传递、事务衔接与自调用失效六大实践,附端到端示例、方案对比与上线检查清单。

封面图
Spring Boot @Async 实现异步的最佳实践 - 封面图
提示

@Async 的注解只负责声明「我想异步」,实际行为完全由代理机制和执行器配置决定。本文按六个问题整理最佳实践:该不该用、在哪个线程池跑、异常谁兜底、上下文怎么带过去、和事务怎么衔接、哪些调用会静默失效。结论以 Spring Framework 6.x / Spring Boot 3.x 官方文档为准(Java 17+),版本差异单独标注,文末附方案对比与上线检查清单。

引子#

一个常见场景:订单支付成功后要做三件事,发短信、发优惠券、刷新报表统计。每件事平均耗时 200ms,串行执行让支付接口从 80ms 涨到 680ms。改动只需一行:方法上加 @Async。上线之后问题接连出现:有的接口响应时间没变、日志丢了 traceId、发版重启时队列里没执行完的任务直接消失、凌晨队列堆积把堆内存吃满触发 OOM。

这四类问题没有一个是 @Async 的 Bug,根因相同:注解只声明了意图,没有回答「在哪个线程池跑、失败了谁兜底、上下文还在不在、任务丢了能不能接受」。本文把这些问题的答案整理成一套可执行的实践。

核心摘要#

  • 适用边界:@Async 是进程内的异步工具,任务不落盘、不可恢复,停机即丢。允许丢失的副动作才用它,不允许丢失的任务走 MQ 或任务表。
  • 线程池:Spring Boot 默认执行器为核心 8 线程加无界队列,队列堆积以堆内存为上限,maxPoolSize 永远不会触发。业务代码必须显式定义 ThreadPoolTaskExecutor,设置有界队列和显式拒绝策略。
  • 异常:void 方法的异常默认仅打一行日志,必须配置 AsyncUncaughtExceptionHandler;CompletableFuture 方法的异常藏在返回值里,必须显式消费。声明非 Future 返回类型的方法,代理直接返回 null。
  • 上下文:MDC、SecurityContext、RequestContext 都基于 ThreadLocal,跨线程必丢,用 TaskDecorator 复制并在 finally 中清理。
  • 事务:事务上下文不跨线程。调用方事务提交前触发的异步任务读到旧数据,事务后置动作用 AFTER_COMMIT 事件衔接。
  • 失效场景:未加 @EnableAsync、同类内部调用、非 public 方法、生命周期回调中调用,这四种情况 @Async 静默失效,无任何报错。

一、先判断该不该用#

@Async 解决的问题只有一个:把「提交任务」和「执行任务」解耦,让调用线程不等结果。判断标准是两个问题:任务丢了有没有业务后果,需不需要拿到结果。

方案定位任务可恢复性与事务的衔接典型场景
@Async进程内异步执行停机或崩溃即丢需 AFTER_COMMIT 手动衔接发通知、刷缓存、记审计日志
CompletableFuture + 自定义线程池进程内并行编排,需要结果停机或崩溃即丢同上接口内聚合多个下游查询
@TransactionalEventListener进程内领域事件停机或崩溃即丢天然 AFTER_COMMIT领域事件驱动的进程内副动作
MQ(RocketMQ / Kafka)跨进程可靠投递持久化,可重试事务消息或本地消息表不允许丢失、需要削峰与重试的任务

结论:@Async 适合「丢了可以接受、失败可以补偿」的副动作。短信、积分、报表这类有资金或履约含义的任务,直接上 MQ。进程内方案的一切优雅,都以「任务可以丢」为前提。

二、背景:@Async 的执行机制#

2.1 代理与执行链#

@Async 由 AOP 代理实现。调用方拿到的是代理对象,代理把方法体包装成任务提交给执行器,然后立即返回。真正的业务逻辑在工作线程上执行:

工作线程noticeExecutorAOP 代理调用线程(Tomcat)工作线程noticeExecutorAOP 代理调用线程(Tomcat)该线程上没有 MDC、SecurityContext、事务void 方法异常走 AsyncUncaughtExceptionHandlerFuture 方法异常存入返回值noticeService.sendPaidNotice(orderId)submit(任务)立即返回 void 或 CompletableFuture从队列取出任务执行业务逻辑
工作线程noticeExecutorAOP 代理调用线程(Tomcat)工作线程noticeExecutorAOP 代理调用线程(Tomcat)该线程上没有 MDC、SecurityContext、事务void 方法异常走 AsyncUncaughtExceptionHandlerFuture 方法异常存入返回值noticeService.sendPaidNotice(orderId)submit(任务)立即返回 void 或 CompletableFuture从队列取出任务执行业务逻辑

这张图标注了后文所有问题的来源:工作线程是池里复用的裸线程,调用线程上的一切线程绑定资源都不会跟过去。

2.2 启用前提:@EnableAsync#

Spring Boot 自动配置了 applicationTaskExecutor 这个 Bean,但不会自动开启 @Async 注解处理。配置类上没有 @EnableAsync 时,所有 @Async 方法按普通同步方法执行,无任何报错。这是「加了注解没生效」的第一嫌疑。

@Configuration
@EnableAsync
public class AsyncConfig {
}

2.3 默认执行器的解析链#

方法上没有指定执行器时,Spring 按以下顺序解析,命中即停:

  1. @Async("beanName") 显式指定的执行器 Bean(名字或 qualifier);
  2. AsyncConfigurer.getAsyncExecutor() 返回的执行器;
  3. 容器中唯一的 TaskExecutor Bean,若有多个则找名为 taskExecutor 的;
  4. Spring Boot 自动配置的 applicationTaskExecutor;
  5. 都没有时,new 一个 SimpleAsyncTaskExecutor 兜底:每个任务新建一个线程,仅启动时打印一条 warn 日志。

这条链的第 5 级在生产环境等于无界并发。常见触发方式:项目里定义了多个执行器 Bean,既没有命名为 taskExecutor,方法上也没写限定符,此时 Spring 找不到「唯一」执行器,静默落到 SimpleAsyncTaskExecutor。表现是线程名不带池前缀、jstack 里线程数随流量无上限增长。

2.4 默认配置为什么不能用#

Spring Boot 自动配置的 applicationTaskExecutor 是 ThreadPoolTaskExecutor,默认值(来源:Spring Boot 官方文档 Task Execution 章节):

参数默认值后果
corePoolSize8常驻 8 个工作线程
queueCapacityInteger.MAX_VALUE队列无界
maxPoolSizeInteger.MAX_VALUE形同虚设
keepAlive60s非核心线程空闲 60s 回收
threadNamePrefixtask-多业务共用时日志无法区分来源

关键在队列无界这一条。ThreadPoolExecutor 只有在队列装满之后才会扩容线程到 maxPoolSize,无界队列意味着这个条件永远不成立,容量上限就是堆内存。

按 5000 次/s 峰值通知、单任务消费能力 1000 次/s 估算:队列每秒净增 4000 个任务对象,每个任务连同步方法捕获的对象引用按 2KB 保守计算,每小时净增约 28GB 引用数据,远早于任何告警到达堆上限。结果是 OOM,且队列里所有未执行任务一并丢失。

spring.task.execution.* 可以调整这个默认池,但它仍是全站共享的单池。业务系统的惯例是:不调整默认池,为业务显式定义执行器,让默认池只兜底框架自身行为。

三、实践一:显式定义执行器,按业务分池#

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
public static final String NOTICE_EXECUTOR = "noticeExecutor";
public static final String REPORT_EXECUTOR = "reportExecutor";
@Bean(NOTICE_EXECUTOR)
public ThreadPoolTaskExecutor noticeExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// IO 密集型:起步按 线程数 = 核数 * (1 + 等待时间/计算时间) 估算,以压测为准
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
// 有界队列是底线,容量 = 预期可容忍的积压量
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
// 线程名前缀用于日志与 jstack 定位,按业务命名
executor.setThreadNamePrefix("notice-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 优雅停机:等待队列中任务执行完再关池
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.setTaskDecorator(new ContextCopyTaskDecorator());
executor.initialize();
return executor;
}
@Bean(REPORT_EXECUTOR)
public ThreadPoolTaskExecutor reportExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// CPU 密集型:核数 + 1 起步
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors() + 1);
executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() + 1);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("report-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.setTaskDecorator(new ContextCopyTaskDecorator());
executor.initialize();
return executor;
}
/** 未指定执行器的 @Async 方法的默认池 */
@Override
public Executor getAsyncExecutor() {
return noticeExecutor();
}
/** void 方法未捕获异常的统一兜底 */
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
LoggerFactory.getLogger("ASYNC_UNCAUGHT")
.error("async method failed, method={}, params={}",
method.getDeclaringClass().getSimpleName() + "#" + method.getName(),
Arrays.toString(params), ex);
}
}

三个要点:

  1. 分池隔离。通知类慢任务和报表类 CPU 任务用不同的池,一个池被打满不拖垮另一个业务。所有 @Async 方法显式写 @Async("noticeExecutor") 这样的限定符,不依赖解析链的隐式行为。
  2. 池大小不拍脑袋。CPU 密集取核数 + 1,IO 密集按等待/计算比放大,最终以压测为准,并受下游容量约束(数据库连接池、下游接口限流)。估算公式与监控闭环在本站《Java 线程池配置指南》中有完整展开,本文不重复。
  3. 优雅停机。setWaitForTasksToCompleteOnShutdown(true) 让关闭时先执行完队列中任务,setAwaitTerminationSeconds(30) 给等待加上限,防止个别任务卡住阻塞整个停机流程。

拒绝策略怎么选#

队列满且线程达到 maxPoolSize 时触发拒绝策略,这是池「表达饱和」的唯一方式,必须显式选择而不是接受默认:

策略行为适用场景代价
AbortPolicy(JDK 默认)抛 TaskRejectedException调用方能感知失败并补偿从 HTTP 线程调用时接口直接 500
CallerRunsPolicy调用线程自己执行任务通知类「宁慢勿丢」异步退化为同步,接口 P99 随饱和劣化
DiscardPolicy / DiscardOldest静默丢弃仅限可丢且可对账的任务故障无感知,排查困难
自定义 Handler记日志、打点、降级生产环境推荐需要少量自研代码

CallerRunsPolicy 有一个必须知道的副作用:饱和时 @Async 调用会在调用线程同步执行,相当于自动降级。对背压这是好事,但意味着队列饱和期间接口响应时间会跟着任务时长走,容量规划时要按「maxPoolSize + queueCapacity」整体计算承接能力。

四、实践二:异常必须闭环#

@Async 方法有三种返回形态,异常行为完全不同:

// 形态一:void。异常不会传给调用方,默认行为是打一行日志后丢弃。
// 必须通过 AsyncConfig 里的 AsyncUncaughtExceptionHandler 兜底。
@Async("noticeExecutor")
public void sendPaidNotice(Long orderId) {
smsClient.send(orderService.loadPhone(orderId), "支付成功");
}
// 形态二:CompletableFuture。异常被捕获进返回值,调用方不消费就等于丢失。
@Async("reportExecutor")
public CompletableFuture<OrderStats> loadStats(Long orderId) {
return CompletableFuture.completedFuture(reportService.stats(orderId));
}
// 形态三:非 Future 返回类型。代理立即返回 null,方法体在后台照常执行。
// 编译器不报错,运行期不报错,是最隐蔽的一类错误。
@Async("reportExecutor")
public OrderStats loadStatsWrong(Long orderId) { // 调用方拿到 null
return reportService.stats(orderId);
}

官方文档对规则的定义(Spring Framework 文档 Asynchronous Methods 章节):返回值方法必须声明 Future 类型,void 方法的异常无法传递,需要注册 AsyncUncaughtExceptionHandler 处理。对应到工程动作:

  1. void 方法:在 AsyncConfigurer 中统一配置 handler(见上文 AsyncConfig),按方法名和参数记录错误日志并告警。依赖默认行为等于依赖「有人恰好看了日志」。
  2. CompletableFuture 方法:调用链上必须有人消费异常,exceptionally、handle 或 whenComplete 三选一:
noticeService.loadStats(orderId)
.whenComplete((stats, ex) -> {
if (ex != null) {
log.error("load stats failed, orderId={}", orderId, ex);
}
});
  1. 返回值写法约束:团队规约中直接禁止第三种形态。方法要么 void,要么 CompletableFuture,其余写法视为 Bug。

五、实践三:上下文传递用 TaskDecorator#

MDC(traceId)、SecurityContext(登录用户)、RequestContextHolder(请求属性)都基于 ThreadLocal。图 2.1 里已经标注:工作线程上这些全部为空。表现为异步日志没有 traceId、异步任务里拿不到当前用户、异步代码里调用 RequestContextHolder 直接抛异常。

解法是 TaskDecorator:提交任务时在调用线程捕获上下文副本,包装任务在工作线程恢复,执行完清理。清理这一步不能省,池线程长期复用,残留上下文会污染下一个任务:

public class ContextCopyTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 在调用线程捕获快照
Map<String, String> mdc = MDC.getCopyOfContextMap();
SecurityContext securityContext = SecurityContextHolder.getContext();
return () -> {
try {
if (mdc != null) {
MDC.setContextMap(mdc);
}
if (securityContext != null) {
SecurityContextHolder.setContext(securityContext);
}
runnable.run();
} finally {
// 池线程复用,必须清理,否则上下文残留污染下一个任务
MDC.clear();
SecurityContextHolder.clearContext();
}
};
}
}

两个补充:

  1. 把 SecurityContext 带进异步线程意味着任务以用户身份执行,权限校验和审计要意识到这一点。只想要 traceId 的场景,只复制 MDC 就够。
  2. Spring Security 也提供了现成包装 DelegatingSecurityContextAsyncTaskExecutor,把执行器包一层即可传递安全上下文,适合不想要自定义 Decorator 的项目。

六、实践四:与事务的边界#

事务绑定在数据库连接上,连接绑定在线程上,这三者都不跨线程。由此产生三条规则。

规则一:异步方法没有调用方的事务。 @Async 方法在工作线程上执行时没有任何事务;它自己声明的 @Transactional 会开启一个新事务。不能指望异步代码「加入」调用方的事务。

规则二:调用方提交前触发异步任务,存在脏读竞态。 以 MySQL InnoDB 为例,未提交的修改对其他事务不可见:

异步线程MySQL业务线程(事务中)异步线程MySQL业务线程(事务中)基于旧数据执行,问题静默发生UPDATE order SET status = PAID(未提交)提交异步任务SELECT 订单状态旧状态(对方事务未提交)COMMIT
异步线程MySQL业务线程(事务中)异步线程MySQL业务线程(事务中)基于旧数据执行,问题静默发生UPDATE order SET status = PAID(未提交)提交异步任务SELECT 订单状态旧状态(对方事务未提交)COMMIT

正确做法是把「事务内」和「事务提交后」分开:事务内只发布事件,事务提交后由监听器触发异步动作。Spring 事件机制原生支持这个时序:

// 事务内:只发事件,不做任何异步动作
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
@Transactional
public void paySuccess(Long orderId) {
orderMapper.updateStatusPaid(orderId);
publisher.publishEvent(new OrderPaidEvent(orderId));
}
}
// 事务提交后触发;监听器方法内的 @Async 调用把执行挪到工作线程
@Component
public class OrderPaidListener {
private final NoticeService noticeService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPaid(OrderPaidEvent event) {
noticeService.sendPaidNotice(event.orderId());
}
}

规则三:跨线程只传 ID,不传实体。 传实体有两个坑:实体可能引用未提交的数据;延迟加载的关联属性在工作线程上访问时抛 LazyInitializationException,因为打开它的数据库会话留在调用线程。传 orderId 这类标量,让异步方法自己查一遍,代码边界也干净。

一个容易漏掉的细节:AFTER_COMMIT 阶段原事务已经提交,监听器内如果直接写库且事务传播级别是 REQUIRED,写操作会挂到已完成的资源上,改动不会真正提交。异步方法在新线程上执行,天然拿不到原事务资源,@Transactional 会正常开新事务,所以「监听器只做转发、写库动作放进 @Async 方法」的分工正好绕开这个坑。

七、实践五:让失效场景显形#

@Async 的失效全部是静默的:方法照常执行,只是变回同步。汇总成一张表:

失效场景表现修复方式
配置类未加 @EnableAsync所有 @Async 同步执行加注解,启动时验证线程名前缀
同类内部调用 this.sendNotice()该调用同步执行拆分到独立 Bean,或自注入代理
方法不是 public该方法同步执行改为 public
对象由 new 创建而非容器管理该方法同步执行交给 Spring 管理,注入使用
@PostConstruct 中调用 @Async 方法官方明确不支持用独立的初始化 Bean 触发

自调用失效的根因:@Async 靠代理拦截,this.method() 绕过代理直接调到目标对象。修复方案对比:

方案做法评价
拆分到独立 Bean(推荐)把异步方法挪到 NoticeService 之类的专职 Bean,注入后调用结构最清晰,顺带解决职责问题
自注入代理ObjectProvider<OrderService> 注入自身,调用 provider.getObject().sendNotice()可行,但结构别扭,仅用于不便拆分的历史代码
AopContext.currentProxy()开启 exposeProxy = true 后强转获取代理代码侵入性强,Spring 官方不推荐
AspectJ 织入模式编译期或加载期织入,能拦截内部调用能根治,但要在构建期引入 AspectJ 编译插件并维护织入配置,一般不值得

一个低成本的自查手段:开发环境把异步方法首行日志带上线程名,验证输出的是池前缀(如 notice-1)还是调用线程名(如 http-nio-8080-exec-3),后者即失效。这类问题在压测或日预案演练中最容易暴露:表现是「明明异步了,接口却没变快」。

八、实战演示:订单支付后的异步通知#

把前文实践组装成完整链路。事件定义、配置、监听器、异步服务:

/** 领域事件:只携带 ID 等标量 */
public record OrderPaidEvent(Long orderId) {
}
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
public static final String NOTICE_EXECUTOR = "noticeExecutor";
@Bean(NOTICE_EXECUTOR)
public ThreadPoolTaskExecutor noticeExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("notice-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.setTaskDecorator(new ContextCopyTaskDecorator());
executor.initialize();
return executor;
}
@Override
public Executor getAsyncExecutor() {
return noticeExecutor();
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
LoggerFactory.getLogger("ASYNC_UNCAUGHT")
.error("async failed, method={}, params={}", method.getName(),
Arrays.toString(params), ex);
}
}
@Service
public class NoticeService {
private static final Logger log = LoggerFactory.getLogger(NoticeService.class);
private final OrderMapper orderMapper;
private final SmsClient smsClient;
public NoticeService(OrderMapper orderMapper, SmsClient smsClient) {
this.orderMapper = orderMapper;
this.smsClient = smsClient;
}
/** 新线程上执行,@Transactional 开新事务,读写都基于已提交的数据 */
@Async(AsyncConfig.NOTICE_EXECUTOR)
@Transactional
public void sendPaidNotice(Long orderId) {
Order order = orderMapper.selectById(orderId);
if (order == null) {
log.warn("order not found, skip notice, orderId={}", orderId);
return;
}
smsClient.send(order.getPhone(), "订单 " + order.getOrderNo() + " 支付成功");
}
}
@Component
public class OrderPaidListener {
private static final Logger log = LoggerFactory.getLogger(OrderPaidListener.class);
private final NoticeService noticeService;
public OrderPaidListener(NoticeService noticeService) {
this.noticeService = noticeService;
}
/** 事务提交后才触发;本方法只转发,不写库 */
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPaid(OrderPaidEvent event) {
try {
noticeService.sendPaidNotice(event.orderId());
} catch (Exception ex) {
// AFTER_COMMIT 阶段原事务已提交,这里的失败只能靠日志与补偿
log.error("dispatch paid notice failed, orderId={}", event.orderId(), ex);
}
}
}

验证清单:日志里 sendPaidNotice 的线程名是 notice-N,traceId 与调用方一致(TaskDecorator 生效);停服发版时启动日志出现执行器关闭前的等待动作(优雅停机生效);把队列容量临时调成 1 并压测,可观察 CallerRunsPolicy 的同步退化行为。

九、踩坑点与注意事项#

坑 1:TaskRejectedException 打到用户脸上#

AbortPolicy 下,队列满且线程到顶时抛 TaskRejectedException。如果调用方是 HTTP 线程,用户收到 500。容量规划按 maxPoolSize + queueCapacity 计算,超量部分要么被 CallerRunsPolicy 背压吸收,要么走降级路径,不要把默认 AbortPolicy 暴露给用户请求路径。

坑 2:同池嵌套阻塞导致死锁#

@Async("reportExecutor")
public CompletableFuture<Report> buildReport() {
// 危险:阻塞等待的 detailTask 也路由到 reportExecutor
return detailTask().get(); // 池满时两败俱伤
}

外层任务占满池线程后阻塞等待内层任务,内层任务排在队列里永远轮不到执行,池整体死锁。规则:池内任务不得阻塞等待路由到同一个池的任务。要么用 thenCompose 做非阻塞编排,要么内外层分池。

坑 3:发版重启丢任务#

@Async 的队列在内存里。发版、扩容、崩溃都会丢掉队列中未执行的任务。上线检查清单里「不允许丢失的任务已改走 MQ 或任务表」针对的就是这个问题。@Async 的正确预期是:丢了可以接受,或者丢了可以靠对账兜住。

坑 4:开启虚拟线程后行为变化#

Java 21 + spring.threads.virtual.enabled=true 时,applicationTaskExecutor 自动变为基于虚拟线程的 SimpleAsyncTaskExecutor(来源:Spring Boot 官方文档)。行为差异有三点:每个任务新建一个虚拟线程,没有池化和队列,天然无背压;上文所有关于有界队列和拒绝策略的配置对它不生效,并发上限需要用 spring.task.execution.simple.concurrency-limit 单独设置;上下文传递依旧需要 TaskDecorator(Spring 6.1 起 SimpleAsyncTaskExecutor 同样支持)。另外 Spring Boot 3.5 引入了 spring.task.execution.mode=force,把自动配置执行器的使用范围扩大到更多集成点,升级时注意行为变化。虚拟线程本身的适用边界与编排方式,见本站《虚拟线程与异步编排》。

坑 5:只配不监控#

Spring Boot 会为容器中的 ThreadPoolTaskExecutor Bean 自动注册 Micrometer 指标(executor.queued、executor.active、executor.pool.size、executor.completed 等)。重点告警项是 executor.queued 持续增长:它是积压的直接信号,通常比 OOM 早几个小时出现。线程名前缀在这里也发挥作用,出问题时按前缀从 jstack 输出里快速圈定线程栈。

十、上线检查清单#

  • 配置类已加 @EnableAsync
  • 每个业务 @Async 方法显式指定了具名执行器
  • 执行器使用有界队列,容量按「maxPoolSize + queueCapacity」做过容量规划
  • 拒绝策略显式选择,且饱和路径有日志或指标
  • AsyncUncaughtExceptionHandler 已配置,void 方法异常可告警
  • 所有 CompletableFuture 调用链有 exceptionally / whenComplete 消费异常
  • 无非 Future 返回类型的 @Async 方法(用 ArchUnit 或 Code Review 规约固化)
  • TaskDecorator 复制 MDC 与 SecurityContext,且 finally 中清理
  • 事务后置动作走 AFTER_COMMIT 事件,跨线程只传 ID
  • setWaitForTasksToCompleteOnShutdown 与 setAwaitTerminationSeconds 已设置
  • executor.queued 已接入监控并配置告警阈值
  • 不允许丢失的任务已改走 MQ 或任务表,未停留在 @Async

总结#

@Async 的价值是把「提交任务」和「执行任务」解耦,代价是把线程池治理、异常闭环、上下文一致性、任务持久性四个问题交给开发者,而注解本身对这四个问题只字未提。默认配置(8 核心线程加无界队列)够跑 Demo,够不着生产。判断一个项目的 @Async 用得对不对,看三处就够:执行器是否显式且分池,void 异常是否有统一兜底,不丢任务的需求是否已经移出了进程内方案。@Async 和 MQ 的分工边界是本文最重要的判断:前者处理「丢了无所谓」,后者处理「丢了有后果」,用错位置的修复成本远高于一开始就选对。

参考资料#


提示

如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。

Spring Boot @Async 实现异步的最佳实践
https://tblog.mmzhiku.xyz/posts/details-springboot-async-best-practices/
作者
MmzMing
发布于
2025-11-13
许可协议
CC BY-NC-SA 4.0
主页
Oracle ERP 性能优化:SHRINK 致聚簇因子恶化的排查与恢复

评论区

暂无歌词

暂未播放

0:00
0:00
目录
logo

隐私政策

更新日期: 2026 年 7 月 15 日
生效日期: 2026 年 7 月 15 日

适用范围#

本政策适用于 MmzMing 的博客(以下简称“本站”)。本站是个人博客,用于发布和分享内容;不提供账户注册、支付、定位或广告投放服务。访问本站、发表文章评论或在留言板留言前,请阅读本政策。

信息收集与使用#

本站只在提供内容、评论和留言功能,以及维护站点安全所需的范围内处理信息。

  • 访问与统计信息:访问页面时,统计服务可能处理访问时间、页面地址、来源页、浏览器和设备相关信息,用于了解内容访问情况、排查故障和改进站点。
  • 评论信息:使用文章评论功能时,Waline 可能处理您主动提交的昵称、邮箱、站点链接和评论内容;还可能处理 IP 地址等必要信息,用于防止垃圾评论、滥用和维护服务安全。评论内容、昵称和站点链接(如填写)可能公开展示在文章下方;邮箱不会公开展示。
  • 留言信息:留言板使用 Waline /guestbook/ 频道。Waline 可能处理您主动提交的昵称、可选邮箱、站点链接、留言内容和图片,以及浏览器、操作系统、IP 地址等必要的反滥用信息。默认情况下,留言图片以内嵌数据随留言提交;如站点维护者配置了远程图片上传接口,图片会先发送至该接口并在留言中保存返回的图片地址。留言内容、昵称、图片和站点链接(如填写)可能公开展示;邮箱和 IP 地址不会在留言板公开展示。

请不要在评论或留言中提交身份证件、银行卡、住址、密码或其他不必要的敏感个人信息。

第三方服务#

为实现本站功能,以下第三方会在各自服务范围内处理相关数据:

  • Umami:用于匿名化的网站访问统计和出站链接点击统计,帮助我了解本站的使用情况。
  • Waline:用于文章评论、留言板及访问量统计。服务会按照其自身规则处理您在评论或留言时提交的信息及必要的反滥用信息。
  • Cloudflare:可为本站提供静态资源分发和域名基础设施。
  • unpkg:用于加载 Waline 的前端脚本、样式和表情资源;请求这些资源时,您的浏览器会与该服务建立连接。

第三方服务可能有独立的隐私政策和数据保存规则。请在使用相关功能前查阅其规则;本站无法控制其独立的数据处理活动。

本站主要使用浏览器本地存储(Local Storage 或 Session Storage)保存使用偏好,例如主题颜色、明暗模式、文章列表视图和音乐播放设置。留言板会在本地保存匿名资料、未发送草稿和登录状态,以便恢复输入与会话;管理员登录状态仅保存在当前会话。

本站不主动设置用于广告定向的第一方 Cookie。评论、统计或资源服务可能按照其自身规则使用 Cookie 或类似技术。您可以通过浏览器设置查看、删除或限制 Cookie 和本地存储;清除后,部分偏好或互动状态可能会恢复为默认值,评论功能也可能受到影响。

信息公开、保存与安全#

评论和留言属于公开互动内容,提交后可能被搜索引擎收录、被他人引用或在缓存中短暂保留。请谨慎决定发布内容。除非您提出删除请求、内容违反规则或法律法规另有要求,公开内容会持续保留以维持讨论上下文。

本站会采取合理措施保护数据安全,包括使用 HTTPS、输入校验、内容转义和访问频率限制。但互联网传输和第三方服务均无法保证绝对安全,请理解并自行承担公开发布信息的相应风险。

你的权利#

你可以通过 784774835@qq.com 联系我,申请查询、更正或删除由本站直接保存的评论、留言或相关公开内容。为保护他人权益,请在请求中提供足以定位内容的信息,并说明你与该内容的关系;必要时可能需要进行合理核验。

对于由 Waline、Umami、Cloudflare 或 unpkg 独立处理的数据,你也可以直接向对应服务提供方行使相关权利。删除公开评论或留言后,第三方缓存、搜索引擎索引或他人转载的副本可能无法立即同步删除。

未成年人条款#

未满 14 周岁的未成年人应在监护人同意和指导下使用本站的评论、留言等互动功能。监护人如发现未成年人未经同意提交了个人信息,可通过上述联系方式与我联系,我会在合理范围内协助处理。

政策更新与联系#

我可能因本站功能或适用规则变化更新本政策,更新后的版本将在本站公布并标明日期。继续使用相关功能即表示你已阅读并理解更新后的政策。

如对本政策或数据处理有疑问,请联系 784774835@qq.com。

用户协议

更新日期: 2026 年 5 月 19 日
生效日期: 2026 年 5 月 19 日

适用范围#

本协议适用于你访问 MmzMing 的博客,以及使用文章评论、留言板等互动功能的行为。继续浏览本站或提交评论、留言,即表示你已阅读、理解并同意遵守本协议及本站的隐私政策。

评论及留言规则#

请在交流中保持友善、理性和尊重。你不得利用本站发布、传播或实施以下行为:

  • 发布任何违反中华人民共和国法律法规的内容。
  • 发布任何侵犯他人合法权益的内容,包括但不限于隐私、名誉、肖像、著作权、商标权和其他知识产权。
  • 恶意攻击、辱骂、骚扰、威胁、歧视其他用户或任何第三方。
  • 发布垃圾广告、恶意推广、刷屏、灌水,或与讨论主题明显无关的重复内容。
  • 利用本站进行网络诈骗、钓鱼、传播恶意软件,或发布可能危害网络和信息安全的内容。
  • 绕越或试图绕越本站的审核、限流、封禁等管理措施。
  • 冒充他人、伪造身份,或收集、公开他人的个人信息。

内容与访问管理#

你应对自己发布的评论和留言负责,并保证拥有发布该等内容所需的合法权利。论坛管理员有权在不另行通知的情况下删除违规内容、限制或封禁违规账号,或限制其继续使用本站互动功能。

如发现涉嫌违法犯罪、严重侵权或危及本站安全的内容,本站可保留相关记录,并在法律法规要求或必要时向有关部门提供协助。对管理措施有疑问时,可通过文末联系方式说明情况;本站会结合实际情况处理,但不承诺恢复已删除内容或访问权限。

知识产权与内容授权#

本站原创文章、页面设计和其他受保护内容的权利归作者或权利人所有。未经授权,请勿复制、转载、镜像或用于商业用途;法律法规允许的合理使用除外。

你发布评论或留言时,授予本站为展示、存储、备份、审核、删除和维护互动功能所必需的非独占、免费的使用许可。该许可不改变你对原创内容依法享有的权利。

免责声明#

本站内容仅用于个人记录、学习交流和一般信息参考,不构成任何专业意见、承诺或担保。你应结合自身情况独立判断,并对据此采取的行动负责。

评论、留言和外部链接中的内容由其发布者或运营者负责,不代表本站立场。本站会在合理范围内处理明显违规内容,但不保证所有内容均及时发现,也不对第三方网站的可用性、内容、安全性或隐私实践承担责任。

因网络故障、不可抗力、第三方服务异常、维护升级或超出合理控制范围的原因导致本站暂时无法访问、内容延迟或数据丢失的,本站会尽力恢复,但不承担由此产生的间接损失。

未成年人条款#

未满 14 周岁的未成年人应在监护人同意和指导下使用评论、留言等互动功能。监护人应协助未成年人理解本协议,并对其使用行为进行必要的引导。

其他条款#

我可以根据本站功能、管理需要或法律法规变化更新本协议,更新后的版本将在本站公布并标明日期。继续使用本站即视为接受更新后的协议。

本协议的订立、执行和解释适用中华人民共和国法律。因本协议或使用本站产生争议时,双方应先友好协商;协商不成的,依法向有管辖权的人民法院解决。

如对本协议或内容管理有疑问,请联系 784774835@qq.com。

复制成功,转载请标注本文地址