☰
直播间送礼场景下观察者模式的工程实践与深度解析
2026/10/8 3:57:11 网站建设 项目流程

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 List<GiftObserver> 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) { List<GiftObserver> 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 List<GiftObserver> 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 修饰符,能省掉你在并发排障时的很多痛苦。

在我做过的直播项目里,观察者模式让送礼系统的扩展变得特别干净。每次产品提新需求,比如“加一个礼物带货入口”“加一个礼物积分活动”,团队都能很轻松地新增一个观察者并接入,而原有逻辑完全不受影响。这就是设计模式存在于教科书之外的真实价值——它不只是考题,更是工程里每天都在用的工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询