2026/10/9 6:36:14

观察者模式实战:直播间送礼系统的解耦设计与实现

观察者模式实战:直播间送礼系统的解耦设计与实现 打开任何一个直播App走进一个正在热闹开播的直播间。观众点了一下礼物栏送出一发火箭。接下来几秒钟里弹幕频道滚出XXX 送出了火箭全屏特效炸开主播的语音感谢响起礼物榜排名跳动主播的收益数字累加粉丝勋章顺势升级。这一连串动作涉及五六个独立模块而它们全部由同一件事触发——一条礼物事件。如果你负责写这套送礼逻辑第一反应多半是在收到礼物的入口里把每个模块的方法按顺序调一遍。写起来是快但需求一变更就头疼。这正是观察者模式要解决的问题也是设计模式系列里最实用、最好上手的模式之一。这篇博文我就拿直播间送礼系统当例子从硬编码的痛点讲起手把手实现完整的观察者模式代码再聊推拉模型、并发安全、异步通知这些实战中绕不开的坑。无论你是正在准备设计模式期末还是做设计模式大作业或者已经在写业务代码想重构一把这篇都能给你一份可以直接照抄的方案。1. 为什么送礼逻辑会写不下去先看没有模式时的代码有多脆弱1.1 一次送礼引发的连锁反应用户送礼这个动作背后可以拆成两层逻辑。第一层是核心业务校验账户余额、扣款、生成礼物记录、更新礼物库存。这一层是必须成功的属于事务性操作。第二层则是外围响应弹幕服务要滚动播报、特效服务要播放动画、榜单服务要刷新排名、收益服务要更新主播账户、勋章服务要检查升级。这些响应有一个共同特点——它们不是送礼本身的必须事务但少了任何一环用户和主播的体验都会明显打折。核心业务和外围响应本质上是一种主从关系或者叫一对多关系。一次送礼事件多个模块需要协作响应。观察者模式的设计目标恰好就是为这种关系提供一个干净的协作框架。1.2 硬编码版送礼系统是怎么一步步失控的很多经历过快速迭代的团队代码会长成下面这样。我先声明这段代码不是虚构的它是无数直播间业务代码的真实缩影。public class GiftService { private BarrageService barrageService; private EffectService effectService; private RankService rankService; private IncomeService incomeService; public void onReceiveGift(String userId, String giftId) { // 1. 核心业务扣钱、记录流水 GiftRecord record doPayAndSave(userId, giftId); // 2. 外围响应依次调用 barrageService.pushMessage(record.getUserName() 送出了 record.getGiftName()); effectService.play(record); rankService.update(record); incomeService.addIncome(record); // 第3天加个礼物置顶弹幕继续加一行 // barrageService.pinTop(record); // 第5天加个粉丝勋章再加一行 // medalService.upgrade(record); } }这段代码如果只活两天一点问题没有。但直播业务恰恰是互联网里需求变更最频繁的赛道之一。第3天产品说要给火箭弹幕置顶第5天说要新增粉丝团勋章第10天说礼物价格要联动福袋抽奖。每一次需求变更都要打开这个onReceiveGift方法往里面加一行调用。渐渐地方法体越来越大依赖越来越多测试用例越来越难写。更危险的是这个方法的健壮性被外围逻辑绑架了。假设弹幕服务因为瞬时流量出现了超时异常异常一路冒泡到送礼入口导致整个送礼流程回滚。用户礼物没送出去、钱扣了体验直接崩盘。可弹幕服务明明和核心支付没有关系它只是围观送礼这个动作而已。1.3 核心问题核心业务被外围逻辑绑架把这段硬编码的问题归类一下你会看得更清楚。第一耦合度过高。GiftService直接依赖BarrageService、EffectService、RankService、IncomeService这些具体实现。核心流程被外围模块拖累任何一个外围模块的变动都可能影响送礼主链路。第二违背开闭原则。每加入一个响应方就要修改一次GiftService。修改越频繁引入回归的风险越大。第三响应方无法独立选择。有些场景不需要弹幕播报有些礼物不值得播全屏特效。硬编码方式下要么所有模块全调一遍要么写一堆if (giftPrice 100) effectService.play()之类的判断把业务规则散落在调用入口。观察者模式针对的正是这三个问题解耦事件源和响应方让响应方自己决定要不要订阅、如何响应从而让核心方法保持稳定、简洁。先把这个目标记住后面看代码会特别有感觉。2. 观察者模式的角色拆解把送礼通知改造成订阅-广播模型2.1 一句话讲清观察者模式观察者模式定义了对象之间一种一对多的依赖关系。一个主题对象Subject的状态发生变化时所有依赖它的观察者Observer都会收到通知并自动更新自己的行为。用直播间的语言翻译一下直播间就是一个主题弹幕系统、特效系统、榜单系统、收益系统都是观察者。用户送礼后直播间状态发生变化于是广播一条礼物事件所有订阅了这件事的系统各自做出响应。整个过程主题对象不需要知道观察者内部怎么处理观察者也不需要知道主题对象内部怎么记账。两边只通过一个约定好的通知接口打交道。2.2 四个核心角色和直播间一一对应观察者模式有四个经典角色对应关系非常清晰角色直播间例子核心职责Subject主题直播间类 LiveRoom维护观察者列表提供 attach/detach 方法状态变化时触发通知ConcreteSubject具体主题LiveRoom 的具体实现实现订阅管理和通知逻辑持有最近一次礼物事件的状态Observer观察者GiftObserver 接口定义接收通知的统一接口是所有响应方的契约ConcreteObserver具体观察者弹幕、特效、榜单、收益等系统实现接口收到通知后执行各自的业务逻辑初学的时候容易混淆的是 Subject 和 ConcreteSubject。我习惯这样区分Subject 是抽象层定义了可以被观察的接口ConcreteSubject 是真干活的对象它存着观察者列表并在事件发生时遍历通知。在送礼场景里LiveRoom 就是那个 ConcreteSubject它本身是直播间的一个领域对象具备送礼的能力同时也具备被观察的能力。2.3 一个容易混淆的点观察者模式不是简单的回调很多初学者会把观察者模式和回调Callback搞混这很正常。观察者模式确实需要借助回调式的接口调用但两者语义不一样。回调强调的是把一段逻辑作为参数传给某个方法由方法在合适的时机调用它核心是控制流的反转。观察者模式则强调维护一组观察者在事件发生时按顺序遍历通知核心是一对多的关系管理。举个具体例子。如果直播间的弹幕服务只是给receiveGift方法传一个Runnable进去那是回调。但实际上我们要注册的是弹幕服务、特效服务、榜单服务这三个独立对象而且它们随时随地可以动态增删。这就是观察者模式。理解这个区别面试时被追问观察者模式和回调的关系就不会翻车。3. 手把手实现直播间送礼系统六步跑通观察者模式这一章我会用纯 Java 手写完整实现代码可以直接复制运行。每写一步我都会解释关键设计决策因为设计模式最重要的不是记住结构而是理解每一步为什么这么做。3.1 第一步定义礼物事件对象 GiftEvent观察者模式的通知中观察者需要知道发生了什么。我的习惯是定义一个只读的、不可变的事件对象把一次送礼的所有上下文打包进去。这样通知的语义最清晰观察者拿到的数据也是快照式的不会出现事件被后续修改的情况。public class GiftEvent { private final String userName; // 送礼人 private final String giftName; // 礼物名 private final int price; // 礼物价值钻 private final long timestamp; // 送礼时间 public GiftEvent(String userName, String giftName, int price, long timestamp) { this.userName userName; this.giftName giftName; this.price price; this.timestamp timestamp; } public String getUserName() { return userName; } public String getGiftName() { return giftName; } public int getPrice() { return price; } public long getTimestamp() { return timestamp; } Override public String toString() { return GiftEvent{userName userName , giftName giftName , price price , timestamp timestamp }; } }为什么第一步是定义事件对象而不是先写接口因为事件对象是整个观察者模式的通用语言。后面所有观察者都会依赖它。如果先把接口写好再回头补事件类遇到字段不够用的情况就得反复改接口签名那体验相当糟糕。先定事件模型再定通知接口是正确顺序。3.2 第二步定义观察者接口 GiftObserverpublic interface GiftObserver { void onGiftReceived(GiftEvent event); }这里有两个设计细节值得说。第一为什么用接口而不是抽象类因为观察者只有一个接收通知的契约接口天然适合定义契约。而且 Java 是单继承用抽象类会限制观察者再继承其他业务基类。实际工程中观察者实现类往往是各种服务类接口方式最干净。第二为什么方法名要具体化为onGiftReceived而不是通用的update接口语义越具体业务代码越容易读。读到onGiftReceived你能立刻猜到这是收到礼物后的回调读到update你得去查文档。3.3 第三步实现被观察者 LiveRoomLiveRoom 需要三件事记录订阅者、允许订阅/退订、在收到礼物时广播消息。注意几个细节。import java.util.ArrayList; import java.util.List; public class LiveRoom { private final ListGiftObserver observers new ArrayList(); public void attach(GiftObserver observer) { if (observer null) { throw new NullPointerException(observer cannot be null); } observers.add(observer); } public void detach(GiftObserver observer) { observers.remove(observer); } public void receiveGift(String userName, String giftName, int price) { System.out.println([直播间] 收到礼物 userName 送出 giftName 价值 price 钻); // 把原始参数包装成事件对象 GiftEvent event new GiftEvent(userName, giftName, price, System.currentTimeMillis()); // 广播给所有观察者 notifyObservers(event); } private void notifyObservers(GiftEvent event) { for (GiftObserver observer : observers) { observer.onGiftReceived(event); } } }attach里检查空对象不是小题大做。直播平台的 LiveRoom 会被多个业务方复用防御性校验能避免许多莫名其妙的问题。detach依赖 List 的 equals/hashCode所以具体观察者如果没有重写这几个方法就用同一个对象引用来注册和退订。一般我们在系统启动时实例化观察者并注册运行中退订的场景不多但接口必须留着。另外一个常见设计选择是LiveRoom 是否要承担所有事件的广播比如观众进入直播间也要广播吗更纯粹的做法是抽象出一个GiftSubject接口让 LiveRoom 实现它只暴露礼物事件的订阅和通知能力。这样直播间就只对礼物事件负责将来新增关注事件时单独建一个FollowSubject不会和礼物逻辑混在一起。项目里如果事件类型多务必这么拆如果只有礼物一种LiveRoom 直接充当 Subject 也是完全可以接受的。3.4 第四步写三个具体观察者每个观察者是一个独立模块。我故意让三个类的内容差异足够大方便感受同一个接口完全不同的响应逻辑。弹幕观察者public class BarrageObserver implements GiftObserver { Override public void onGiftReceived(GiftEvent event) { System.out.println([弹幕] event.getUserName() 送出了 event.getGiftName() 大家快来感谢老板); } }特效观察者public class EffectPlayerObserver implements GiftObserver { Override public void onGiftReceived(GiftEvent event) { if (event.getPrice() 100) { System.out.println([特效] 全屏火箭动画启动持续5秒); } else { System.out.println([特效] 播放小表情动画); } } }收益与榜单观察者import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class IncomeRankObserver implements GiftObserver { private int totalIncome 0; private final MapString, Integer userGiftMap new HashMap(); Override public void onGiftReceived(GiftEvent event) { totalIncome event.getPrice(); userGiftMap.merge(event.getUserName(), event.getPrice(), Integer::sum); System.out.println([收益] 直播间累计收益 totalIncome 钻); System.out.println([榜单] Top3送礼榜); for (Map.EntryString, Integer entry : getTop3()) { System.out.println( entry.getKey() entry.getValue() 钻); } } private ListMap.EntryString, Integer getTop3() { return userGiftMap.entrySet().stream() .sorted(Map.Entry.String, IntegercomparingByValue().reversed()) .limit(3) .collect(Collectors.toList()); } }注意IncomeRankObserver内部是有状态的它保存了直播间的累计收益和送礼榜。观察者完全可以有自己的内部状态这和观察者不应该修改主题状态并不冲突。主题负责广播事实观察者负责基于事实更新自己的状态。3.5 第五步组装直播间跑一遍全流程public class Demo { public static void main(String[] args) { LiveRoom room new LiveRoom(); // 注册观察者 room.attach(new BarrageObserver()); room.attach(new EffectPlayerObserver()); room.attach(new IncomeRankObserver()); // 观众送礼 room.receiveGift(小明, 棒棒糖, 5); room.receiveGift(小红, 火箭, 100); } }运行这段代码控制台会输出[直播间] 收到礼物小明 送出 棒棒糖价值 5 钻 [弹幕] 小明 送出了 棒棒糖大家快来感谢老板 [特效] 播放小表情动画 [收益] 直播间累计收益5 钻 [榜单] Top3送礼榜 小明5 钻 [直播间] 收到礼物小红 送出 火箭价值 100 钻 [弹幕] 小红 送出了 火箭大家快来感谢老板 [特效] 全屏火箭动画启动持续5秒 [收益] 直播间累计收益105 钻 [榜单] Top3送礼榜 小红100 钻 小明5 钻注意Demo类里没有任何一条业务逻辑涉及特效如何播放、榜单如何排名。它只做三件事创建直播间、注册观察者、触发送礼。想要让某个系统不再响应只需要一行room.detach(...)连 LiveRoom 都不用改。这就是解耦最直观的体现。3.6 第六步验证开闭原则需求来了产品说直播间要上线粉丝勋章功能用户送礼累计价值达到门槛勋章升级。按照观察者模式我们只需要写一个新的观察者类。public class MedalObserver implements GiftObserver { Override public void onGiftReceived(GiftEvent event) { int level event.getPrice() / 10; System.out.println([勋章] event.getUserName() 的粉丝勋章升级到 Lv. level); } }然后在组装处加一行room.attach(new MedalObserver());LiveRoom 没有改动三个老观察者没有改动核心送礼入口没有改动。观察者模式对对扩展开放、对修改关闭这句开闭原则做了最直白的诠释。这套代码以后每接一个礼物相关的模块都是新增一个类加一行注册而不是打开 GiftService 再塞一行调用。4. 真实业务中的三个关键问题推拉模型、并发安全与异步通知上面六步是观察者模式的教科书实现能应付大作业和面试。但如果把它直接搬到高并发的直播生产环境你会踩到三个大坑。这一章是实战中总结出的关键经验。4.1 推模型还是拉模型结合直播间场景选型观察者模式经典的分类是推模型Push Model和拉模型Pull Model。推模型主题把变动的详细数据主动推给观察者。上面的 GiftEvent 就是推模型主题把用户名、礼物名、价格、时间打包成事件对象直接塞给每一个观察者。好处是观察者不需要反向依赖主题通知及时。坏处是事件对象容易越撑越大变成什么都装的上帝对象。拉模型主题只发送我变了这样一个信号观察者收到信号后自己从主题对象中取数据。比如接口可以设计成public interface GiftObserver { void onGiftReceived(LiveRoom room); }观察者收到通知后自己调用room.getLastGiftEvent()来获取它需要的字段。这样事件对象不会臃肿但观察者必须知道主题对象的具体类型哪怕它只需要礼物事件里的一个用户名也要依赖整个 LiveRoom 类。这既增加了耦合也增加了取数据的时机风险——如果主题状态在通知后又变了观察者拿到的可能不是事件发生那一刻的数据。我的建议很明确直播间送礼场景直接用推模型。原因是送礼事件的上下文非常固定且有限四个核心字段足够描述一次赠礼行为。把最小化的 GiftEvent 推给观察者各取所需即可。但如果某个观察者需要额外信息比如要查送礼者的 VIP 等级来决定弹幕样式那它应该自己去调用户中心的服务而不是让 GiftEvent 承载所有可能的扩展字段。守住事件对象的精简边界推模型就不会变味。4.2 CopyOnWriteArrayList观察者列表的并发安全第 3 章示例中observers 用的是 ArrayList。单线程跑没问题但直播间的后端是高度并发的。很有可能出现这样一个请求正在遍历 observers 列表通知观察者另一个线程同时执行 attach 或 detach 修改列表。ArrayList 在遍历期间被并发修改会立即抛ConcurrentModificationException导致通知中断严重时影响送礼主流程。解决方案不是加一把大锁而是换用并发容器。CopyOnWriteArrayList是观察者列表的最佳选择。private final ListGiftObserver observers new CopyOnWriteArrayList();它的原理是写入操作add/remove会复制一份底层数组在副本上修改然后切换引用读操作始终读取原数组。这样遍历和修改可以并发执行互不干扰。代价是写操作开销偏高但观察者的注册和退订频率远低于送礼广播频率。在这个场景下用空间换并发安全非常划算。4.3 异步通知与异常隔离别让外围逻辑拖垮送礼主流程再回看 receiveGift 的流程扣款、记录流水、同步遍历观察者。如果每个观察者处理耗时分别是 10ms、50ms、20ms一次送礼广播就要额外占用 80ms。直播间送礼往往是活动期间的高频请求假设某大主播开播抽奖一分钟收到几千条礼物消息同步通知这些观察者会直接把送礼链路的响应时间拉垮。因此生产环境几乎不会把观察者通知放在送礼请求的调用线程里而是丢进线程池异步执行让核心送礼立即返回通知在后台并行处理。import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class LiveRoomAsync { private final ListGiftObserver observers new CopyOnWriteArrayList(); private final ExecutorService executor Executors.newFixedThreadPool(8); public void attach(GiftObserver observer) { observers.add(observer); } public void receiveGift(String userName, String giftName, int price) { GiftEvent event new GiftEvent(userName, giftName, price, System.currentTimeMillis()); System.out.println([直播间] 收到礼物 userName 送出 giftName); for (GiftObserver observer : observers) { executor.submit(() - observer.onGiftReceived(event)); } } }异步化带来的问题也要心里有数。第一观察者执行顺序不再确定弹幕可能比特效晚出现。直播间里这些观察者通常各自独立顺序要求不强但如果有必须先更新榜单再发弹幕这样的硬性顺序依赖异步就需要配合更复杂的编排得不偿失。第二线程池提交的任务如果抛异常异常会被封装在 Future 里没人调用 get 就静默吞掉。所以异步后观察者内部必须自己 catch 异常、打日志绝不能把异常裸奔出去。关于异常隔离还有一层哪怕是同步模式观察者之间也绝不能共用一个 try-catch 包裹整个循环。一个观察者抛异常后面的观察者全被中断。正确做法是在循环内部逐个捕获保证单个观察者的失败不影响其他通知。private void notifyObservers(GiftEvent event) { for (GiftObserver observer : observers) { try { observer.onGiftReceived(event); } catch (Exception e) { System.err.println(观察者处理礼物事件失败: e.getMessage()); } } }我在实际项目里还会用装饰器模式给观察者统一包一层异常日志让业务观察者只管写自己的逻辑异常处理全部收敛到装饰器里。这样代码的职责边界更清楚。5. 从手写观察者到EventBus与消息队列框架究竟帮我们做了什么5.1 手写观察者的四个局限手写观察者代码总量不大但在大型应用里它有几个明显的短板。局限说明事件类型单一一个接口只能表达一种通知语义想同时监听送礼、弹幕、关注等多种事件接口会越来越乱基础设施缺失异步执行、异常隔离、错误重试、线程模型都要自己实现注册管理分散观察者的 attach 散落在各业务入口时间一长没人说得清整个系统有哪些观察者缺少通配与过滤有些观察者只想收火箭级别的礼物事件手写代码就得在观察者内部加过滤逻辑这些痛点催生了事件总线EventBus这类框架。事件总线的本质仍然是观察者模式但把接口换成了注解把按事件类型分发这个繁琐工作收进了框架内部。5.2 Guava EventBus 与 Spring EventListener注解化的观察者如果你用 Guava 的 EventBus观察者的写法和手写版差异很大。观察者不需要实现 GiftObserver 接口只需在处理方法上标记Subscribeimport com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; public class BarrageSubscriber { Subscribe public void onGiftReceived(GiftEvent event) { System.out.println([弹幕] event.getUserName() 送出了 event.getGiftName()); } }发布事件时更简单EventBus eventBus new EventBus(live-room); eventBus.register(new BarrageSubscriber()); eventBus.post(new GiftEvent(小明, 火箭, 100, System.currentTimeMillis()));EventBus 内部会自动维护事件类型到订阅者列表的映射关系。post 什么类型的事件它就只通知注册了该类型处理方法的订阅者。手写观察者里的每个事件类型一个接口被每种事件类型一个注解方法替代了观察者的扩展性立刻变好。Guava 还提供了AsyncEventBus发布时自动异步派发。Spring 生态里大家更熟悉EventListener逻辑一模一样。发布者通过ApplicationEventPublisher.publishEvent(event)发事件监听器用EventListener注解消费。注意一点EventBus 解决的是单机进程内的问题跨服务、跨机器场景它无能为力。5.3 分布式直播架构下观察者模式如何演化为消息队列真实的大型直播平台不会只是一个单机应用。一个直播间的送礼通知要到达弹幕集群、特效集群、榜单服务、用户中心等多个独立部署的服务。这时候观察者模式的思想和消息队列Kafka/RocketMQ结合起来是再自然不过的事。链路变成这样直播间服务作为 Subject收到送礼后就地校验、记账、生成礼物消息然后生产一条消息写入某个 Topic消息队列担当通知分发中心弹幕服务、特效服务、榜单服务作为观察者各自以消费者组的方式订阅同一个 Topic消费同一条礼物事件。这个模型和本地观察者模式一一对应Subject 生产事件Observer 消费事件消息队列天然提供异步、削峰、失败重试、消费者组这些能力。你可以把它理解成分布式版的观察者模式。面试系统设计时如果被问到观察者模式你在讲完本地 GiftObserver 案例后补一句在分布式场景下这个模型可以无缝迁移到消息队列会比只背概念加分不少。当然分布式版也有代价。生产者和消费者不再属于同一个进程事件丢失、重复消费、顺序乱序都成了必须面对的新问题。这也是为什么本地观察者模式永远不会被消息队列完全替代两者解决的问题层级不同。最后再分享一点个人心得。观察者模式看起来只是改了几个类的写法实际上改变的是谁负责发起通知的思维方式。核心业务只负责确认一次事实——有人送了一份礼物至于弹幕怎么飞、特效怎么放、榜单怎么排那是其他模块自己的事。这种控制反转带来的清爽只有在一个需求反复变更的系统中维护过代码的人才能真正体会。学这个模式时建议你亲手把礼物系统从观察者模式改写回硬编码版本体验一下改动某一个需求时的痛苦再改回来感受一下设计的冗余度。写两遍理解就真正到位了。