我一直记得这个项目是怎么开始的。某个下午,我收到一条业务侧转来的提需单,标题栏只有三个字母:rea。项目正文是空的,关键词是空的,摘要描述也是空的,连预期时间都没写。第一眼看上去像系统 bug,第二眼才确认对面是真的传丢了内容,但对方业务负责人一口咬定:需求就这么多,你们先按 rea 理解。
正常情况下,这种信息我会直接打回。但那次时间紧,我决定把“rea”当成一次内部解谜:把它理解为 Record-Evaluate-Act,也就是“记录-评估-行动”三段式任务编排引擎。这个名字是我起的,背后对应一个很朴素的需求——把人工每天重复的“看数据-做判断-点操作”变成一条自动流水线。这篇文章就把从三个字母到可运行系统的完整过程讲出来,包括三阶段拆解、落地时的三个大坑、性能调优和最后的边界判断。如果你也接过这种“零信息需求”,或者正在设计类似的任务编排系统,这篇应该对你有用。
1. 只有“rea”三个字母:先拆需求,再拆代码
1.1 三个字母可能代表什么
拿到这种残缺需求,第一件事不是猜技术,而是给命名做推演。rea 能展开成很多词,常见的有 reactive(反应式)、research(调研)、real-time analytics(实时分析),以及我最后定下来的 record-evaluate-act。不同的展开,项目方向完全不同。reactive 会引导你做响应式架构,research 会引导你做数据探索平台,而我选择 record-evaluate-act,是因为它最贴合“把人工重复判断动作自动化”的诉求。
选 record-evaluate-act 还有个原因:这个名字的内核对业务方几乎没有理解成本。业务流程天然存在“先看数据、再想对策、最后执行”的三步,而 rea 恰好把这三步浓缩成三个动词。项目代号一旦能被业务方直接翻译成日常操作,后面做需求对齐会省掉大量沟通成本。这一点在你只有模糊标题时非常重要。
1.2 核心问题清单:给谁用、解决什么、怎么算成功
在没有正文的情况下,我用三个问题把 rea 的项目目标锁死:
- 给谁用:面向内部运营人员,不是终端用户,所以交互上优先考虑可读性和可追溯,而不是漂亮的 UI。
- 解决什么:把每天需要登录系统、查看报表、比对阈值、执行操作的重复劳动变成自动化流程。
- 怎么算成功:一周以内,原先需要人工花费两小时以上的某类日常任务,能无人值守跑完,且每次执行都有留痕可供回看。
这个清单看起来简单,但它决定了后续所有设计。比如因为要“无人值守”,所以必须考虑失败重试和告警;因为要“留痕”,所以每个阶段都必须落日志、落状态;又因为操作对象是业务数据,所以执行动作必须支持回滚或补偿。没有这一层锚定,后面很容易做出一个“看起来什么都行、实际什么都不敢自动跑”的半成品。
1.3 边界线:rea 覆盖什么,不覆盖什么
需求模糊的时候,边界比功能更重要。我给自己画了一张覆盖表,至今贴在项目文档第一页:
| 覆盖范围 | 不覆盖范围 |
|---|---|
| 定时触发、事件触发 | 实时流计算(秒级以下) |
| 规则判断与优先级排序 | 机器学习模型训练 |
| 调用业务接口执行动作 | 底层数据修复与清洗 |
| 执行记录、失败重试、告警通知 | 数据仓库建设 |
| 多步骤任务编排 | 人机交互审批流(一期不做) |
这张表直接帮我拒绝了很多“顺手加个功能”的请求。比如有人问能不能让 rea 直接修改数据库里错误的字段,我说不在范围内,因为那是数据修复工具,不是编排引擎。边界划定之后,需求再模糊也不会无限发散,这是拆解零信息立项最关键的一步。
2. Record-Evaluate-Act 三阶段原理:为什么这样拆最稳
2.1 Record:先留下痕迹,再谈处理
rea 的第一阶段是 Record,也就是把触发任务所需的所有信息采集下来并标准化。为什么第一步不是“直接做判断”?因为判断链条上任何一个环节出问题,都需要回到原始数据复查。没有记录,就无法回答“当时到底看到了什么”。
我在设计上做了一件事:所有进入 rea 的事件都必须转换成统一格式,包括事件编号、来源、时间、业务对象 ID、原始载荷、采集方标识。统一格式带来的好处是后续 Evaluate 阶段不用关心来源是接口回调还是文件上传,也不用为每种来源写一套判断逻辑。这一层标准化花的时间不长,但它是后续所有稳定性的地基。
Record 阶段还有一个容易被忽略的点:原始载荷必须原样保留,不能只存“解析后字段”。因为规则会迭代,旧规则跑出来的结果如果出了问题,你得能回到当时的原始输入重新推演。只存结果而不存原始输入,等于把回溯能力直接扔了。
2.2 Evaluate:规则与优先级分离
Evaluate 阶段的核心不是“用 if-else 判断”,而是把规则做成可配置、可独立评估的数据。这样做的直接原因是:业务判断经常变化,今天阈值是 80%,下周可能变成 85%,如果阈值写在代码里,每次变更都要发版,没人能接受。
我把规则抽象成几部分组成:触发条件、生效时间、优先级、判定结果、执行动作引用。其中优先级用来解决多条规则同时命中的问题。最初版本我按照“更具体的规则优先”来处理,例如“所有异常都告警”和“库存异常且金额大于一万才告警”同时命中时,后者的优先级更高,因为更具体、动作更收敛。
Evaluate 阶段还有个原则:只输出“判定结果”,不直接执行。判定结果本身是一份结构化数据,包含命中了哪条规则、依赖了哪些输入、可信度是多少。这样中间层可以被审计、被回放,甚至可以在上线前用历史数据做模拟跑批,看看新规则会不会误伤。
2.3 Act:可重试、可补偿的执行器
第三阶段 Act 是风险最高的部分,因为它会真实改变业务状态。为了不让执行动作变成一团乱麻,我把所有动作都封装成“执行器”,每个执行器必须实现三件事:执行、回查、回滚。执行是发起操作,回查是确认操作是否真的成功,回滚是在失败时把状态恢复到执行前。
举个例子,如果某个动作是“给指定对象打标”,执行器会先调用业务接口打标,然后立即回查该对象的最新标签;如果标签不一致,就触发重试或回滚。为什么必须回查?因为很多业务接口返回的是“受理成功”,并不等于“处理成功”,中间还有异步状态流转。如果不做回查,你会收到一堆假成功,后续流程全部建立在不存在的成功之上。
Act 阶段要遵守的另一个原则是:动作要尽量小、尽量原子。一次任务如果包含三个操作,我会拆成三个独立执行器,而不是写一个大型函数。拆开的好处是重试粒度小,第一个动作成功、第二个失败时,只需要重试第二个,不需要连带重放第一个。
2.4 阶段间用队列而不是函数调用的原因
很多人问过我,三阶段内部通信为什么不用函数直接调用,非要中间塞队列?核心原因有三个。
第一是防止级联失败。如果 A、B、C 三个阶段用同步函数调用,B 挂了会把 A 拖住,A 占用线程等 B 超时,线程池很快被耗尽。用队列之后,B 挂掉只会导致队列积压,A 不受影响,可以继续生产消息。
第二是削峰填谷。人工操作的高峰往往集中在特定时间窗口,比如每天上午十点。队列能让这波洪峰在队列里排队,下游按自己的节奏消化,而不是瞬间被打垮。
第三是便于回放。队列消息只要保留足够长时间,就可以在业务低峰期重新跑一遍历史任务,模拟新规则的效果。同步函数调用很难做到这种级别的可追溯性。
基于这个考虑,rea 的架构变得非常简单:三个阶段各管一段队列,中间用结构化格式传递。整体链路是标准的流水线,每一段都可以单独观察、单独扩容、单独降级。
3. 实际落地踩过的坑:状态冲突、重试风暴与丢失的执行结果
3.1 状态机没有表驱动,任务被重复投递
第一个严重问题出在状态管理上。最早版本我用一串散落的 if 判断来维护任务状态:判断是否是新的、是否已执行、是否已通知。结果上线不到两天,就出现同一个任务被重复执行的情况。
排查后发现根因是状态更新和动作执行不是原子的。任务从“已判定”变成“执行中”的过程中,两个消费者同时读到了“已判定”,两边的逻辑都认为“该我执行”,于是一条动作被执行了两次。
解决办法是把状态迁移改成表驱动。我整理了一张状态迁移表,列出每个状态允许进入的下一个状态,只有合法的迁移才允许更新。这张表后来成为 rea 最关键的兜底逻辑,任何状态变更都必须走统一入口,禁止各自散写更新代码。
| 当前状态 | 允许进入的下一状态 |
|---|---|
| pending | evaluating / expired |
| evaluating | actionable / discarded |
| actionable | acting / skipped |
| acting | succeeded / failed_retryable / failed_final |
| failed_retryable | acting / failed_final |
| failed_final | closed |
| succeeded | closed |
有了这张表之后,重复执行的问题没有再出现过。“统一入口 + 表驱动状态机”是这类系统的保命设计,越早做越好。
3.2 失败重试无限堆积,消息队列被打满
第二个坑是重试策略太天真。一开始我写的是:失败就重试,默认重试 3 次。听起来没什么问题,但没考虑重试的时间分布。某天凌晨第三方接口完全宕机,所有动作连续失败,每个失败任务立刻重试,重试又失败,队列积压飞速上升,到早上已经堆了几十万条消息。
事后看,根因是“失败后立即重试”会把偶发故障放大成群体故障。修复方案有两块:一是重试间隔必须退避,二是失败超过上限必须进死信队列,不再参与常规消费。我采用的退避策略是第一次失败后等 1 分钟、第二次等 4 分钟、第三次等 15 分钟,最多重试 4 次,超过后进入死信队列并触发人工告警。
这套策略上线后,同样遇到第三方接口长时不通,系统顶多是队列积压,而不会出现重试风暴。所有失败的任务都会安静待在死信队列里,等外部服务恢复后统一捞起重放。
3.3 执行结果回执丢失,数据对不上
第三个问题最有隐蔽性。Act 阶段执行完动作后,我直接把回查结果写到数据库。表面看一切正常,可后来对账发现,某些任务显示“执行成功”,但业务侧说根本没收到操作。
后来定位到问题在回查机制本身:回查只做了一次,且回查的“成功”来自中间层缓存,不是来自业务终态。也就是说,我当时只看了一眼接口响应,响应说“已受理”我就当成了成功。这个“成功”是假的。
修复措施是给执行器加回执确认机制:执行接口返回后,必须再指定时间点做终态回查,回查结果包含业务对象的最新实际状态,并且只有终态确认后才允许把任务状态改成 succeeded。对账时以业务侧最终状态为准,而不是以我们的回执为准。这个过程听起来麻烦,但它是防止“假成功”扩散到下游的唯一办法。
3.4 一次凌晨告警的完整排查过程
凌晨告警很有代表性。某个深夜,rea 连续发出多条“动作执行失败”的消息。我现场排查的链路是:先看死信队列,发现失败任务全部集中在同一种操作类型;再看失败原因,发现错误码清一色是“会话过期”;接着查执行器配置,发现这个操作的凭证有效期只有 12 小时,凌晨时正好到期。
真正的根因是凭证续期任务依赖一个每天固定时间运行的计划任务,而计划任务在当天因为上游数据延迟没有执行,导致执行器带着过期凭证操作。
这个事的教训不是“要做凭证监控”,而是 rea 整体依赖链上的每一个外部依赖都要有独立监控。如果一个重试依赖另一个自动任务,那另一个自动任务的状态也要暴露出来。我后来给所有外部凭证加了一个过期时间预检,提前 1 小时告警,不再等执行期报错。
4. 性能调优:从一天处理几千条到几十万条的变化
4.1 第一版压测瓶颈:数据库轮询
rea 第一版能跑通,但性能很差。最开始我用关系数据库的轮询方式做任务分发:各个阶段每秒钟扫描一次任务表,发现新数据就处理。压测数据停在每天几千条时还凑合,数据量涨到几万条后,数据库 CPU 直接打满,扫描出来的大部分数据都是重复处理过或不需要处理的,浪费大量资源。
瓶颈分析很清楚:轮询是拉模式,不管有没有新任务,都在空转。改成事件驱动之后,每次有新的任务生成,才主动向队列投递一条消息,消费者收到消息才工作,没有消息就完全空闲。同样一台机器,处理量直接上了一个量级,数据库 CPU 占用率掉到 20% 以下。
4.2 事件驱动后的核心变化
改造前后的差别不仅仅在性能数字上,还在运维复杂度上。轮询模式下一旦处理速度跟不上,数据库里的任务表会越积越大,越是积压就越难查。事件驱动模式下,队列本身的积压就是一个明确的健康指标,看一眼队列长度就知道系统状态。
事件驱动让每个阶段变成独立伸缩的单元。Evaluate 阶段积压了,就多开几个消费者;Act 阶段依赖的下游接口慢,那就调低这个阶段消费者的并发数,反正消息都在队列里跑不掉。这个灵活性是轮询模式完全给不了的,也是 rea 能从容应对几十万条数据的底气。
4.3 分片与多消费者配置参考
当处理量继续上升,单队列多消费者也会遇到瓶颈,主要是队列竞争变多、消息顺序难保证。我的解法是按业务对象 ID 做分片,比如对业务 ID 取模,分成 16 个分片,每个分片是一条独立队列。
下面是调优稳定后的一组参考配置,适用于日均二十万条左右的任务量:
| 参数项 | 配置值 | 说明 |
|---|---|---|
| 分片数 | 16 | 按业务 ID 取模,保证同一对象的消息进同一分片 |
| 单分片消费者数 | 2 | 太少积压,太多会造成分片内竞争 |
| 单消费者批量拉取数 | 32 | 减少空转,同时避免单次处理过大 |
| 重试上限 | 4 次 | 超过进死信队列 |
| 重试退避间隔 | 1/4/15/60 分钟 | 指数放大,避免重试风暴 |
| 阶段队列长度告警阈值 | 10000 | 超过则告警,提前干预 |
这几个参数是实测后定下来的,不同业务场景会有差异,但思路可以复用:先保证消息不乱序,再追求并发;在队列长度和消费速度之间找到平衡点,而不是盲目调大并发数。
4.4 容易忽略的性能细节
除了主体链路,还有几个容易被忽略的细节影响整体性能。每个阶段的输出都精简,能不落库的中间字段不落库;尽量本地缓存,热点规则数据放内存而不是每次查数据库;批量执行和批量回查分开做,避免大批量动作时业务接口被频繁调用。
另一个细节是日志。之前的做法是每个阶段打一条日志,后来发现日志量对磁盘 IO 影响很大。我把关键日志分成三类:必留的审计日志、可采样的调试日志、可直接丢弃的烦琐明细。保留级别通过配置切换,平时只开审计日志,排查时再临时打开调试日志。
5. rea 的边界与后续扩展:什么样的场景不该用它
5.1 适用场景与反例
rea 并不是通用的自动化平台,它有自己的适用边界。通过这段时间的实践,我总结出合适的场景是:数据获取稳定、规则判断明确、动作结果可查、允许异步处理。比如定时同步数据、按阈值触发批量操作、失败自动重试这类任务,都很适合 rea。
不适合的场景也很清晰。如果判断依赖大量主观经验,规则很难写成结构化条件,rea 就不合适;如果动作必须实时回给用户,比如用户点击后必须立即返回结果,异步编排的模式也不合适;如果操作对象本身状态混乱、没有可靠的回查接口,强上 rea 只会把混乱放大。
有一个反例很典型:某团队想用 rea 做自动清理接口字段,但源系统的数据质量极差,同一个字段在不同时间可能代表不同类型的内容。这种场景下规则根本写不稳,硬跑只会产生大量错误清洗结果。最后我建议他们在源系统侧先做数据规范,而不是让 rea 去做数据修复的兜底。
5.2 后续可以扩展的方向
rea 在我这边的下一步规划大致有三个方向。
一是人工审批节点。目前 rea 是全自动执行,有些高风险动作业务方还是希望先看到判断结果、点一下确认再执行。所以我会在 actuar 前面加一个“待审批”状态,支持场景化的手动放行或驳回。
二是可视化编排。现在任务链路是代码里写死的,想改一条链路就得改配置甚至发版。后期打算把流程设计器做出来,让业务方自己画“采集什么、判断什么、执行什么”,降低使用门槛。
三是执行器插件化。当前每新增一种操作类型都要写一套代码,后续想做成插件式,执行器打成一个独立包,通过配置注册即可接入,不需要改主流程。
5.3 一点个人体会:零信息需求的处理顺序
回看整个 rea 项目,最有价值的部分反而不是代码,而是“拿到零信息需求后怎么推进”的思考方式。我的处理顺序是:先给标题一个合理解释,再定义边界,做出第一版核心链路,然后根据真实数据不断修正。
遇到只有标题的需求,别急着写第一行代码,也别急着问谁要什么。先自己想清楚一个可能的方向,带着方向去和业务方对齐,比直接要需求更高效。业务方很多时候讲不清自己想要什么,但能判断你给的方向对不对。rea 这个项目就是:我用三个字母拼出了一个方向,对方说对,然后才有一切。
如果你也在处理类似的模糊项目,我的建议是六个字:定边界、做闭环。先确保三阶段能完整跑通一条最小任务,再去优化性能、打磨细节。只要核心闭环是稳的,后面的扩展都有地方落;闭环不稳,再漂亮的功能也是空中楼阁。