Guava EventBus:进程内事件总线的边界与两个坑
EventBus 让发布方不必知道订阅方是谁,代码看起来清爽了很多。但它有两个容易忽略的性质:注册之后不反注册,订阅者对象永远不会被回收;订阅者抛出的异常不会传给发布方。实测都能在几行代码里复现。
本文用 Guava 33.5.0 实测 EventBus 的投递行为、异常处理、死亡事件和内存泄漏风险(JDK 21)。
一、先说结论
- EventBus 只解决进程内的解耦,它不是消息队列:没有持久化、没有重试、进程一挂事件就没了。
- 异常是隔离的:实测一个订阅者抛异常,其他订阅者照常收到事件,发布方也不会感知到失败。
- 没有订阅者的事件会变成
DeadEvent,实测能被专门的监听器捕获——这是排查「事件发了但没人处理」的关键。 - 注册关系是强引用:实测把订阅者置为 null 并触发 GC,对象依然存活;只有
unregister之后才能回收。 - 默认是同步投递:实测订阅者 sleep 200ms,
post就阻塞 202ms;AsyncEventBus下post立即返回(0ms)。
二、投递行为实测
2.1 异常隔离
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));实测结果:
订阅者 A 收到:[A-created-1001]
订阅者 C 收到:[C-created-1001]
交给 SubscriberExceptionHandler 的异常:1 个(订阅者 B 处理失败)
post 向调用方抛出:无这是一把双刃剑:好处是一个订阅者出错不影响其他人;坏处是发布方以为「事件处理成功了」。默认的 EventBus 只会打一条日志,异常很容易被忽略。
所以使用时必须自定义 SubscriberExceptionHandler,把异常上报到监控,而不是只落日志:
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
订阅方法按参数类型匹配,父类型的订阅者也会收到子类型的事件。发布一个没有任何订阅者的事件时:
bus.post(new NoSubscriber("hello"));
→ DeadEvent 监听器收到:[NoSubscriber[x=hello]]生产环境应该始终注册一个 DeadEvent 监听器并打日志或打点。否则「事件明明发出去了,却没人处理」这类问题几乎无法排查——常见原因是订阅者所在的 Bean 没有注册,或者事件类型写错了。
2.3 同步还是异步
同步 EventBus:订阅者 sleep 200ms → post 返回耗时 202ms
AsyncEventBus:同样的订阅者 → post 返回耗时 0ms默认的 EventBus 在调用 post 的线程上同步执行所有订阅者。这意味着:
- 订阅者的耗时直接加到主流程上;
- 订阅者和发布者在同一个事务、同一个
ThreadLocal上下文里(这可能是好事,也可能是隐患)。
AsyncEventBus 需要自己提供线程池,于是又要回答线程池的老问题:队列多大、拒绝策略是什么、异常谁处理,见 线程池参数怎么定。
三、内存泄漏:最容易踩的坑
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 内部用强引用持有订阅者,注册就等于延长生命周期。这在两种场景下会变成真实的泄漏:
- 短生命周期对象注册了长生命周期的总线:每个请求创建一个处理器并注册,请求结束却不反注册。
- Spring Bean 被销毁但没有反注册:作用域是 prototype 或者容器重启时。
处理方式:
@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 打补丁。
配套实验
- codesphere-labs/java/guava-eventbus:异常隔离、DeadEvent、同步与异步投递、注册关系的强引用(验证记录)
参考资料