Skip to content

Guava EventBus:进程内事件总线的边界与两个坑 ​

EventBus 让发布方不必知道订阅方是谁,代码看起来清爽了很多。但它有两个容易忽略的性质:注册之后不反注册,订阅者对象永远不会被回收;订阅者抛出的异常不会传给发布方。实测都能在几行代码里复现。

本文用 Guava 33.5.0 实测 EventBus 的投递行为、异常处理、死亡事件和内存泄漏风险(JDK 21)。

一、先说结论 ​

  • EventBus 只解决进程内的解耦,它不是消息队列:没有持久化、没有重试、进程一挂事件就没了。
  • 异常是隔离的:实测一个订阅者抛异常,其他订阅者照常收到事件,发布方也不会感知到失败。
  • 没有订阅者的事件会变成 DeadEvent,实测能被专门的监听器捕获——这是排查「事件发了但没人处理」的关键。
  • 注册关系是强引用:实测把订阅者置为 null 并触发 GC,对象依然存活;只有 unregister 之后才能回收。
  • 默认是同步投递:实测订阅者 sleep 200ms,post 就阻塞 202ms;AsyncEventBus 下 post 立即返回(0ms)。

二、投递行为实测 ​

post 事件订阅者 A正常处理订阅者 B抛出异常订阅者 C仍然收到异常被捕获只记录日志没有订阅者的事件 → DeadEventregister 后不 unregister,订阅者对象无法被 GC 回收(实测:置为 null 并 GC 后依然存活)
图 1 · 实测:一个订阅者抛异常不影响其他订阅者;没有订阅者的事件进入 DeadEvent;注册后不反注册会被强引用持有

2.1 异常隔离 ​

java
EventBus bus = new EventBus((exception, context) -> handlerErrors.add(exception));
bus.register(subscriberA);       // 正常
bus.register(throwingSubscriber); // B:抛 IllegalStateException
bus.register(subscriberC);       // 正常
bus.post(new OrderCreated(1001));

实测结果:

text
订阅者 A 收到:[A-created-1001]
订阅者 C 收到:[C-created-1001]
交给 SubscriberExceptionHandler 的异常:1 个(订阅者 B 处理失败)
post 向调用方抛出:无

这是一把双刃剑:好处是一个订阅者出错不影响其他人;坏处是发布方以为「事件处理成功了」。默认的 EventBus 只会打一条日志,异常很容易被忽略。

所以使用时必须自定义 SubscriberExceptionHandler,把异常上报到监控,而不是只落日志:

java
EventBus bus = new EventBus((exception, context) -> {
    log.error("事件处理失败 event={} subscriber={}", context.getEvent(), context.getSubscriber(), exception);
    meterRegistry.counter("eventbus.error", "event", context.getEvent().getClass().getSimpleName()).increment();
});

2.2 类型匹配与 DeadEvent ​

订阅方法按参数类型匹配,父类型的订阅者也会收到子类型的事件。发布一个没有任何订阅者的事件时:

text
bus.post(new NoSubscriber("hello"));
→ DeadEvent 监听器收到:[NoSubscriber[x=hello]]

生产环境应该始终注册一个 DeadEvent 监听器并打日志或打点。否则「事件明明发出去了,却没人处理」这类问题几乎无法排查——常见原因是订阅者所在的 Bean 没有注册,或者事件类型写错了。

2.3 同步还是异步 ​

text
同步 EventBus:订阅者 sleep 200ms → post 返回耗时 202ms
AsyncEventBus:同样的订阅者      → post 返回耗时 0ms

默认的 EventBus 在调用 post 的线程上同步执行所有订阅者。这意味着:

  • 订阅者的耗时直接加到主流程上;
  • 订阅者和发布者在同一个事务、同一个 ThreadLocal 上下文里(这可能是好事,也可能是隐患)。

AsyncEventBus 需要自己提供线程池,于是又要回答线程池的老问题:队列多大、拒绝策略是什么、异常谁处理,见 线程池参数怎么定。

三、内存泄漏:最容易踩的坑 ​

java
EventBus bus = new EventBus();
Subscriber tmp = new Subscriber();
WeakReference<Subscriber> ref = new WeakReference<>(tmp);
bus.register(tmp);
tmp = null;
System.gc();                      // 多次触发
System.out.println(ref.get() != null);   // 实测:true,对象还活着
bus.unregister(...);
System.gc();
System.out.println(ref.get() == null);   // 实测:true,被回收了

EventBus 内部用强引用持有订阅者,注册就等于延长生命周期。这在两种场景下会变成真实的泄漏:

  1. 短生命周期对象注册了长生命周期的总线:每个请求创建一个处理器并注册,请求结束却不反注册。
  2. Spring Bean 被销毁但没有反注册:作用域是 prototype 或者容器重启时。

处理方式:

java
@Component
class OrderEventSubscriber {
    private final EventBus bus;

    @PostConstruct  void register()   { bus.register(this); }
    @PreDestroy     void unregister() { bus.unregister(this); }
}

只注册单例对象,并且成对写 register / unregister。

四、什么时候该用,什么时候不该用 ​

场景建议
同一进程内的模块解耦,事件丢了无所谓(比如刷新本地缓存、打点)适合
需要在事务提交后触发用 Spring 的 @TransactionalEventListener,它有明确的事务阶段语义
跨进程、跨服务用消息队列,见 Kafka 不丢、不重与 Exactly Once
事件不能丢、需要重试和追溯用消息队列 + Outbox
需要顺序保证EventBus 不提供全局顺序,同步模式下按注册顺序执行,异步模式取决于线程池

在 Spring 项目里,ApplicationEventPublisher 通常是更好的默认选择:它与容器生命周期一致(Bean 销毁自动解除监听)、支持事务阶段、支持 @Async,不需要手动管理注册关系。Guava EventBus 的价值主要在非 Spring 环境,或者需要多个互相隔离的总线实例时。

五、常见误区 ​

  • 「EventBus 是消息队列的轻量替代」:它没有持久化、没有重试、不跨进程,进程退出即丢失。
  • 「订阅者抛异常,发布方会知道」:实测异常被隔离,只交给 SubscriberExceptionHandler。
  • 「用了事件就解耦了」:事件的类型和字段仍然是契约,改字段照样会破坏订阅方,只是编译期不再报错——这是它相对直接方法调用的代价。
  • 「注册之后不用管」:强引用会让订阅者一直存活。
  • 「异步就一定更好」:异步意味着丢失调用上下文(事务、MDC、SecurityContext),并且异常更难追踪。

小结 ​

EventBus 适合「进程内、可丢失、不需要顺序」的通知类场景,用它换来的是发布方与订阅方的编译期解耦。使用时把三件事做全:自定义异常处理器、注册 DeadEvent 监听、成对写 register / unregister。超出这个边界——需要可靠投递、跨进程、可追溯——就应该换成消息队列,而不是给 EventBus 打补丁。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权