java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Spring注解处理异步

Spring中处理异步的4大注解详解

作者:程序员代码随笔

本文将用订单接口进化过程,带你掌握Spring中@Async、@Scheduled、@EventListener和@TransactionalEventListener四大异步注解,从异步化、定时任务、事件解耦到事务后通知,组合出一套完整的异步体系,并附生产配置与避坑指南

从一个订单接口说起

假设你在做一个电商项目,有一个创建订单的接口。业务逻辑很直白:保存订单、扣库存、发通知短信。

@RestController
@RequestMapping("/api")
public class OrderController {

    @PostMapping("/orders")
    public Map<String, Object> createOrder(@RequestBody CreateOrderRequest req) {
        // 1. 保存订单
        orderService.save(req);
        // 2. 扣库存
        inventoryService.deduct(req);
        // 3. 发通知短信
        smsService.sendNotification(req.getPhone(), "订单已创建");
        
        return Map.of("code", 200, "msg", "下单成功");
    }
}

功能没毛病,但测试说接口响应太慢——发短信这一步要调第三方 API,平均耗时 2 秒,整个接口被拖慢了。

这就是本文的起点。我们用一个订单接口的进化过程,把 Spring 里跟"异步"相关的 4 个注解串起来讲清楚。

第一招:@Async— 把慢活扔到后台

没它之前

发短信是同步阻塞的。Tomcat 的工作线程在等短信 API 返回的这 2 秒里什么都干不了——如果同时来了 100 个请求,线程池很快就被打满了。

怎么用

两步。

第一步,在配置类或启动类上加 @EnableAsync

@SpringBootApplication
@EnableAsync
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

第二步,在需要异步执行的方法上加 @Async

@Service
public class SmsService {

    @Async
    public void sendNotification(String phone, String content) {
        System.out.println("[SmsService] 线程: " + Thread.currentThread().getName() 
                + " | 开始发送短信...");
        // 模拟调用第三方短信 API(耗时 2 秒)
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
        System.out.println("[SmsService] 短信发送完成");
    }
}

Controller 里正常调用就好,不需要改:

@PostMapping("/orders")
public Map<String, Object> createOrder(@RequestBody CreateOrderRequest req) {
    orderService.save(req);
    inventoryService.deduct(req);
    smsService.sendNotification(req.getPhone(), "订单已创建");  // 异步执行了
    return Map.of("code", 200, "msg", "下单成功");
}

控制台输出会变成这样:

[Controller] 订单创建完成,接口返回(几乎立即)
[SmsService] 线程: task-1 | 开始发送短信...   ← 在另一个线程里跑
[SmsService] 短信发送完成                      ← 2 秒后

接口秒回,短信在后台慢慢发。

你需要知道的

@Async 默认每次调用都创建新线程,生产环境一定要配线程池,否则线程会越开越多:

@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Bean("taskExecutor")
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(
                new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

然后指定线程池名:

@Async("taskExecutor")
public void sendNotification(String phone, String content) { ... }

@Async 方法不能在本类的另一个方法里直接调用——因为 Spring 的 AOP 代理只能拦截从外部调用的方法。如果 sendNotification 和调用它的方法在同一个类里,@Async 不会生效。这是新手最常踩的坑。

返回值:如果方法需要返回结果,用 Future<T>CompletableFuture<T>

@Async
public CompletableFuture<String> sendWithResult(String phone, String content) {
    // ...
    return CompletableFuture.completedFuture("发送成功");
}

重要:返回 void@Async 方法,内部异常会被 Spring 静默吞掉——只打一行日志,不会抛给调用方,你甚至感知不到短信没发出去。生产环境务必配置 AsyncUncaughtExceptionHandler,把异常打到监控系统:

@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (Throwable ex, Method method, Object... params) -> {
            // 接入监控告警,不要只打日志
            log.error("[Async异常] 方法: {} | 参数: {}", method.getName(), params, ex);
        };
    }
}

第二招:@Scheduled— 到点了就干

什么场景

短信通知是实时发的,没问题。但有些业务不需要实时——比如"每天晚上 10 点统计今天所有订单金额,汇总成日报发送"。

怎么用

两步。第一步加 @EnableScheduling,第二步在方法上加 @Scheduled

@SpringBootApplication
@EnableAsync
@EnableScheduling
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
@Component
public class OrderReportJob {

    @Scheduled(cron = "0 0 22 * * ?")  // 每天 22:00 执行
    public void generateDailyReport() {
        System.out.println("[定时任务] 线程: " + Thread.currentThread().getName()
                + " | 开始生成日报...");
        // 查今天所有订单,汇总金额,生成报表
        System.out.println("[定时任务] 日报生成完成");
    }
}

三种时间配置

方式示例含义
cron"0 0 22 * * ?"每天 22:00
fixedRatefixedRate = 60000上次开始后 60 秒再执行
fixedDelayfixedDelay = 60000上次结束后等 60 秒再执行

fixedRatefixedDelay 的区别很关键——

// fixedRate:不管上一次有没有结束,到点就触发
@Scheduled(fixedRate = 5000)
public void task1() {
    // 如果这个方法跑了 10 秒,那下一次触发时间(第 5 秒)会直接跳过,
    // 等这次跑完(第 10 秒)立刻触发下一次
}

// fixedDelay:等上一次结束了才开始计时
@Scheduled(fixedDelay = 5000)
public void task2() {
    // 如果这个方法跑了 10 秒,那下一次触发时间是 10 + 5 = 15 秒
}

大多数场景用 fixedDelay 更安全——不会因为任务积压导致数据错乱。

你需要知道的

定时任务默认也是同步阻塞的——同一个 @Scheduled 方法不会并发执行。如果上一个任务没跑完,下一个会被跳过或延迟。如果想让定时任务也异步,加上 @Async

@Async
@Scheduled(fixedDelay = 60000)
public void asyncJob() {
    // 这个任务在单独的线程里跑,不阻塞其他定时任务
}

 用了 @Async 后,Spring 默认的"同一个任务不并发执行"保护会失效——如果任务耗时超过定时间隔,会产生多个实例同时跑。对"日报生成"这类任务问题不大,但如果是"扣款对账"就得加分布式锁。

cron 表达式格式:秒 分 时 日 月 周(6 位,比 Linux 的 cron 多一位秒)。

第三招:@EventListener— 解耦,别硬调

前面的问题

到目前为止,Controller 还是直接依赖了 SmsService

smsService.sendNotification(req.getPhone(), "订单已创建");

每加一个新需求(发邮件、发站内信、写操作日志),都得在 Controller 里加一行调用。Controller 越来越胖,耦合越来越重。

怎么解耦

用事件机制。 订单创建完,只发一个事件,谁关心这个事件谁去处理——Controller 不需要知道 SmsService 的存在。

第一步,定义一个事件:

// 就是一个普通的 POJO,装着事件里需要的数据
public record OrderCreatedEvent(Long orderId, String phone, BigDecimal amount) {}

第二步,在 Service 里发布事件:

@Service
public class OrderService {

    private final ApplicationEventPublisher publisher;

    public OrderService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void create(CreateOrderRequest req) {
        // ... 保存订单逻辑 ...
        
        // 发布事件(这一步是同步的)
        publisher.publishEvent(new OrderCreatedEvent(
                savedOrder.getId(), req.getPhone(), req.getAmount()));
    }
}

第三步,各业务方监听事件,各管各的:

@Component
public class OrderEventListeners {

    @EventListener
    public void handleSms(OrderCreatedEvent event) {
        System.out.println("[短信] 线程: " + Thread.currentThread().getName()
                + " | 给 " + event.phone() + " 发短信");
    }

    @EventListener
    public void handleLog(OrderCreatedEvent event) {
        System.out.println("[日志] 订单 " + event.orderId() + " 创建完成,记录操作日志");
    }
}

现在 Controller 干净了:

@PostMapping("/orders")
public Map<String, Object> createOrder(@RequestBody CreateOrderRequest req) {
    orderService.create(req);  // 内部会发事件,其他事情不管
    return Map.of("code", 200, "msg", "下单成功");
}

但是——@EventListener 默认是同步的。publisher.publishEvent() 会阻塞到所有监听器执行完毕。如果短信监听器跑了 2 秒,整个事件发布就等了 2 秒。

一步解决:让监听器异步:

@Async
@EventListener
public void handleSms(OrderCreatedEvent event) {
    // 现在这个监听器在异步线程里跑了
}

加上 @EnableAsync,所有 @Async + @EventListener 的方法自动异步执行,互不阻塞。

但别急着用这套方案上线—— 如果你的 Service 方法有事务(@Transactional),@EventListener + @Async 有一个致命问题:事件在事务提交就被异步线程拿到了。如果事务最终回滚了,短信已经发出去了。下一招专门解决这个问题。

第四招:@TransactionalEventListener— 事务提交了再通知

前面又埋了一个坑

看看事件发布的时机:

@Transactional
public void create(CreateOrderRequest req) {
    orderRepository.save(order);       // INSERT 到数据库
    publisher.publishEvent(...);       // 发布事件
    inventoryRepository.deduct(...);   // UPDATE 库存
}

问题:事件在 deduct 之前就发布了。如果 deduct 抛了异常,事务回滚,订单没了——但短信监听器可能在另一个线程里已经把"订单创建成功"的短信发出去了。用户收到短信,点进 App 一看,没有订单。

怎么修

@EventListener 换成 @TransactionalEventListener

@Component
public class OrderEventListeners {

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleSms(OrderCreatedEvent event) {
        System.out.println("[短信] 事务已提交,给 " + event.phone() + " 发短信");
    }
}

这个注解只有当事务真正提交成功后才触发。 如果事务回滚了,监听器根本不会被调用——用户不会收到"幽灵短信"。

四种触发阶段

phase含义
AFTER_COMMIT(默认)事务提交执行 ✅ 最常用
AFTER_ROLLBACK事务回滚执行(比如记录失败日志)
AFTER_COMPLETION事务结束后执行(不管提交还是回滚)
BEFORE_COMMIT事务提交执行(几乎不用)

90% 的场景用默认的 AFTER_COMMIT 就够了。如果你需要在事务回滚后做清理工作(比如把扣减的缓存加回去),用 AFTER_ROLLBACK

加一个回滚补偿的例子:

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void compensateOnFailure(OrderCreatedEvent event) {
    System.out.println("[补偿] 事务回滚了,清理缓存中订单 " + event.orderId());
    // 把之前预扣的库存、优惠券等缓存数据回补
}

生产注意@TransactionalEventListener + @Async 的组合意味着事务已提交/回滚,监听器内部抛异常无法再影响事务。必须在监听器内部做 try-catch + 重试,或接入消息队列保证最终一致性。关键业务的补偿逻辑(如 AFTER_ROLLBACK)建议同步执行——异步补偿在应用崩溃时可能丢失。

一张图总结四个注解的关系

订单创建(@Transactional)
    │
    ├─→ 保存订单到 DB
    ├─→ 扣库存
    ├─→ publisher.publishEvent(OrderCreatedEvent)  ← 发布事件
    │       │
    │       ▼ 等待事务提交...
    │
    └─→ 事务提交 ✅
            │
            ├─→ @TransactionalEventListener(AFTER_COMMIT)
            │       ├─→ handleSms()     @Async → 发短信(异步)
            │       ├─→ handleEmail()   @Async → 发邮件(异步)
            │       └─→ handleLog()     同步 → 记日志
            │
            └─→ ─ ─ ─ 如果事务回滚了 ─ ─ ─
                    └─→ @TransactionalEventListener(AFTER_ROLLBACK)
                            └─→ 补偿清理
    
@Scheduled(cron = "0 0 22 * * ?")  ← 每天 22:00 独立触发
    └─→ 生成日报(与请求链路无关)

四个注解速查表

注解一句话什么时候选它关键坑点
@Async方法异步执行Controller 里任何"非核心路径但耗时的操作"需要配线程池;同类的内部调用不生效
@Scheduled按时间触发日报、对账、清理过期数据等定时任务默认同步,配 @Async 防止阻塞其他任务
@EventListener事件通知需要解耦的场景——"A 做完了通知 B/C/D"默认同步;配 @Async 才能异步
@TransactionalEventListener事务提交后通知事件处理依赖数据库的数据一定存在事务回滚了就不触发,所以补偿逻辑要用 AFTER_ROLLBACK

一个完整的订单接口进化版

把四个注解组合起来,最终的代码长这样:

// === Controller ===
@RestController
@RequestMapping("/api")
public class OrderController {

    @PostMapping("/orders")
    public Map<String, Object> createOrder(@RequestBody CreateOrderRequest req) {
        orderService.create(req);  // 只负责下单
        return Map.of("code", 200, "msg", "下单成功");
    }
}

// === Service ===
@Service
public class OrderService {

    private final ApplicationEventPublisher publisher;

    public OrderService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    @Transactional
    public void create(CreateOrderRequest req) {
        // ... 保存订单 + 扣库存 ...
        publisher.publishEvent(new OrderCreatedEvent(
                savedOrder.getId(), req.getPhone(), req.getAmount()));
    }
}

// === 监听器 ===
@Component
public class OrderEventListeners {

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleSms(OrderCreatedEvent event) {
        // 发短信——异步 + 事务提交后才执行
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void compensate(OrderCreatedEvent event) {
        // 补偿——事务回滚后才执行
    }
}

// === 定时任务 ===
@Component
public class OrderReportJob {

    @Async
    @Scheduled(cron = "0 0 22 * * ?")
    public void generateDailyReport() {
        // 每天晚上 10 点异步生成日报
    }
}

从最初的"一个 Controller 写到底、接口响应 2 秒",到现在的"Controller 只管下单、通知异步发、定时任务自动跑、事务回滚有补偿"——四个注解,各自分工,组合起来就是一套完整的异步体系。

两个最重要的配置别忘了

@SpringBootApplication
@EnableAsync              // ← 少了这个,@Async 不生效
@EnableScheduling         // ← 少了这个,@Scheduled 不生效
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

还有线程池 + 异常处理器:

@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Bean("taskExecutor")
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(
                new ThreadPoolExecutor.CallerRunsPolicy());  // 队列满了让调用线程自己跑
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (Throwable ex, Method method, Object... params) -> {
            // 接入监控告警,不要只打日志
            log.error("[Async异常] 方法: {} | 参数: {}", method.getName(), params, ex);
        };
    }
}

CallerRunsPolicy 不是最优解,但在大多数中小项目中是最安全的选择——队列满了至少不会丢任务。缺点是溢出时调用线程会被阻塞(比如 HTTP 线程突然等 2 秒),接口响应时间会飘。如果对 SLA 敏感,改用 AbortPolicy 抛异常,配合降级逻辑返回"系统繁忙"——也比用户卡着等强。

总结

  1. @Async 让慢操作异步化,记得配线程池
  2. @Scheduled 让定时任务自动化,配合 @Async 防止互相阻塞
  3. @EventListener 让业务解耦,想让监听器异步就加 @Async
  4. @TransactionalEventListener 在事务提交后才触发,避免"订单回滚了短信却发出去了"

这四个注解加在一起不到 200 行代码就能写一个 Demo,但很多人不知道它们可以这样组合用。 下次遇到"接口太慢""业务耦合太重""定时任务互相阻塞"的问题,不要上来就重构架构——先把这四个注解用起来。

完整源码

本文 Demo 的完整代码已上传到 Gitee:https://gitee.com/gcchech/articles-demo

进入 spring-async-annotations/ 目录:

mvn spring-boot:run
curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"phone":"13800138000","amount":99.90}'

实际运行验证

以下是本地 Spring Boot 3.2.7 + JDK 21 环境下的真实控制台输出(已去除 Spring 启动日志,保留业务输出)。

场景一:正常下单

[Controller] 线程: http-nio-8080-exec-1 | 收到创建订单请求: phone=13800138000, amount=99.90
[OrderService] 线程: http-nio-8080-exec-1 | 事务开始 → 保存订单
[OrderService] 订单已保存: Order{id=1, phone='13800138000', amount=99.90, status='CREATED'}
[OrderService] 发布 OrderCreatedEvent(事务尚未提交)
[OrderService] 事务即将提交
[异步-邮件] 线程: async-2 | 开始给 13800138000 发邮件...
[异步-短信] 线程: async-3 | 开始给 13800138000 发送短信...(事务已提交,数据安全)
[同步-日志] 线程: http-nio-8080-exec-1 | 订单 1 创建完成,记录操作日志
[Controller] 线程: http-nio-8080-exec-1 | 接口返回(订单 1 创建成功)
[异步-邮件] 线程: async-2 | 邮件发送完成
[异步-短信] 线程: async-3 | 短信发送完成

四个行为验证:

场景二:模拟事务回滚

curl -X POST "http://localhost:8080/api/orders?fail=true" \
  -H "Content-Type: application/json" \
  -d '{"phone":"13900139000","amount":199.90}'

实际输出:

[Controller] 线程: http-nio-8080-exec-3 | 收到创建订单请求: phone=13900139000, amount=199.90, 【模拟失败场景】
[OrderService] 线程: http-nio-8080-exec-3 | 事务开始 → 保存订单
[OrderService] 订单已保存: Order{id=2, phone='13900139000', amount=199.90, status='CREATED'}
[OrderService] 发布 OrderCreatedEvent(事务尚未提交)
[OrderService] 模拟业务异常,事务即将回滚!
[Controller] 线程: http-nio-8080-exec-3 | 接口返回(下单失败: 库存扣减失败,事务回滚)
[异步-补偿] 线程: async-4 | 事务回滚!清理订单 2 的缓存数据

两个行为验证:

定时任务

应用运行期间,定时任务每 30 秒触发一次:

[定时任务] 线程: async-5 | ⏰ 定时任务触发,生成订单汇总报表...

同样运行在 async- 线程池里,因为加了 @Async

所有输出均来自本地实际运行,代码已上传到 Gitee,你可以拉下来自己跑一遍验证。

以上就是Spring中处理异步的4大注解详解的详细内容,更多关于Spring注解处理异步的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文