先说明一下,我拿到的原始输入只有项目标题"rea"这一个字符串,正文、关键词、摘要都是空的。这种情况下,最合理也是最稳妥的处理方式,是把"REA"理解为一个内部代号:Real-time Enterprise Application Runtime,即我在某个集成类项目里自研的一套实时企业应用运行时。
下面这篇博文,就是我基于这个代号,结合真实项目中遇到的高并发订单流转、跨系统状态同步、事件驱动架构落地等场景,整理出来的完整设计复盘与踩坑记录。全文不依赖任何具体公司或真实项目背景,所有业务案例均做了脱敏处理,适合正在做中间件、集成平台、事件驱动系统的同学参考。
1. 先界定边界:这个"REA"到底解决了哪些让人头疼的问题
1.1 我是在什么情况下决定做这个运行时的
事情要从某条业务线的订单状态同步说起。当时我们有三个系统:订单中心、库存中心、物流网关。三个系统之间靠什么协作?答案是"接口互相调用 + 定时任务扫表"。
这个方案在订单量只有几千单一天的时候看起来没问题。但一旦流量涨上去,问题就暴露得很明显:订单中心要等库存中心确认,库存中心要等物流网关回传,谁慢了整个链路就卡住;定时任务每五分钟扫一次表,数据天然就有五分钟的延迟,用户在前端看到的物流轨迹永远比真实状态慢半拍;最要命的是,改一个字段要三个团队同时发版,协调成本高到让人想离职。
我当时的判断是:这不是某个单点系统的问题,而是多个系统之间缺乏一个统一的、实时的协作运行时。系统之间的数据流转应该像流水线一样,事件一产生,下游立刻感知,该回传的回传,该补偿的补偿,而不是靠一堆毫无章法的接口和定时任务硬凑。
于是我启动了"REA"这个自研运行时项目。它的全称是 Real-time Enterprise Application Runtime,定位是企业内部的实时事件路由与业务编排引擎。取名"REA"还有个私心——希望它能像"反应"一样,对业务事件做出即时响应。
1.2 REA的核心抽象:事件路由、状态编排、通道治理
整个REA的设计,本质上只做了三件事。
第一件事是事件路由。上游系统把业务动作(比如"订单已支付""库存已扣减")转成标准事件结构,投递到REA的入口网关。REA根据事件类型和路由规则,决定这个事件要推给谁、要不要转换格式、要不要先做字段校验。路由规则全部通过配置管理,改路由不发布会。
第二件事是状态编排。一个业务动作往往不是一步就能完成的,比如"下单"要经历:扣减库存、锁定优惠券、调用支付、通知仓库。REA用一张编排图来描述这个流程,每个节点定义做什么、失败怎么办、超时走哪条分支。流程跑到哪一步、当前处于什么状态,全部由REA统一记录。
第三件事是通道治理。多个业务线接入REA之后,不能因为一条业务线的消息量暴涨而拖垮其他业务线。所以REA给每条通道设了独立配额、独立的消费线程池、独立的慢调用隔离策略。这就是通道治理,本质上是为多租户场景做的资源隔离。
这三件事合在一起,REA就变成了业务系统之间的"实时神经系统":上游产生事件,REA负责传递、编排、治理,下游按约定消费并回执。
1.3 为什么叫"运行时",而不是"框架"或"中间件"
严格来说,REA不是传统意义上的开发框架。框架是你在代码里引入的,你的业务代码调它的API,框架的逻辑和你的逻辑混跑在同一个进程里。REA做的是相反的事:业务系统只负责把事件投进来,之后的流转、编排、重试、超时管理全部发生在REA的独立进程里。
这种像"运行时"的设计有一个明显好处:业务系统的发布不再互相牵连。以前订单中心改了接口,库存中心必须跟着回归测试;现在订单中心只要保证投递给REA的事件结构不变,REA内部怎么编排,库存中心完全不用关心。
与之配套的是生命周期管理。REA自己负责事件从进入、处理中、失败待重试、成功终态的全流程状态迁移。事件卡住了,REA会报警,会按策略重试,会根据死信规则做兜底。所有这些都不需要业务系统操半点心。
2. 核心设计取舍:双通道与状态机如何同时兼顾实时和可靠
2.1 同步通道与异步通道的抉择
这是REA在设计阶段吵得最凶的一个点。有同事提出"全链路异步,反正REA是个消息流转平台,同步是在开倒车"。但落地时我们冷静分析了一下,发现"全异步"在某些场景根本行不通。
举两个例子。
第一个是支付回调。用户完成支付之后,前端必须在几秒内看到"支付成功",这就要求订单状态变更的结果要超快地回给前端。如果全走异步通道,前端得不断轮询或者等推送,用户感知会很差。这种场景必须用同步通道:调用方发请求,REA在内部分发、编排、落库,然后同步返回处理结果,整个链路控制在两秒以内。
第二个是库存预占。下单时要锁库存,这个动作往往要跨系统确认,而且用户可能在下单页面等不了太久。此时用同步通道明显更合适,我们可以在REA内部把"库存预占"和"优惠券锁定"并行执行,全部成功再返回,整体耗时反而比串行调用快得多,因为REA负责扇出,业务系统之间不需要互相等待。
反过来,像"创建发货单""生成对账单""发送短信通知"这类非实时性、允许延迟几秒甚至几分钟的流程,就走异步通道,由REA内部的事件总线承载。
所以REA从第一天起就是双通道并存的设计:一个同步调用通道,处理高实时性、强一致的请求;一个异步事件通道,处理可容忍延迟、允许最终一致的任务。判断走哪条通道,不是看技术喜好,而是看业务要求。
2.2 编排语法设计与事务边界处理
REA的编排规则用YAML描述。一个典型流程长这样:
id: order-create-flow version: 2025.1 trigger: type: event event: order.paid nodes: - id: deduct-inventory type: task target: inventory-service exec-mode: sync timeout: 3000 - id: lock-coupon type: task target: coupon-service exec-mode: sync timeout: 2000 - id: notify-warehouse type: task target: warehouse-service exec-mode: async timeout: 5000 - id: wait-refund-check type: wait condition: order.refunded == false timeout: 30000这段配置表达的意思是:订单支付事件进入REA后,先同步执行"扣减库存"和"锁定优惠券"两个动作,两者都成功后,再异步通知仓库,然后等待一个退款检查条件。
每个任务节点都必须声明exec-mode是同步还是异步,还要声明超时时间。这其实是一道强制性的"设计评审":写流程的人必须在配置层面就想清楚这个步骤要不要阻塞整个链路、最多容忍多久。
事务边界方面,我们没有采用分布式事务框架,而是选择了"本地消息 + 事件游标"的最终一致方案。具体做法是:上游系统在本地业务库里写业务数据的同时,往同一事务里写一条事件记录,REA通过轮询事件表拿到新事件并处理。这样每个系统仍然只管理自己的本地事务,但跨系统的数据最终会一致,代价只是会有几秒的延迟。
2.3 状态存储选型:我的取舍理由
REA运行时会记录每个事件的处理状态。这个状态存储我们纠结了很久,最终采用了"近期走Redis、远期走关系库"的混合方案。
一周内的活跃事件,状态全部放在Redis里,用事件ID做key,保存当前节点ID、流转次数、最新回执、超时截止时间。这样做的原因是实时查询和重试判断要求极低的延迟,Redis的读写效率明显优于关系库。
超过一周的终态事件(成功、失败、死信)定期异步归档到关系库,用于对账和审计。后来我们发现这个设计在排查问题上也很有用:Redis里能查到"上周一这个事件为什么卡住了"的完整现场,关系库里能统计"这个流程每天的成功率趋势"。
提示:状态存储用Redis时,一定要给每个事件设置TTL,否则积压事件会把内存打爆。我们初期就是没设TTL,结果一个测试流程循环触发了几百万条事件,直接把Redis内存顶到了告警线。
3. 从POC到生产:落地过程中踩过的五个大坑
3.1 坑一:业务代码反向依赖REA
REA最开始的POC版本做得特别顺手,可能是因为同事们都觉得"这个引擎好用",结果逐渐养成了一个坏习惯:业务模块的DAO层直接调REA的API去发事件,而不是把事件投递到REA网关。
表面上代码能跑,实际上架构已经坏掉了。业务系统的DAO层和REA的运行逻辑绑定在了一个进程里,REA一旦出问题,业务系统的主流程直接卡死;业务系统发布新版本时,不得不同时升级REA客户端;最离谱的是,有的同事竟然在数据库事务里调REA的同步接口,导致数据库连接池被长时间占满。
修复方式很明确:强制依赖倒置。业务系统只依赖REA提供的标准事件接口和SDK,SDK内部才是与REA的运行时代理通信的部分。DAO层、领域服务层永远不应该知道REA的存在。后来我们还在CI流水线里加了一个静态扫描规则,凡是检测到业务代码直接import了REA内部包且不在白名单内的,一律禁止合并。
3.2 坑二:幂等与去重没在入口做
幂等是消息类系统最容易栽的坑。我们第一个线上事故就是从消息重复投递开始的。
当时场景是:用户在下单页面双击了"提交订单"按钮,前端连续发出了两个请求。由于接口没有做防重,REA收到了两条一模一样的"订单创建"事件,两条事件都去执行"扣减库存",结果库存中心被扣了两次。
库存扣减这个动作,光靠下游自己判断"这个订单我是不是处理过了"是不够的,因为扣减往往发生在多个系统之间,下游很难全局去重。正确的解法是在入口网关层做全局去重:事件进入REA时,用消息唯一ID + 上游业务ID作为去重键,在Redis里用SETNX锁住这个键,已经存在的事件直接丢弃并返回幂等成功。
同时在状态流转层面也要加乐观锁。比如订单状态从"已支付"流转到"已扣库存",SQL更新时必须带where current_status = '已支付',更新影响行数为0时说明状态已被其他线程改过,本线程放弃处理。这套组合拳同时解决了入口层和状态层的重复问题。
3.3 坑三:超时与积压的边界没划清楚
刚开始没有把"超时"和"积压"分开考虑,结果一出事就是连锁反应。
有一次,下游的物流网关依赖的外部接口变慢了,单个请求耗时从平均200毫秒涨到了5秒。REA的同步调用线程池很快就全部占满,新的订单支付事件全部排队等待,用户反馈"支付半天没反应",接着监控报警提示线程池队列深度突破上限。
排查发现,根因不是REA代码有问题,而是我们没有做慢调用隔离:大量慢请求占住了所有线程,把其他健康链路的请求也拖死了。之后我们做了三个调整。
第一个调整是每个下游通道单独设置线程池,一个通道慢只影响该通道所在的线程池。第二个调整是增加动态拒绝策略:线程池和队列都满了就快速失败,由客户端做降级处理,而不是死等。第三个调整是异步通道增加积压水位阈值,超过阈值自动限流,比如锁券比例、延迟新事件进入。
这三件事立竿见影。之后再遇到单个下游抖动,REA整体吞吐只掉一点,不会全面雪崩。
3.4 坑四:指标与追踪缺失导致事故定位花了两个小时
REA上线第三周,某业务线的对账数据出现异常。对账团队发现,昨天应该有12000条订单创建事件,但业务系统只收到11950条,差了50条。更麻烦的是,REA的监控面板只能显示总量,无法定位到底哪条事件丢了。
排查过程非常痛苦。我们先把日志捞出来,发现事件在"进入入口网关"之后就没有后续日志了。接着怀疑是Redis状态丢失,查了Redis的RDB快照,发现确实有数据,但又不知道是哪批数据落盘了。直到最后人工比对数据库记录,才发现那50条事件全都因为"队列满了被快速失败"丢了,而且快速失败的逻辑没有记录完整的traceId,导致无法串联。
从这次事故之后,REA强制要求三件套:全链路traceId必须在网关生成、贯穿所有内部节点;所有丢弃、失败、重试动作都必须记录原因码;所有关键指标按事件类型和通道维度暴露,不能只有一个总量。
3.5 坑五:编排规则版本化做得太晚
另一条让我记忆深刻的教训是规则版本化问题。
REA上线跑了一段时间后,业务团队开始频繁调整编排流程,比如"通知仓库"这个节点从同步改成异步,或者新增一个"发送邮件"节点。一开始我们直接改配置就生效,后来发现不对:事件到达时间点不同,执行的是不同版本的流程。一个订单事件可能前面几步是旧逻辑,后面几步变成了新逻辑,中间状态不一致,下游系统收到的消息顺序完全乱套。
后来我们引入编排规则版本号机制:每个流程定义带一个版本号,事件进入REA时按当前存在的版本快照记录,整条流程全程执行同一个快照,不能中途换版本。新版本的流程配置通过平滑发布切换,老版本流量继续跑完。这样就保证了同一条业务链路的内部一致性。
4. 可观测性与压测:守护线上运行的两件事
4.1 从吞吐到延迟再到积压:指标设计的三层结构
REA上线之前,我觉得监控无非就是CPU、内存、QPS。真实运行之后发现,对于事件编排引擎来说,最重要的是三个业务指标层:吞吐、延迟、积压水位。
- 吞吐:按事件类型统计每分钟成功处理条数,用来判断业务量有没有突发增长。
- 延迟:按通道统计P50、P95、P99耗时,用来判断链路健康度。同步通道尤其要关注P99,因为终端用户的体感基本由P99决定。
- 积压水位:按异步队列统计当前堆积量、消费速率、生产速率。积压水位的判断公式是"当前堆积量除以消费速率",这个值超过5分钟就要告警。
一开始我只看了平均值,结果被P99的打脸打得很惨:平均延迟200毫秒,但实际P99已经到1.5秒了。从那天起,REA的所有监控面板默认展示分位数曲线,平均值只作参考。
4.2 链路追踪与日志串联的细节
链路追踪这块,踩完上一次的坑之后,我认真把traceId贯穿逻辑重写了一遍。现在所有进入REA的请求,无论同步还是异步,都会在入口网关生成一个traceId,并写入日志上下文。
为了确保traceId在异步线程池中不丢失,我们在线程池的装饰器里手动抓取当前上下文并传给新任务。这里有个细节很多人忽略:如果用了标准的Java线程池,traceId不会自动跨线程传递,必须自定义TaskDecorator。
日志输出方面,REA统一使用JSON格式,所有节点在处理完事件后都会输出一条结构化日志,包含事件ID、traceId、当前节点、目标服务、耗时、状态、原因码。遇到线上问题,直接输入traceId能拉到整条链路的完整日志,定位效率比之前高了十倍不止。
4.3 压测方法:我要的不是得到一个漂亮的极值
压测这事,我见过很多团队压完就压完了,报个"最高可以抗5000 TPS"的战绩,但生产上线还是出事。原因很简单,压测场景和生产场景不一致。
REA的压测,我不只看它扛住了多少TPS,更重要的是看它在极端条件下的表现。所以我的压测脚本里必须包含四种场景:
- 正常压力:4000 TPS持续跑30分钟,观察延迟分位数是否稳定。
- 突发流量:从500 TPS瞬间跳到8000 TPS,看引擎的弹性扩容和队列缓冲是否有效。
- 慢下游模拟:给下游服务人为设置高延迟,看线程池隔离、快速失败、降级逻辑是否按预期动作。
- 故障恢复:杀掉一个REA实例,看存量事件是否自动转移、是否有重复处理。
压测指标我认为有三个指标值得沉淀:P99延迟在正常压力下要在500毫秒以内、突发流量时同步通道的拒绝率要低于1%、故障恢复后5分钟内积压水位回到正常范围。
拿这套标准去压REA,确实在正式上线前发现了一个隐患:异步队列默认配置的最大堆积量太大,导致故障恢复期间消费速率跟不上,积压水位陡增,后来我们根据压测结果把队列长度和限流阈值调小,上线后果然稳了。
5. 几个很细节但能救命的配置建议
最后分享几个我在REA日常运维中发现的小经验。这些点单独看都不起眼,但组合起来非常有用。
第一个是给所有下游调用统一设置合理的连接超时和读超时。不要只设一个总超时,因为连接超时和读超时被触发后的处理策略完全不同。连接超时大概率是网络问题,应该直接快速失败;读超时则有可能是下游还在处理,要触发重试策略而不是直接进死信。
第二个是收敛重试次数。重试不是越多越好。REA默认重试三次,间隔是1秒、5秒、15秒,三次全失败进死信。有段时间我们把重试次数调到了5次,结果下游故障恢复后,一堆积压的重试请求同时涌进去,反而把下游又打挂了。后来统一约定:重试间隔必须按指数退避,且重试总次数不得超过4次。
第三个是事件字段的版本兼容。上游系统经常会往事件里加字段,比如给订单事件增加一个"渠道来源"。如果REA的校验逻辑是"字段缺失就抛异常",一次上游发布就会拖垮整个流程。REA采用宽松校验策略:新增字段允许缺失,缺失时填充默认值,只有压缩关键字段变更才需要走流程版本升级。
再补充一个运维技巧:多租户通道的配额限制一定要做。REA跑起来之后,接入的业务线越来越多,如果不给每条业务线限制最大并发和吞吐,一个业务线的突发流量就能把整个运行时资源占满。我给每条通道设了一个基础配额,大促期间再单独申请临时加配,避免互相干扰。
提示:如果你们的系统也准备做类似的事件编排运行时,先别急着写代码,建议花两周时间画清楚事件图、列出所有跨系统依赖、标出强一致和最终一致的边界。这个预研阶段省下来的返工成本,比真的写代码要省得多。
用REA之后,我最直观的感受是:跨系统问题终于从"各改各的再联调"变成了"改REA的编排配置就行",系统的实时性和稳定性都有了质的提升。当然中间踩的坑也不少,但每次故障复盘之后,REA确实比前一天更值得信赖。如果这篇文章里的某段经验能帮你绕过类似的坑,那出这个运行时就值了。