☰
REA实战:进程内资源事件代理的设计与踩坑记录
2026/10/21 15:11:11 网站建设 项目流程

如果你所在的业务系统慢慢变成了一张拆不开的蜘蛛网,模块之间互相创建互相调用,时间一长,你大概率会认真考虑一件事:让状态变化自己长腿跑起来。我最近在内部项目里把之前随手写的一个小模块正式抽成了独立组件,代号就叫 REA(Resource Event Agent,资源事件代理)。它解决的问题说大不大、说小不小:多个数据源同时变化的时候,怎么把变化事件稳定、有序、不重复地送到所有关心它的地方。这篇文章是我从设计、编码、踩坑到压测的完整记录,也适合想自己搭一套轻量事件机制的开发者参考。

1. REA 这个项目是怎么冒出来的

1.1 事件驱动架构里的一个尴尬空白

我所在的业务线维护着一个中台系统,订单、库存、积分、消息通知四块业务强耦合。最早的代码是典型的“调用链式”写法:订单状态一变,控制器直接去调库存服务,库存服务扣完又要通知积分服务,积分加完还要触发消息推送。每加一个联动环节,就要改一圈上游调用方,测试起来也异常痛苦,经常出现“一个库存接口改了,订单模块莫名挂掉”的连锁反应。

后来我们尝试引入事件驱动思维:某个数据源变了,发一个事件,谁关心谁自己订阅。这个思路本身没有错,但落地的时候发现市面上能用的方案处于两个极端。一头是重量级的消息中间件,部署、运维、分区、消费组全都要管,只是为了几个进程内的模块联动,实在有点大炮打蚊子。另一头是轻量的发布订阅库,这类库往往只做了一件事:on和emit。问题在于它们没有“资源快照”和“版本变化”的概念,事件发得倒是快,但订阅方根本不知道该不该处理,也没有办法判断当前自己拿到的状态是不是最新状态。

那个尴尬的空白刚好卡在中间:我需要一个既轻量又带一点业务感知的事件骨架。它能理解“某个资源发生了变化”,能根据资源的版本号做去重,还能把事件按照固定的顺序送给多个处理方。于是 REA 就诞生了。它的定位从一开始就不是通用的消息中间件,而是进程内的数据资源与业务事件之间的翻译层。

1.2 为什么自己写而不直接用现成方案

决定自研之前,我大概花了一个晚上把现有方案做了个选型对比。当时考虑过三条路:通用事件总线库、全局状态管理框架、消息队列中间件。它们的核心差异其实非常明显。

方案方向优点不足适配场景
通用事件总线库轻量、API 简单、上手快没有资源概念与事件去重,订阅方容易重复处理简单的 UI 通知、组件解耦
全局状态管理框架带响应式状态、组件联动方便对自定义业务事件不够友好,模板化严重前端页面状态同步
消息队列中间件可靠、可扩展、跨服务部署和运维成本高,进程内场景太重分布式服务间事件流转
自研 REA贴合自家业务模型,能控制细节需要自己维护中台内部的资源联动

我们真正需要的组合是:以资源为核心,以事件为信号,以代理为处理方。资源负责提供当前快照和版本号,事件负责描述“什么变了”,代理负责响应变化。这三者缺一不可。通用事件总线库能给我事件和代理,但给不了资源快照;全局状态管理框架能给我资源和联动,但事件表达又被捆得很死。与其在别人的抽象上层打补丁,不如写一个只有八百行核心代码的小模块,按我们的业务习惯来建模。

事实证明这个决定是对的。项目上线后,新业务接入 REA 基本只需要定义一个新的资源类型和几个代理,不需要修改核心逻辑。而且因为代码在自己手里,后续加错误隔离、幂等控制、顺序屏障等等都变得非常直接,不要求着某个开源社区改需求。

2. REA 的核心模型:资源、事件、代理三者怎么配合

2.1 Resource 抽象:把数据源统一成一个接口

REA 里最核心的概念是资源(Resource)。任何能被观察的数据源,比如一个订单记录、一个库存数字、一个用户积分账户,都可以抽象成 Resource。它不需要知道自己的业务语义,只需要提供三个能力:标识、快照、版本号。

export interface Resource<TState = unknown> { readonly id: string; readonly version: number; getSnapshot(): TState; }

version是整数,每次资源状态变化时自增。REA 在派发事件之前会比较版本号,如果新事件携带的版本和当前注册表里的版本相同,就说明这次变化已经被处理过,直接丢弃。这样设计避免了在派发路径上做深比较,省掉了大量无谓的序列化和对象比对开销。

getSnapshot()的作用是让代理在处理事件时能拿到资源的当前状态。很多事件总线只传事件内容,不提供“当前值”,导致代理必须自己维护一份状态缓存,既容易过期也容易不一致。REA 把资源快照挂在注册表上,代理按需读取,永远取到最新值。

实际建模的时候,我不会让业务对象直接实现这个接口,而是用一个适配器包一层。比如订单服务里有自己的订单实体,我就写一个OrderResourceAdapter,把实体的变化映射成 REA 的资源版本更新。这样 REA 完全不侵入业务存储层。

2.2 Event 定义:避免字符串魔法

早期原型里我用的是字符串事件名,例如emit('order:updated', data)。第一天用着很爽,第二天就发现问题:事件名靠字符串硬编码,搜索起来麻烦,写错一个字母也不会报错,而且没法从事件本身知道它是从哪个资源发出来的。

后来我把事件定义成了结构化的DomainEvent:

export type EventType = string | symbol; export interface DomainEvent<TMeta = unknown> { type: EventType; sourceResourceId: string; sequence: number; occurredAt: number; meta: TMeta; }

每个事件都绑定了sourceResourceId,这样代理可以按来源资源过滤。sequence是全局单调递增的序号,用来判断事件的先后关系。occurredAt是发生时间,方便排查问题时做时间线还原。meta才是真正的事件载荷。

这里有一个很重要的设计决策:事件类型允许symbol。字符串事件名虽然直观,但容易在不同模块间产生碰撞,用symbol则天然隔离。我实际项目里采用的是字符串常量加as const断言,便于日志打印,同时约定所有事件类型集中定义在一个文件里,不允许随便散落。

事件不是凭空产生的。REA 要求所有事件必须由一个资源变化触发,这是刻意设计的约束。事件如果满天飞,没有归属,系统最终会退化成纯广播总线,等于退回了老路。

2.3 Agent 生命周期:注册、派发、销毁

有了资源和事件,还需要一个处理方,REA 里叫代理(Agent)。代理就是一个对象,声明自己关心哪些事件类型,并提供onEvent方法处理事件。

export interface Agent { id: string; interests: EventType[]; onEvent(event: DomainEvent): void | Promise<void>; }

注册代理的入口长这样:

function subscribe(agent: Agent): () => void { // 在内部索引中登记 agent // 返回一个取消订阅函数 }

这个返回的取消函数非常重要。我们项目里所有代理注册的代码都必须保存返回值,并在合适的生命周期钩子里执行。注册是第一步,注销同样不可以省略。

代理销毁时不仅要取消订阅,还要处理自己维护的临时状态。比如一个聚合统计代理,内存里缓存了一批待处理事件,注销前要把这些数据落库或清理,否则就会静默丢失。REA 本身不负责代理内部状态,但它提供了一个dispose()钩子的约定,代理实现者需要自己保证清理干净。

3. 从零实现 REA 的关键代码与设计取舍

3.1 内核实现:存储结构选择

REA 的内核我写得很克制,核心存储就是两层 Map。第一层resources根据资源 id 定位资源条目,第二层agents根据代理 id 定位代理条目。资源条目内部维护了该资源触发的事件类型到代理集合的映射。

interface ResourceEntry { resource: Resource<unknown>; listeners: Map<EventType, Set<Agent>>; } class ReaRegistry { private resources = new Map<string, ResourceEntry>(); private agents = new Map<string, Agent>(); }

为什么用Set<Agent>而不是数组?因为代理可能因为业务逻辑重复注册,数组去重需要indexOf遍历,Set天然保证唯一,而且删除订阅时平均开销更低。为什么要在资源条目里维护listeners?因为派发时我是从资源出发找代理的,这样可以避免扫描全部代理再做事件类型匹配,查找复杂度是 O(1)。

注册代理的时候,REA 会遍历订阅者关心的所有事件类型,把代理挂到对应资源的监听表里。这个遍历是注册时一次性完成的,派发时的热路径不会出现类型匹配的循环。

function subscribe(agent: Agent): () => void { if (this.agents.has(agent.id)) { throw new Error(`agent duplicated: ${agent.id}`); } this.agents.set(agent.id, agent); for (const type of agent.interests) { for (const resource of this.resources.values()) { let listeners = resource.listeners.get(type); if (!listeners) { listeners = new Set(); resource.listeners.set(type, listeners); } listeners.add(agent); } } return () => { this.agents.delete(agent.id); for (const resource of this.resources.values()) { for (const listeners of resource.listeners.values()) { listeners.delete(agent); } } }; }

真实项目里我还会在这种遍历前加一层事件类型索引,避免subscribe对全部资源做无意义扫描。不过对于几百个资源以内的系统,上面的简单实现反而更容易读。

3.2 异步事件派发:用微任务还是同步循环

这是开发中拿不准的部分之一。刚开始我采用的是同步派发:资源写入方法里直接调用emit,emit立刻遍历代理并执行onEvent。同步派发的优点是时序清晰,出错了能在调用栈里直接定位到是哪一次资源写入引发的。

但很快踩了坑。某个代理在处理完事件后,会反过来更新同一个资源,资源更新又触发新事件,新事件又进入代理。一旦有循环依赖,调用栈就会无限增长,最后直接爆栈。就算没有循环,同步派发也会让一个代理的错误中断整个资源写入流程,把事件处理的异常和业务写入的异常搅在一起。

于是我改成了“同步收集、异步派发”。资源写入后,事件先进入 pending 队列,然后通过微任务调度统一 flush。

class ReaCore { private pending: DomainEvent[] = []; private scheduling = false; protected emit(event: DomainEvent) { this.pending.push(event); this.scheduleFlush(); } private scheduleFlush() { if (this.scheduling) return; this.scheduling = true; queueMicrotask(() => { this.flush(); }); } private flush() { this.scheduling = false; const events = this.pending; this.pending = []; for (const event of events) { this.dispatchToInterestedAgents(event); } } }

这里的关键是 pending 队列的折叠效应。同一轮同步代码里连续写入多个资源,每个资源产生的多个事件会合并到同一次 flush 中处理。微任务会在当前宏任务同步执行完毕后统一启动,因此写入逻辑可以安全返回,代理处理时也不会打断业务操作。

顺带纠正一个常见误区:微任务和微任务之间不会存在事件循环插入,所以同一批 pending 中的事件顺序是确定的。但跨资源事件的业务顺序,不能只靠微任务来保证,这一点后面踩坑部分细说。

3.3 错误隔离与死循环防护

事件代理最怕一个代理出错连累全局。我在flush循环里给每个代理的onEvent都单独包了一层 try/catch,任何代理抛出的异常都不会中断同一批其他代理的执行。

for (const agent of listeners) { try { const result = agent.onEvent(event); if (result instanceof Promise) { result.catch((err) => this.handleError(err, agent, event)); } } catch (err) { this.handleError(err, agent, event); } }

同步异常和异步异常分开处理。同步异常立刻被 catch,异步异常通过 Promise catch 兜住。handleError只做三件事:记录日志、写入错误队列、抛出全局错误事件。全局错误事件本身也可以被代理订阅,方便做告警。

死循环防护用的是“同轮窗口去重 + 派发深度计数”。资源更新事件如果在极短时间内被重复派发,REA 会记录sourceResourceId + type + version的组合,同一个微任务窗口内相同组合只派发一次。另外每轮 flush 内对单个代理设置派发深度上限,超过阈值就跳过后续派发并告警。这两个机制合在一起,至少能把“订单完成更新订单状态,又触发订单完成”这类循环扼杀在早期。

4. 接入业务系统时最容易踩的坑

4.1 订阅泄漏:一个真实的内存上涨排查

REA 第一次被集成进某个报表页面时,QA 反馈页面反复打开关闭后内存持续上涨。我一开始怀疑是跨域接口的问题,但打开浏览器的内存分析工具做了两次堆快照对比,发现增长的主体非常集中:一个业务代理对象出现了几百个实例,数量和页面历史打开次数一一对应。

问题不在 REA 核心,而在调用方:注册页面代理时只调了rea.subscribe(),没有把返回的取消函数挂到页面卸载钩子里。页面关了以后,代理对象依然留在注册表中,引用链没断,内存自然不被回收。

修复很简单:

const cleanup: Array<() => void> = []; // 页面初始化 cleanup.push(rea.subscribe(orderAgent)); cleanup.push(rea.subscribe(stockAgent)); // 页面卸载时统一执行 cleanup.forEach((unsubscribe) => unsubscribe());

我把这条经验固化成了两个约定。第一,业务方接入 REA 时必须保存并执行 unsubscribe 返回值。第二,REA 在注册代理时,如果检测到同 id 代理重复注册,会直接抛出异常。这个约定一开始看起来有点严格,后来救了好几次线上“幽灵订阅”的问题。

4.2 事件顺序被破坏:两个并发事件的竞争条件

异步派发有一个隐藏问题:事件的入队顺序和业务因果顺序不一定一致。我遇到过这样一个场景:库存资源先扣减成功,随后订单资源更新为已支付,业务希望代理按“先库存后订单”的顺序处理,实际收到的却是“订单先、库存后”。

原因出在资源写入路径上。两个资源分别由不同模块维护,模块 A 先写入库存并 emit,模块 B 后写入订单并 emit,但模块 B 的同步代码因为恰好赶上另一个异步任务,比模块 A 更早进入 pending 队列。微任务队列只保证“谁先入队谁先出队”,不保证“业务上谁先发生谁先入队”。

我给这个场景补了事件序号机制。每个事件携带全局递增的sequence字段,需要有序消费的代理维护一个lastHandledSequence,只处理序号更大的事件。但这解决的是同一资源的事件顺序,跨资源因果顺序仍然需要业务层显式表达。

最终的做法是引入“屏障事件”。订单支付完成后,业务层显式发出一个order.paid屏障事件,库存扣减和积分累计的下游代理都要等待屏障事件到达后再执行。屏障事件不承载复杂数据,只表示“前面的因果链条已经结束,现在可以安全处理了”。把这个机制写清楚之后,我们项目里再没发生过跨资源顺序错乱的事。

4.3 重复回调:幂等设计缺失导致重复扣减

异步派发一个绕不过去的问题是失败重试。某个代理在处理库存扣减事件时,因为数据库连接超时失败了,REA 的重试策略会重新派发同一个事件。如果业务处理逻辑没有幂等保护,第二次执行就会再次扣减库存。

这个坑的教训不是“不要重试”,而是“重试之前必须确认处理是可重入的”。REA 在框架层做了一件事:为每个事件生成event.id = sourceResourceId + ":" + sequence,并提供已处理事件 ID 的校验工具。业务代理在入口处先检查这个 ID 是否处理过,处理过就跳过。

function handleDeductStock(event: DomainEvent, context: ReaContext) { const eventId = `${event.sourceResourceId}:${event.sequence}`; if (context.isHandled(eventId)) { return; } // 执行实际扣减逻辑 deductStock(event.meta.amount); context.markHandled(eventId); }

这里有个顺序细节:先扣减后markHandled,还是先markHandled后扣减?我建议先执行业务操作,成功之后再标记。如果先标记再执行,业务操作失败时标记已经写入,重试就永远不会来了。当然,如果业务系统有可靠的事务保证,可以把扣减和标记放在同一事务里,那是更优解。

幂等标记不是万能药。跨服务、跨数据库的分布式环境下,单纯靠内存集合做isHandled是不够的,需要把标记写入持久化存储。REA 提供的是抽象接口,默认实现是内存 Set,线上则换成了数据库。关键在于业务层要理解这个工具的存在,并且主动使用。

5. REA 的性能表现与可靠性测试

5.1 压测数据:100 万事件场景下的内存与耗时

写小轮子最怕性能翻车。我在内部测试环境做了一轮压测,环境是普通开发机,模拟注册了 100 个代理,每个代理关注两个事件类型。然后连续制造 100 万次资源版本变化,每次变化都触发对应事件。结果总耗时在 1.6 到 2.0 秒之间,内存新增约 28 到 36 MB。

这个数据算不上严谨 benchmark,核心派发路径只验证了两件事:一是如果不用深比较,单纯靠 version 和事件类型索引,单次派发开销可以压到微秒级;二是事件对象和 pending 队列是内存增长的主要来源,业务代理的临时状态往往占比更大。

我特别看了一下内存分配的大头:事件对象每次派发都会新建,meta载荷越大,内存上涨越快。后来针对高频场景做了小优化:pending队列里不再直接存完整事件对象,而是存事件的构造参数元组,flush 的时候再组装成DomainEvent。这让内存峰值下降了大约 20%,代价是代码可读性变差了一点。如果业务量没到百万级,不建议做这个优化。

5.2 靠重试和超时兜底:Agent 的保障策略

REA 提供一个withPolicy包装器,用来给普通代理加上可靠性策略。我实际常用的配置如下:

const protectedAgent = withPolicy(orderAgent, { retry: 3, timeoutMs: 5000, exponentialBackoff: true, });

withPolicy内部做的事情不复杂:调用onEvent时如果返回的 Promise 在超时时间内没有 settle,就视为失败;失败后按照指数退避策略重试;重试次数耗尽仍失败,就把事件写入死信队列并在日志里输出完整上下文。

死信队列是排查问题的金矿。记录的内容至少要包含事件 ID、来源资源、代理 ID、失败次数、错误堆栈、事件发生时间。没有这些信息,重试失败后就是一团迷雾,只能靠翻日志碰运气。

重试和幂等必须成对出现。没有任何保障策略时,重试只会放大错误。我在接入withPolicy之前先让所有敏感代理都做了幂等改造,然后才允许开启重试。顺序反过来,线上一定会出重复处理的故障。

6. 往分布式方向的扩展和现在的收尾

6.1 从单机进程内到跨服务:代理语义怎么变

项目中期,有别的团队想把 REA 用在跨服务场景,我当时差点直接把注册表改成远程调用。冷静下来分析后,发现“本地事件代理”和“分布式消息”的语义差异非常大,强行复用只会让两边都别扭。

差异主要体现在四个方面。第一,注册表从进程内 Map 变成了消息队列的订阅关系,不能再靠对象引用找到代理。第二,事件从内存对象变成需要序列化的消息结构,必须带上 schema 版本,否则老消费者读到新字段会直接崩。第三,派发时机从微任务变成了网络投递,原来“同步收集、异步派发”的折叠效应完全失效。第四,顺序保证从内存序号变成了分区有序,跨分区仍然没有全局顺序。

于是我们做了一个折中选择:REA 继续保留进程内实现,但在边界上增加一个适配器,负责把本地事件转换成远程消息发送给已有消息通道,同时监听远程消息并转换为本地事件。REA 不是消息中间件的替代品,而是消息中间件和业务逻辑之间的翻译层。这样既保护了现有代码,又让跨服务的事件流转能复用消息通道已有的可靠性能力。

6.2 一些个人的体会

从写下第一行原型到稳定运行,REA 这个代号在项目里已经用了很长一段时间。我自己最大的体会是:事件代理这种东西,千万不要等到架构完全定了再动手。最好的方式是从业务里挑两三个真实卡点,先写一个最简版本,让它跑起来解决实际问题,然后再根据问题反推设计。第一版大概率不是通用组件,但它一定是最贴合真实场景的那一版,后面的通用能力都是在这种“贴合”之上长出来的。

如果你准备在自己的项目里做类似的东西,我只有一个建议:先把资源、事件、代理的边界画清楚,再去写任何代码。边界清楚了,后面所有的踩坑其实都有规律可循。

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

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

立即咨询