1. 先说清楚:为什么我们会去做一个叫 rea 的东西
凌晨两点半被电话叫醒,值班同事说接口报错告警已经刷了半个钟头,群里全是重复的异常信息。我登录后台一看,同一个服务、同一个接口、同一行代码的异常,按分钟重复推送,整整两百多条。运维的告警策略倒是没停,但人已经被这种重复噪音折磨到麻木,真正重要的信号全被淹没了。这是那年我们做可观测性改造时最典型的一个画面。
这种问题不是加个告警规则就能解决的,也不是日志平台本身能解决的。日志系统负责把内容存下来、帮你查,但它不理解“这条日志意味着什么”“这条日志和那条日志是不是同一件事”。我们真正需要的,是一个能理解事件语义、能对事件流做实时判断的东西。
于是团队内部立项做了一个轻量级的实时事件分析引擎,内部代号就叫 rea,全称是 Realtime Event Analysis,一句话概括:它负责把源源不断的日志和业务行为转换成结构化事件,然后实时计算、去重、聚合,最终输出干净的指标和真正值得触发的告警。
如果你也是一线后端工程师,或者正在负责监控链路、日志中台、可观测性建设,恰好又受够了重复告警、指标口径不一致、规则写起来费劲这些事,这篇文章值得看完。下面我会尽量把设计和实现讲透,包括我们踩过的一些坑。
2. rea 到底管什么:从事件定义到处理边界的取舍
任何一个自研组件,最容易出问题的地方不是技术选型,而是没想清楚“哪些事我不做”。rea 立项初期我们花了整整一周讨论边界,最后敲定下来的核心原则很简单:rea 只处理事件,不处理文本;只做聚合,不做全文检索;只负责实时计算,不负责存储海量原文。
2.1 不是所有日志都配叫“事件”
这是整个设计里最重要的一次认知转变。传统日志是纯文本行,你可以用 grep 去捞,但机器本身不知道这行的业务含义。事件则不一样,它是一段有明确语义的结构化数据,至少包含三样东西:
- 事件唯一标识:用来做幂等和去重的锚点
- 事件时间戳:表示这件事发生的时间,不是日志被采集的时间
- 业务维度与指标:比如服务名、接口路径、设备类型、响应时长、错误码
举个例子,日志里的一行2025-01-12 10:00:02 ERROR Login timeout, userId=1023只是一条文本;但转换成语义化事件后,它应该长这样:
{ "eventId": "svc-auth_20250112100002_001a2b3c", "eventTime": 1736654402, "bizType": "login_failure", "dimensions": { "service": "auth-svc", "api": "/v1/login", "errorCode": "TIMEOUT" }, "metrics": { "count": 1 }, "rawLogRef": "offset://logs/2025/01/12/10/00/001a2b3c.log" }这样做的好处是:规则引擎可以直接对dimensions.errorCode和metrics.count做数学运算,而不是对着字符串写正则。事件化之后,我们所有后续处理都变得轻松,包括去重、计数、百分位估算、环比同比。
2.2 rea 的处理边界:只做四件事
我们把 rea 的职责收敛为四个动作,任何超出这四个动作的需求,一律不做:
| 动作 | 说明 | 对应场景 |
|---|---|---|
| 采集 | 从应用侧接收原始日志或埋点,低成本接入 | 日志转发、SDK 上报 |
| 清洗与结构化 | 将文本解析成事件对象,补全时间戳、维度、指标 | 日志解析、协议转换 |
| 规则匹配与聚合 | 基于 SQL-like 规则做窗口、阈值、去重计算 | 告警触发、指标聚合 |
| 输出与回调 | 把结果写入指标库、推送告警、触发 webhook | 监控大盘、值班通知 |
链路追踪和分布式 trace 我们不去碰,因为有专门的工具做;全文检索和任意字段的 ad-hoc 查询我们也不去碰,那是日志平台的事。rea 的定位不是替代已有的监控体系,而是做监控体系落地前的“事件解释层”,帮它把噪音滤掉、把口径统一好。
这个边界带来的直接好处是,我们的引擎可以做得非常轻,单个节点不需要依赖外部存储就能完成大部分计算,部署成本也低。它不像一个全景式监控平台那样什么都接,但对大多数业务团队来说,恰好够用。
3. 核心设计思路:采集、处理、存储每一层是怎么定的
整个 rea 的架构没有特别复杂的地方,概括起来就是三条管道加一个状态层。但每个环节的取舍,都对应了实际业务里踩过的坑。
3.1 采集层:轻量代理加统一写入网关,业务侧零侵入
最初我们尝试过在应用里直接放一个 SDK,由 SDK 负责日志格式化、缓存、批量上报。但很快发现这个方案推不动,因为业务组不想在自己的核心链路里塞太多东西,而且 SDK 一旦出问题,影响的是业务代码本身。
后来改成两段式:客户端只做最轻量的事情——读取标准的日志输出或主动埋点数据,压缩后通过 UDP 或短连接发送到本地代理;本地代理负责临时缓冲、重试、批量转发到服务器端的统一写入网关。这样业务侧的依赖只有一个配置文件,几乎做到零侵入。
代理层每个节点大概给 128MB 内存做缓冲队列,超过就执行丢弃策略并记录丢弃量。这里有个很重要的参数:丢弃优先级低的日志,而不是随机丢弃。我们会按日志级别分队列,debug 和 info 级可以丢,warning 和 error 级必须保,这样在流量洪峰时损失的是可读性,而不是故障现场的线索。
3.2 处理层:常驻状态运算器,规则编译成 DAG 再执行
服务端拿到事件后,会进入一个常驻的处理进程。每条规则会被解析成一组算子,比如过滤算子、窗口算子、聚合算子、比较算子,这些算子组成一个有向无环图(DAG)。事件在 DAG 里流动,每经过一个节点就做一次变换。
为什么用 DAG 而不是传统的“if-else 一堆判断”?核心原因在于规则有很多组合场景。比如“错误率超过 5% 且调用量大于 1000 次”这条规则,拆开就是两个过滤、一个窗口聚合、一个阈值判断。如果每条规则都独立写死在代码里,加规则就要发版本;但如果把规则当作可编排的算子图,新规则只需要配置一张图就可以了。这样 rule 下发、热部署都变得可行。
每个处理节点是单线程模型,靠事件分片来扩展并发。分片键是事件里的维度组合,比如服务名加接口名。同一个分片内的状态是独立的,这样不同分片之间天然不共享内存,扩展就是加节点加分片。我们也没有引入复杂的分布式共识协议,因为窗口聚合本身允许不精确,节点故障时状态丢失一部分,最坏结果就是少算一些指标,不会造成流程中断。
3.3 存储与查询:热数据按时间分桶,冷数据落对象存储
rea 的存储分两层。热数据保留在内存里,按照分钟和小时两个粒度分桶。内存里只保留最近 15 分钟的高频指标桶和最近 1 小时的原始事件引用,超过时间范围就自动淘汰,淘汰时把聚合结果异步刷到外部存储。
这里有个小技巧值得分享:指标桶存的是预聚合结果,而不是原始事件。比如“每分钟调用量”这个指标,内存里只存 60 个整数值和对应的维度标签,原始明细事件在内存里只随机保存一小部分,用于告警后的抽样追踪确认。这样内存占用能压缩到一个相对可控的范围:大约每百万 QPS 的维度组合,单节点内存开销在 1.5GB 左右。
查询接口也非常克制,我们只开放三类查询:按维度组合查询时序指标、按事件 ID 查询原始事件、按时间范围抽样查询错误事件。不做任意字段的组合检索,因为那是搜索引擎的领域。查询响应时间控制在 100 毫秒以内,因为数据已经按分片和时间排好序,不需要全表扫描。
4. 规则引擎实测:去重、窗口与乱序数据的正确处理方式
这是 rea 的价值核心,也是我们在实现过程中反复返工最多的地方。单独看每一条规则的实现都不难,但组合起来就容易出现各种边界问题。这里挑三个重点详细讲。
4.1 为什么“按消息 ID 去重”是最容易被高估的方案
项目刚起步时,我们的第一版去重逻辑特别朴素:每条日志生成一个 messageId,规则引擎收到消息后检查这个 ID 是否已经在最近窗口里出现,出现过就丢掉。上线后效果确实立竿见影,重复告警直接少了八成。但两周后我们发现,它把一些原本应该告警的故障给掩盖了。
问题出在循环调用场景。一个请求在内部循环十次调用下游,每次都超时,产生了十条异常日志。这十条日志如果共用同一个 requestId,那 messageId 的生成规则如果基于 requestId,十条会被当成一条去重掉,实际是一次 batch 故障被降级成一条告警,信息量确实丢了。反过来,如果 messageId 每次都不一样,那同一分钟十万个请求产生十万条同类型异常,又会打爆告警。
真正合理的去重键,应该是“业务语义键加时间窗口”。我们最终用的方案是:服务名 + 文件名 + 行号 + 异常类型 + 堆栈指纹 + 时间窗口(1 分钟)。这样同一个接口同一个堆栈的异常在一分钟内只会告警一次,但不同时间窗口的同一异常依然能告警,既防抖又不漏报。堆栈指纹用 SHA-1 计算,不同行号和不同的异常 message 会被区分开。
伪代码如下:
def build_dedup_key(event: Event, window_ts: int) -> str: term = "_".join([ event.dim.get("service") or "", event.dim.get("file") or "", str(event.dim.get("line") or ""), event.dim.get("exception_type") or "", event.stack_hash or "", str(window_ts) ]) return hashlib.sha1(term.encode()).hexdigest()这条经验在论坛上见过不少相似案例,但真正自己踩过之后才知道,去重规则必须由业务方参与定义,工程侧只能提供框架,不能替你决定什么算“同一个问题”。
4.2 窗口聚合:滚动窗口、滑动窗口和“预聚合输出”的实际选择
规则里最常见的需求是“最近五分钟错误率超阈值”。这里就涉及窗口类型的选择。
滚动窗口最简单:每五分钟切一个桶,桶结束就计算一次,数据不重叠,天然适合做准点报表,但它的短板是检测不及时——假设 10:00:30 开始故障,要等到 10:05:00 才能算出来,整整 4 分半延迟。滑动窗口则是每隔一定时间步长计算一次最近 N 分钟的数据,能更快感知变化,但要维护窗口内的增量状态和过期数据淘汰,复杂度高一个量级。
我们的取舍是:告警类规则尽量用滑动窗口,统计报表类规则用滚动窗口。并且无论哪种窗口,窗口内不是存原始事件,而是按维度键实时更新聚合桶。比如计算“调用量”和“错误率”,进入一个事件我们只更新两个计数器和一条均值记录,窗口结束时直接输出聚合结果,而不是遍历窗口内的历史数据。这个“边来边聚”的做法规避了大数据量下的内存压力。
窗口输出还有一个容易被忽视的点:空桶也要输出。如果某五分钟没有流量,指标线上留一个空位,会造成曲线断档,环比计算到处都是洞。rea 里我们在窗口结束时刻不管有没有数据都输出一个零值桶,这样下游看板和处理环比逻辑时就不会迷茫。
4.3 乱序数据:水位线策略的度与量
真实业务环境里,日志上报不一定按时间严格有序:客户端本地缓存可能延后,代理可能重排,网络抖动可能导致早发生的事晚到达。如果我们严格执行事件时间窗口,就会遇到“明明窗口已经关闭,迟到的数据才到”的问题。
当时团队有两条路线:一种是用处理时间替代事件时间,不处理乱序,简单粗暴;另一种是引入水位线机制,允许对乱序数据做有限容忍。我们选择了后者,但把容忍范围压得很紧。
具体做法是按事件时间和系统时间做偏移判断,如果一条事件的事件时间比当前窗口允许的延迟区间还早,就直接丢弃(记丢弃指标);如果只晚了一点点,就放进延迟队列,重新计算对应日志窗口的聚合桶。默认容忍延迟是 30 秒,特殊规则可以配置到 5 分钟,但代价是输出也要延迟这么久。
提示:乱序容忍的时间窗口不是越长越好。调长一分钟意味着所有依赖该结果的告警至少晚一分钟,故障发现速度也会变慢。如果你的业务对乱序很敏感,优先优化上报链路,别让引擎帮你擦屁股。工程师应该正视乱序的来源,尽量在源头减少它,而不是盲目用引擎兜底。
5. 上线三个月踩过的坑:CPU尖刺、背压与状态膨胀
再好的设计,上线之后都会遇到意想不到的工程问题。下面这三个坑,几乎每个做流式处理的人都会遇到,分享出来希望能帮读者省几天排查时间。
5.1 一个“不起眼”的序列化改动导致 CPU 翻三倍
系统上线初期,我们觉得 JSON 格式解析太慢,想换二进制协议。开发同事在本地做了一轮简单 benchmark,结果看起来二进制很快,于是直接切了线上。切完后 CPU 使用率从 25% 直接飙到 80%,当时第一反应是流量突增,查了半天发现不是,最后定位到是二进制协议里的压缩器选型问题。
不同的二进制序列化库在特定数据分布下的压缩和解压性能差别极大,我们用的这个库默认走通用压缩算法,对短字符串场景要频繁分配内存,反而比 JSON 慢。最后把压缩器切换成面向小缓冲区的流式压缩模式,并把解压后的对象池化复用,CPU 才降回来。
这个教训其实很小,但它很典型:本地 benchmark 不模拟真实数据分布和对象分配压力,结果没有参考价值。现在我们的性能测试里强制加了“按线上采样数据回放”这一项,避免再发生理想环境与真实场景差异过大的问题。
5.2 背压不是加线程就能解决,得把写入节奏让给下游
rea 的写入模块最开始又是同步又是多线程,下游消息队列一抖动,写入线程阻塞,处理线程排队,整体吞吐直接掉到原来的三分之一。我们的第一反应是加大线程池,加了之后反而更糟——大量线程处于阻塞等待状态,占用内存和上下文切换开销,问题被放大了。
后来我们换成写放大因子控制的限流模式:给写入队列设定一个水位阈值,超过阈值,处理进程主动放慢消费节奏,而不是让写入线程拼命重试。通俗点讲,就是下游慢了我就慢点发,但绝不把写入线程池无脑塞满,这样系统整体变成弹性队列,而不是硬耦合的同步链路。
背压处理要从全局看:你要保护的不只是写入模块,更是整个处理链路不因为写入抖动而雪崩。设置好水线阈值,比调线程池大小管用得多。
5.3 状态膨胀:指标桶数量的指数级爆炸
这是我们在测试阶段踩的最危险的一个坑。规则引擎是边来边聚的,状态里会维护“维度组合-k 指标名”到计数器的映射。如果维度组合里包含一个高基数维度(比如 userId、requestId),内存中的状态数量就会随着流量增长,而不是保持在预设范围内。
测试的时候发现,某条规则对每个接口的每个用户单独聚合一分钟错误次数,结果内存里的事件桶数量到了一亿级,直接触发 OOM。这个坑的根因是:维度组合的唯一值空间,必须提前评估。我们后来做了两条限制:
- 规则定义中强制要求显式声明“允许的高基数维度”,默认所有维度都是低基数
- 高基数维度只入原始明细,不进预聚合桶;需要统计用户级指标时走独立的抽样计算
另外,我们对聚合桶做容量上限,超过上限后触发参数自动降级策略——丢弃低频维度的桶,只保留 topN 桶。虽然这会损失一些长尾维度的准确性,但能保证核心指标不会因为资源耗尽而不可用。
6. 性能验证与最终效果:用数据说话
自研组件的价值,最终都要用数据来证明。这里列出 rea 线上环境的性能和效果数据,供大家做一个量级参考。
6.1 压测方式和测试环境
我们做了三轮压测,从单机到集群逐步加码:
- 第一轮:单节点纯 CPU 处理,事件流量 5000 条/秒,验证解析和规则匹配的耗时
- 第二轮:单节点加写入下游,流量 20000 条/秒,验证背压机制和写入通道的稳定性
- 第三轮:三节点集群,流量 10 万条/秒,验证分片扩容、状态累积、GC 抖动情况
事件数据直接采样线上历史日志,并混入 10% 的乱序数据和 3% 的重复数据,尽量接近真实环境。
6.2 压测与线上效果汇总
性能数字对硬件非常敏感,以下数据基于我们内部的普通物理机(32 核、128GB 内存),如果你用的云主机规格不同,数字会有变化,但量级可以参考:
| 场景 | 单节点吞吐(条/秒) | P99 延迟(毫秒) | 状态内存占用 |
|---|---|---|---|
| 纯解析与结构化 | 80000 | 5.2 | 约 300MB |
| 规则匹配与窗口聚合 | 60000 | 8.7 | 约 800MB |
| 完整链路(采集到输出) | 38000 | 24.1 | 约 1.2GB |
线上真实流量压测平稳运行后,我们没有选择无上限地提高吞吐,而是按单节点 30000 条/秒的保守水位做了容量规划。原因很简单:留足 40% 的余量给告警洪峰和下游队列抖动,比把机器榨干更安全。
从业务效果看,接入 rea 后:
- 重复告警数量下降了约 76%,值班群里的“告警轰炸”基本消失
- 告警平均发现时间从 6 分钟缩短到 1 分钟以内
- 新增告警规则不再需要频繁发版本,业务同学通过页面配置即可完成
这套数据其实并不是亮眼的“高性能”成绩,但对我们来说已经足够:我们用较少的机器成本,换来了更清晰的监控体验和更快的响应速度。
7. 边界提示与后续演进:如果你也想做一个类似的东西
最后聊几句这类“小而美”自研组件的定位和未来。如果一个团队已经有一套成熟的监控体系,rea 会不会多此一举?我的看法是,它取决于一个核心矛盾:现成平台能不能低成本地支持你们团队特殊的语义化规则。
如果只是简单的“CPU 超过 80% 就报警”,确实不需要自研。但一旦你开始纠结“去掉噪声、跨字段聚合、按语义维度统计”,通用的监控产品配置起来就是一场和哑铃较劲的战斗。这时候,专门为你团队量身的轻量事件分析组件会顺手得多。
rea 还可以沿三个方向进化,这也是我们团队接下来的计划:
- 规则热更新与版本管理:目前规则变更仍然需要重新加载,我们计划支持带版本号的热更,并保留规则命中率的回看,方便回溯和迭代方向调整
- 自动根因定位能力:当一条聚合告警触发时,自动关联最近的原始事件抽样、日志偏移和部署时间节点,输出一个初步的定位建议,帮助值班人员缩短排查路径
- 多集群联邦聚合:目前 rea 是单集群内聚合,以后跨机房、跨云场景会越来越多,我们计划在聚合输出层加入联邦逻辑,而不是简单地把数据复制一份到一个大集群里
写到最后分享一个个人感受。做 rea 这类组件最让人沮丧的不是技术难点,而是中途不断地有人问“你们是不是在重复造轮子”。转机出现在业务真正跑起来之后:当运维同学发现一个新规则当天就能上线、当天就能收敛告警时,项目才真正有价值感。
所以如果你也在做一个“看起来已经有现成方案”的内部工具,别急着放弃。你的核心优势是可以贴着业务做语义理解,这是通用平台永远给不了的。保持边界清晰,盯着真实的痛点,哪怕它最初只有几个参数和几条规则,一旦它解决了实际问题,就比任何炫技的设计都值得。