做后端这些年,我越来越发现一件事:真正让人头大的往往不是某个算法有多难,而是几十个定时任务、消息触发和状态流转堆在一起时的“编排地狱”。今天想聊的,是我花了一个多月业余时间从零写的 REA——一个轻量级规则引擎与自动化任务编排框架。它解决的是“事件来了之后,下一步该做什么、什么时候做、做了之后怎么保证不会重复做”这一连串问题,适合想摆脱硬编码脚本、又不想引入重量级工作流平台的朋友参考。REA 这个名字是我自己起的,拆开来是 Rule Execution Agent,规则执行代理。它不依赖任何外部中间件,核心就是一个可以嵌入业务进程的调度内核,外加一套用 JSON 描述的规则文件。你写好规则,REA 负责监听事件、匹配条件、触发动作,整个过程可以像看日志一样直观追踪。这篇文章我会把设计思路、核心模型、实际编码过程中的取舍,以及排查过的高频问题一次讲清楚,内容偏实践,代码可以直接照着改。1. 为什么会有 REA:从一次“脚本地狱”说起1.1 当时的痛点:配置爆炸与执行顺序混乱之前在某公司做订单履约系统时,我负责的模块需要处理超时关单、库存预占释放、支付回调后发通知、风控异常后冻结等一系列操作。最初这些逻辑都用队列消费者加 if-else 实现,每个场景一个方法,方法里再调三四个服务。需求变更时,改动一个流程往往要牵动七八个类,测试同学每次回归都像在扫雷。更难受的是,这些逻辑之间存在隐式顺序。比如超时关单必须发生在库存预占释放之前,支付成功通知必须在订单状态落库之后。代码里靠注释和约定维持顺序,但总有人漏看注释,于是线上偶尔出现“先释放库存再关单”的诡异现象。后来我们引入了状态机框架,但状态机只解决状态流转,不解决“到什么状态后需要异步做什么”的问题。定时扫描、延迟消息、重试补偿这些散落得到处都是。当时我就想,能不能把“什么条件触发什么动作”这件事从业务代码里抽出来,做成一个可配置、可观测、可动态调整的独立层。这就是 REA 最初的动机。它不是要替代业务代码,而是把业务代码中反复出现的“事件-条件-动作”模式收拢到一个统一的地方管理。这样一来,新增一个场景不需要改主流程,只需要加一条规则。1.2 市面工具的局限与自研的底气动手之前,我也评估过现成的开源规则引擎和工作流引擎。规则引擎偏重“决策”,适合风控、优惠计算这种输入输出明确的场景,但是对“延迟执行”“重试”“外部动作调用”这些编排能力支持很弱。工作流引擎倒是功能全,但部署成本高,概念多,一个简单的超时关单都要画流程图、定义节点、配置网关,团队学习成本不低。还有一类是像定时任务框架那样的东西,只解决“定时触发”,不关心“触发后条件是否满足”,也不关心“多个触发源怎么汇聚成一次决策”。我要的是一个介于两者之间的轻量层:有点规则引擎的判断能力,有点工作流引擎的编排能力,但不需要那么重的模型和基础设施。自研最大的底气来自场景足够明确。我只解决自己遇到的问题,不用为了通用性引入抽象。REA 的核心就三个对象:事件、规则、动作。事件是外部世界发生的事实,规则是判断条件,动作是最终执行的操作。这个模型简单到我可以完整掌控它的边界,出了问题也能快速定位。现在回头看,如果当时用了一个重框架,遇到问题可能要翻几层源码,未必比自研更快。2. REA 核心设计:把任务编排拆成三层2.1 事件、规则、动作的三段式模型REA 的第一版设计受限于当时的认知,我把事件和触发条件混在一起写,导致同一类事件的不同处理逻辑很难复用。后来重构时我坚持做了分离,规则文件只描述两件事:什么时候需要关心(trigger)和关心之后怎么判断(condition),以及判断通过之后干什么(actions)。事件在 REA 里是一个标准化的消息结构,包含事件类型、事件ID、发生时间、payload 四个字段。无论事件来自 HTTP 回调、消息队列还是定时器,最终都会转换成这个统一结构投递给规则引擎。这样做的好处是,规则编写者不需要关心事件来源,只用面对一个统一的 JSON 对象。规则是 REA 里最核心的实体。一条规则包含五部分:id、name、trigger、condition、actions。其中 trigger 定义“何时启动评估”,condition 定义“当前事件和上下文是否满足要求”,actions 定义“满足后执行什么”。三者分离后,同一个动作可以被多条规则复用,同一条规则也可以被多个事件触发,组合灵活度大幅提升。动作执行器是 REA 的扩展点。我内置了 HTTP 调用、Shell 命令、消息队列投递、日志记录四种常见执行器,同时提供了接口允许自定义执行器。实际使用中,我们 90% 的动作都是 HTTP 调用和消息投递,Shell 命令主要用在本地运维场景。动作执行支持同步和异步两种模式,同步模式会等待执行结果并判断是否重试,异步模式只负责投递触发信号。2.2 为什么用 JSON 描述规则,而不是再发明一种 DSL很多规则引擎喜欢自定义 DSL,宣称“业务人员也能看懂”。但我的实际观察是,业务人员真正自己写规则的情况极少,大多是开发人员代写。既然最终消费者是开发人员,那用 JSON 反而有几个明确优势:序列化简单、所有语言都有现成解析库、可以存数据库动态修改、可以和代码版本管理天然融合。JSON 最大的缺点是写起来啰嗦,且没有注释。为了解决啰嗦的问题,我提供了规则模板的能力,类似函数复用。比如我有一个“通用超时检测模板”,只需要填 timeout 字段,就能生成一条规则。至于注释问题,我采用了一个折中方案:规则文件允许附带meta.description和meta.tags字段,配合规则名称一起用,可读性基本够用。条件表达式是整个规则文件里最需要小心设计的地方。我实现了一个小型表达式求值器,支持逻辑运算、比较运算、字段访问、数组包含等能力。对于更复杂的脚本逻辑,我刻意不内置,而是提供一个script类型的条件节点,允许写一段简短的函数代码。这样既保持了 JSON 的简单,又给极端场景留了后门,不会被表达能力困住。2.3 调度器:基于时间轮的轻量级触发REA 的触发器有四类:事件触发、Cron 定时触发、延迟触发、外部手动触发。其中事件触发最简单,事件投递进来直接匹配规则条件;Cron 定时触发需要调度器支持秒级精度;延迟触发需要在事件进入后等待一段时间再评估。延迟触发如果直接用 Java 的 ScheduledExecutor 也能做,但大量延迟任务堆积时,内存和线程占用都不好看。我选择了时间轮算法来实现定时和延迟触发。时间轮的原理不复杂,可以类比钟表:有 60 个刻度,每个刻度代表一秒;指针每跳一格,就处理该刻度上挂着的所有任务。如果任务需要延迟 90 秒,就把它挂在第 30 格,同时记录剩余圈数;指针每转一圈,剩余圈数减一,减到零时任务才真正到期。REA 的时间轮默认 512 个刻度,每个刻度 100 毫秒,最大可表示延迟约 51.2 秒的定时任务。对于更长延迟,我加了一个溢出队列,超过时间轮范围的任务进入一个最小堆,时间轮执行器每次触发后会把堆顶已经到期的任务重新投回时间轮。实际效果是:秒级以内的任务用时间轮,分钟级以上的任务用最小堆,两者配合既省内存,延迟也够准。3. 实操:从零搭一个 REA 实例3.1 安装与初始化我先说下运行环境:任意一台装了 JDK 17 的 Linux 服务器即可,不需要额外装数据库和消息队列。REA 核心以 jar 包形式运行,外部依赖只有本地文件系统和一个可选的 Redis 实例。Redis 用于集群模式下的分布式锁和去重,单机环境下不装也能跑。安装方式很简单,下载发布包后解压,目录结构如下:bin/存放启动脚本,conf/存放配置文件,rules/存放规则文件,logs/运行日志。我把 REA 设置成“无配置文件也能启动”的模式,所有参数都有默认值,第一次跑的时候你只需要准备一个规则目录路径,启动命令可以短到一行。# 启动单机版 REA,指定规则目录为 ./rules rea-server --rules-dir ./rules --server-port 8080启动成功后会看到一行日志:REA kernel started, loading rules from [./rules]...。接着它会扫描规则目录下的所有.json文件,逐个解析、编译、注册到内核。如果某个规则文件语法错误,不会影响其他规则加载,错误信息会打印在日志里,并把该文件标记为 failed,方便后续单独修复。3.2 定义第一个规则文件我拿“订单超时未支付自动关单”这个经典场景来演示。假设订单系统会把“订单已创建”事件发送到 REA 的 HTTP 端点,REA 需要在这一事件进入后延迟 30 分钟,再判断订单是否仍然是待支付状态,如果是则触发关单动作。规则文件如下。{ "id": "order-timeout-check", "name": "订单超时未支付自动关单", "meta": { "description": "订单创建后30分钟仍未支付,则自动关闭订单", "tags": ["order", "timeout"] }, "trigger": { "type": "event", "eventType": "ORDER_CREATED", "delaySeconds": 1800 }, "condition": { "all": [ { "field": "order.status", "op": "eq", "value": "PENDING_PAY" }, { "field": "order.updatedAt", "op": "olderThanSeconds", "value": 1800 } ] }, "actions": [ { "type": "http", "method": "POST", "url": "http://service.internal/api/orders/close", "body": { "orderId": "{{order.id}}", "reason": "PAY_TIMEOUT" }, "headers": { "Content-Type": "application/json" } } ] }这个文件体现了 REA 的几个设计细节。delaySeconds表示事件进来后先挂起 30 分钟再评估条件,由时间轮负责。条件里的两个判断是“与”关系,必须都满足才执行动作。HTTP 动作的 body 支持 Mustache 模板语法,可以从事件 payload 里提取字段填充。3.3 跑起来:命令行观察执行轨迹规则文件准备好后,把它放到rules/目录,重启 REA 或者调用管理接口触发热加载。我习惯在开发环境开启--debug-mode,这样每次事件进入、规则匹配、条件命中、执行动作都会输出详细轨迹。模拟一次事件投递,使用命令行工具发送 POST 请求到 REA 的 HTTP 入口:curl -X POST http://localhost:8080/events \ -H "Content-Type: application/json" \ -d '{"eventType":"ORDER_CREATED","eventId":"e001","payload":{"order":{"id":"A1001","status":"PENDING_PAY","updatedAt":1699999999}}}'在 debug 模式下,控制台会打印类似下面的内容:[event] received ORDER_CREATED e001, preparing delayed evaluation... [delay] order-timeout-check scheduled after 1800s, queue size: 12 [rule] order-timeout-check triggered by delayed event e001 [cond] order.status == PENDING_PAY -> true [cond] order.updatedAt olderThan 1800s -> true [action] http POST http://service.internal/api/orders/close body={"orderId":"A1001","reason":"PAY_TIMEOUT"} [action] success, latency=37ms每一行都代表内核里发生的关键节点,排查问题时只要顺着事件 ID 搜索这些日志,就能还原整个执行链路。这一步是我自认为比多数开源框架做得更好的地方:可观测性不是辅助能力,而是核心能力。3.4 参数计算与资源限制运行一段时间后,你需要关注几个关键参数:事件缓冲队列长度、延迟任务池大小、动作执行线程数。我默认配置是事件缓冲队列 10000,时间轮任务上限 50000,动作执行线程池核心 8、最大 32。如果你的单条规则动作执行时间长,建议把同步动作改成异步,否则线程池会被长时间占用。参数怎么估?简单算一下:假设每天订单创建事件总量约 20 万,平摊到一秒约 2.3 个,30 分钟的延迟队列里最多同时存活约 4000 个任务。这种情况下时间轮上限开 50000 完全够用。如果是秒杀场景,瞬时几万事件涌入,事件缓冲队列至少要按峰值流速乘以处理时间来估算。公式很简单:队列长度 = 峰值QPS × 平均处理耗时(秒) × 安全系数(2~3)。我在压测时发现一个有意思的现象:动作执行线程数其实是最容易成为瓶颈的。因为事件匹配和条件求值都发生在内存中,速度极快,但一个 HTTP 动作动辄几十毫秒。如果线程池太小,事件队列会积压;线程池太大,则会打爆下游服务。我最终的调优策略是给每条规则单独配置最大并发数,而不是全局统一,这样慢动作就不会拖累快动作。4. 踩坑实录与排查技巧4.1 规则命中但动作不执行:条件求值的隐式转换上线第一周就遇到一个诡异问题:订单状态明明是PENDING_PAY,条件判断也显示为 true,但动作就是不执行。我把 debug 日志打开了,发现条件结果打印的是true,可执行动作的判断逻辑里读到的却是另一个值。最后定位到问题出在字段类型上。事件 payload 里order.status是字符串,但条件配置里我却写成了"value": "PENDING_PAY",这没问题。问题出在另一个规则里,我把status配置成了数字类型比较,请求方实际传的是字符串数字,比如"status": 0和"status": "0"。REA 求值器默认会做类型宽松比较,字符串"0"和数字0相等,于是条件命中,但动作执行器拿到原始事件后做硬编码判断,发现类型不匹配就拒绝了。解决办法是:一方面在条件配置里增加"cast": "string"的显式类型声明,强制转成目标类型再比较;另一方面在动作执行器里统一做防御性转换,不信任外部传参。这个坑提醒我,规则引擎的条件求值和外部系统之间的类型契约必须明确,不能依赖隐式转换,否则排查起来特别痛苦。4.2 时间触发不准:时区与 Cron 表达式的坑另一个高频坑是 Cron 触发的偏差。我有个规则配置成每天凌晨 2 点跑库存对账,但实际执行时间总是比预期晚 8 个小时。查了很久发现,我写 Cron 表达式时没考虑 EastAsia 时区,REA 默认用服务器 UTC 时间解析,导致本地凌晨 2 点被当成了 UTC 凌晨 2 点,也就是本地时间上午 10 点。修复方式不复杂,在 trigger 里增加timeZone字段,显式指定Asia/Shanghai,并在文档中强调:所有 Cron 表达式必须附带时区。除此之外,我还遇到 Cron 表达式里?和*混用导致的解析异常。我用的是 Quartz 风格 Cron,?表示不指定,*表示任意,两个不能在同一位置混用,初学者特别容易把星期那一栏写错。排查这类问题有个小技巧:在 REA 的调试模式里输入一个测试时间,让调度器打印出最近十次触发时间,一眼就能看出偏差。比如命令行工具rea-cli cron --expression "0 0 2 * * ?" --zone "Asia/Shanghai" --next 5,它会输出接下来 5 次触发时刻,和你的预期对比即可。4.3 并发重复执行:幂等性设计规则引擎跑起来后,重复执行是最严重的事故之一。我们的支付回调通知动作由于网络超时,REA 内置重试机制执行了三次,结果下游收到三条重复通知,用户收到了三条重复短信。责任不全在 REA,但规则引擎必须有办法避免重复副作用。我在动作执行器前面加了一层“执行幂等检查”:每个动作实例生成时,根据规则 ID、事件 ID、动作索引计算一个执行键,在动作执行前先写入 Redis 的 SETNX 操作,只有拿到锁的实例才真正执行。对于没有 Redis 的单机模式,则在一个本地 ConcurrentHashMap 里做同样的去重,带上过期时间防止键无限增长。还有一个容易忽略的点:重试间隔也要有策略。REA 默认重试策略是固定间隔 1 秒重试 3 次,后来我改成指数退避:第 1 次 1 秒,第 2 次 5 秒,第 3 次 30 秒,并且最多重试 5 次。这样即使下游短暂不可用,也不会因为重试风暴造成更大的资源压力。4.4 日志爆炸:循环触发链怎么防REA 支持规则动作里再次投递事件,这让我可以实现“链式编排”。但这也引入了一个新的风险:规则 A 触发动作后投递事件 X,事件 X 又触发规则 B,规则 B 再投递事件 X,就会形成无限循环。第一次遇到时,日志在几分钟内刷了几个 GB,服务器差点被打挂。防范措施有三层。第一层是在事件结构中增加traceId和depth字段,每经过一条规则深度加一,当深度超过阈值(默认 10)时直接丢弃并告警。第二层是规则级别增加maxExecutionsPerMinute限流配置,超出后拒绝执行。第三层是在日志里输出规则链的完整路径,比如A -> B -> A -> B,帮助快速定位循环源头。这个问题的本质不是规则引擎缺陷,而是配置者写出的状态机存在环。REA 能做的,是在不限制表达力的前提下,给失控场景加保险。我自己也养成了习惯:任何涉及事件投递的规则,先画一张有向图,确保没有任何环。纸上画一遍比线上排查快得多。5. 性能实测与后续扩展方向5.1 实测数据与瓶颈分析最后说说性能。我在本地用一台 4 核 8G 的虚拟机跑了压测,场景是 1000 条规则、事件触发为主,动作执行使用一个模拟的 10ms 延迟 HTTP 调用。结果如下:事件处理吞吐约 3500 QPS,条件命中到动作执行完成平均耗时 15ms,时间轮任务调度误差在 10ms 以内。这个数据对绝大多数业务系统来说已经足够,瓶颈不在 REA,而在下游服务响应速度。内存占用方面,1000 条规则编译后的元数据大约 20MB,5 万个延迟任务大约占用 30MB 堆内内存。时间轮相比于每个任务一个线程的方案,节省了大量线程开销。我也对比过直接用数据库轮询扫描的方式,REA 的延迟任务调度响应速度比轮询快一个数量级,而且没有数据库压力。如果后续要支撑更高的吞吐,我会考虑两个方向:对外事件接入层改成完全异步,避免使用阻塞式 HTTP;规则条件求值中引入向量化预过滤,先用简单的 tag 匹配把不相关的事件快速丢弃,再做复杂条件运算。预过滤这一步在大流量场景里收益非常明显。5.2 后续可以扩展的方向REA 目前还是单机内核加管理接口的形态,真要大规模部署需要做集群化改造。我的规划是把规则存储从本地文件改为配置中心加载,通过版本号实现灰度发布;动作执行状态写入外部存储,支持跨节点重试;管理接口补齐规则编辑、测试、模拟执行的能力,让非核心开发也能安全地调整规则。另一个想做的能力是“事件回放”。把历史事件保存下来,可以模拟某一时刻的规则运行结果,这对规则上线前的验证特别有用。预告片式地想象一个场景:新规则配置好后,用昨天的真实订单事件跑一遍,看看会不会误动作,比什么都更有说服力。这里也分享一个我踩过之后总结的原则:规则引擎类的自研项目,最先要做的不是功能,而是“控制台”和“日志”。没有可视化控制台,你根本不知道线上跑了什么规则;没有完整日志,你无法回答“为什么这条规则没有触发”这种灵魂问题。我一开始把精力花在条件求值器上,后来发现求值器再强,用户看不到过程就是黑盒,还不如先做观测。写在最后的真心话自研 REA 这个项目,我最大的收获不是代码量,而是对“自动化的边界”有了更清醒的认知。规则化编排确实能把人从重复劳动中解放出来,但同时也把出错的风险从“代码写错”转移到了“规则配错”。一套合格的编排系统,必须同时回答三个问题:现在发生了什么、应该做什么、做完如何验证。缺少任何一个闭环,线上就会出乱子。如果你也想做类似的东西,我的建议是不要一开始追求通用大模型,先把手头最痛的三个场景用最简单的方式跑通。REA 目前的模型只有三层,但已经覆盖了九成以上的自动化诉求。剩下那一成,等真的遇到再迭代也不迟。技术在变,但“事件、规则、动作”这套最小内核,在我看来很长时间内都不会过时。
做后端这些年,我越来越发现一件事:真正让人头大的往往不是某个算法有多难,而是几十个定时任务、消息触发和状态流转堆在一起时的“编排地狱”。今天想聊的,是我花了一个多月业余时间从零写的 REA——一个轻量级规则引擎与自动化任务编排框架。它解决的是“事件来了之后,下一步该做什么、什么时候做、做了之后怎么保证不会重复做”这一连串问题,适合想摆脱硬编码脚本、又不想引入重量级工作流平台的朋友参考。REA 这个名字是我自己起的,拆开来是 Rule Execution Agent,规则执行代理。它不依赖任何外部中间件,核心就是一个可以嵌入业务进程的调度内核,外加一套用 JSON 描述的规则文件。你写好规则,REA 负责监听事件、匹配条件、触发动作,整个过程可以像看日志一样直观追踪。这篇文章我会把设计思路、核心模型、实际编码过程中的取舍,以及排查过的高频问题一次讲清楚,内容偏实践,代码可以直接照着改。1. 为什么会有 REA:从一次“脚本地狱”说起1.1 当时的痛点:配置爆炸与执行顺序混乱之前在某公司做订单履约系统时,我负责的模块需要处理超时关单、库存预占释放、支付回调后发通知、风控异常后冻结等一系列操作。最初这些逻辑都用队列消费者加 if-else 实现,每个场景一个方法,方法里再调三四个服务。需求变更时,改动一个流程往往要牵动七八个类,测试同学每次回归都像在扫雷。更难受的是,这些逻辑之间存在隐式顺序。比如超时关单必须发生在库存预占释放之前,支付成功通知必须在订单状态落库之后。代码里靠注释和约定维持顺序,但总有人漏看注释,于是线上偶尔出现“先释放库存再关单”的诡异现象。后来我们引入了状态机框架,但状态机只解决状态流转,不解决“到什么状态后需要异步做什么”的问题。定时扫描、延迟消息、重试补偿这些散落得到处都是。当时我就想,能不能把“什么条件触发什么动作”这件事从业务代码里抽出来,做成一个可配置、可观测、可动态调整的独立层。这就是 REA 最初的动机。它不是要替代业务代码,而是把业务代码中反复出现的“事件-条件-动作”模式收拢到一个统一的地方管理。这样一来,新增一个场景不需要改主流程,只需要加一条规则。1.2 市面工具的局限与自研的底气动手之前,我也评估过现成的开源规则引擎和工作流引擎。规则引擎偏重“决策”,适合风控、优惠计算这种输入输出明确的场景,但是对“延迟执行”“重试”“外部动作调用”这些编排能力支持很弱。工作流引擎倒是功能全,但部署成本高,概念多,一个简单的超时关单都要画流程图、定义节点、配置网关,团队学习成本不低。还有一类是像定时任务框架那样的东西,只解决“定时触发”,不关心“触发后条件是否满足”,也不关心“多个触发源怎么汇聚成一次决策”。我要的是一个介于两者之间的轻量层:有点规则引擎的判断能力,有点工作流引擎的编排能力,但不需要那么重的模型和基础设施。自研最大的底气来自场景足够明确。我只解决自己遇到的问题,不用为了通用性引入抽象。REA 的核心就三个对象:事件、规则、动作。事件是外部世界发生的事实,规则是判断条件,动作是最终执行的操作。这个模型简单到我可以完整掌控它的边界,出了问题也能快速定位。现在回头看,如果当时用了一个重框架,遇到问题可能要翻几层源码,未必比自研更快。2. REA 核心设计:把任务编排拆成三层2.1 事件、规则、动作的三段式模型REA 的第一版设计受限于当时的认知,我把事件和触发条件混在一起写,导致同一类事件的不同处理逻辑很难复用。后来重构时我坚持做了分离,规则文件只描述两件事:什么时候需要关心(trigger)和关心之后怎么判断(condition),以及判断通过之后干什么(actions)。事件在 REA 里是一个标准化的消息结构,包含事件类型、事件ID、发生时间、payload 四个字段。无论事件来自 HTTP 回调、消息队列还是定时器,最终都会转换成这个统一结构投递给规则引擎。这样做的好处是,规则编写者不需要关心事件来源,只用面对一个统一的 JSON 对象。规则是 REA 里最核心的实体。一条规则包含五部分:id、name、trigger、condition、actions。其中 trigger 定义“何时启动评估”,condition 定义“当前事件和上下文是否满足要求”,actions 定义“满足后执行什么”。三者分离后,同一个动作可以被多条规则复用,同一条规则也可以被多个事件触发,组合灵活度大幅提升。动作执行器是 REA 的扩展点。我内置了 HTTP 调用、Shell 命令、消息队列投递、日志记录四种常见执行器,同时提供了接口允许自定义执行器。实际使用中,我们 90% 的动作都是 HTTP 调用和消息投递,Shell 命令主要用在本地运维场景。动作执行支持同步和异步两种模式,同步模式会等待执行结果并判断是否重试,异步模式只负责投递触发信号。2.2 为什么用 JSON 描述规则,而不是再发明一种 DSL很多规则引擎喜欢自定义 DSL,宣称“业务人员也能看懂”。但我的实际观察是,业务人员真正自己写规则的情况极少,大多是开发人员代写。既然最终消费者是开发人员,那用 JSON 反而有几个明确优势:序列化简单、所有语言都有现成解析库、可以存数据库动态修改、可以和代码版本管理天然融合。JSON 最大的缺点是写起来啰嗦,且没有注释。为了解决啰嗦的问题,我提供了规则模板的能力,类似函数复用。比如我有一个“通用超时检测模板”,只需要填 timeout 字段,就能生成一条规则。至于注释问题,我采用了一个折中方案:规则文件允许附带meta.description和meta.tags字段,配合规则名称一起用,可读性基本够用。条件表达式是整个规则文件里最需要小心设计的地方。我实现了一个小型表达式求值器,支持逻辑运算、比较运算、字段访问、数组包含等能力。对于更复杂的脚本逻辑,我刻意不内置,而是提供一个script类型的条件节点,允许写一段简短的函数代码。这样既保持了 JSON 的简单,又给极端场景留了后门,不会被表达能力困住。2.3 调度器:基于时间轮的轻量级触发REA 的触发器有四类:事件触发、Cron 定时触发、延迟触发、外部手动触发。其中事件触发最简单,事件投递进来直接匹配规则条件;Cron 定时触发需要调度器支持秒级精度;延迟触发需要在事件进入后等待一段时间再评估。延迟触发如果直接用 Java 的 ScheduledExecutor 也能做,但大量延迟任务堆积时,内存和线程占用都不好看。我选择了时间轮算法来实现定时和延迟触发。时间轮的原理不复杂,可以类比钟表:有 60 个刻度,每个刻度代表一秒;指针每跳一格,就处理该刻度上挂着的所有任务。如果任务需要延迟 90 秒,就把它挂在第 30 格,同时记录剩余圈数;指针每转一圈,剩余圈数减一,减到零时任务才真正到期。REA 的时间轮默认 512 个刻度,每个刻度 100 毫秒,最大可表示延迟约 51.2 秒的定时任务。对于更长延迟,我加了一个溢出队列,超过时间轮范围的任务进入一个最小堆,时间轮执行器每次触发后会把堆顶已经到期的任务重新投回时间轮。实际效果是:秒级以内的任务用时间轮,分钟级以上的任务用最小堆,两者配合既省内存,延迟也够准。3. 实操:从零搭一个 REA 实例3.1 安装与初始化我先说下运行环境:任意一台装了 JDK 17 的 Linux 服务器即可,不需要额外装数据库和消息队列。REA 核心以 jar 包形式运行,外部依赖只有本地文件系统和一个可选的 Redis 实例。Redis 用于集群模式下的分布式锁和去重,单机环境下不装也能跑。安装方式很简单,下载发布包后解压,目录结构如下:bin/存放启动脚本,conf/存放配置文件,rules/存放规则文件,logs/运行日志。我把 REA 设置成“无配置文件也能启动”的模式,所有参数都有默认值,第一次跑的时候你只需要准备一个规则目录路径,启动命令可以短到一行。# 启动单机版 REA,指定规则目录为 ./rules rea-server --rules-dir ./rules --server-port 8080启动成功后会看到一行日志:REA kernel started, loading rules from [./rules]...。接着它会扫描规则目录下的所有.json文件,逐个解析、编译、注册到内核。如果某个规则文件语法错误,不会影响其他规则加载,错误信息会打印在日志里,并把该文件标记为 failed,方便后续单独修复。3.2 定义第一个规则文件我拿“订单超时未支付自动关单”这个经典场景来演示。假设订单系统会把“订单已创建”事件发送到 REA 的 HTTP 端点,REA 需要在这一事件进入后延迟 30 分钟,再判断订单是否仍然是待支付状态,如果是则触发关单动作。规则文件如下。{ "id": "order-timeout-check", "name": "订单超时未支付自动关单", "meta": { "description": "订单创建后30分钟仍未支付,则自动关闭订单", "tags": ["order", "timeout"] }, "trigger": { "type": "event", "eventType": "ORDER_CREATED", "delaySeconds": 1800 }, "condition": { "all": [ { "field": "order.status", "op": "eq", "value": "PENDING_PAY" }, { "field": "order.updatedAt", "op": "olderThanSeconds", "value": 1800 } ] }, "actions": [ { "type": "http", "method": "POST", "url": "http://service.internal/api/orders/close", "body": { "orderId": "{{order.id}}", "reason": "PAY_TIMEOUT" }, "headers": { "Content-Type": "application/json" } } ] }这个文件体现了 REA 的几个设计细节。delaySeconds表示事件进来后先挂起 30 分钟再评估条件,由时间轮负责。条件里的两个判断是“与”关系,必须都满足才执行动作。HTTP 动作的 body 支持 Mustache 模板语法,可以从事件 payload 里提取字段填充。3.3 跑起来:命令行观察执行轨迹规则文件准备好后,把它放到rules/目录,重启 REA 或者调用管理接口触发热加载。我习惯在开发环境开启--debug-mode,这样每次事件进入、规则匹配、条件命中、执行动作都会输出详细轨迹。模拟一次事件投递,使用命令行工具发送 POST 请求到 REA 的 HTTP 入口:curl -X POST http://localhost:8080/events \ -H "Content-Type: application/json" \ -d '{"eventType":"ORDER_CREATED","eventId":"e001","payload":{"order":{"id":"A1001","status":"PENDING_PAY","updatedAt":1699999999}}}'在 debug 模式下,控制台会打印类似下面的内容:[event] received ORDER_CREATED e001, preparing delayed evaluation... [delay] order-timeout-check scheduled after 1800s, queue size: 12 [rule] order-timeout-check triggered by delayed event e001 [cond] order.status == PENDING_PAY -> true [cond] order.updatedAt olderThan 1800s -> true [action] http POST http://service.internal/api/orders/close body={"orderId":"A1001","reason":"PAY_TIMEOUT"} [action] success, latency=37ms每一行都代表内核里发生的关键节点,排查问题时只要顺着事件 ID 搜索这些日志,就能还原整个执行链路。这一步是我自认为比多数开源框架做得更好的地方:可观测性不是辅助能力,而是核心能力。3.4 参数计算与资源限制运行一段时间后,你需要关注几个关键参数:事件缓冲队列长度、延迟任务池大小、动作执行线程数。我默认配置是事件缓冲队列 10000,时间轮任务上限 50000,动作执行线程池核心 8、最大 32。如果你的单条规则动作执行时间长,建议把同步动作改成异步,否则线程池会被长时间占用。参数怎么估?简单算一下:假设每天订单创建事件总量约 20 万,平摊到一秒约 2.3 个,30 分钟的延迟队列里最多同时存活约 4000 个任务。这种情况下时间轮上限开 50000 完全够用。如果是秒杀场景,瞬时几万事件涌入,事件缓冲队列至少要按峰值流速乘以处理时间来估算。公式很简单:队列长度 = 峰值QPS × 平均处理耗时(秒) × 安全系数(2~3)。我在压测时发现一个有意思的现象:动作执行线程数其实是最容易成为瓶颈的。因为事件匹配和条件求值都发生在内存中,速度极快,但一个 HTTP 动作动辄几十毫秒。如果线程池太小,事件队列会积压;线程池太大,则会打爆下游服务。我最终的调优策略是给每条规则单独配置最大并发数,而不是全局统一,这样慢动作就不会拖累快动作。4. 踩坑实录与排查技巧4.1 规则命中但动作不执行:条件求值的隐式转换上线第一周就遇到一个诡异问题:订单状态明明是PENDING_PAY,条件判断也显示为 true,但动作就是不执行。我把 debug 日志打开了,发现条件结果打印的是true,可执行动作的判断逻辑里读到的却是另一个值。最后定位到问题出在字段类型上。事件 payload 里order.status是字符串,但条件配置里我却写成了"value": "PENDING_PAY",这没问题。问题出在另一个规则里,我把status配置成了数字类型比较,请求方实际传的是字符串数字,比如"status": 0和"status": "0"。REA 求值器默认会做类型宽松比较,字符串"0"和数字0相等,于是条件命中,但动作执行器拿到原始事件后做硬编码判断,发现类型不匹配就拒绝了。解决办法是:一方面在条件配置里增加"cast": "string"的显式类型声明,强制转成目标类型再比较;另一方面在动作执行器里统一做防御性转换,不信任外部传参。这个坑提醒我,规则引擎的条件求值和外部系统之间的类型契约必须明确,不能依赖隐式转换,否则排查起来特别痛苦。4.2 时间触发不准:时区与 Cron 表达式的坑另一个高频坑是 Cron 触发的偏差。我有个规则配置成每天凌晨 2 点跑库存对账,但实际执行时间总是比预期晚 8 个小时。查了很久发现,我写 Cron 表达式时没考虑 EastAsia 时区,REA 默认用服务器 UTC 时间解析,导致本地凌晨 2 点被当成了 UTC 凌晨 2 点,也就是本地时间上午 10 点。修复方式不复杂,在 trigger 里增加timeZone字段,显式指定Asia/Shanghai,并在文档中强调:所有 Cron 表达式必须附带时区。除此之外,我还遇到 Cron 表达式里?和*混用导致的解析异常。我用的是 Quartz 风格 Cron,?表示不指定,*表示任意,两个不能在同一位置混用,初学者特别容易把星期那一栏写错。排查这类问题有个小技巧:在 REA 的调试模式里输入一个测试时间,让调度器打印出最近十次触发时间,一眼就能看出偏差。比如命令行工具rea-cli cron --expression "0 0 2 * * ?" --zone "Asia/Shanghai" --next 5,它会输出接下来 5 次触发时刻,和你的预期对比即可。4.3 并发重复执行:幂等性设计规则引擎跑起来后,重复执行是最严重的事故之一。我们的支付回调通知动作由于网络超时,REA 内置重试机制执行了三次,结果下游收到三条重复通知,用户收到了三条重复短信。责任不全在 REA,但规则引擎必须有办法避免重复副作用。我在动作执行器前面加了一层“执行幂等检查”:每个动作实例生成时,根据规则 ID、事件 ID、动作索引计算一个执行键,在动作执行前先写入 Redis 的 SETNX 操作,只有拿到锁的实例才真正执行。对于没有 Redis 的单机模式,则在一个本地 ConcurrentHashMap 里做同样的去重,带上过期时间防止键无限增长。还有一个容易忽略的点:重试间隔也要有策略。REA 默认重试策略是固定间隔 1 秒重试 3 次,后来我改成指数退避:第 1 次 1 秒,第 2 次 5 秒,第 3 次 30 秒,并且最多重试 5 次。这样即使下游短暂不可用,也不会因为重试风暴造成更大的资源压力。4.4 日志爆炸:循环触发链怎么防REA 支持规则动作里再次投递事件,这让我可以实现“链式编排”。但这也引入了一个新的风险:规则 A 触发动作后投递事件 X,事件 X 又触发规则 B,规则 B 再投递事件 X,就会形成无限循环。第一次遇到时,日志在几分钟内刷了几个 GB,服务器差点被打挂。防范措施有三层。第一层是在事件结构中增加traceId和depth字段,每经过一条规则深度加一,当深度超过阈值(默认 10)时直接丢弃并告警。第二层是规则级别增加maxExecutionsPerMinute限流配置,超出后拒绝执行。第三层是在日志里输出规则链的完整路径,比如A -> B -> A -> B,帮助快速定位循环源头。这个问题的本质不是规则引擎缺陷,而是配置者写出的状态机存在环。REA 能做的,是在不限制表达力的前提下,给失控场景加保险。我自己也养成了习惯:任何涉及事件投递的规则,先画一张有向图,确保没有任何环。纸上画一遍比线上排查快得多。5. 性能实测与后续扩展方向5.1 实测数据与瓶颈分析最后说说性能。我在本地用一台 4 核 8G 的虚拟机跑了压测,场景是 1000 条规则、事件触发为主,动作执行使用一个模拟的 10ms 延迟 HTTP 调用。结果如下:事件处理吞吐约 3500 QPS,条件命中到动作执行完成平均耗时 15ms,时间轮任务调度误差在 10ms 以内。这个数据对绝大多数业务系统来说已经足够,瓶颈不在 REA,而在下游服务响应速度。内存占用方面,1000 条规则编译后的元数据大约 20MB,5 万个延迟任务大约占用 30MB 堆内内存。时间轮相比于每个任务一个线程的方案,节省了大量线程开销。我也对比过直接用数据库轮询扫描的方式,REA 的延迟任务调度响应速度比轮询快一个数量级,而且没有数据库压力。如果后续要支撑更高的吞吐,我会考虑两个方向:对外事件接入层改成完全异步,避免使用阻塞式 HTTP;规则条件求值中引入向量化预过滤,先用简单的 tag 匹配把不相关的事件快速丢弃,再做复杂条件运算。预过滤这一步在大流量场景里收益非常明显。5.2 后续可以扩展的方向REA 目前还是单机内核加管理接口的形态,真要大规模部署需要做集群化改造。我的规划是把规则存储从本地文件改为配置中心加载,通过版本号实现灰度发布;动作执行状态写入外部存储,支持跨节点重试;管理接口补齐规则编辑、测试、模拟执行的能力,让非核心开发也能安全地调整规则。另一个想做的能力是“事件回放”。把历史事件保存下来,可以模拟某一时刻的规则运行结果,这对规则上线前的验证特别有用。预告片式地想象一个场景:新规则配置好后,用昨天的真实订单事件跑一遍,看看会不会误动作,比什么都更有说服力。这里也分享一个我踩过之后总结的原则:规则引擎类的自研项目,最先要做的不是功能,而是“控制台”和“日志”。没有可视化控制台,你根本不知道线上跑了什么规则;没有完整日志,你无法回答“为什么这条规则没有触发”这种灵魂问题。我一开始把精力花在条件求值器上,后来发现求值器再强,用户看不到过程就是黑盒,还不如先做观测。写在最后的真心话自研 REA 这个项目,我最大的收获不是代码量,而是对“自动化的边界”有了更清醒的认知。规则化编排确实能把人从重复劳动中解放出来,但同时也把出错的风险从“代码写错”转移到了“规则配错”。一套合格的编排系统,必须同时回答三个问题:现在发生了什么、应该做什么、做完如何验证。缺少任何一个闭环,线上就会出乱子。如果你也想做类似的东西,我的建议是不要一开始追求通用大模型,先把手头最痛的三个场景用最简单的方式跑通。REA 目前的模型只有三层,但已经覆盖了九成以上的自动化诉求。剩下那一成,等真的遇到再迭代也不迟。技术在变,但“事件、规则、动作”这套最小内核,在我看来很长时间内都不会过时。