
1. 从直播间送礼说起观察者模式到底解决了什么问题1.1 一次送礼背后系统要干多少事我最早接触观察者模式是在做直播业务的后端系统时。当时产品提了一个需求用户在直播间送出一发“火箭”然后主播端和观众端要同时触发各种效果。你以为只是简单扣个钱、加个特效实际拆开来看一次送礼至少要触发下面这些动作扣减送礼人的虚拟币余额生成一笔礼物订单直播间公屏滚动一条送礼弹幕比如“某某某 送出火箭”全屏播放火箭起飞特效观众端实时渲染主播语音播报“感谢某某某的火箭”让没看屏幕的主播也能听到更新直播间的礼物贡献榜送礼金额大的要排到前面粉丝团亲密度增加粉丝牌等级可能要升级后台统计系统记录流水用于主播分成、礼物收入报表、热门主播排行如果这些逻辑全部硬编码在“送礼”这个业务方法里一次送礼就会变成一个几百行的巨型方法。更可怕的是每新增一个需要响应送礼事件的功能你都得去改送礼入口的代码。我当时就踩过这种坑。第一次做类似需求时我在送礼方法里直接调用了特效方法、弹幕方法、榜单方法。后面产品说加一个“礼物连击播报”我就得再打开送礼方法改一段再加一个“礼物价值统计”又改一段。那个方法最终膨胀到一千多行中间还出现过因为一次异常导致整个送礼流程回滚——一个子功能挂了送礼人都送不出礼物。这时候观察者模式的价值就体现出来了。它的核心思想很简单把“事件发生”和“事件引发的动作”解耦。送礼是一个事件而特效、弹幕、榜单、播报这些都是对这个事件的“观察者响应”。当事件发生时事件中心只需要通知所有注册过的观察者至于每个观察者是谁、要干什么、怎么干事件中心完全不需要关心。1.2 观察者模式的核心角色结合直播间场景来理解观察者模式有三个核心角色我习惯用直播间的例子来解释给团队新人听角色类名直播间里的对应物主题/事件中心Subject直播间里的“礼物事件中心”负责接收送礼事件并广播给所有订阅者观察者接口Observer定义了“收到事件后要做什么”的统一规范比如update(GiftEvent)具体观察者ConcreteObserver特效系统、弹幕系统、语音播报、榜单系统、统计系统主题维护了一个观察者列表提供三个基本操作注册观察者、移除观察者、通知所有观察者。当礼物事件发生时主题遍历观察者列表逐个调用观察者的回调方法。用生活化的类比来说观察者模式就像你在直播间关注的几个“情报小助手”。你告诉小助手“如果某某人送礼了就马上告诉我”小助手把这些订阅需求记在一个本子上。每次有人送礼小助手就翻本子挨个通知“喂你关注的事件发生了。”至于通知完之后你要怎么反应——是放特效、发弹幕还是更新榜单小助手根本不管。这种设计的最大好处是开闭原则增加新的响应动作不需要修改已有的主题和事件逻辑只要新增一个观察者并注册进去。我在后面第3章会完整演示这个流程你会发现新增“礼物总价值统计”功能时一行原有代码都不用动。2. 结构拆解观察者模式的接口、事件与通知机制设计2.1 观察者接口怎么设计事件对象该长什么样观察者模式最基础的结构是这样的一个主题类持有一个观察者列表观察者实现统一的接口主题在事件发生时遍历并通知所有观察者。但真要落地到直播间送礼系统有几个设计细节值得多想一层。首先是观察者接口的方法签名。最经典的是 JDK 里那种update(Observable o, Object arg)但现代工程已经不推荐直接继承java.util.Observable了因为它是类不是接口限制了主题的继承灵活性而且它的状态管理方式比较笨重。我更推荐自己定义接口就像这样public interface GiftObserver { void onGiftReceived(GiftEvent event); }接口参数是消息对象本身。很多新手会问为什么不直接传礼物ID、用户ID这种散装参数如果观察者将来需要更多数据比如送礼时间、连击数量、礼物数量你就得改接口签名所有观察者都得跟着改。而传一个GiftEvent对象将来加字段只改事件类本身观察者按需读取接口保持稳定。然后是事件对象的设计。送礼事件至少应该包含这些字段public class GiftEvent { private Long giftId; // 礼物ID比如 1001 表示火箭 private String giftName; // 礼物名称 private Long fromUserId; // 送礼人 private Long toUserId; // 接收人也就是主播 private Long roomId; // 直播间ID private Integer amount; // 礼物数量一次送多个 private Integer priceInCoins; // 单价单位是虚拟币 private Long timestamp; // 送礼时间 private Integer comboCount; // 连击数 // getters / setters 省略 }为什么要保留roomId因为观察者往往需要判断自己是否与该事件相关。比如用户同时开着几个直播间特效播放器只响应当前直播间的礼物事件再比如主播的语音播报组件只收听自己直播间的送礼事件。观察者可以通过事件对象里的roomId做过滤。2.2 主题类的实现注册、移除、通知三件套主题类的写法其实非常固定我直接给出一个完整版本代码里加了同步锁因为直播间场景下同一场直播可能同时有大量用户送礼多线程并发注册和通知是必然发生的public class GiftEventCenter { private final ListGiftObserver observers new ArrayList(); private final Object lock new Object(); // 注册观察者 public void attach(GiftObserver observer) { synchronized (lock) { if (!observers.contains(observer)) { observers.add(observer); } } } // 移除观察者 public void detach(GiftObserver observer) { synchronized (lock) { observers.remove(observer); } } // 通知所有观察者 public void notifyObservers(GiftEvent event) { ListGiftObserver snapshot; synchronized (lock) { snapshot new ArrayList(observers); } for (GiftObserver observer : snapshot) { observer.onGiftReceived(event); } } }这段代码有两个细节我在实际项目中踩过坑先说给你听。第一通知时为什么要复制一份快照而不是直接遍历原列表因为如果某个观察者在执行onGiftReceived时触发了detach操作比如特效系统播完特效后把自己移除直接遍历原列表会抛ConcurrentModificationException。用快照遍历就不会影响当前这一轮通知。第二如果不做contains去重同一个观察者可能被重复注册导致一条礼物事件被通知两次。直播间特效如果被触发两次用户看到的就是礼物特效闪了一下就消失然后重新播一遍观感极差。我在实际排查过类似线上问题最后发现就是注册代码被调用多次引起的。2.3 同步通知还是异步通知这是个关键选择这是观察者模式在工程落地时最重要的一个决策。如果采用同步通知也就是主题在notifyObservers里直接挨个调用观察者的方法那么一个观察者执行慢了整个送礼流程都会被拖慢。比如语音播报要请求第三方语音合成服务网络耗时可能几百毫秒。一个送礼请求如果要在送礼线程里等语音合成回来用户的送礼体验会非常卡顿。如果采用异步通知则要权衡线程管理和消息顺序。比如弹幕必须按送礼顺序展示吗从用户体验来说建议按顺序展示而语音播报和榜单更新顺序要求就没那么严格。我个人的建议是核心链路用同步次要响应用异步。具体来说扣费、订单这些属于送礼主流程根本不应该放进观察者体系里它们应该在业务入口直接完成。而特效、弹幕、榜单、播报这些属于“事件后的响应”适合用观察者模式。对于异步化我推荐在主题内部持有线程池public class AsyncGiftEventCenter { private final ExecutorService executor Executors.newFixedThreadPool(8); private final ListGiftObserver observers new CopyOnWriteArrayList(); public void notifyObservers(GiftEvent event) { for (GiftObserver observer : observers) { executor.submit(() - observer.onGiftReceived(event)); } } // attach/detach 逻辑省略 }线程池用了CopyOnWriteArrayList而不是普通ArrayList因为异步场景下读操作远多于写操作这个选择能减少锁竞争。不过异步也带来了新问题观察者之间的执行顺序不再可控异常也更难追踪。这个话题我在第4章排查技巧里会展开讲。3. 实操实现从零搭建直播间送礼事件系统3.1 基础代码礼物事件中心与首个观察者这一章我会带你完整实现一个可运行的送礼事件系统。我尽量让代码贴近真实直播业务但又保持精简方便你直接抄作业。开发环境你可以用任意 Java 8 版本不需要额外框架。第一步先定义观察者接口和事件对象事件对象在第2章已经给出这里不重复。第二步实现事件中心。同步版本和异步版本我都写了真实场景我建议你先用同步版本跑通逻辑再替换成异步。第三步是最有意思的——实现观察者。先说特效观察者。直播间特效系统通常需要把特效数据和礼物做绑定映射然后推给前端渲染。这里模拟一个特效观察者public class GiftEffectObserver implements GiftObserver { Override public void onGiftReceived(GiftEvent event) { // 根据礼物ID查特效配置比如 1001 对应 rocket_effect String effectType resolveEffectType(event.getGiftId()); // 在这里调用实时消息服务把特效指令推送给直播间所有观众端 boolean pushSuccess pushEffectToClients( event.getRoomId(), effectType, event.getFromUserId()); if (!pushSuccess) { // 记录失败日志但不抛出异常避免影响其他观察者 logger.warn(特效推送失败: roomId{}, giftId{}, event.getRoomId(), event.getGiftId()); } } }我特意在代码里写明“失败时只记录日志不抛出异常”这是观察者模式实现里特别容易被忽视的经验。一个观察者失败不应该拖垮整个通知链路因为每个观察者本质上是“额外动作”对于送礼主流程来说特效播放失败用户依然应该收到礼物余额依然应该被扣除。3.2 批量实现弹幕、榜单、播报、统计四个观察者弹幕观察者的工作很简单组装一条送礼弹幕文本发送到直播间公屏。弹幕文本有一个经典格式“用户昵称 送出 礼物名 x数量”。如果GiftEvent里没有用户昵称就需要通过用户ID去查询。这里有个小技巧可以把用户昵称直接放进事件对象里避免观察者重复查库。public class DanmakuObserver implements GiftObserver { Override public void onGiftReceived(GiftEvent event) { String text event.getUserName() 送出 event.getGiftName() (event.getAmount() 1 ? x event.getAmount() : ); pushDanmaku(event.getRoomId(), text); } }榜单观察者则要更新直播间的礼物贡献榜。这里的实现思路是读取当前直播间的榜单缓存把送礼人的贡献值累加再写回缓存。这份数据后续有两处会用到主播端实时展示的榜单以及整场直播结束后的最终结算。public class RankObserver implements GiftObserver { Override public void onGiftReceived(GiftEvent event) { long totalCoins (long) event.getPriceInCoins() * event.getAmount(); increaseRankContributions(event.getRoomId(), event.getFromUserId(), totalCoins); } }语音播报观察者是最典型的异步场景。你需要把“送礼人礼物名”拼成一句文本调用第三方TTS文本转语音接口再把音频推给主播端。这个流程比较耗时必须异步化而且失败也不应该影响主流程。最后一个是新增的“礼物总价值统计”观察者。这个功能要统计主播当天收到的礼物总价值用于后台的收益报表。放在观察者模式里新增它只需要三步写一个类实现GiftObserver在初始化时注册完事。主题类、事件类、其他观察者不用动一行代码。这就是观察者模式帮你守住的开闭原则。3.3 联调流程与验证方法代码都写好了接下来要验证效果。我写一个简单的模拟客户端来跑通整个链路public class GiftDemoApp { public static void main(String[] args) { GiftEventCenter center new GiftEventCenter(); center.attach(new GiftEffectObserver()); center.attach(new DanmakuObserver()); center.attach(new RankObserver()); center.attach(new VoiceBroadcastObserver()); // 模拟用户“小A”送出2发火箭给主播 GiftEvent event new GiftEvent(); event.setGiftId(1001L); event.setGiftName(火箭); event.setFromUserId(10001L); event.setToUserId(20002L); event.setRoomId(888L); event.setAmount(2); event.setPriceInCoins(1000); event.setTimestamp(System.currentTimeMillis()); center.notifyObservers(event); } }跑完你可以看到四个观察者各自输出了自己的日志。你还可以写几个测试用例来验证核心行为测试一观察者注册两次事件触发后只收到一次通知。测试二移除观察者后事件触发不再收到通知。测试三某个观察者抛出异常其他观察者依然正常执行。这三个测试用例我建议你在工程里保留着以后改动观察者注册逻辑时回归测试能帮你兜住不少意外。4. 真实项目中的坑与排查技巧4.1 观察者生命周期管理最容易出线上事故观察者模式最常见、最隐蔽的坑就是生命周期问题。简单理解观察者注册了不注销就会产生两类问题——内存泄漏和事件空转。直播间场景下尤其严重。直播间是有生命周期的开播时创建关播时销毁。如果每个直播间的特效观察者、弹幕观察者、榜单观察者都注册到全局事件中心但关播时没有移除那么后面任何一场直播的送礼事件都会发给这些“僵尸”观察者。它们拿着已经销毁的直播间上下文去做操作轻则浪费资源重则空指针、使用已关闭的房间连接导致异常。我在实际项目里就处理过一个线上事故主播下播后某个清理定时任务一直没有正确执行观察者的detach导致每场直播的送礼事件都会触发上播期间遗留的处理逻辑服务器的无效调用暴涨下游数据库压力翻了几倍。解决办法有三条我建议按优先级依次做在直播间组件销毁的回调里显式调用detach移除所有观察者。观察者接口增加一个isActive()方法事件中心在通知前检查观察者是否仍可用不可用则自动移除。事件中心使用弱引用持有观察者让不可达的观察者能被垃圾回收。第三种方案我实际用过但它有个副作用如果观察者被强引用到其他地方弱引用失效就会导致事件中心收不到通知排查起来很费劲。所以我更推荐前两种组合使用这也是我在多个直播系统中验证过的稳妥方案。4.2 异常隔离与通知保序观察者模式里如果某个观察者的onGiftReceived抛出异常默认会中断当前线程的后续调用。这意味着特效观察者挂了弹幕、榜单、播报全都不执行。这对直播送礼是绝对不能接受的。解决思路是在事件中心的通知代码里做统一异常捕获for (GiftObserver observer : snapshot) { try { observer.onGiftReceived(event); } catch (Exception e) { logger.error(观察者处理失败: {}, observer.getClass().getSimpleName(), e); } }这样做还有一个额外好处你可以在日志里清楚地看到是哪个观察者出的问题而不是整个送礼线程异常中断后留下一堆难以定位的堆栈。我在第3章的代码里特意让特效观察者自己捕获异常其实就是为了演示这个思路。最稳妥的做法是“双重防护”观察者自身做好异常处理事件中心再兜底一次。通知保序则是另一个很容易被忽略的问题。同步通知天然有序但异步通知下必须用LinkedBlockingQueue保证提交顺序和执行顺序一致或者对同一直播间的通知使用单线程executor。如果你让8个线程并发送出弹幕通知观众端很可能看到顺序错乱的弹幕。我在工程里的做法是为每个直播间分配一个单线程的消息专用执行器这样既保证隔离也保证顺序。4.3 工程化改进Spring事件机制与消息队列如果你用 Spring 框架做后端开发会发现 Spring 自带的ApplicationEventPublisher就是观察者模式的框架级实现。用法很简单发布事件然后在监听方法上加注解。Service public class GiftServiceImpl { Autowired private ApplicationEventPublisher publisher; public void sendGift(GiftRequest request) { // 主流程扣费、确认礼物成功 GiftEvent event buildEvent(request); publisher.publishEvent(event); } } Component public class RankEventListener { EventListener public void onGiftEvent(GiftEvent event) { // 更新榜单 } }用 Spring 事件的好处是你不用自己管理观察者的注册和移除Spring 容器会帮你创建监听器实例并完成自动装配。但注意Spring 默认的EventListener是同步执行的如果希望异步需要加Async注解并且启用异步支持。另外发布事件的方法执行太慢时可以考虑TransactionalEventListener它支持在事务提交后再触发事件避免业务还未落库观察者就去查数据查不到。如果系统规模再上一个台阶比如每天的送礼事件量达到百万级以上单一进程内的事件广播就不够了。这时候可以用消息队列比如 RocketMQ 或 Kafka。你在送礼主流程里发送一条“礼物事件”消息特效服务、弹幕服务、榜单服务各自订阅这个消息分别处理。从本质上看消息队列就是分布式版本的观察者模式消息队列充当了“事件中心”消费者就是“观察者”。理解了观察者模式你理解消息队列里的发布订阅模型就会很容易。这种演进路径非常自然单机进程内先用观察者模式解耦流量上来后再把观察者拉成独立服务用消息队列接替事件中心。我在好几个项目里都是按照这个节奏演进的每一步都顺理成章。5. 写在最后的一点个人体会观察者模式是我在高并发直播业务里用的最多的设计模式之一但它不是银弹有几个场景我会明确避免用它。如果事件和响应之间的需求极其简单只有一两个固定动作直接调用比观察者模式更清晰如果一个事件会被几十个观察者订阅每个观察者都执行大量逻辑代码会变得很“飘”因为事件的因果关系被拆散了出问题时要跨很多类去追溯。我个人的习惯是给一个主题设置观察者上限超过10个就拆成多条事件或者对观察者做分组比如“礼物特效组”“礼物数据组”“礼物通知组”。这样控制复杂度也能保证系统可维护。最后分享一个小技巧写观察者时尽量让事件对象保持不可变即所有字段在构造时确定不提供 setter。不可变对象在线程间共享时天然安全观察者模式配合异步化最怕的就是一个观察者改了事件里的字段另一个观察者读到脏数据。为这个字段加一个 final 修饰符能省掉你在并发排障时的很多痛苦。在我做过的直播项目里观察者模式让送礼系统的扩展变得特别干净。每次产品提新需求比如“加一个礼物带货入口”“加一个礼物积分活动”团队都能很轻松地新增一个观察者并接入而原有逻辑完全不受影响。这就是设计模式存在于教科书之外的真实价值——它不只是考题更是工程里每天都在用的工具。