1. 为什么要做“rea”:一次迟到四十分钟的故障提醒
项目代号就叫rea,全称是Real-time Event Analysis,直译过来就是“实时事件分析”。这名字听起来有点正式,实际上就是给日志、指标、告警这类杂乱事件修一条统一的高速路,让它们从分散的服务器、容器、中间件里汇聚到一个地方,在秒级时间内完成解析、关联、判定,然后决定是存档、报警,还是直接触发自动恢复动作。
最初想做这个东西,是因为一次特别尴尬的故障。某天下午,核心订单服务的错误日志其实早就开始刷屏了,但公司原有的监控还在按老节奏轮询,五分钟拉一次指标,阈值设得也比较保守,高峰期本来就容易触发一些无关紧要的告警,运维见多了也就麻木了。结果就是服务异常持续了四十多分钟,直到用户打电话过来投诉,大家才反应过来。那次故障之后,整个后端组开了个复盘会,结论非常一致:光看指标不够,得把日志里的事件变成能够实时响应的信号。
于是rea就在这种背景下立项了。它的定位不是再做一个传统监控平台,而是补上从“日志产生”到“人知道并处理”这一段链路里的时间差。团队里做后端、做运维、做SRE的同学都可以从这套系统里找到参考:不管你是自己搭一套轻量事件管道,还是想在现有监控体系里做实时规则引擎,这篇文章里的设计思路和踩坑记录应该都能帮上忙。
1.1 原有监控体系最大的问题不是缺数据,而是数据太散
很多人以为故障发现慢是因为监控产品不够好,其实大部分情况是把数据打通这件事想简单了。我们当时的现状是:业务日志落在不同服务器上,Nginx访问日志有单独采集链路,中间件的错误日志只保留七天,数据库慢查询又要从另一个平台看。每个系统之间没有关联,一个请求从入口到下游服务,中间经历了什么,哪怕事后对日志都要靠时间戳硬凑。
更麻烦的是告警设置。固定阈值只能抓住“持续异常”,抓不住“瞬时尖峰”,也抓不住“多个低等级事件叠加”的隐性故障。举个例子,某个支付回调偶发超时,单看错误率根本没超阈值,但如果这一天内超时次数突然变成平时的五倍,而且集中在某一条链路上,这其实就是一个需要关注的事件。固定阈值根本不会报,因为它只看百分之几的错误率。
所以rea在设计时定了几条硬性原则。第一,接入实时性要达到秒级,至少不能让人等到五分钟后才看到告警。第二,规则要灵活,不能每个指标改一次告警条件都要发版。第三,也是我最坚持的一点:告警不是越多越好,必须去重、收敛、聚合,把一百条重复日志压缩成一个带事件发生次数的单条告警。第四,系统需要能执行动作,而不只是发消息,比如调用自愈脚本、触发服务重启前的健康检查,或者自动拉起一个诊断任务。
1.2 rea的两个关键设计判断:事件化与可响应
项目初期,团队里有过一次方向争论:要不要接入分布式追踪系统做链路分析?那套方案确实专业,但落地周期长,而且需要业务代码埋点改造,推进阻力非常大。后来我们统一了认知:当下最需要解决的是“知道有问题”,而不是“知道问题在哪一条调用链上”。于是把核心放在“事件”上,而不追求全链路追踪。
所谓事件,我把它定义为“系统里值得关注的一个离散信号”。它可以是日志里的ERROR行,可以是一次超时记录,也可以是一个自定义的业务计数。rea要做的事就是把这些信号汇聚、过滤、判断,并给出一系列响应。这就引出了第二个判断:系统必须能“响应”,而不只是“通知”。
通知只是手段,响应才是目的。我们在设计里保留了自动动作能力,比如某个规则触发后可以调用预设的Webhook、写回标注、执行脚本。当然自动动作一上来就做很危险,容易把还在抖动中的服务误伤,所以在后续我们给动作加上熔断、限频和人工确认模式,这些细节放到后面章节详细说。
2. 整体架构与技术选型:先想清楚要hold住多少数据再来选工具
rea的架构没有用特别新潮的组件,主流思路,但每一层选型都经过比较。整体分四层:采集层负责把各类日志和事件源收进来;传输层用Redis Stream做缓冲;处理层用Python异步任务消费、解析、规则判定;存储层分两条线,明细事件进ClickHouse,规则配置和告警状态放MySQL与Redis。
2.1 采集端选型:为什么不用SDK埋点而是统一代理
采集端我们最初有两个候选方案。一是让各业务服务通过SDK直接上报,这样数据格式最可控,但代价是业务代码要侵入式改造,而且每个语言都得维护一套SDK,最后大概率会变成没人愿意升级的历史包袱。二是用一个轻量采集代理,统一从文件、标准输出或系统日志采集,对业务方透明。
我选的是第二种。这里要说明的是,这个选择有一个前提:团队负责的绝大部分服务已经容器化部署,日志统一写到标准输出,采集代理可以稳定地从节点上获取。我们用Fluent Bit作为采集端,原因很直接:它单实例内存占用实测在10MB左右,比另外几个采集器低不少,而且支持tail、systemd、TCP等多种输入,插件生态也够用。
采集端配置里有个容易忽略的细节:文件读取位置记录。Fluent Bit会把读取位点存在本地,但要确保存储卷不要被回收,否则容器重启后会重新读一遍旧日志,造成事件重复。这个问题我们后来在Kubernetes环境里配置了hostPath存position文件,才彻底解决。
2.2 为什么用Redis Stream而不是直接上Kafka
传输层肯定要被问:为什么不用Kafka?我们团队确实已经有Kafka集群,但维护成本一直居高不下,分区再均衡、磁盘水位、消费者组的延迟监控,每一项都要专人盯。对于rea当时每天几十GB到几百GB的日志量级,几十个处理节点,延迟目标秒级的场景,Kafka的很多高级能力是用不上的。
Redis Stream这套数据结构正好匹配需求。它支持消费组、消息确认、Pending Entries List,语义上非常接近Kafka的consumer group。我们在写入时调XADD,消费时用XREADGROUP,每处理完一条消息就XACK。Redis本身团队已经在用哨兵模式部署,高可用方案现成,不需要额外引入一个新存储系统。
当然Redis Stream有短板,比如消息堆积受内存限制,不能无限留存。我们的应对是结合场景做取舍:采集端写入前设置MAXLEN,大约保留最近50万条,就像一根水位线,超出后老消息按策略截断。这个设计只适用于轻量事件流,如果你要重放几天的数据做离线分析,Kafka仍是更合适的选择,这点不能照搬经验。
2.3 存储层:事件明细进ClickHouse,状态与规则配置分开放
处理完成的事件明细需要支撑查询,比如“某应用过去一小时有哪些ERROR事件”“某个规则触发了多少次”。ClickHouse这类列式数据库非常合适,压缩比高,聚合查询快。明细表按天做分区,TTL只保留三十天,查询时限制按时间范围检索,整体压力可控。
规则配置放MySQL,因为规则需要人工编辑和审计,关系型数据库最适合。告警的激活状态、冷却时间、去重指纹这类高频读写数据放Redis,和应用缓存、Stream分开使用独立的Redis实例,避免互相影响。
这里有一个经验:同一套数据不要散落太碎。我们刚开始把规则、维度表、token信息都放MySQL,查询链路每次都要join,后来把低基数的静态维度(比如应用负责人、环境标签)直接缓存进worker内存,冷数据每周重启刷新一次,执行效率明显提升。
3. 核心模块实现细节:事件管道与规则引擎
rea最核心的部分不在存储,而在处理管道和规则引擎。这部分实现直接决定了系统的实时性上限和误报率。下面按模块拆开讲,代码级别的细节会挑关键片段展示。
3.1 事件管道的四段式流程
管道分四段:消费、解析、富化、判定。消费者进程从Redis Stream拉取一批消息,每条消息是采集端写入的原始日志或事件体。解析阶段根据事件的source类型选择不同parser,常见格式是JSON、还有nginx日志这类非结构化文本。解析失败的事件进入DLQ,不会因为个别脏数据阻塞整条链路。
富化阶段会给事件补充上下文信息,比如根据IP映射机房和部署环境,根据应用名补负责人和告警级别,甚至从计数器中读取某个接口五分钟内的调用量。这一步的价值在于让后续规则可以直接引用丰富字段,不用每条规则里都写复杂的子查询。
判定阶段是规则引擎的核心。渲染引擎会按事件中的应用名、级别、来源匹配规则集,但绝不是简单if-else,而是一个带时间窗口的状态计算过程。所有命中的事件会先按指纹写入统计桶,等窗口内的计数超过阈值才真正触发告警或动作。这样设计的原因很简单:单条error日志没有意义,有意义的是“某一类error在短时间内出现的频率或总量”。
3.2 规则引擎长什么样:JSON DSL、热加载与版本控制
规则引擎我们用了一套轻量JSON DSL。每一条规则就是一个JSON对象,核心字段包括规则名、适用的应用范围、匹配条件、时间窗口、阈值和动作。下面这条规则示例表示:支付服务在过去60秒内出现超过10次“Connection refused”错误时,触发一次P1告警,并调用自愈接口。
{ "name": "payment_connection_refused", "app": "payment-service", "level": "P1", "condition": { "match_all": [ {"field": "event.type", "op": "eq", "value": "ERROR"}, {"field": "message", "op": "contains", "value": "Connection refused"} ] }, "window": 60, "threshold": 10, "actions": [ {"type": "webhook", "url": "http://selfheal.internal/handle", "timeout": 3000} ] }字段取值支持点号路径,比如event.type取嵌套JSON字段。比较运算符覆盖了eq、neq、gt、gte、lt、lte、contains、regex,日常够用。匹配阶段先做粗筛,再做细筛,避免每条规则都全字段遍历。
规则热加载是这套引擎里最容易翻车的地方。我们第一版是定期扫描配置表,发现变更就整体替换规则集合,但这样有一个并发问题:正在处理的事件和新的规则版本可能交叉,同一批消息有的用旧阈值,有的用新阈值,边界行为不可预期。后来改成双buffer加版本号方案:所有规则放入不可变快照,新版本生成时创建新对象,事件在消费开始时绑定当时最新的快照版本,整体切换,保证同一批事件内规则一致性。
3.3 滑动窗口的实现:为什么不用固定窗口
我认为固定窗口是监控领域最隐蔽的坑。想象一个60秒统计窗口,如果按自然分钟切分,那么一批请求在00分59秒进来时计数归零,下一批在01分00秒进来时又开始从零累计,一个持续不断的流量尖峰会被切成两段,极容易漏报或误报。
rea里的滑动窗口用时间桶实现。以10秒为一个桶,窗口长度60秒就是保留最近6个桶,每个桶里按规则+事件指纹维护一个计数器。新事件到达时写入当前时间对应的桶,同时把所有超出窗口的桶置为过期并回收内存。计算窗口内总和时,只需要把6个桶累加一次,成本很低。
关键点在于:窗口计算一律使用rea的接收时间,而不是日志自带的时间戳。原因很现实,容器环境的时钟往往有偏移,日志里记录的时间可能比真实时间快几分钟,如果拿它做窗口统计,你会看到各种诡异的现象,比如事件还没发生就已经被统计。这个坑我们在后面的踩坑实录里专门讲。
def add_to_window(rule_key, event_key, now_ts): bucket = int(now_ts / BUCKET_SECONDS) % WINDOW_BUCKETS counter_key = f"{rule_key}:{event_key}" pipe = redis.pipeline() pipe.hincrby(f"win:{counter_key}", bucket, 1) pipe.expire(f"win:{counter_key}", WINDOW_SECONDS * 2) pipe.execute() total = sum(redis.hvals(f"win:{counter_key}")) return total这段代码是最简实现,实际生产环境中为了避免大Key问题,每个counter key对应的事件指纹不能太多,超出的指纹会走独立空间做近似统计,否则Redis内存会很难受。
3.4 告警去重与聚合:别再让一百条相同告警轰炸值班群
告警去重是值班体验的分水岭。一套监控的告警越多,失去信任的速度越快。很多时候故障还没处理完,值班群就被几十上百条重复告警刷屏,真正关键的信息反而被淹没。
我们做了三层收敛。第一层是指纹去重:事件经过规则匹配后,用“规则名+关键字段值+时间片”算一个MD5指纹,在Redis里用SETNX写入指纹并设置过期时间。如果60秒内相同指纹已经存在,就不再重复触发告警,而是把计数值累加进已有记录的payload里。第二层是聚合发送:一个规则在60秒内对N个不同IP触发告警时,不是发N条,而是发一条汇总消息,带上分布维度。第三层是冷却机制:规则触发后进入cooldown状态,比如P1告警冷却5分钟,期间重复触发不会再次通知,但内部计数继续更新,冷却结束后如果计数仍然超过阈值,再发一条“问题持续中”的告警。
这套机制上线后最直观的变化是:一个原本会刷120条重复告警的故障,最终只产生5条有效通知。值班同学从“关群静音”到愿意认真看每一条告警,信任是熬回来的。
4. 部署形态与性能实测:三台服务器也能跑,但要懂取舍
rea的部署形态不算重。一组Redis哨兵、三到五个worker副本、一个ClickHouse节点,再加两个采集端DaemonSet,整套系统就能跑得很稳。算下来硬件成本并不高,和动辄十几节点的大数据平台相比,确实很“省”。
4.1 资源估算:根据日志峰值反推
读到这里你可能会问:这套东西到底该配多大资源?我一般建议按峰值每秒事件数来估算,而不是按平均。
假设你的系统峰值每秒产生8000条事件,每条事件原始大小平均2KB,写入Redis Stream时事件已经过初步清洗,真实大小约1.2KB。一秒写入就是约9.6MB,保留50万条消息,约600MB内存。考虑到Redis还需要处理窗口计数、指纹去重等操作,内存再放大一倍到1.5GB左右,一个主节点加一个副本节点,总内存约3GB。worker节点主要吃CPU,三副本各2核足够。ClickHouse按日分区,TTL三十天,存储空间约为事件明细的20倍压缩比,即一天约30GB原始数据对应1.5GB存储,一个月大约45GB,一台普通机器加块数据盘完全扛得住。
这套估算是简化的,但方向对:先定延迟目标,再算吞吐,最后匹配存储和内存。很多人一上来就买大机器,结果资源冗余了一倍,维护成本却一点没少。
4.2 延迟实测:P99到秒级是常态
处理延迟由三段时间组成:采集端批量推送周期、Redis消息排队时间、worker消费与判定耗时。Fluent Bit默认配置下最多缓冲几秒才推送,这是可控的,我们调到1秒刷一次。Redis Stream的读取延迟基本在毫秒级别。worker消费端最耗时的是规则匹配和ClickHouse写入,因为ClickHouse对高频小批量写入并不友好。
我们把ClickHouse写入改成批量攒批:worker每攒够5000条或每2秒刷一次。实测下来,端到端延迟P99在800毫秒左右,P50在200毫秒以内,符合秒级实时性要求。
值得提一句:即便ClickHouse批量写入,如果worker崩溃,缓冲区里的几千条事件会丢失。我们为缓解这个风险,在Redis Stream里保留原始事件,ClickHouse写入失败不会删除Stream消息,重启后会重新消费并写一次。这个流程保证了最终一致性,代价是偶尔有重复写入,靠ClickHouse表引擎的ReplacingMergeTree去重兜底。
4.3 消费者Lag与Pending队列:必须主动治理
Redis Stream消费组有一个隐藏的特点:消息被读取之后,如果处理进程崩溃或超时,消息会进入Pending Entries List。相比Kafka,Redis Stream没有自动rebalance,你得自己处理“孤儿消息”。
我们的做法是三层兜底。第一层:每个处理任务都包在asyncio.wait_for里,超时后判定为失败并显式XACK然后写入DLQ,不让消息停留在pending里太久。第二层:后台每5分钟扫描一次各Stream的pending列表,找到idle时间超过10分钟的消息,用XCLAIM认领到当前worker处理。第三层:如果pending数量超过正常值,触发告警通知到人,避免类似“消息一直卡在某消费者手里但没人发现”的问题。
这里有一个团队内部流传的小笑话:任何队列系统的故障,最后查到底要么是消费者死了但没心跳,要么是消息卡在pending里没人管。Stream的pending设计已经避免了很多麻烦,但如果你不管它,它就会变成懒账本,越积越多。
5. 踩坑实录:这些问题,文档里通常不会写
每个系统上线都会遇到文档里查不到的问题,rea也不例外。下面几个问题是我们真实踩过的,每一个都花了至少半天时间排查,写出来供后来人避坑。
5.1 一条正则规则吃掉了整个worker的CPU
规则引擎上线后某天,运维反馈有一个worker节点的CPU长期跑满,但业务量并没有明显增长。查了一圈,最后定位到一条新上线的规则,patter写成了类似(.*)+嵌套结构。这类正则匹配在大多数情况下没问题,但一旦遇到较长且符合某种特征的文本,正则引擎会陷入灾难性回溯,复杂度直接飙到指数级别。
解法有两层。第一层是在规则上线前增加自动化校验:校验规则里的正则片段,禁止高危结构,并跑一轮样例日志的耗时测试,单条消息匹配超过50毫秒直接拒绝上线。第二层是在运行期做兜底:使用支持超时控制的regex库处理规则匹配,单次匹配超过200毫秒就中断并记录慢规则。这两层加完之后,类似的CPU打满事件再没发生过。
5.2 时钟漂移引发的误告警,排查了一整天
上线一周后,某条告警规则频繁误报。明明错误日志很少,但窗口统计出来却超过了阈值。排查了很久才发现,某台服务的容器时钟快了约3分钟。因为窗口统计最初使用日志自带时间戳,提交到统计桶里的数据全被算进了未来时间窗,导致前一分钟的计数异常偏高。
这个坑提醒我们:分布式环境里,永远不要假设所有节点时间一致。rea后续把所有统计和窗口计算全部切到接收时间,日志自带时间戳只保留在原始字段里做追溯展示,不再参与任何阈值判定。切完以后误报率立刻降到可接受范围。
5.3 热点事件把某个消费者打挂
后台任务在扩consumer数量时遇到了倾斜问题:某几个应用的日志量特别大,占了总流量的60%以上。按Stream的分区策略,这几个应用的事件会集中在同一个分片,那个consumer的Lag持续上涨,其他consumer却很闲。
解决办法不是增加消费者数量,而是从入口拆分Stream。按应用分组设置不同的Stream key,热点应用单独一个Stream,配上独立消费者。相当于把高速路上最堵的车流单独分流出一条车道,而不是让所有车都挤在同一个收费口。经过这一步,每个消费者的负载基本均衡。
5.4 Redis内存差点被打爆,罪魁祸首是堆栈字段
Stream的MAXLEN设了50万条,本以为内存可控,但某次大促前压测时Redis内存使用率飙到85%。排查后发现,平均事件大小从2KB涨到了8KB,主要来源是一些服务打印的完整异常堆栈,一个堆栈动辄几KB,乘以50万条就是很可观的内存占用。
解法分两层。采集端先做字段裁剪:堆栈只保留前20行,多余的直接截断;同时写入Redis Stream前,对事件的detail字段做gzip压缩,压缩率超过70%。这两步操作成本极低,内存占用却降了下来。现在每次大促前,我都会检查一遍事件平均大小的趋势,这比临时扩容更靠谱。
6. rea自身也得上监控,以及下一步想做的事
rea帮别人做监控,自己也不能裸奔。每个worker进程暴露了消费延迟、规则匹配耗时、DLQ数量这些指标,接入已有的时序库和看板。代表rea健康的几个关键指标是:Redis Stream最大Lag、pending队列长度、规则匹配P99耗时、ClickHouse写入失败率。这几个指标一旦出现异常,值班同学会立刻收到告警。
rea这一版做下来,最大的收益不是“告警更快了”,而是让团队认识到:事件数据的价值,只有在统一汇聚和实时处理之后才真正显现。下一步我们的方向主要有两个。
一是把固定阈值升级为动态基线。比如某个接口的错误率,凌晨和高峰期天然不是一个量级,固定阈值要么白天误报,要么凌晨漏报。我们计划引入按时间分片的EWMA动态基线,让阈值跟着历史周期走,先从简单的每日周期性波动开始做。
二是探索事件关联分析。rea目前的规则都是单事件类型独立判断,还没有把“上游服务超时、下游队列堆积、磁盘水位上升”这类多事件关联起来。我们想做的是先给事件打上链路维度标签,再在窗口聚合做二维下钻,逐步逼近链路分析的能力。
如果你也在考虑搭一套类似的东西,我最后的建议是:不要一上来就追求大而全。先解决“日志能统一、告警能收敛、响应能自动”这三个问题,一个小而稳的rea比一个半年都上不了线的宏大平台有价值得多。