如果有人问我在过去半年里写的最值得的一个项目,我会毫不犹豫地说是 rea——一个不到三千行代码的实时事件响应引擎。rea 的全称是 Real-time Event Assistant,也可以理解成 reactive 思路的变体,核心就一句话:一有动静,立刻安排。我之前负责一个内部的数据同步模块,业务方要求的触发链路越来越长:文件上传后要触发解析,解析出结果要触发数据清洗,清洗完成要触发入库,入库失败要触发告警,告警长时间没人处理还要升级给值班组。一开始我们用回调函数硬编码,后来回调里套回调,日志里根本看不出一次完整的请求到底经过了哪几步。痛到最后,我和搭档决定不再补丁摞补丁,而是抽出时间自己写一个足够轻量的事件编排工具,也就是 rea。
rea 的规模很小,核心逻辑不依赖任何重量级外部组件,之后陆续接进了六个内部项目,跑了半年多基本稳定。这篇文章不是一份官方文档,而是围绕 rea 的设计、落地和踩坑过程做的一次完整复盘。我会把"事件怎么流动"和"事件怎么处理"为什么要拆开、核心 API 怎么用、接入一条真实业务流水线需要哪些步骤、以及上线后遇到的两个棘手问题都摊开讲。如果你手头也在做任务编排、消息转发、多服务联动之类的事情,这里的大部分取舍可以直接借用。
1. 被事件埋没之后:为什么我会自己写rea这个实时响应引擎
1.1 一堆回调叠出来的系统,排障全靠考古
接手这个数据同步模块时,系统已经运行了大半年,我看到的第一个现象是:不同业务服务各自监听状态变化,A 服务处理完就调 B 服务的接口,B 服务处理完再调 C 服务。表面上看每一步都很直接,可链路一长,问题就变得不可控。之前有一次某文件解析失败,我们需要确认"告警有没有发、告警有没有送达、任务有没有被标记成终态",结果翻开四套服务的日志拼了半天,才勉强还原出全貌。运气好的话能还原,运气不好只能靠猜,猜来猜去还得等第二天业务再来问一次。
更麻烦的是,大量接收方都在处理自己不该处理的事件。库存服务收到订单创建事件,要先判断"要不要锁库存";财务服务收到同样的订单创建事件,还要判断"要不要生成账目"。这两份判断逻辑长得差不多,却分散维护在三四处,改了一个地方忘了另外两处,是经常发生的事。这种场景让我意识到一个很重要的分界:事件系统真正复杂的地方往往不是消息本身,而是"事件应该流向哪里"和"事件应该怎么处理"这两件事被人为地搅在了一起。我们要做的小引擎,必须先把这个分界划清楚。
1.2 对比了一圈,我决定放弃大而全的框架
在动手之前,我认真看过市面上的方案。功能最完整的是可视化工作流平台,能拖拽编排、支持定时器、人工审批甚至子流程,确实很强大,但部署它要额外起一个调度服务,还要维护专门的存储表,对于只有十几人维护的中小型内部系统来说明显偏重。另外一类是企业总线产品,连接器丰富,学习成本也非常高,新同事想理解清楚通道、端点、聚合器这些概念,没一两周下不来。最让我犹豫的是,这类系统一旦接进来,团队就不得不跟着它的升级节奏走,后续维护压力会持续累积。
而当时我们的真实需求其实很朴素:把事件接进来,根据事件内容做路由,调用不同的处理函数,失败能自动重试,关键步骤留痕。这个量级用重方案只是杀鸡用牛刀。于是我们决定自研一个"刚好的工具"。这个决策背后有一条很重要的原则:先明确自己的真实工作负载,再选刚好的工具,而不是先选定平台再反过来适配业务。rea 就是在"刚好的工具"这个定位下诞生的,它不替代任何重型平台,只解决自己能力范围内的事。
1.3 rea的三条设计底线
开工第一天,我们先定了三条底线,之后所有设计都围绕这三条走。
第一,核心逻辑不依赖中间件。rea 要能作为库直接嵌进现有服务,不要求你额外部署消息队列或注册中心。这样做接入成本最低,几个小时就能把链路跑起来。第二,事件分发规则必须是显式的。一个事件流向哪里、为什么流向那里,应该能从路由配置里直接看到,不能散落在各个业务的 if 分支里。第三,每个事件都要能追踪。不管成功还是失败,都要能拿到这个事件从进入到结束的完整路径。这三条底线看似简单,却决定了 rea 的整体架构形态,也决定了它不会在后续落地里退化成又一个黑盒转发器。
2. rea核心架构拆解:Event、Rule、Handler是如何配合的
2.1 三段式核心:Ingress、Router、Handler Registry
rea 的内部被我分成三个相对独立的部分:入口适配层(Ingress)、规则路由器(Router)、处理器注册中心(Handler Registry),以及一个贯穿始终的上下文存储(ContextStore)。
入口适配层负责把不同来源的数据翻译成统一的内部事件对象。来源可以是 HTTP 回调、定时器、文件监听,也可以是外部消息队列,只要在 Ingress 写一个适配函数,后面的处理流程完全一致。这一步非常关键,它让 rea 不与任何传输协议绑定。规则路由器则承担"事件该去哪"的决策,它拿到一个事件,读取主题和 payload 字段,按路由规则算出目标处理器列表,再把事件投递过去。处理器注册中心维护着一张"处理器名 -> 处理函数 -> 配置参数"的映射表,handler 可以是一个幂等函数、一个长耗时异步任务,也可以是一个封装了第三方接口调用的适配器。
三条管线合在一起,构成一次完整的事件生命周期:Ingress 收进来,Router 决定方向,Handler 执行副作用,Context 串起整条链路的线索。用快递分拣来打比方,Ingress 是快递员收件,Router 就像分拣中心的扫码口,Handler 是不同目的地的派送工位,Context 则相当于运单号——每个包裹从哪里进、停在哪里、在哪里出过岔子,全都能查。
2.2 核心API与一段可运行的示例
跑通 rea 的核心只需要三个 API:handle注册处理器,route定义路由规则,fire触发一次事件。这里给一段最基础的用法。
const REA = require('rea-core'); const engine = REA.create({ serviceName: 'order-sync' }); engine.handle('stock.deduct', async (ctx) => { const { orderId, skuId, qty } = ctx.payload; await ctx.invoke('stock-api/deduct', { orderId, skuId, qty }); ctx.set('stockDeducted', true); }, { id: 'stock-deduct-handler', maxRetries: 3, retryDelayMs: 1000, timeoutMs: 5000 }); engine.handle('finance.createBill', async (ctx) => { const { orderId, amount } = ctx.payload; await ctx.invoke('finance-api/create', { orderId, amount }); }, { id: 'finance-bill-handler', maxRetries: 2 }); engine.route('order.created', (evt) => evt.payload.amount >= 100, 'stock.deduct'); engine.route('order.created', (evt) => evt.payload.amount >= 100, 'finance.createBill'); const event = engine.fire({ topic: 'order.created', payload: { orderId: 'A10086', skuId: 'SKU-21', qty: 2, amount: 9800 } }); event.trace().then((trace) => { console.log(trace.steps); });这段代码里有三个细节值得展开。ctx.invoke不是直接发起 HTTP 请求,而是把外部调用也注册为一次受管动作,写入上下文。这样做的好处是查链路时能看到"这个 handler 确实调了外部接口、结果如何、耗时多少"。maxRetries和retryDelayMs控制失败重试,重试之间带指数退避,防止下游一恢复就被打爆。另外,route的第二个参数是一个纯函数,只做判断不产生副作用,这让所有路由规则都能单独做单元测试,也方便在测试环境回放线上事件做验证。
2.3 路由规则为什么要设计成"可独立运算"的
如果路由逻辑只是简单的 topic 判断,那根本不需要做成函数。实际场景里,一个事件是否要流向某个处理器,经常需要结合多个字段综合判断。比如订单金额大于 100 元且支付状态为 success 时才需要通知财务创建账单。这种条件如果散落在各业务代码里,每个 handler 都要重复判断一次,漏一处就会出错。
rea 把这类判断统一收集到路由层,有两个直接收益。第一是可观测,打开路由配置就知道"什么事件在什么条件下会触发哪些处理器",相当于一张可读的决策表。第二是可组合,当多个 handler 命中同一个条件时,直接把路由串联起来即可,不需要各自维护。因为是纯函数,路由规则本身可以独立测试,这给后续维护带来了很大安心感。我记得某次线上临时加需求,金额超过一万元的大订单必须额外触发风控审核,我们只加了一行 route 配置,其余代码完全没动,十几分钟就上了线。这就是把路由独立出来的价值。
3. 一次完整接入:用rea编排数据同步流水线
3.1 场景设定与事件模型设计
用一个实际搭过的场景把接入流程串起来。那是一个模拟项目X,负责把线下门店的订单数据定时同步到总部的数据仓库。数据源是门店上传的 Excel 文件,文件放到共享目录后,系统依次做四件事:解析文件内容、清洗异常数据、写入数据仓库、记录本次同步结果;如果解析或者入库失败,还要通知值班群。
第一阶段的工作是设计事件模型。文件落地建模为file.landed事件,payload 携带{ filePath, storeId, fileSize }。解析完成产生file.parsed,清洗完成产生data.cleaned,入库成功产生sync.succeeded,失败则产生sync.failed。事件命名遵循"模块.动作"的格式,简单、可枚举,日志里一眼能看懂。这个命名规范后来被团队沿用到了其他项目里,非常实用。
还要处理好 payload 的最小化原则。事件对象里只放路由和处理器真正需要的字段,不要把整张大对象塞进去。我们曾经把整个文件句柄放进 payload,后来发现它在 Context 里被长期持有,既浪费内存又容易造成句柄泄漏,改成只存文件路径和元信息后问题就消失了。这条经验在后面排查内存问题时帮了很大忙。
3.2 接入的五个步骤
整个接入过程我总结成五步。
第一步,初始化引擎并注册中间件。中间件会在每个事件进入 Router 之前执行,我们用它统一记录日志、给事件生成序号、检查上游限流标记。第二步,在 Ingress 层写一个目录监听适配器,每隔十秒扫描共享目录,发现新文件就生成file.landed事件,这一步不需要新增常驻服务,就在现有进程里启动一个定时任务即可。第三步,按业务阶段注册 handler,解析器、清洗器、入库器、通知器各注册一个,每个 handler 只做一件事。其他团队改自己的处理逻辑时不会碰路由层,也不会影响别的处理器。第四步,在 Router 层定义流转链路:file.landed命中后触发file.parsed,file.parsed再触发data.cleaned,依次串联。第五步,启动引擎,观察初始日志确认链路正常。
下面是简化后的核心配置。
engine.middleware(async (ctx, next) => { ctx.set('seq', seqGen.next()); await next(); ctx.logger.info(`[${ctx.event.topic}] handled in ${ctx.costMs}ms`); }); engine.onTick('10s', async () => { const files = await scanDirectory('/data/store-upload'); for (const f of files) { engine.fire({ topic: 'file.landed', payload: { filePath: f.path, storeId: f.storeId, fileSize: f.size } }); } }); engine.handle('file.landed', fileParseHandler, { id: 'parser', maxRetries: 2 }); engine.handle('file.parsed', dataCleanHandler, { id: 'cleaner', maxRetries: 2 }); engine.handle('data.cleaned', warehouseWriter, { id: 'writer', maxRetries: 4, breaker: { threshold: 10 } }); engine.handle('sync.failed', notifier, { id: 'alert', maxRetries: 1 }); engine.route('file.landed', (evt) => evt.payload.fileSize > 0, 'file.parsed'); engine.route('file.parsed', (evt) => evt.payload.rowCount > 0, 'data.cleaned'); engine.route('data.cleaned', (evt) => evt.payload.validCount > 0, 'sync.succeeded'); engine.route('data.cleaned', (evt) => evt.payload.validCount === 0, 'sync.failed'); engine.route('file.parsed', (evt) => evt.payload.parseError === true, 'sync.failed');配置里的breaker是熔断参数,当 writer 下游连续失败超过十次时,rea 会暂时不再向该 handler 投递新事件,避免下游被持续压垮。熔断触达后会有一个冷却期,冷却结束再放开,这比单纯重试更能保护稳定状态。如果你在接入时发现某个下游常常过载,建议一定要把breaker用起来。
3.3 实测效果与运行指标
流水线跑通后,我做了一轮压测。文件从落地到完成入库,本地内存模式下单个事件链路平均耗时约 1.2 毫秒;叠加真实的 HTTP 仓库写入后,整条链路平均耗时约 18 毫秒。重试带来的成功率提升也很明显:在模拟下游偶发超时的场景里,不加重试时同步失败率约 3.7%,加了两次重试后失败率降到 0.2% 以下。连续运行八小时观察内存,Context 存储稳定在 340MB 左右,没有持续上涨,说明清理策略是有效的。
需要说明的是,这些数字来自我们的内部测试环境,每个业务机器的配置不同,结果会有差异。但作为相对量级的参考是没问题的。如果你想在自己的项目里复现这套方案,建议先关注两个指标:事件平均耗时和失败重试占用量。前者决定链路会不会拖垮主业务,后者决定重试机制会不会产生失控堆积。rea 在 Context 里记录了每个事件的重试次数和最终状态,压测阶段直接查 trace 就能定位瓶颈。
4. 上线后踩掉的两个深坑:乱序和内存泄漏
4.1 重试把队列搞乱了:时序失真的完整排查过程
第一个坑出现在接入后的第二周。业务方反馈,订单导入明细里后导入的数据居然先出现在数据库里,先导入的数据反而延迟了几分钟,偶尔甚至完全没进去。刚开始我以为是入库接口的问题,查了半天没有头绪。
后来我把思路从"数据对不对"转到"时序对不对"。查看 rea 的流水日志时,我注意到两个事件 A 和 B 都在被处理,两者的 topic 都发往同一个 writer handler,而 writer 配置了最多四次重试。场景还原是:A 事件第一次处理失败,进入重试等待;B 事件随后进入队列,处理一次就成功直接写库;等 A 事件重试成功时,写库顺序已经变成 B 在 A 前面。对单个事件来说处理是成功的,但从业务视角看,先落地的文件反而晚写库,时序就乱了。
根因有两层。第一,writer 的并发度虽然配置为 1,本意是串行处理,但我当时没有意识到,重试并不会重新排队,它在退避之后直接再次执行,绕过了等待队列。第二,各个事件之间缺少版本比较机制,处理函数只能看到当前事件本身,不知道还有更早的事件没完成。这两个问题叠加,就出现了"后写库但更早触发"的错觉。
修复也对应这两层。一方面,我把所有最终落库类 handler 都切换成串行队列模式,让新事件必须等前一个事件进入终态后才会被处理;另一方面,在事件 meta 里增加sourceSeq字段,入库前校验当前序号是否不小于本轮最大已处理序号,发现旧事件晚到时就直接丢弃并记录告警。加了这两层保护之后,乱序问题没有再出现过。这次排错让我意识到,事件引擎里"重试"这个功能不能只当可靠性手段,也要当成时序控制的一部分来设计。
4.2 上下文缓存只进不出:内存上涨的根因定位
第二个坑更有隐蔽性。系统连续运行十几天后,例行检查时发现进程内存从启动时的 150MB 涨到了 900MB 以上。第一反应就是有东西没被释放。
我先抓了两份堆快照做对比,发现大量占用来自 rea 的 Context 实例。每个 Context 里存着事件原始 payload、路由过程、各 handler 的执行耗时,单独看都不大,但数量很多。继续查代码才发现,设计 Context 存储时我只记录了事件开始、把结果写进 map,却在事件进入终态后忘了清除这条记录。事件本身的配置标记了"处理完成",但上下文释放的逻辑挂在一个没有执行到的清理分支上。简而言之:只进不出。
修复分两步。先写了一个定时兜底:每隔五分钟扫描一次 Context 表,凡是终态事件且创建时间超过十分钟的,强制释放。然后引入finalize钩子,事件进入终态时统一执行收尾逻辑,包括清理内存、聚合更新指标、若被外部订阅再广播完成信号。内存曲线在修复后逐步回落到 300MB 附近的稳定水平。这个坑给我的教训是:自定义引擎里凡是有缓存的地方,必须先设计好出口再谈入口,清理逻辑要绑定事件生命周期,而不是绑定某条偶发的路径。
4.3 沉淀成通用规避清单
踩完这两个坑,我整理了一份内部规避清单,之后新项目接入时都会过一遍:
- 重试不能破坏业务顺序,必须有队列或版本校验兜底;
- 缓存必须有生命周期出口,不能假设所有路径都会走到清理分支;
- 处理器配置里并发度和重试次数要放在一起设计,分开思考容易互相打架;
- 每次变更路由规则后,跑一遍旧事件样本做回归,验证路由前后一致。
清单很简单,但每条背后都对应一次真实的线上事故。整理出来之后,新来的同事照着做也能少走很多弯路。如果你用的是别的引擎,可能具体 API 不一样,但"重试乱序"和"缓存泄漏"这两个问题几乎是自研事件系统绕不过去的两个坎,建议提前预防。
5. rea的边界:哪些场景不该用它
5.1 三个明确的"不适用"信号
rea 好用,但我不会在所有场合推荐它。到了项目稳定期,我反而会先看有没有以下三个信号,命中任何一个就考虑其他方案。
第一个信号:单机吞吐量达到百万级每秒。rea 为了轻量,把事件对象和上下文都放在进程内存里,没有做多副本水平扩展的配套,单机处理能力有明确上限。真遇到百万级事件流,应该选择成熟的分布式流处理平台,那才是带分区、副本和 checkpoint 的正规军。
第二个信号:需要严格的分布式事务。rea 只负责事件流转,不负责事务协调。如果两个服务必须保持强一致性,比如扣款和加积分必须同时成功或同时失败,那需要的是事务性发件箱、Saga 编排这类机制,而不是靠 rea 的"重试到达"来碰运气。
第三个信号:事件处理链路必须长时间保持状态。rea 的处理器默认是无状态的,接收一次事件、调用外部接口、返回结果。多人审批、流程积压到第二天继续这种场景需要状态机持久化,rea 里没有这一块,硬要套会造成状态丢失。
5.2 rea与成熟方案的选择矩阵
我整理了一个选择矩阵,给团队同学看,大家反馈比纯粹讲道理直观得多。
| 业务特征 | 优先选择 | 原因 |
|---|---|---|
| 事件量中等,链路几十条,迭代快 | rea 这类嵌入式引擎 | 部署简单,规则可测,改造成本低 |
| 需要可视化编排、多人协作设计流程 | 成熟工作流平台 | 自带运维界面和审批节点,不用自己造轮子 |
| 需要跨机房、跨地域、海量事件 | 成熟消息流平台 | 有分区和副本机制,天然支持水平扩展 |
| 强一致性事务 | 事务消息与 Saga 机制 | 需要事务协调,事件路由单独解决不了 |
| 团队已有标准技术栈和运维体系 | 跟随团队既有方案 | 避免引入孤岛组件,降低长期维护成本 |
这个矩阵不是非此即彼,而是一个思考锚点。用 rea 之前,我会先问团队三个问题:事件量大概每分钟多少?失败重试的容忍度有多高?现有团队能不能接受再维护一个几百行代码的小引擎?答案决定了你到底应该花两周写一个小引擎,还是直接采用成熟平台的能力。
我自己折腾了半年后,依然会在中小规模、快速迭代的项目里继续使用 rea 这类思路。写一个贴着自己业务切面的小工具,核心价值不在于省下那点部署成本,而在于每个设计决策都能被团队完全理解,出了问题能顺着源码一路查到源头。这种掌控感,是黑盒平台买不到的。
最后再分享一个我自己的习惯:每次准备调整路由规则时,我会先用 rea 的事件回放功能,把上周的线上事件重新灌进测试环境跑一遍,对比新旧规则下每个事件的最终流向。这招看着土,但有一次真的捞出一个边界情况——某个老事件在改完规则后会落到完全不同的处理分支。用历史数据当验收基准,比任何文档都让我安心。