观察者模式从原理到实战:解耦、线程安全与事件驱动设计

发布时间:2026/10/13 6:36:01
观察者模式从原理到实战:解耦、线程安全与事件驱动设计 观察者模式这个题目我估计大部分人都听过但真正把它说清楚、用明白的并不多。文档里都是那套“定义一对多依赖状态改变通知依赖方”背会了没用换到真实项目里还是会踩坑。这篇文章我打算从实际编码和架构设计的角度把观察者模式彻底拆开聊底层原理、说代码细节、讲排查方法希望能给正在学设计模式或者工作里被耦合问题折磨的朋友一点实在参考。1. 观察者模式到底在解决什么1.1 一对多通知的困境先想想没有观察者模式的时候功能怎么做。最朴素的方式模块A的状态发生变化B、C、D都需要响应直接在A的更新逻辑里写死调用B的方法、C的方法、D的方法。写起来倒是直截了当问题在于今天加E要听通知明天F不要听了后天B改接口了连累A也要改编译。这种直接耦合的代码形成了一种单向依赖链A依赖B、C、D的具体实现。改动量一大维护成本很快就失控。观察者模式其实是把这个过程反了过来。让B、C、D各自声明自己关注A的变化A完全不知道谁在关注自己只知道维护一个订阅者列表。当状态变化时按列表逐个发通知。这样A不用关心订阅者是谁、数量有多少、接口是否有时效性订阅者也不用关心通知是谁发出的只要订阅关系还在就能收到消息。这个解耦动作放在微服务架构里本质就是事件驱动放在业务代码里就是状态同步的基础。很多号称“低耦合”的系统架构最终落到底层还是观察者模式在支撑。1.2 观察者、发布订阅和回调的区别很多人把观察者模式和工作队列、消息中间件混为一谈名字相近但根本不是一回事。澄清一个关键区别对选型很重要。观察者模式强调类层级内的直接订阅通常发布者和订阅者在同一个进程里。它定义的是对象之间一对多的依赖关系核心是状态同步。发布订阅模式是观察者模式的演进加入了消息通道作为中间层。订阅者和发布者互相完全不认识通信全走通道。这个模式下单播变广播异步化更容易但也意味着消息可能乱序、丢失。至于回调它是一次性的松散通知机制比如异步操作完成后调一下函数。回调通常针对单个事件观察者模式则面向持续性的状态流转、多个订阅者。实际上很多现代框架里的“事件监听器”就是观察者模式加上中间层就是发布订阅。理解观察者模式是理解事件总线、响应式状态管理的基础。1.3 什么时候该用什么时候别用观察者模式不是银弹它能解耦通知链但也增加了间接性。用得不好代码会变成一团蜘蛛网谁订阅了谁都查不清楚。适合用的情况包括一个对象状态变化多个无关模块需要响应。比如登录成功要清除缓存、刷新菜单、记录日志。需要动态增删响应逻辑。商品库存减少时库存模块希望监听、风控模块也希望接入那就不能写死在业务代码里。希望保持A模块足够简单不想知道外部有哪些依赖方。系统里已经存在事件驱动的协作方式新增通知不需要大改核心逻辑。不适合用的情况通知链路极少只通知一处或两处。为了一层间接性引入监听器查找代码反而费劲。发布者和订阅者生命周期强绑定用直接调用更清晰。性能极度敏感。每变化一次都遍历几百个订阅者在低延迟场景下就不划算。无法接受异步、乱序、通知遗漏需要强一致性和确定性执行顺序。观察者模式如果支持异步就天然丢失顺序同步也会因为订阅者注册顺序带来不确定。我的经验是观察者模式最危险的场景是过度使用后出现的“回调地狱”一条业务链路里套了七八层监听看起来解耦实际上只是把调用链打散到了不同文件里阅读难度指数级上升。控制数量数据流转足够清晰才值得用。2. 基础实现手写一个线程安全的观察者集合2.1 最小可用版本怎么写观察者模式的理论实现很简单主题维护一个订阅者列表提供订阅、退订、通知三个方法。但真到工程代码正确性考察全在这里。下面我给一个生产可用级别的线程安全版本用Java来说明思路其他语言思想相同public interface EventListenerE { void onEvent(E event); } public class EventDispatcherT { private final SetEventListenerT listeners Collections.newSetFromMap(new ConcurrentHashMap()); private final ReentrantReadWriteLock lock new ReentrantReadWriteLock(); private volatile boolean dispatching false; public void subscribe(EventListenerT listener) { if (listener null) throw new IllegalArgumentException(listener); lock.writeLock().lock(); try { listeners.add(listener); } finally { lock.writeLock().unlock(); } } public void unsubscribe(EventListenerT listener) { lock.writeLock().lock(); try { listeners.remove(listener); } finally { lock.writeLock().unlock(); } } public void dispatch(T event) { ListEventListenerT snapshot; lock.readLock().lock(); try { snapshot new ArrayList(listeners); } finally { lock.readLock().unlock(); } for (EventListenerT listener : snapshot) { try { listener.onEvent(event); } catch (Exception ex) { // 通知不应阻断整体分发异常按策略进行记录 logError(listener, event, ex); continue; } } } }需要注意的地方第一为什么用并发集合还要加读锁。ConcurrentHashMap作为Set可以保证并发安全和迭代安全但通知时需要保证“订阅快照一致”而且退订和通知可能同时发生。先拷贝一份快照再遍历可以避免在通知过程中被改列表导致的行为漂移。第二为什么通知方法要捕获异常。真实项目里某个订阅者抛异常不应该导致其他订阅者收不到通知更不能让事件源崩溃。框架里往往有统一的异常处理策略这里就是那个位置。第三用读写锁保证订阅和退订的独占性通知可以并行。当然如果是在单线程环境一个CopyOnWriteArrayList就够用了。2.2 正确性细节重复通知、顺序与移除安全初学者最容易忽略三个细节。重复通知的原因是同一个对象注册了多次。大部分监听器应该是幂等注册的最好用Set去重否则同一个消费者被注册两次事件触发后会收到两遍。这个问题的隐蔽性在于代码里看上去注册了两次但写在不同模块全球调没注意就挂上去了。统一用Set存储能直接挡掉这类问题。顺序问题。同步观察者模式在单线程内是按注册顺序依次通知。但如果你引入并发、异步顺序就不可靠。业务里如果对顺序有要求通常的做法是给事件加序列号或者干脆绕开观察者模式用显式的责任链。移除安全指的是正在通知时某个订阅者执行过程中退订其他订阅者或重新订阅。如果通知时直接遍历原Set就会抛并发修改异常或导致新订阅者收到本次通知。正确做法是通知时拷贝快照本次通知不影响当前轮次退订在下一轮才生效。我在实际项目里遇到过最滑稽的情况监听器A在通知过程中把自己从列表里删了结果分发给B的时候B没有收到数据不一致排查了半天。加快照解决了。2.3 都说JDK的Observable不值得用为什么Java标准库里有现成的Observable类和Observer接口。按理说可以直接用但我不建议在严肃项目里用它原因有几条设计早于泛型时代Observable的notifyObservers(Object arg)要强转类型容易ClassCastException。Observable是类而不是接口如果一个业务类想继承它就不能再继承其他类Java不支持多继承。状态锁定机制粗糙setChanged()和notifyObservers()两个方法拆开调用容易漏掉setChanged()导致事件静默丢失这个坑很多人踩过。内部用Vector做存储性能不是最优迭代时也没做并发防护。自己实现一个几十行的Dispatcher已经很成熟了没必要用旧库拖延问题。3. 推模型与拉模型数据怎么传3.1 推模型的实现与适合场景观察者模式里事件对象作为一个载荷传给订阅者订阅者接收数据后进行处理这就是推模型。推模型的好处是订阅者拿到的数据现成不需要反向查询主题。适合主题状态变化后就要立即反映到下游的场景比如用户登录状态变化事件里直接携带用户ID菜单权限、日志记录都能直接消费。实现上可以把状态对象封装成不可变的事件结构public record LoginEvent(Long userId, String username, LocalDateTime loginTime) {}推送时要注意载荷大小。一次事件塞一个完整业务对象几百个订阅者并发消费内存和序列化成本都会被放大。只要传必要的信息即可敏感数据更不能随意带。3.2 拉模型的使用场景拉模型相反事件本身只带一个变化标识订阅者收到后按需去获取最新数据。比如商品库存变动订阅者收到“库存发生了变化”的信号后自己去查询商品的当前库存再决定下一步行为。拉模型适合那些对数据实时性要求高、又不想推送太多无关字段的场景。实现模式上主题需要暴露查询状态的方法public interface StockTopic { int getStock(String skuId); }订阅者收到通知后调getStock获取需要的数据。拉模型的缺点也明显订阅者必须知道主题的查询接口回查本身有延迟还会增加主题压力。推模型和拉模型不是对立的很多系统是混合用事件里带部分核心字段需要更完整的数据再去查。3.3 事件对象的封装策略很多初学者的事件对象喜欢用Map传数据键值对直来直去。项目一跑起来就发现字段拼写错误只能在运行时暴露类型转换失败也难排查。成熟的团队更倾向于给每类事件定义一个强类型对象哪怕是个record字段清晰、编译期检查、文档化程度高。事件对象里值得补充的通用元素事件ID、发生时间、来源模块、版本号。这些字段对排查问题帮助很大时间戳尤其重要异步分发时能判断事件先后顺序。我还习惯把事件类型字段放进去比如枚举类型下游可以直接基于事件类型走不同分支不必通过instanceof判断。事件对象保持不可变性序列化、并发传递都更安全。3.4 订阅过滤的重要性如果所有订阅者都关心所有事件通知数量会爆掉。应该说多数事件分发器都会做过滤有的通过事件类型有的通过订阅时指定条件。实现上可以在Dispatcher内部保存事件类型到监听器的映射MapClass?, ListEventListener? typeIndex;订阅时传入关注的事件类型分发时按类型索引快速找到目标监听器不用全部遍历一遍。在真实系统里事件被滥用的另一个问题是发布端过于慷慨什么状态变化都推订阅端不得不挨个判断是否是有效事件。做了类型过滤后下游代码清爽不少。4. 同步还是异步观察者模式的高阶选择4.1 同步通知带来的痛点默认实现里notify方法在调用线程里同步执行所有订阅者逻辑。好处是流程确定、结果可控但后果也很直接只要有一个订阅者执行时间很长整个事件源就被拖住了。举例来说用户点击下单触发一个订单创建事件订阅者里有一个在做数据仓同步、发很多外部调用下单接口的响应时间就从10毫秒变成了3秒。这不是观察者模式本身的问题而是我们把慢操作放进主链路的结果。要解决这类问题最简单的方案是异步执行订阅者逻辑。4.2 异步执行和消息队列的边界划分可以在事件分发器里维护一个线程池把通知任务提交过去异步执行public void dispatchAsync(T event) { for (EventListenerT listener : snapshot) { executor.execute(() - runWithSafeLog(listener, event)); } }异步带来的问题同样要正视。一是顺序性。多个订阅者同时在多个线程执行处理顺序不再固定。对强顺序业务需要给任务分配序号或者用单线程的线程池确保串行处理。二是幂等性。异步通知可能因为机器重启、任务重试造成重复消费。订阅者要做幂等设计比如用事件ID去重。三是背压。线程池队列无限增长时系统内存会被打满。需要一个有界队列对任务提交做拒绝策略比如由调用方继续执行或丢弃最近任务。那是否干脆用消息队列代替异步观察者这个要看系统边界。观察者模式的异步化适合单体应用内的解耦消息队列一般用于跨服务的事件传递。两者不冲突单体应用内用观察者异步服务之间用消息队列是分层的合理设计。4.3 重入通知和订阅者内部再发布观察者模式有个隐蔽问题订阅者A在处理事件时又触发了同一主题的状态变化于是分发器又发出了一个新事件最后导致递归通知调用栈深不见底。这种情况必须处理。一个简单策略是在分发器内部加一个重入计数如果当前线程正在分发同一事件类型就拒绝或延后处理新事件。另一个策略是设置最大分发深度超过直接抛异常或记日志。实际业务里重入不一定全是bug比如用户资料变更后驱动权限刷新权限刷新又触发资料读取这种联动可能是有意的。但无论这样都要设置明确的上限避免死循环无感知地跑满CPU。5. 实战从UI事件到跨模块状态同步5.1 场景案例一个通用的购物车状态写一个贴近业务的例子来说明观察者模式的整体价值。假设某个跨平台系统里有一个购物车状态多个模块需要感知购物车的变化。使用的通知事件大致体现public enum CartChangeType { ADD, REMOVE, CART_COUNT_UPDATED } public record CartChangeEvent( String cartId, String userId, CartChangeType type, int itemCount, BigDecimal totalAmount, Instant occurredAt ) {} public class CartChangeNotifier { private final EventDispatcherCartChangeEvent dispatcher new EventDispatcher(); public void changed(CartChangeEvent event) { dispatcher.dispatch(event); } public void register(EventListenerCartChangeEvent listener) { dispatcher.subscribe(listener); } }购物车模块只负责通知“购物车变了”具体谁响应、响应什么购物车模块完全不关心。下游注册的监听器有很多方向。比如一个用来刷新角标一个用来更新优惠计算一个用来埋点分析。每个监听器独立维护购物车模块不因新需求改代码新模块接入只需要注册新监听器。5.2 用观察者模式解决跨模块状态漂移在单体项目里不同模块之间经常要共享一些状态比如用户是否登录、商品最低价是否变化、订单状态流转等。最常见的坏味道就是各模块直接互相调用setter改一个状态还要连带改其他模块。用观察者模式收口状态变化之后能够把“改状态”和“对状态做反应”分开。主题只负责状态变更、发布事件反应逻辑各自在监听器里维护。后续某个模块反应逻辑变化只需要改动自己的监听器不用去动状态源。这带来很强的可测试性。主题发布事件是纯函数般的行为事件可以单独构造测试监听器各自独立被测不需要启动整个系统。5.3 性能优化合并通知、弱引用与自动解绑高频率事件场景下每次变都通知会造成性能损耗。常见的优化策略是合并通知比如前端频繁修改筛选条件不需要每调一次就通知后端重新查接口可以积累300毫秒合并一次。这种“防抖节流”的思想结合到观察者模式里能大幅度减少无效消费。还有一个工程经验是监听器生命周期管理。最常见的内存泄漏是监听器注册了但对象本身还持有主题的引用关系主题又长期存在导致监听器无法被GC回收。如果监听器生命周期短就需要在对象销毁时退订语言层面可以用弱引用或者提供自动解绑机制。实际项目里我会统一做一个注册管理表回收时做批量解绑避免漏掉某个地方注册的订阅。6. 常见故障与排查实录6.1 内存泄漏的排查经验现象是系统运行几天后内存直线上升dump文件分析时发现很多本该回收的对象被某个长期存活的容器持有。排查思路很简单找出哪个集合元素一直在膨胀那个集合通常就是事件订阅列表。误注册的场景一般是某个监听器在初始化时被永久注册它所在的对象却需要回收回收逻辑又没解绑导致该对象永远存活着。修复方式是在退化后解绑或者注册持弱引用的监听器。通用经验是不要依赖“对象销毁时会自动解绑”语言层面没有这种机制自己注册的退订操作要在撤销生命周期里补上。6.2 事件没有被监听到最让人头疼的是明明已经发布事件订阅者却没反应。我遇到过的原因大致归纳成几类发布方和订阅方使用的是不同的Dispatcher实例订阅关系没打通。事件类型类型不匹配监听器注册的是A但发布时用的是A的子类或父类索引匹配不上。订阅发生在事件发布之后时序倒挂漏掉首轮。广播被异步线程池调度延迟误判为丢失。订阅过程中被过滤器拒绝但仍以为该收到。排查这类问题最好的办法是给Dispatcher加可观测能力记录每次订阅、退订、分发的事件信息必要时开启调试模式输出日志。没有日志的观察者模式就是一个没有仪表盘的飞机平时飞得好一出问题就是灾难。6.3 通知期间的并发修改异常代码里保存监听器用的是ArrayList一次通知遍历时监听器内部退订自己运行期直接抛ConcurrentModificationException。这是新建Dispatcher最典型的坑。解决方案是通知时拷贝快照比如我前面用new ArrayList(listeners)而不是直接迭代原集合。如果要更高性能用CopyOnWriteArrayList免拷贝读但写入成本高。6.4 通知顺序混乱导致阻塞异步化之后监听器A先作出反应监听器B后反应但业务要求B一定先执行结果出现脏读。排查这类问题要么调成同步模式并控制注册顺序要么在事件里增加优先级序号。给事件增加优先级字段是一个常用做法。分发器可以先按优先级排序再通知但注意优先级只在同类型事件中有意义跨类型事件不该排序。6.5 高频事件对系统压力过大某个状态一秒变几十次每次通知都触发一轮全量计算CPU飙满。应对措施前面提到过合并通知、限流、降频推送必要时对同一类型的监听器做分片只通知受影响范围。一个更实用的技巧是让事件通知与业务处理分离主题只管快速发事件处理方内部自行做合并和聚合用消费队列吸收瞬时高峰。7. 观察者模式的框架选型与扩展方向7.1 常见语言的监听机制对比Java里可以自己写Dispatcher也可以用Spring事件ApplicationEvent、Guava EventBus。Spring事件核心也是观察者模式支持同步和异步处理机制灵活。Guava EventBus的简洁API很有吸引力但注意强依赖本地进程不支持跨服务。C#里事件和委托本身就是观察者模式的语法级实现event关键字带有一组和-的订阅管理天然顺手。JavaScript的EventEmitter是Node生态的观察者模式标准实现浏览器端就是DOM事件监听、EventTarget。不管语言怎么封装底层本质没有变维护订阅列表状态变化时发通知。学一个语言里的具体机制能从观察者模式本身的理解出发会快得多。7.2 响应式编程和观察者模式的关系响应式编程理念和观察者模式高度相关典型的就是数据流一个数据源变化时所有被依赖的计算自动更新。比如Excel表格里的单元格如果A1是B1C1B1一变A1自动变。这本质上就是一个观察者模式的应用。但响应式编程比基础观察者模式更进一层它加入了数据流变换、线程调度、背压等能力相当于观察者模式的工业化版本。如果项目里已经采用响应式框架比如Rx家族、响应式流库那底层的事件分发机制已经有完整实现不需要重复造轮子。7.3 什么时候应该升级为事件驱动架构单体应用里观察者模式用得差不多了需求开始要求跨进程协作就该考虑引入消息中间件。中间件天然具有发布订阅能力把本地Dispatcher换成消息通道事件序列化后发给下游服务就完成了从单体到服务化的迁移。但这个过程要克制。服务间调用变事件化之后最终一致性、幂等、消息顺序、失败重试这些问题会全面暴露比单体里的观察者模式复杂好几个数量级。我建议按照边界先局部试点比如先迁移日志类事件验证链路稳定后再扩大范围。8. 一些长期贯彻的经验心得最后说几点我踩坑多次沉淀下来的想法。一观察者模式永远要让事件的数据模型尽量小且聚焦。事件带的数据太多下游容易被带动做超出职责的事情导致事件依赖膨胀。带最小必要数据更有利于各自独立演进。二订阅关系的边界要明确定义。谁可以订阅、谁可以发布最好在架构规范里写清楚。没有边界的订阅关系最后一定出现一堆对象互相监听、循环踢皮球的乱象。三不要把所有变化都“观察者化”。有些业务逻辑是强顺序、强一致性的就应该用显式调用串联用观察者反而掩盖了依赖关系。观察者模式是解耦工具不是隐身斗篷。四测试观察者模式时多写“只订阅一次、只发布一次、乱序请求多次发布”的边界用例。这一类并发、重入、取消订阅的测试能帮你发现真正的实现错误。框架自带的测试覆盖往往不够自己要补。观察者模式看起来简单一百行代码能写完Dispatcher但真正用得好的人不多因为它考验的是对依赖关系的判断力。把这种模式用在自己的代码里会逐步改变你拆分模块的习惯也更愿意为可扩展性留出空间。如果你正准备重构一个耦合严重的模块从列表里找出哪些是一对多的通知关系先用观察者模式解耦往往比引入复杂的框架要稳得多。等这个演进走通了再多留意通知链路里出现的异常和性能问题慢慢就能形成自己一套可落地的实践判断。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询