1. 从一次线上故障说起:为什么监听器不是"配了就能用"
去年帮一个朋友排查过一个挺典型的问题。他们做的是一个订单履约系统,用的是Spring Boot那一套。需求本身不复杂:订单状态变更之后,要同步更新缓存、发一条站内信、再写一条操作日志。他们团队的做法很直接,在订单状态变更的那个Service方法里,挨个调用缓存服务、消息服务、日志服务,三行代码排排坐。
上线初期没什么问题,后来业务方加了个需求:状态变更之后还要触发一个积分计算。于是又加了一行。再后来要触发风控校验,再加一行。半年下来,那个方法里塞了七八个调用,方法体长得要滚动两屏。更麻烦的是,有一次积分服务那边接口超时,直接把整个订单状态变更的事务给拖垮了,用户下单之后状态一直卡着不动。
这个场景其实特别常见。很多人对Spring监听器的理解停留在"加个@EventListener注解就能用"的层面,但真正把它用对、用稳,需要搞清楚它背后的那套机制到底是怎么运转的。监听器本质上解决的是解耦问题——事件的发布方不需要知道谁在监听、监听之后要干什么,它只负责把"这件事发生了"这个事实广播出去。至于谁来接、接了之后做什么、做失败了怎么办,都是监听方自己的事。
但解耦是有代价的。你把调用关系从"显式"变成了"隐式",代码上看不出来了,排查问题的时候就得顺着事件链路去找。而且Spring的事件机制默认是同步的,发布方会一直等到所有监听器执行完才继续往下走,这跟很多人想象中的"发个消息就完事"完全不是一回事。所以这篇文章我想把Spring监听器这套东西从头到尾捋一遍,包括它的核心组件、注册流程、发布机制、同步异步的取舍,以及实际项目里那些容易踩的坑。适合已经用过@EventListener但没深究过原理的开发者,也适合正在做模块解耦、想把事件机制引入项目的同学。
2. 拆开Spring事件机制的四个核心角色
要理解监听器,得先把Spring事件体系里的几个关键角色认清楚。这套机制其实是观察者模式的一个标准实现,只不过Spring在它外面包了一层容器管理的能力,让它能跟Bean的生命周期、依赖注入这些东西无缝配合。
2.1 ApplicationEvent:事件的载体
ApplicationEvent是所有Spring事件的基类,它继承自java.util.EventObject。这个类本身很简单,核心就两个东西:一个是事件源source,记录这个事件是谁发出来的;另一个是时间戳timestamp,记录事件产生的时间。
public abstract class ApplicationEvent extends EventObject { private static final long serialVersionUID = 7099057708183571937L; private final long timestamp; public ApplicationEvent(Object source) { super(source); this.timestamp = System.currentTimeMillis(); } public final long getTimestamp() { return this.timestamp; } }从Spring 4.2开始,事件对象不再强制要求继承ApplicationEvent了。任何一个普通的POJO都可以作为事件发布,Spring会把它自动包装成一个PayloadApplicationEvent。这个改动看起来不起眼,但实际用起来舒服很多——你不需要为了发个事件专门去定义一个继承体系,直接拿业务对象当事件就行。
// 老写法:必须继承ApplicationEvent public class OrderCreatedEvent extends ApplicationEvent { private String orderId; public OrderCreatedEvent(Object source, String orderId) { super(source); this.orderId = orderId; } } // 新写法:普通POJO即可 public class OrderCreatedEvent { private String orderId; // getter/setter }我个人的建议是,新项目直接用POJO,除非你有特殊需求要拿到source或者timestamp。POJO的好处是它跟Spring没有任何耦合,你可以在任何地方new它、测试它,不用依赖容器。
2.2 ApplicationListener:监听器的接口定义
ApplicationListener是监听器的顶层接口,定义非常简洁:
@FunctionalInterface public interface ApplicationListener<E extends ApplicationEvent> extends EventListener { void onApplicationEvent(E event); }泛型E指定了这个监听器关心的事件类型。Spring在派发事件的时候,会根据泛型信息做过滤,只有类型匹配的监听器才会被调用。这个类型匹配的逻辑在ApplicationEventMulticaster里实现,后面会细说。
实现这个接口是最原始的监听器写法:
@Component public class OrderCreatedListener implements ApplicationListener<OrderCreatedEvent> { @Override public void onApplicationEvent(OrderCreatedEvent event) { // 处理逻辑 } }但这种写法有个明显的缺点:一个类只能监听一种事件。如果你要监听多种事件,就得写多个类,或者用@EventListener注解。所以实际项目里,@EventListener才是主流用法。
2.3 ApplicationEventMulticaster:事件派发的调度中心
这是整个机制里最核心也最容易被忽略的组件。ApplicationEventMulticaster负责管理所有监听器的注册,以及在事件发布时决定把事件派发给哪些监听器。
它的接口定义是这样的:
public interface ApplicationEventMulticaster { void addApplicationListener(ApplicationListener<?> listener); void addApplicationListenerBean(String listenerBeanName); void removeApplicationListener(ApplicationListener<?> listener); void removeApplicationListenerBean(String listenerBeanName); void removeAllListeners(); void multicastEvent(ApplicationEvent event); void multicastEvent(ApplicationEvent event, @Nullable ResolvableType eventType); }Spring默认提供的实现是SimpleApplicationEventMulticaster。这个类里有一个taskExecutor属性,如果给它设置了一个线程池,那么事件派发就会变成异步的;如果不设置,就是同步派发。这个设计很巧妙——同步还是异步,不是由监听器决定的,而是由这个派发器统一控制的。
public class SimpleApplicationEventMulticaster extends AbstractApplicationEventMulticaster { @Nullable private Executor taskExecutor; public void setTaskExecutor(@Nullable Executor taskExecutor) { this.taskExecutor = taskExecutor; } @Override public void multicastEvent(ApplicationEvent event, @Nullable ResolvableType eventType) { ResolvableType type = (eventType != null ? eventType : resolveDefaultEventType(event)); Executor executor = getTaskExecutor(); for (ApplicationListener<?> listener : getApplicationListeners(event, type)) { if (executor != null) { executor.execute(() -> invokeListener(listener, event)); } else { invokeListener(listener, event); } } } }注意这里有个细节:getApplicationListeners返回的是所有匹配的监听器,然后逐个调用。如果是同步模式,一个监听器执行慢了,后面的都得等着。这就是为什么前面那个订单系统的例子会出问题——积分服务超时,把整个链路都堵住了。
2.4 ApplicationEventPublisher:发布事件的入口
ApplicationEventPublisher是发布方直接打交道的接口,只有一个方法:
@FunctionalInterface public interface ApplicationEventPublisher { void publishEvent(Object event); }ApplicationContext继承了这个接口,所以任何ApplicationContext实例都可以直接发布事件。我们平时用的ApplicationEventPublisher,注入进来的其实就是当前的ApplicationContext。
@Service public class OrderService { @Autowired private ApplicationEventPublisher publisher; public void createOrder(Order order) { // 业务逻辑 publisher.publishEvent(new OrderCreatedEvent(order.getId())); } }publishEvent方法内部会做几件事:如果事件不是ApplicationEvent类型,先包装成PayloadApplicationEvent;然后拿到ApplicationEventMulticaster,调用它的multicastEvent方法。整个链路就串起来了。
3. 监听器从定义到生效,中间经历了什么
很多人以为加个@EventListener注解,Spring就能自动识别并调用。这中间其实有一整套注册和解析的流程,搞清楚这个过程,才能理解为什么有些监听器"看起来配了但没生效"。
3.1 @EventListener的解析:EventListenerMethodProcessor登场
Spring容器在启动过程中,会加载所有实现了SmartInitializingSingleton接口的Bean。EventListenerMethodProcessor就是其中之一,它负责扫描所有Bean,找出标注了@EventListener的方法,把它们包装成ApplicationListener并注册到ApplicationEventMulticaster里。
public class EventListenerMethodProcessor implements SmartInitializingSingleton, ApplicationContextAware { @Override public void afterSingletonsInstantiated() { ConfigurableListableBeanFactory beanFactory = this.beanFactory; for (String beanName : beanFactory.getBeanDefinitionNames()) { // 遍历所有Bean,查找@EventListener方法 // ... } } }这里有个关键点:afterSingletonsInstantiated是在所有单例Bean都实例化完成之后才调用的。也就是说,@EventListener的注册时机比较晚。如果你在某个Bean的构造方法或者@PostConstruct方法里发布事件,这时候监听器可能还没注册上,事件就丢了。
注意:在
@PostConstruct里发布事件是一个经典陷阱。因为@PostConstruct的执行时机早于afterSingletonsInstantiated,此时@EventListener标注的监听器还没被注册,事件发出去也没人接。如果确实需要在这个阶段做事情,要么改用ApplicationListener接口方式(它在Bean初始化时就会注册),要么把逻辑挪到ApplicationRunner或CommandLineRunner里。
3.2 ApplicationListener的注册:两条不同的路径
跟@EventListener不同,直接实现ApplicationListener接口的Bean,注册时机要早得多。AbstractApplicationContext在refresh方法的registerListeners阶段,会做两件事:
protected void registerListeners() { // 1. 注册硬编码的监听器(通过addApplicationListener添加的) for (ApplicationListener<?> listener : getApplicationListeners()) { getApplicationEventMulticaster().addApplicationListener(listener); } // 2. 注册容器里的监听器Bean String[] listenerBeanNames = getBeanNamesForType(ApplicationListener.class, true, false); for (String listenerBeanName : listenerBeanNames) { getApplicationEventMulticaster().addApplicationListenerBean(listenerBeanName); } }注意第二步,这里只是把Bean的名字注册进去了,并没有立即实例化这些Bean。真正的实例化发生在事件派发的时候——AbstractApplicationEventMulticaster在查找匹配的监听器时,会通过getBean方法去获取Bean实例。这种延迟初始化的设计,避免了容器启动时就实例化所有监听器,算是一种性能优化。
两条路径的差异导致了一个实际现象:ApplicationListener方式的监听器,在容器启动早期就能响应事件;而@EventListener方式的监听器,要等到所有单例Bean初始化完成后才生效。这个差异在排查"事件丢失"问题时特别有用。
3.3 事件派发时的类型匹配逻辑
当multicastEvent被调用时,AbstractApplicationEventMulticaster需要从所有注册的监听器中,找出跟当前事件类型匹配的那些。这个匹配逻辑在getApplicationListeners方法里:
protected Collection<ApplicationListener<?>> getApplicationListeners( ApplicationEvent event, ResolvableType eventType) { // 先查缓存 ListenerCacheKey cacheKey = new ListenerCacheKey(eventType, sourceType); ListenerRetriever retriever = this.retrieverCache.get(cacheKey); if (retriever != null) { return retriever.getApplicationListeners(); } // 缓存没有,遍历所有监听器做匹配 retriever = new ListenerRetriever(true); for (ApplicationListener<?> listener : this.applicationListeners) { if (supportsEvent(listener, eventType, sourceType)) { retriever.applicationListeners.add(listener); } } // ... }supportsEvent方法会检查监听器的泛型参数是否跟事件类型兼容。对于@EventListener方式注册的监听器,Spring会把它包装成一个ApplicationListenerMethodAdapter,这个适配器里保存了方法签名信息,包括参数类型。匹配的时候就是拿事件类型跟方法参数类型做比对。
这里有个性能优化点:匹配结果会被缓存到retrieverCache里。缓存的key是事件类型加事件源类型。所以如果你的事件类型很多,缓存也会相应变大。不过一般情况下这不是问题。
3.4 一个容易忽略的细节:监听器的排序
当多个监听器都匹配同一个事件时,它们的执行顺序是怎样的?默认情况下,顺序是不确定的,取决于注册的顺序。但Spring提供了@Order注解和Ordered接口来控制顺序。
@Component public class FirstListener { @EventListener @Order(1) public void handle(OrderCreatedEvent event) { // 先执行 } } @Component public class SecondListener { @EventListener @Order(2) public void handle(OrderCreatedEvent event) { // 后执行 } }@Order的值越小,优先级越高,越先执行。这个机制在需要保证执行顺序的场景下很有用,比如先做参数校验,再做业务处理,最后做日志记录。
但要注意,@Order只在同步派发时才有意义。如果用了异步派发,监听器会被提交到线程池,执行顺序就不受@Order控制了。这是一个很容易踩的坑——你以为配了@Order就能保证顺序,结果异步一开,全乱了。
4. 同步还是异步:一个必须想清楚的选择
Spring事件机制默认是同步的,这个默认值背后有它的道理,但在实际项目里,很多时候我们需要异步。这一节把同步和异步的取舍讲透。
4.1 同步派发的本质:方法调用链的延伸
同步派发的时候,publishEvent方法会一直阻塞,直到所有监听器执行完毕。从调用栈上看,监听器的执行就是publishEvent方法调用链的一部分。
// 同步派发的调用栈 OrderService.createOrder() -> ApplicationEventPublisher.publishEvent() -> SimpleApplicationEventMulticaster.multicastEvent() -> invokeListener() -> ApplicationListener.onApplicationEvent() -> 监听器业务逻辑这意味着几件事:第一,监听器里抛出的异常会传播到发布方,如果发布方在事务里,事务会回滚;第二,监听器的执行时间会累加到发布方的响应时间上;第三,监听器里可以拿到发布方的线程上下文,比如ThreadLocal里的数据。
同步派发适合什么场景?适合那些"必须完成才能继续"的逻辑。比如订单创建之后,必须写一条审计日志,这条日志写失败了整个操作就应该失败。这种场景下,同步是合理的。
4.2 开启异步的三种方式
如果想让事件异步处理,有几种做法。
第一种是给ApplicationEventMulticaster设置一个taskExecutor。这需要自定义一个ApplicationEventMulticaster的Bean:
@Configuration public class AsyncEventConfig { @Bean(name = "applicationEventMulticaster") public ApplicationEventMulticaster applicationEventMulticaster() { SimpleApplicationEventMulticaster multicaster = new SimpleApplicationEventMulticaster(); multicaster.setTaskExecutor(new SimpleAsyncTaskExecutor()); return multicaster; } }注意Bean的名字必须是applicationEventMulticaster,因为AbstractApplicationContext在初始化时会按这个名字去查找。如果找不到,就用默认的SimpleApplicationEventMulticaster。
第二种是用@Async注解配合@EventListener:
@Component public class AsyncOrderListener { @Async @EventListener public void handle(OrderCreatedEvent event) { // 异步执行 } }这种方式需要先开启@EnableAsync,并且配置一个线程池。它的粒度更细,可以针对单个监听器开启异步,而不是全局。
第三种是自定义一个ApplicationEventMulticaster,在里面用线程池。这种方式最灵活,但代码量也最大。
4.3 异步带来的三个新问题
异步不是免费的,它引入了几个必须处理的问题。
问题一:异常被吞掉。同步模式下,监听器抛异常会传播到发布方。异步模式下,异常发生在另一个线程里,发布方根本感知不到。如果不做处理,异常就静默消失了。解决办法是给异步监听器配一个AsyncUncaughtExceptionHandler:
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) -> { // 记录日志、发告警 log.error("异步事件处理失败: {}", method.getName(), throwable); }; } }问题二:事务不一致。这是最隐蔽也最危险的问题。假设发布方在一个事务里,先写数据库,再发布事件。异步监听器在另一个线程里执行,它可能读不到发布方还没提交的数据。更糟的是,如果发布方的事务最终回滚了,监听器可能已经执行完了,产生了"脏副作用"。
@Transactional public void createOrder(Order order) { orderRepository.save(order); // 此时事务还没提交 publisher.publishEvent(new OrderCreatedEvent(order.getId())); // 异步监听器可能在这里就去查数据库,查不到刚插入的订单 }解决这个问题的标准做法是用@TransactionalEventListener,它可以把监听器的执行时机绑定到事务的某个阶段:
@Component public class OrderCreatedListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(OrderCreatedEvent event) { // 事务提交后才执行 } }TransactionPhase有四个值:BEFORE_COMMIT、AFTER_COMMIT、AFTER_ROLLBACK、AFTER_COMPLETION。最常用的是AFTER_COMMIT,保证监听器只在事务成功提交后才执行。
问题三:线程上下文丢失。同步模式下,监听器跟发布方在同一个线程,ThreadLocal里的数据可以直接拿到。异步模式下,换了线程,ThreadLocal就空了。如果监听器里需要用到用户身份、租户信息这类存在ThreadLocal里的数据,就得手动传递。
// 发布前把上下文取出来,塞到事件对象里 public void createOrder(Order order) { String userId = UserContext.getCurrentUserId(); publisher.publishEvent(new OrderCreatedEvent(order.getId(), userId)); }4.4 同步异步的选型对照表
| 维度 | 同步派发 | 异步派发 |
|---|---|---|
| 执行线程 | 发布方线程 | 独立线程池 |
| 异常传播 | 传播到发布方 | 需单独处理 |
| 事务一致性 | 同一事务 | 需用@TransactionalEventListener |
| 响应时间影响 | 累加监听器耗时 | 不影响发布方 |
| 执行顺序 | 可通过@Order控制 | 不可控 |
| 上下文传递 | 自动共享 | 需手动传递 |
| 适用场景 | 强一致、必须完成 | 弱一致、可容忍失败 |
我的一般建议是:默认用同步,只在明确需要异步且能接受最终一致性的场景下才开异步。不要为了"性能好"就无脑开异步,异步带来的复杂度往往比那点性能收益更值得权衡。
5. 实际项目里那些绕不开的坑
前面讲的都是机制层面的东西,这一节聊几个实际项目里高频出现的问题,以及排查思路。
5.1 事件发了但监听器没反应
这是最常见的问题,排查起来有几个方向。
方向一:监听器没被扫描到。@EventListener所在的类必须是一个Spring Bean。如果这个类没有加@Component、@Service之类的注解,或者不在包扫描范围内,Spring就不会处理它。这个错误很基础,但确实经常发生,尤其是把监听器写在一个工具类里忘了加注解。
方向二:泛型类型不匹配。监听器方法的参数类型必须跟发布的事件类型一致。如果发布的是OrderCreatedEvent,监听器参数写成了OrderEvent(父类),那也能匹配上;但如果写成了OrderPaidEvent(兄弟类),就匹配不上。Spring的类型匹配是"事件类型是监听器参数类型的子类型"才算匹配。
方向三:发布时机太早。前面提过,@EventListener的注册时机是afterSingletonsInstantiated。如果在BeanPostProcessor或者@PostConstruct里发布事件,监听器还没注册,事件就丢了。这种情况的典型症状是:日志里能看到事件发布的记录,但监听器里的日志一条都没有。
方向四:条件注解拦截了。如果监听器上加了@ConditionalOnXXX之类的条件注解,条件不满足时监听器根本不会注册。这个排查起来需要看启动日志里的条件评估报告。
5.2 循环依赖导致的启动失败
监听器之间如果存在互相依赖,可能引发循环依赖问题。比如监听器A依赖服务B,服务B又发布了事件,事件被监听器A处理,形成了一个环。
@Service public class ServiceA { @Autowired private ServiceB serviceB; @EventListener public void handle(SomeEvent event) { serviceB.doSomething(); } } @Service public class ServiceB { @Autowired private ApplicationEventPublisher publisher; public void doSomething() { publisher.publishEvent(new SomeEvent()); } }这种循环在运行时才会暴露,表现为栈溢出或者死循环。解决办法是打破环:要么把监听器里的逻辑抽出来,要么用异步切断调用链,要么重新设计事件模型。
5.3 事务回滚了但监听器已经执行
这个坑前面提过,但值得再强调一次。同步监听器如果没加@TransactionalEventListener,它会在发布方的事务里执行。如果监听器里做了不可回滚的操作(比如发消息、调外部接口),而发布方的事务最终回滚了,就会产生不一致。
@Transactional public void createOrder(Order order) { orderRepository.save(order); publisher.publishEvent(new OrderCreatedEvent(order.getId())); // 如果这里抛异常,事务回滚 // 但同步监听器已经执行了发消息的逻辑,消息已经发出去了 throw new RuntimeException("模拟异常"); }正确的做法是给监听器加@TransactionalEventListener(phase = AFTER_COMMIT),让它在事务提交后才执行。但要注意,AFTER_COMMIT阶段事务已经提交,监听器里如果再做数据库操作,需要开启新的事务。
5.4 异步监听器把线程池打满
异步监听器用的是线程池,如果事件量突然增大,或者某个监听器执行特别慢,线程池可能被占满,导致后续事件排队甚至被拒绝。
@Configuration @EnableAsync public class AsyncConfig { @Bean("eventExecutor") public Executor eventExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setThreadNamePrefix("event-"); executor.initialize(); return executor; } }几个参数需要根据实际压测来调:核心线程数、最大线程数、队列容量、拒绝策略。拒绝策略建议用CallerRunsPolicy,这样当线程池满了,事件会在发布方线程里执行,相当于降级为同步,至少不会丢事件。
提示:异步监听器的线程池一定要配名字前缀,不然排查问题时线程dump里全是
pool-1-thread-1这种,根本分不清是哪个业务的线程池。
5.5 监听器里的异常处理策略
同步监听器抛异常会传播到发布方,这有时候是好事(触发事务回滚),有时候是坏事(一个不重要的监听器失败导致主流程失败)。需要根据业务重要性来决定。
如果监听器的失败不应该影响主流程,可以在监听器内部捕获异常:
@EventListener public void handle(OrderCreatedEvent event) { try { // 非核心逻辑 } catch (Exception e) { log.error("处理失败", e); // 不往外抛 } }但这样做的问题是异常被吞掉了,可能掩盖真正的问题。更好的做法是记录日志并发送告警,让问题可见但不影响主流程。
6. 把监听器用好的几条实战经验
聊完机制和坑,最后分享几条我在实际项目里总结出来的经验,都是踩过坑之后才明白的。
6.1 事件命名要体现"过去时"
事件代表的是"已经发生的事实",所以命名应该用过去时。OrderCreatedEvent比CreateOrderEvent好,PaymentCompletedEvent比PaymentEvent好。这个命名习惯能帮你区分"命令"和"事件"——命令是"请你做某事",事件是"某事已经发生了"。监听器监听的是事件,不是命令。
6.2 事件对象要设计成不可变的
事件一旦发布,就不应该再被修改。如果多个监听器都能改事件对象,很容易出现"前面的监听器改了,后面的监听器拿到的是改过的数据"这种问题。所以事件对象的字段尽量用final,只提供getter不提供setter。
public class OrderCreatedEvent { private final String orderId; private final BigDecimal amount; private final long timestamp; public OrderCreatedEvent(String orderId, BigDecimal amount) { this.orderId = orderId; this.amount = amount; this.timestamp = System.currentTimeMillis(); } // 只有getter }6.3 监听器只做"通知",不做"决策"
监听器里应该只做那些"知道了这件事之后要做的动作",而不应该包含复杂的业务决策。如果监听器里有一堆if-else判断,那说明这个逻辑可能更适合放在发布方或者一个专门的服务里。监听器保持简单,才能保证它的可靠性。
6.4 给关键事件加监控
事件机制是隐式的,出了问题不容易发现。建议给关键事件加上监控:发布了多少次、监听器执行成功多少次、失败多少次、平均耗时多少。这些指标能帮你快速定位问题。
@EventListener public void handle(OrderCreatedEvent event) { long start = System.currentTimeMillis(); try { // 业务逻辑 metrics.recordSuccess("order.created"); } catch (Exception e) { metrics.recordFailure("order.created"); throw e; } finally { metrics.recordTime("order.created", System.currentTimeMillis() - start); } }6.5 不要用事件机制做RPC
有些人会把事件机制当成进程内的方法调用来用,发布一个事件,然后指望监听器返回结果。这是不对的。事件机制是单向的、广播式的,发布方拿不到监听器的返回值。如果你需要返回值,那就应该直接调用方法,而不是发事件。
6.6 测试时要覆盖监听器
监听器的逻辑容易被忽略测试,因为它不在主流程的调用链上。但恰恰是这种"隐式"的逻辑,出问题的时候最难排查。建议给每个监听器写单元测试,验证它在收到事件后做了正确的事情。
@Test public void testOrderCreatedListener() { OrderCreatedEvent event = new OrderCreatedEvent("order-1", BigDecimal.TEN); listener.handle(event); // 验证副作用 verify(cacheService).update(anyString()); }6.7 考虑用Spring Modulith做模块间事件
如果项目比较大,模块之间用事件通信,可以考虑Spring Modulith。它提供了一套更结构化的模块间事件机制,包括事件的持久化、重试、死信处理等。不过这是另一个话题了,这里不展开。
监听器这套东西,说简单也简单,加个注解就能跑;说复杂也复杂,同步异步、事务、异常、顺序,每个点都有讲究。我的体会是,把它当成一个"轻量级的消息队列"来理解——它解决的是解耦问题,但不像真正的MQ那样有持久化和重试能力。所以用它的时候,要清楚它的边界在哪里,哪些场景适合,哪些场景不适合。用对了,代码会清爽很多;用错了,排查问题的时候会很痛苦。