Guava EventBus实战:进程内消息队列如何优雅解耦Java业务模块
接手过一套老系统的同学大概率遇到过我下面说的这种场景下单方法里硬生生排着发短信、扣库存、埋点、通知推荐系统再加个新需求就要在核心流程里插一行新代码改到最后连文档都找不着北。当时我在一个业务后台里折腾这种通知风暴果断换成了Google Guava自带的EventBus也就是常说的进程内消息队列思路——进程内部模块之间通过事件解耦互不直接依赖。这篇文章就把EventBus从原理到底层线程模型、从坑到实战改造完整拆一遍适合刚接触事件驱动开发的Java同学也适合那些已经在用但被各种隐性坑折磨过的老手。1. 先搞清楚EventBus到底解决什么问题1.1 一个每天都在发生的代码膨胀场景很多系统一开始都是这样的有一个核心服务方法比如创建订单业务逻辑在方法里逐行往下排。public void createOrder(OrderDTO dto) { Order order saveOrder(dto); smsService.sendSms(order.getUserId(), 下单成功); stockService.deduct(order.getProductId(), order.getCount()); trackService.track(order_created, order); recommendService.notify(order); }短期看没什么问题等到第四五个业务方跑过来说我也要在下单后拿到订单数据这个方法就会变成两百行的怪物。而且每次新增需求都要动核心代码测一次回归、发一次上线风险全压在这一个方法上。这种场景说白了就是直接耦合生产者主动去调用所有消费者新消费者加入时生产者代码必须跟着改。1.2 发布-订阅的核心思想解耦EventBus做的事情很简单就是把这个模型倒过来。核心流程只有三个角色事件Event一个普通的Java对象比如OrderCreatedEvent。订阅者Subscriber通过Subscribe注解标记一个方法表示我对这类事件感兴趣。事件总线EventBus负责记录谁订阅了什么并在你发布事件时把事件投递给对应的订阅者。发布方只做一件事eventBus.post(new OrderCreatedEvent(order));订阅方只做一件事Subscribe public void onOrderCreated(OrderCreatedEvent event) { // 处理自己的业务 }两边谁都不认识谁。新增一个订阅方不需要改发布方的代码只需要往总线里注册新的监听器。这就是解耦的实际价值。1.3 EventBus和常见的MQ本质区别在哪标题里管EventBus叫进程内消息队列这个叫法在工程圈见多了但它和Kafka、RabbitMQ这些真实消息中间件本质上是两码事。列个表看得清楚维度Guava EventBus消息队列Kafka/RabbitMQ等通信范围同一个JVM进程内跨进程、跨服务、跨机器消息持久化无事件就是内存对象支持落盘、消息不丢可靠性进程挂了事件就没了有ack、重试、数据复制消费者管理注册/反注册都在本地有消费组、偏移量管理性能就是一次方法调用极快有网络开销但可水平扩展解耦边界模块间解耦服务间解耦所以更严谨的说法是EventBus是一个进程内的发布订阅组件不是完整的消息中间件。它不需要Broker、不需要序列化、不需要网络传输把一个事件从发布到处理压缩成了一次方法调用的成本。1.4 什么时候用EventBus什么时候该上MQ我个人的判断标准就三条事件消费方和发布方在同一个应用进程里用它。接收方不要求持久化、不要求消息不丢能容忍进程重启后事件丢失用它。只是想让代码结构更好看不想为了发个通知就搭一套消息中间件用它。反过来一旦出现跨服务调用、需要消息确认机制、需要削峰填谷、需要历史消息回溯直接上MQ别拿EventBus硬撑。EventBus的另一边其实是责任链、观察者模式这类设计模式而不是Kafka。2. 五分钟上手依赖配置与最简实践2.1 引入Guava依赖EventBus是Guava工具库里的一个模块没有单独发版所以引入的是Guava本体。Maven写在pom里dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version33.2.1-jre/version /dependencyGradle的话implementation com.google.guava:guava:33.2.1-jre版本号建议跟着官方最新稳定版走上面的33.2.1-jre只是我写这篇文章时比较稳的一个版本。旧版本和新版本在个别API上有一点点差异比如粘性事件相关方法老版本里没有这个后面会提到。引入完顺手校验一下能正常拉到包就行。2.2 最小可运行代码事件、订阅者、总线我习惯把事件、订阅者、总线拆成三个文件最简单的完整例子长这样。事件类就是一个普通POJOpublic class OrderCreatedEvent { private final Long orderId; private final Long userId; public OrderCreatedEvent(Long orderId, Long userId) { this.orderId orderId; this.userId userId; } public Long getOrderId() { return orderId; } public Long getUserId() { return userId; } }订阅者核心就是那个Subscribe注解import com.google.common.eventbus.Subscribe; public class OrderEventListener { Subscribe public void handleOrderCreated(OrderCreatedEvent event) { System.out.println(收到订单创建事件订单号 event.getOrderId()); } }主流程组装import com.google.common.eventbus.EventBus; public class DemoMain { public static void main(String[] args) { EventBus eventBus new EventBus(order-bus); eventBus.register(new OrderEventListener()); eventBus.post(new OrderCreatedEvent(1001L, 888L)); } }运行起来就能看到订阅者打印了日志。这里有个细节EventBus的register方法接收的是任意Object它会扫描这个对象里所有标注了Subscribe的方法。也就是说你不必实现任何接口、继承任何基类注解驱动。2.3 事件对象设计的几个讲究用久了EventBus会发现事件对象设计得好不好直接决定后续扩展顺不顺。几个原则是我踩坑总结出来的第一事件类尽量不可变。字段全部final构造时赋值只提供getter。事件在总线里分发时可能被多个订阅者读取如果某个订阅者手滑改了事件字段下一个订阅者看到的就是脏数据非常难排查。第二一个事件类只表达一个业务动作。比如订单创建成功后可能有OrderCreatedEvent也可能有OrderPaidEvent不要搞一个OrderChangedEvent带一堆状态位让订阅方自己去判断。第三事件类可以继承。发布一个子类事件父类类型的所有订阅方法也能收到消息这一点很多人没意识到可以用来做事件的增量扩展。但要注意顺序问题后面讲坑的时候会细说。3. 线程模型选型是EventBus用得好的关键3.1 POSTING默认的同步模式EventBus默认的线程模式是POSTING也就是说订阅方法会在调用post()的那个线程里直接执行。Subscribe public void onOrderCreated(OrderCreatedEvent event) { // 这个方法运行在发布事件的线程里 }这种模式最大的好处是简单、可控、没有线程切换开销事件处理的顺序和发布顺序一致事务也能绑定在同一个线程上。代价也明显如果某个订阅方法很慢比如在方法里调了远程接口、查了慢SQL发布方的线程会被跟着拖住。说白了POSTING模式是同步执行不是异步执行。所以一个很重要的经验是同步模式下不要在订阅方法里做耗时操作否则整个发布链路都会变成性能瓶颈。3.2 ASYNC异步模式与其线程池配置想让订阅方法真正异步执行用AsyncEventBus加上线程池import com.google.common.eventbus.AsyncEventBus; import com.google.common.util.concurrent.ThreadFactoryBuilder; import java.util.concurrent.*; ExecutorService executor new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(2000), new ThreadFactoryBuilder().setNameFormat(event-bus-worker-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() ); AsyncEventBus asyncEventBus new AsyncEventBus(executor);同时订阅方法的注解要改成Subscribe(threadMode ThreadMode.ASYNC) public void onOrderCreated(OrderCreatedEvent event) { // 这个方法运行在线程池的某个线程里发布线程不等待 }注意线程池不要图省事用Executors.newFixedThreadPool因为它默认是无限队列任务积压时会占满内存。队列设个有界容量再配CallerRunsPolicy的拒绝策略意思是线程池满的时候让发布方自己执行这样既不会丢事件也能给发布方一个反压信号我实测这种组合在流量突刺时最稳。3.3 BACKGROUND和MAIN的适用边界除了POSTING和ASYNC还有两个模式很多人容易搞混。MAIN模式面向Android UI线程设计的事件会尽量切到主线程执行。普通Java服务里基本用不上除非你在写Android。BACKGROUND模式文档上的意思是如果发布方不在主线程订阅方法就直接在发布线程执行如果发布方在主线程就把事件扔到后台线程串行执行。这就带来一个坑在普通Java后端服务里根本没有主线程这种概念Spring Boot的主流程也是各个业务线程池里的线程在跑。所以BACKGROUND在绝大多数后端场景下实际表现和POSTING基本一样并不会保证异步。如果在网上看到有人说BACKGROUND是后台异步线程执行那是对文档的误读。3.4 三种线程模式的选型对照我平时做技术选型时心里就一张表线程模式事件在哪执行是否阻塞发布方典型使用场景POSTING发布线程会阻塞快速内存操作、日志、简单的状态更新BACKGROUND发布线程后端场景会阻塞同上普通Java服务与POSTING区别不大MAINAndroid主线程视情况Android UI刷新ASYNC线程池不阻塞远程调用、写库、耗时统计、非关键链路一句话总结拿不准的时候先用POSTING确定要异步了再上ASYNC别一上来就全异步。异步虽然不阻塞主流程但带来了顺序不确定、事务隔离、线程池耗尽等一系列新问题。4. 容易被忽略的高级特性与隐藏的坑4.1 Subscribe方法还有哪些讲究用了几年EventBus我见过太多注册时报错的案例问题出在订阅方法上。Subscribe标注的方法有硬性要求修饰符必须是publicprivate和protected会直接注册失败。必须有且仅有一个参数参数类型就是它关心的事件类型。不能是static方法其实static方法也可以但参数规则一样。还有一个限制需要注意同一个类的实例里不能有两个Subscribe方法监听同一个事件类型否则注册时会抛异常。但同一个实例可以监听不同的事件类型方法名随你取不受影响。踩过的一个坑是写内部类做监听器时内部类如果是非静态内部类或者private修饰反射扫描有可能拿不到或者方法可见性不满足。我后来统一要求监听器必须是独立的public类入参和出参都定义清楚避免在内部类上折腾。4.2 粘性事件sticky的用法和内存风险粘性事件Sticky Event是EventBus里比较进阶的功能。它的含义是事件被发布后总线会保存最近一条该类型的事件之后新注册的订阅者会立刻收到这条老事件。适合状态同步场景。比如用户登录状态某个模块后启动时关心当前是否已经登录直接等新事件可能等不到粘性事件就能解决这个问题。使用方式public class LoginListener { Subscribe(sticky true) public void onUserLogin(UserLoginEvent event) { System.out.println(收到登录事件 event.getUserId()); } } EventBus eventBus new EventBus(); // 发布粘性事件需要Guava较新版本老版本要自己用Map缓存 eventBus.postSticky(new UserLoginEvent(9527L)); // 后注册的订阅者会立刻收到上面那条事件 eventBus.register(new LoginListener());这个功能好用但内存风险很大。每一条粘性事件都会被总线一直引用着如果不主动清理相当于拿内存换状态。老的粘性事件和新的粘性事件类型不同时都会保留如果业务里高频发布粘性事件又没有移除机制时间一长就是内存泄漏。我的做法是粘性事件只用于低频的状态快照比如登录状态、配置版本号并且在使用方完成初始化后调用移除方法把粘性事件清掉。用完即清别留着过年。4.3 DeadEvent死信事件作用与滥用代价Guava EventBus有个特殊机制当你发布一个事件总线找不到任何订阅者时它不会静默丢弃而是把该事件包装成DeadEvent对象再发布一次。如果你恰好注册了DeadEvent的监听就能捡到这些无人处理的事件。import com.google.common.eventbus.DeadEvent; Subscribe public void onDeadEvent(DeadEvent deadEvent) { System.out.println(未被处理的事件 deadEvent.getEvent().getClass()); }这个机制在调试时很有用能帮你发现发布了半天事件结果根本没订阅者的尴尬情况。但线上环境要慎用否则就掉坑里了如果业务大量发布没有被监听的事件每个都会变成DeadEvent对象被打包发布高并发时GC压力一下就上来了。我见过一个系统因为注册了DeadEvent监听又存在大量无效事件Young GC频率直接翻倍的案例。结论是DeadEvent最适合在开发环境和测试环境做问题发现线上要开也得多加监控别裸奔。4.4 异常处理没有你想的那么简单Guava EventBus对订阅方法有统一的异常处理机制。默认情况下一个订阅方法执行时抛了异常总线不会直接中断整个post流程而是把异常交给SubscriberExceptionHandler处理默认的handler只是把错误日志打出来。也就是说业务上依赖事件处理失败会影响发布方结果的逻辑默认是不成立的。这个默认行为经常坑人。有一次一个订阅方法里跑了半天的逻辑然后抛异常发布方毫不知情数据对不上账排查了很久才定位到是EventBus吞了异常。解决办法有两个方向。方向一在订阅方法内部自己捕获异常做补偿或记录Subscribe public void onOrderCreated(OrderCreatedEvent event) { try { // 业务逻辑 } catch (Exception e) { // 记录错误、重试或告警 } }方向二全局自定义异常处理器import com.google.common.eventbus.EventBus; import com.google.common.eventbus.SubscriberExceptionContext; import com.google.common.eventbus.SubscriberExceptionHandler; public class LogAndAlertHandler implements SubscriberExceptionHandler { Override public void handleException(Throwable exception, SubscriberExceptionContext context) { // 拿到订阅方法、事件实例、总线实例做统一告警 System.err.println(订阅方法执行异常 context.getSubscriberMethod()); } } EventBus eventBus EventBus.builder() .subscriberExceptionHandler(new LogAndAlertHandler()) .build();我实际项目里用的是第二种统一在handler里把异常按级别切分、汇聚到监控平台线上问题能第一时间感知到。4.5 生命周期管理与内存泄漏问题EventBus的反注册很容易被遗忘。register之后总线会持有订阅者的引用如果订阅者是个短生命周期的对象比如某个请求作用域的服务忘记unregister这个对象就永远无法被GC回收。正确的配套操作eventBus.register(listener); // 不再需要时 eventBus.unregister(listener);我建议注册和反注册都在一个生命周期里成对维护。如果用的是Spring容器监听器最好做成单例Bean生命周期和应用同进退避免频繁注册、反注册带来的额外损耗。否则高并发创建临时监听器又不反注册内存能被慢慢耗尽。5. 实战案例下单逻辑的EventBus改造5.1 改造前一条主流程里堆了5种业务回到开头的createOrder方法假设一个订单服务里同时要处理短信、库存、埋点、推荐同步、优惠券核销原来的方法像流水账一样一长串。每次这些周边业务里任何一个抛异常都会直接影响下单主流程。有一次优惠券核销超时用户连订单都下不了整个链路全部牵连。这就是典型的核心链路和周边链路耦合问题。核心链路应该只做核心的事周边通知和状态同步该剥离出去。5.2 基于EventBus的订阅者拆分改造后核心服务只发布事件public class OrderService { private final EventBus eventBus; public OrderService(EventBus eventBus) { this.eventBus eventBus; } public Order createOrder(OrderDTO dto) { Order order saveOrder(dto); eventBus.post(new OrderCreatedEvent(order)); return order; } }短信、库存、埋点各自做成独立监听器public class SmsNotifier { Subscribe public void onOrderCreated(OrderCreatedEvent event) { smsService.sendSms(event.getUserId(), 下单成功); } } public class StockSubscriber { Subscribe public void onOrderCreated(OrderCreatedEvent event) { stockService.deduct(event.getProductId(), event.getCount()); } } public class TrackSubscriber { Subscribe public void onOrderCreated(OrderCreatedEvent event) { trackService.track(order_created, event); } }启动时统一注册eventBus.register(new SmsNotifier()); eventBus.register(new StockSubscriber()); eventBus.register(new TrackSubscriber());以后新增一个业务只需要新建一个监听器类再注册一次核心服务一行代码都不用改。这个改造对代码可维护性的提升是肉眼可见的。5.3 事务一致性与异步化取舍改造之后有个新的隐患原来所有逻辑在一个方法里同一个事务拆成事件后如果用的是同步POSTING模式订阅方法还在同一个线程里执行事务还能勉强保持一致如果换成AsyncEventBus订阅方法跑在工作线程事务就各自独立了。所以核心链路里的一致性要求不能靠异步事件硬扛。我的原则是强一致场景扣库存、状态流转不异步化或者另外做分布式事务方案。最终一致场景短信通知、埋点推荐放心异步化失败了记录日志做补偿。从成本上看EventBus解决的是代码组织和模块解耦的问题不是解决跨服务数据一致性的银弹。想清楚哪些事件能异步、哪些不能异步这个改造才算落地干净。6. 常见问题排查实录与个人经验6.1 监听器不触发、重复触发怎么查我统计了一下EventBus最多的问题就是我发布了事件为什么订阅方法没跑。第一步看是否register了。EventBus不会自动扫描包不register就是没人听。第二步看事件类型是否匹配。发布的是一个子类事件而订阅方法监听的是父类虽然也能收到但如果反过来就收不到。第三步看订阅方法是否符合要求。不是public、参数不是恰好一个、方法上有坑都会导致注册时直接抛异常异常还会在register阶段报出来仔细看堆栈。重复触发的问题多半是同一个监听器实例被register了两次或者同一个类注册了多个实例。EventBus内部记录订阅关系是允许重复注册的重复注册就会重复收到同一个事件。排查时先检查启动流程是不是在多个地方初始化了总线或者重复加载了监听器。6.2 订阅顺序和并发下的乱序问题EventBus官方不保证同一类型多个订阅者的执行顺序。我实际观察下来同步模式下基本按照注册顺序执行但这个行为没有被文档承诺业务不能依赖它。如果多个监听器之间存在先后依赖比如A先写日志、B再发通知别指望注册顺序应该重构成一个监听器内部按顺序处理或者给事件加上阶段标记。另一个是异步模式下的顺序问题。AsyncEventBus里事件从队列到线程池如果有多个线程执行订阅方法的执行顺序和发布顺序大概率不一致。需要严格顺序的业务场景比如资金流水别用多线程异步EventBus承接要么串行线程池要么设计成单线程消费者。6.3 我在这几年里比较推荐的用法边界如果让我给个参考配置在普通Spring Boot单体项目里我最常用的组合是核心业务内的事件快速、可靠、强相关EventBus默认POSTING同步模式保证顺序和事务。外围的、慢的、可靠性要求低的事件短信、推送、埋点AsyncEventBus独立线程池队列有界拒绝策略CallerRuns异常统一打监控。一个应用只建少量总线不搞几十个EventBus实例区分事件类型比区分总线更实用。我个人在实际操作中的体会是看着简单的东西深挖全是细节EventBus这类工具尤其如此。它把解耦这件事变得太容易反而让人放松了对线程模型、异常处理、生命周期这些底层的警惕。你越把它当普通消息中间件用越容易在性能和一致性上出事只有在心里时刻清楚它是进程内的、内存的、默认同步的轻量组件才能把它安置在合适的位置。最后再分享一个小技巧刚接手一个老系统时可以先全局搜一下业务代码里有没有已经用了EventBus但没有任何订阅者的地方通常在DeadEvent监听或日志里就能看出模块间真实的依赖关系这个排查思路比对着调用链翻代码快很多。