2387 字
约 7 分钟
13
S07-X09 · Spring 事件机制与异步监听:解耦的边界

下单成功后要发短信、写日志、更新报表、通知库存。直接写在同一个方法里,方法越来越长;拆成事件发出去,又会出现"消息先于事务提交发出去"、"异步监听挂了没人知道"、"同一事件被处理三次"。事件机制的 API 只有三个类,踩的点却全在时机和线程上。

这篇按"角色 → 时机 → 事务边界 → 异步 → 异常与可追溯"拆,最后说清什么时候该换成 MQ。


1. 三个角色

角色 类型 职责
发布者 ApplicationEventPublisher 调用 publishEvent(),ApplicationContext 已实现该接口
事件对象 任意 POJO 传递数据;继承 ApplicationEvent 是旧写法
监听器 ApplicationListener<E> / @EventListener 接收并处理,方法级注解更常用
public record OrderCreatedEvent(Long eventId, Long orderId, Long userId, Instant at) { }

@Service
public class OrderService {
    private final ApplicationEventPublisher publisher;

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

    public void create(OrderCmd cmd) {
        Long orderId = orderRepository.save(cmd);
        publisher.publishEvent(new OrderCreatedEvent(
            IdWorker.next(), orderId, cmd.userId(), Instant.now()));
    }
}
@Component
public class OrderCreatedListener {
    @EventListener
    public void on(OrderCreatedEvent e) {
        log.info("order created: {}", e.orderId());
    }
}

事件对象用 POJO/record,不要为了"规范"去继承 ApplicationEvent:Spring 4.2 之后已支持任意对象,继承反而让 record 用不了。


2. 同步执行:默认就在发布线程里

属性 默认 含义
执行线程 发布方线程 不换线程,监听器同步跑完才返回
执行顺序 @Order 升序 没有注释则顺序不保证
异常处理 向发布方传播 监听器抛异常 → publishEvent 抛异常
事务 与发布方同一事务 监听器里的写操作会一起回滚
@Service
public class OrderService {
    public void create(OrderCmd cmd) {
        orderRepository.save(cmd);
        publisher.publishEvent(new OrderCreatedEvent(...));   // 同步,抛异常会中断 create
        // 后面的代码可能永远不执行
    }
}

这条默认行为是很多 bug 的起点:监听器里查库失败,发布方整个业务也失败。事件本该"通知",却变成了"串行调用"。


3. @Async 监听的启用条件

条件 说明
@EnableAsync 存在 在配置类或启动类上,缺了它 @Async 完全不生效
方法经过代理 自调用不异步,和 @Transactional 同理
返回类型 void 或 Future(CompletableFuture 亦可)
线程池 未定义 TaskExecutor Bean 时用 SimpleAsyncTaskExecutor
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Bean("eventExecutor")
    public ThreadPoolTaskExecutor eventExecutor() {
        ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
        e.setCorePoolSize(8);
        e.setMaxPoolSize(16);
        e.setQueueCapacity(200);
        e.setThreadNamePrefix("event-");
        e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return e;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> log.error("async failed: {}", method, ex);
    }
}
@Component
public class SmsListener {
    @Async("eventExecutor")
    @EventListener
    public void on(OrderCreatedEvent e) { smsService.send(e.userId()); }
}

SimpleAsyncTaskExecutor 每次调用都新建线程且不复用,高并发下等于自己造了一个线程泄漏。Boot 3 下如果没有自定义 TaskExecutor,@Async 用的就是它——这是第 8 节第二条坑的根源。


4. @TransactionalEventListener 的四个阶段

阶段 触发时机 监听器所在事务
BEFORE_COMMIT 提交前,事务仍开启 加入原事务
AFTER_COMMIT(默认) 提交成功后 原事务已结束,无活动事务
AFTER_ROLLBACK 回滚后 无
AFTER_COMPLETION 提交或回滚后 无
@Component
public class OrderMessagePublisher {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCommit(OrderCreatedEvent e) {
        // 事务已提交,此处的写操作需要自己的事务
        outboxMapper.markSent(e.eventId());
    }
}

关键点:AFTER_COMMIT 时原事务已提交、连接多已归还,监听器里再做数据库写不会自动加入原事务——要么监听器自己加 @Transactional(propagation = REQUIRES_NEW),要么接受"消息发出但标记失败"的可能,用幂等兜住。

还有一个隐藏开关:@TransactionalEventListener(fallbackExecution = false)(默认)表示没有活动事务时该监听器不执行。在非事务上下文(比如定时任务)里发布同一个事件,监听器会被静默跳过。这个默认值坑过很多人。


5. 事务内发事件的时序问题

@Transactional
public void create(OrderCmd cmd) {
    orderRepository.save(cmd);
    publisher.publishEvent(new OrderCreatedEvent(...));   // 普通 @EventListener
}

如果监听器是普通 @EventListener 或 @Async,事件在提交之前就被处理:

风险 场景
消费者查不到数据 监听器在另一个线程/连接查该订单,事务未提交 → 查不到
消息先发出去,事务回滚 用户收到"下单成功"短信,实际订单不存在
下游系统读到旧状态 同库不同连接,隔离级别下不可见

正确写法只有一个方向:要么用 @TransactionalEventListener(AFTER_COMMIT),要么用本地消息表在同一个事务里落库、再由调度器投递。

@Transactional
public void create(OrderCmd cmd) {
    orderRepository.save(cmd);
    outboxRepository.insert(new OutboxRecord(cmd));   // 同一事务落库,可靠
}

@Scheduled(fixedDelay = 5000)
public void relay() { outboxRelay.sendPending(); }

6. 顺序与异常

控制点 手段 注意
监听器顺序 @Order / Ordered 仅对同步监听器有意义
异步监听器顺序 无法保证 线程池调度决定
同步异常 传播给发布方 需要隔离就 try-catch 或改异步
异步异常 默认被吞 必须配 AsyncUncaughtExceptionHandler
事件对象被多监听器共用 各自处理 无"消费一次"语义
@Order(1) @EventListener
public void audit(OrderCreatedEvent e) { }

@Order(2) @EventListener
public void notify(OrderCreatedEvent e) { }

事件是广播语义:所有匹配的监听器都会收到同一个对象引用,没有"队头取走即消费"的排他性。需要"一次且仅一次"的处理,事件机制给不了,要靠幂等键去重。


7. 事件的形状与可追溯性

字段 作用
eventId 幂等键、日志关联、去重
业务主键 方便监听器回查(不要塞整个实体)
发生时间 排查时序
事件版本 多监听器并存时区分语义
public record OrderCreatedEvent(String eventId, Long orderId, Long userId,
                                Instant occurredAt, int version) {
    public static OrderCreatedEvent of(Long orderId, Long userId) {
        return new OrderCreatedEvent(UUID.randomUUID().toString(), orderId, userId,
                                     Instant.now(), 1);
    }
}

不要往事件里塞大对象:事件在同步监听时是引用传递,但在异步场景下会被放进队列,直到被处理前都占着堆。塞一个 10MB 的列表进事件,队列深度 200,就是 2G 的隐性占用。


8. 排错表

现象 原因 处理
@Async 完全不生效 缺 @EnableAsync;或自调用 加注解;换 Bean 调用
异步监听里查不到刚写的数据 发布方事务未提交 改 @TransactionalEventListener
消息发出去了但订单回滚 普通监听在提交前处理 同上,或本地消息表
异步任务报错无日志 异常被吞 配 AsyncUncaughtExceptionHandler
事件没人接收 fallbackExecution=false 且无事务 显式传 fallbackExecution = true
队列满后事件丢失 拒绝策略为 DiscardPolicy 用 CallerRunsPolicy 或扩队列
同一事件被处理多次 广播语义 + 重试 用 eventId 做幂等

9. 事件与 MQ 的分界

维度 Spring 事件 MQ
范围 同进程内 跨进程
可靠性 无持久化,进程挂了就没了 有持久化与重投
顺序 同步有序、异步无序 取决于分区策略
背压 队列满即拒 由 broker 缓冲
运维成本 零 需要集群与监控
适用 同 JVM 内解耦 跨服务、需重投

别把事件当可靠消息用。事件的价值是把"下单"和"发短信"在代码层面解耦;一旦跨服务、一旦需要重投与死信,就该上 MQ。中间形态是本地消息表 + outbox 投递,它在同库事务内保证"至少一次"。


10. 边界

场景 建议
同进程内的旁路逻辑(日志、缓存刷新) Spring 事件,同步即可
必须与主流程同事务 普通 @EventListener
提交后才该做的事(发消息、通知) @TransactionalEventListener(AFTER_COMMIT)
跨服务通知 MQ + 幂等
大量短任务 直接提交线程池,不必绕事件
需要重试与死信 MQ

一句话:事件解决的是"同一进程内谁关心这件事",不解决"这件事一定被处理"。把这两件事混在一起,才是事件机制出问题的根源。

// 最小验证:确认监听器真的被注册了
@EventListener(ApplicationReadyEvent.class)
public void dump(ApplicationReadyEvent e) {
    Arrays.stream(ctx.getBeanNamesForType(ApplicationListener.class))
          .forEach(n -> log.info("listener: {}", n));
}

11. 自测

  1. 默认的 @EventListener 在哪个线程执行?监听器抛异常会怎样?
  2. @TransactionalEventListener 默认是哪个阶段?此时监听器里的写操作在哪个事务里?
  3. 在事务方法里用普通 @EventListener 发"下单成功",最坏会出现什么?
  4. 不配 TaskExecutor 时 @Async 用哪个执行器?它的代价是什么?
  5. fallbackExecution = false 加在非事务上下文里会看到什么现象?
  6. 什么情况下应该放弃 Spring 事件改用 MQ?
S07-X09 · Spring 事件机制与异步监听:解耦的边界
https://www.clxhxhhr.top/posts/5856/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。