这两年处理线上日志和业务事件流的时候,我越来越觉得现成的监控告警体系有点"重",动不动就是一套完整的大数据平台,部署成本高、调参周期长,遇到突发流量还要人盯着扩容。后来我把自己常用的那套逻辑单独抽了出来,做了一个叫 REA 的轻量级实时事件分析项目。REA 就是 Real-time Event Analytics 的缩写,名字没什么玄机,核心就解决三件事:把分散的业务事件快速收进来,按时间窗口做聚合统计,然后用一套尽量简单的规则触发告警或推送。这篇文章就是把我从选型到设计、从压测到排坑的完整过程记录下来,给那些想自己搭一套"够用、不重、能改"的事件分析管道的朋友做个参考。
1. 起因:为什么放着现成方案不用,非要自己写
1.1 当时的场景和痛点
我那会儿在做一个偏中台性质的服务,业务方会把用户行为、订单状态、支付回调、任务执行结果这些事件以 JSON 形式打到消息队列里,再由下游系统去消费。最开始流量不大,直接写个消费者把数据落库,需要排查问题的时候用 SQL 查一查就完事了。等业务方变多、事件类型变杂之后,问题就来了:一天几千万条事件,落库倒是没问题,但要实时知道"过去五分钟支付成功率下降了多少""某个渠道的调用量是不是突增",SQL 查询已经扛不住,传统的批处理任务又要等分钟级甚至小时级调度。
我自己最难受的一个场景是:某天凌晨某个上游接口开始频繁超时,影响的是订单创建流程,但是从事件产生、落地、定时聚合、告警通知到有人真正看到,已经是十分钟之后的事。十分钟对于高风险操作来说太长了。也就在那个阶段,我决定先不去折腾公司那套重量级平台,而是用一个独立的小项目验证一下:如果我自己写一个内存优先、窗口聚合、规则可配的事件分析引擎,能不能把从事件产生到告警通知的延迟压到秒级。
1.2 为什么否掉了三个"现成"选项
做这件事之前,我确实认真对比过开源方案。首先是 Flink 这类流处理框架,能力没得说,但部署依赖挺重,光是搞清楚 checkpoint、状态后端、TaskManager 资源规划就需要一个完整的学习周期,而且对于一个"只想稳定跑几个聚合规则"的场景来说,配置复杂度是过剩的。其次是已有监控系统自带的流式查询能力,比如用 Prometheus 配一套自定义 exporter 来实现业务事件的指标收集,这条路的问题是业务事件普遍带有很多临时字段,强行映射成指标格式反而丢了不少细节,而且告警规则表达能力有限,像"同一个 userId 在 5 分钟内触发超过 3 次失败事件"这种带分组语义的逻辑,用标签机制写起来很别扭。第三个选项是直接改造消息队列的消费者:在消费端自己做内存聚合再定时写库,这个思路最接近,但缺少统一的规则管理和告警出口,最后容易变成每个团队各写各的,维护成本失控。
所以 REA 的定位从一开始就很清楚:不是要替代任何流处理框架,而是做一个"介于消费者逻辑和重型流处理平台之间"的轻量引擎,它只需要支持几个关键能力——事件接入、按需分组、窗口聚合、可配置规则、可靠告警。剩下的都尽量不做。
2. 整体设计与核心模块:一个轻量事件流引擎应该怎么拆
2.1 模块边界和一条完整的事件旅程
REA 的代码结构我尽量按数据流方向来拆,一共五个模块:接入层负责接收外部事件,支持两种方式,一种是直接 HTTP POST 推送,另一种是消费消息队列里的主题;标准化层把各种来源的事件统一成一个内部 Event 结构,后续窗口计算和规则匹配只认这个结构;聚合引擎是核心,负责按时间窗口和分组键做计数、求和、最大值、平均值等计算;规则引擎接收聚合结果或原始事件,按布尔表达式判断是否需要触发动作;输出层把告警和统计结果分发到钉钉/企业微信这类即时通讯工具、Webhook 或普通数据库。
一条事件从进入系统到产生告警,走的是这条链路:
- HTTP 或 MQ 接入模块收到原始数据,先做基础校验(字段必填、时间戳合法性)。
- 标准化模块把原始 JSON 转换成 Event 对象,字段统一为 event_type、source、user_id、value、occurred_at、raw_data。
- 聚合引擎根据配置好的窗口和分组键对 Event 做实时累计,窗口数据存储在内存中。
- 每个窗口结束时触发规则判断,把窗口结果送入规则引擎;规则引擎按优先级逐条匹配。
- 命中的规则交给输出层,输出层做一次去重和限流,然后往目标渠道推送。
这里有个容易被忽略的点:为了让重启不丢太多数据,我在聚合引擎前面加了一个轻量的"写前日志",也就是把最近一段时间内的原始事件按行追加到本地磁盘。重启之后可以回放这段日志来恢复窗口状态,而不用依赖外部状态存储。这个设计思路很像数据库的 WAL,但实现简单得多,因为我的窗口数据本身只保留最近几分钟,恢复速度很快。
2.2 事件模型和分组键的设计细节
事件模型是整个引擎的地基,一开始我就踩了一个小坑:原本把聚合统计和原始事件细节放在同一个结构体里,导致后面想要在告警消息中附带自定义字段时,要么到处加字段,要么做类型断言。后来我改成两层结构:Event 负责描述"发生了什么",包含固定字段和一个 KV 结构的 attributes;AggregateMetric 负责描述"统计出了什么",包含窗口信息、分组键、指标名和数值。
分组键的设计我单独说,因为这是很多人写类似系统时容易拍脑袋的地方。分组键是一个字符串数组,比如["user_id", "channel"],聚合时把它拼接成唯一 key,规则配置里可以用.号引用分组键的各个部分。这里有一个实际教训:分组键的取值必须限定为字符串、数字和布尔值,不能把数组或嵌套对象放进去。当时我觉得支持数组挺自然,结果在拼接 key 的时候数组序列化顺序不稳定,同样的分组内容有时候对不上,排查了大半天。后来直接在标准化阶段就把嵌套结构展平或者拒绝掉,问题彻底消失。
3. 窗口聚合的落地:滚动窗口、滑动窗口和会话窗口
3.1 三种窗口的适用场景和实现差异
聚合引擎支持三种窗口:滚动窗口、滑动窗口、会话窗口。它们的区别用一句话说:滚动窗口是固定时间片,每次只算一份数据;滑动窗口是固定大小但按固定间隔推进,数据可能同时属于多个窗口;会话窗口则是由空闲间隔来划分,长时间没有新事件的会话会自动结束。实现差异主要体现在内存里的数据结构上。
滚动窗口最简单,我用一个环形数组来管理,数组下标代表时间槽,每个时间槽里放一个分组的计数 map。窗口结束时把当前槽的数据整体取走做规则判断,然后清空槽位继续下一个周期。滑动窗口的内存压力最大,因为同一份事件会被计进多个窗口结果里,如果并发度还高,内存会成倍增长。我用的是"桶叠加"策略:把窗口时间切成小时段,比如一个 5 分钟滑动窗口,每 30 秒滑动一次,那我内部维护 10 个桶,每进来一条事件就按所属时间落到对应桶里,窗口结果由当前覆盖到的所有桶累加得到。这样不用为每条事件重复聚合,代价是查询窗口结果时要临时累加,好在单桶数据量可控,几毫秒就能完成。
会话窗口是三种里最需要小心的,因为它没有固定长度。我用的是"会话表 + 过期扫描"的方法:每来一个分组键的新事件,就更新该分组最近一次活跃时间;后台每隔一小段时间扫描一遍,发现空转超过阈值就把整个会话作为窗口结果推出去并从会话表中移除。这里要特别注意过期的判定粒度不能太粗,否则有些活跃度低的用户会被拆成很多个碎会话,规则判断的语义就变了。
3.2 窗口结果触发规则的两条路径
规则引擎和聚合引擎之间,我设计了两种触发模式:
- 窗口结束触发:滚动窗口和会话窗口在结束时,把整个窗口的聚合结果提交给规则引擎。这种模式适合"统计类"规则,比如"近 5 分钟支付失败数超过 100 则告警"。
- 事件实时触发:某些场景等不到窗口结束,比如单条事件本身就代表一个严重异常,这时应该直接把标准化后的事件送进规则引擎,实时判断。
这两种模式可以同时存在,互不冲突。实现上我让聚合引擎和接入层都往规则引擎的数据通道里写消息,只是消息类型不同。规则引擎在处理时只看消息里的 event_type 和 data_type,不用关心数据是从哪一代送进来的。所以外部配置规则时只要明确一条规则是绑定事件类型还是绑定窗口结果类型就行。
我建议把规则按优先级排序执行,而不是按配置顺序执行,这样可以保证"全量阻断型"规则一定比"统计通知型"规则先执行。我曾经因为顺序问题吃过亏:一条阻断规则排在后面,结果它要拦截的事件已经先触发了几条低优先级的通知规则,用户收到一堆无效告警,等到真正需要看的阻断告警到来时反而被淹没。
4. 规则引擎:用最简单的方式表达"如果-那么"
4.1 配置格式和表达式解析
规则引擎部分我没有用复杂的规则引擎库,而是自己实现了有限但够用的表达式解析器。原因是当时仔细评估过后发现,我的场景里面 90% 的规则都是比较和逻辑组合,比如"统计结果中的失败数大于阈值且渠道等于某个值",这类表达式用自研的递归下降解析器完全可以承载,而且能保证没有隐藏的依赖风险,调试起来也更直接。
规则配置的格式我设计成下面这个样子:
{ "rule_id": "pay_failure_alert", "enabled": true, "priority": 10, "binding": { "data_type": "window_result", "event_type": "payment_failure", "window": "rolling_5m" }, "condition": { "op": "and", "terms": [ { "field": "metric.value", "op": "gt", "value": 100 }, { "field": "group.channel", "op": "in", "value": ["android", "ios"] } ] }, "actions": [ { "type": "notify", "target": "dingtalk", "template": "pay_failure_alert" } ] }条件表达式支持的操作符包括比较、字符串包含、枚举成员判断、存在性判断,以及 and/or/not 组合。解析器把 JSON 结构编译成一颗条件树,叶子节点是具体的"字段-操作符-值"元组,内部节点是逻辑运算符。每条规则到达时,引擎从数据对象里取出对应字段做比较,最终得到布尔结果。需要说明的是,字段提取支持点路径,比如metric.value和group.channel,这个设计让规则配置里的人读起来比较自然,不用记忆 flat 的字段名。
4.2 告警风暴是怎么压住的
规则引擎能跑通不算本事,关键是不能把用户打扰疯了。我处理告警风暴用了三层手段:第一层是分组去重,在输出层维护一个滑动集合,同一个规则同一个分组键在设置的时间窗口内只推送一条告警,默认是 5 分钟。第二层是自定义静默时间,比如某个规则触发过一次之后,强制间隔至少 15 分钟才能再触发。第三层是动态阈值修正,这个是我后加的:如果某个规则的命中次数在短时间内异常飙升,引擎会自动把阈值上调一个百分比,并产生一条"阈值已自动调整"的系统事件,防止同一个问题反复刷屏。
这三层机制上线之后,告警量降了大概八成,剩下的告警可信度高了很多。代价是偶尔会漏掉一些阈值之间的波动,比如本来刚好在阈值边缘的指标,动态调整之后可能暂时不告警了。我的处理方式是给这类规则加一个"冷却期结束后的补偿检查",如果指标在冷却期内一直超标,补偿检查会再发一次,避免完全静默。
5. 压测数据和调优过程:真实环境里的数字
5.1 我用什么方式压测
REA 写完之后,我在一个三节点的普通服务器集群上做了压测,机器配置不算高,四核八G内存那种。压测工具用的是一款开源的负载生成器,模拟业务方持续推送事件,事件大小平均在 800 字节左右,含有 20 个字段。压测场景有三个:纯滚动窗口、滚动窗口叠加规则引擎、滑动窗口叠加会话分析。
这里要说一个经验:压测最怕的就是只看平均延迟,因为流处理系统的尾部延迟才是真正影响告警及时性的指标。所以我统计了 p50、p95、p99 三条延迟曲线。第一轮测试结果中,纯滚动窗口场景在每秒 5000 条事件时 p99 延迟只有 25 毫秒,表现还可以;但叠加规则引擎后,单节点吞吐掉到每秒 3000 条左右,p99 延迟上升到 180 毫秒。这个数字虽然不算差,但是我发现 CPU 开销不对劲,正常来说规则匹配不该这么贵。
5.2 性能瓶颈定位和三个有效优化
我用 CPU 火焰图看到,热点集中在表达式解析器的字段提取环节。原因是我每次判断一个规则时都重新解析条件树,而且字段提取时做了大量不必要的字符串切割。这里我做了三个优化:第一,规则改为启动时预编译成执行对象,字段路径也换算成对应的索引数组,运行时不再解析;第二,把字段查询改成"先取缓存快照,再按索引读取",减少反复查找;第三,给条件树加了一层短路评估,遇到必假分支立即终止,避免无谓计算。
优化后再压测,相同场景、相同配置,单节点吞吐上升到每秒 12000 条,p99 延迟压到 60 毫秒以内。这个结果对我来说完全够用。我又顺手调了内存参数,重点是滑动窗口的桶数量和扫描间隔。原先把扫描间隔设成每 100 毫秒一次,实际算下来完全没必要,改成 1 秒一次,CPU 占用立刻降了十几个点。
5.3 内存和回收的平衡
聚合引擎的内存策略也需要调。滚动窗口由于每个时间片结束就清空,内存很稳定;但滑动窗口和会话窗口如果不加控制,内存会缓慢上涨。我在引擎里加了一个"事件采样比例"参数,默认是 1.0,意思是所有事件都参与聚合;当某台机器的内存占用连续 30 秒超过阈值时,自动把采样比例降到 0.8,并把降低采样的事件数作为 metrics 上报给监控。这个方案不太优雅,但对一个轻量引擎来说是关键时刻的保命措施。
测试中发现 Go 的 GC 压力主要来自频繁创建小对象,也就是聚合计数器 map 的不断增删。我把计数器改成了预分配容量 + 复用旧对象,GC 频率明显下降。顺带提一句,如果你用 Java 写,可以考虑用堆外内存来存窗口临时数据,思路是一样的:减少 GC 压力比单纯堆吞吐量更重要。
6. 上线后踩过的四个坑,每一个都值得记下来
6.1 时间字段的时区陷阱
事件标准化模块收到的时间戳来自不同业务方,有些传的是 13 位毫秒时间戳,有些传的是带时区的 ISO 字符串。第一次上线我就发现窗口聚合的结果在每天凌晨出现一次跳动,排查发现是有个上游系统用 UTC 时间存储,另一个用 UTC+8 存储,两者在标准化时被我统一转换,但转换时有一个 bug 导致部分事件被分配到了错误的时间槽。这个问题的根因是测试数据里没有构造跨时区的事件,我后来在标准化模块里加了一个强制规则:所有时间戳先解析成 UTC 的毫秒数,再根据配置的全局时区转成窗口时间,任何直接使用本地时间做窗口计算的代码一律不允许。加了这个强制约束之后,凌晨跳点消失。
6.2 窗口边界的"数据漂移"
另一个坑是事件在窗口边界附近到的"太晚"。假设一个滚动窗口是 10:00:00 到 10:05:00,事件在 10:04:59 产生,但因为网络延迟或消费堆积,10:05:01 才到达引擎。此时按到达时间计算它会被分到下一个窗口,但按事件产生时间它应该属于上一个窗口。我最初按下发时间分桶,结果统计出来的指标比实际业务指向晚了一个窗口,导致基于窗口结果的规则连锁误判。
解决方法是引入"事件时间与处理时间"的双轨机制:正常聚合按事件时间,也就是业务发生的时间;同时设置一个延迟容忍窗口,默认 30 秒,在这个容忍范围内的事件允许补进它应该属于的窗口,超过容忍度则丢弃并计数。实现上并不复杂,只是每个窗口结束时不立即清空,还要保留一小段"缓冲期"等待迟到事件。这个改动让我从"数据漂移"的痛苦里解脱出来。
6.3 重复消费和告警重复
消息队列消费场景下,客户端在重平衡或网络抖动时可能出现重复投递。我第一次用 REA 接线上队列时,就发现告警偶尔会出现两条一模一样的内容。排查后发现消费端把一批事件提交了 offset,但事务其实没有完成,重启后同一批事件被重新消费,聚合引擎又把它们加进了窗口。虽然窗口结果本身是幂等重算的,但规则引擎在触发时的动作发送没有做幂等。
我的解决方案是给每条事件加一个 messageId,在接入标准化阶段维护一个近期 messageId 的布隆过滤器。重复事件在进入聚合引擎之前就被拦截。同时输出层的动作也加了一个 requestId 拼接字段,保证同一动作在同一窗口内只发送一次。双管齐下之后,重复告警的问题得到解决,但这个坑提醒我:流处理系统中凡是涉及"外部副作用"的环节都要单独做幂等,不能假设消费语义可靠。
6.4 规则热更新时的数据丢失
最后一个坑和规则热更新有关。我最初实现规则管理时,API 支持直接修改某条规则,但修改时整个规则引擎会短暂地把旧规则清空再加载新规则,这中间如果正好有窗口结束事件在排队,就会丢失这一次触发机会。后来我把规则变更改成"版本化切换":新规则先在一个临时区域预编译好,等下一个窗口边界才整体替换旧规则,这样既不会中断数据流,也不会让同一个窗口内的规则判断出现新旧混用的情况。
再往后我又加了一条规则依赖的显式声明,比如某条规则需要依赖某个窗口类型的聚合结果,平台在配置阶段就校验依赖是否存在,避免规则热更新上线半天之后才发现一直没触发,排查半天原来是对不上字段名。这类问题很隐蔽,最好是提供规则测试接口,让运维在更新前先对一批历史事件做模拟判断。
7. 个人体会:做这种轻量项目,最重要的是想清楚"不做什么"
REA 从头到尾没有引入任何分布式流处理框架、没有依赖外部状态存储、也没有做跨节点的一致性协调。这个选择看似保守,但它让我能在很短的时间内把一个可靠好用的工具跑起来,也让后续维护变得非常简单。如果你也想做类似的内部工具,我的建议是:先明确最简单的闭环是什么,把这套闭环做到极致,比一开始就考虑扩展性要重要得多。很多时候我们被各种"最佳实践"牵着走,最后做出了一个较大较复杂的系统,反而忽略了最初想解决的那个具体问题。
实际使用中,我最后悔的一件事是没早点把 REA 的接入协议设计成版本化。后来业务方多了,事件结构总在微调,每次调整都要手动检查标准化层的字段映射,确实低效。如果你也在做同类项目,建议第一天就给每个事件类型加一个 schema_version 字段,哪怕初期所有事件都填 1。等到需要改字段那天,你会发现这个字段帮你省了大量排查时间。