一个项目代号如果有三个字母“rea”,一线做监控的同事基本都能猜出它的定位:想用一套轻量的方案,把线上日志里的错误实时抓出来,自动收敛、分析根因、定向告警。我这次分享的就是这样一个内部项目:rea(Real-time Error Analyzer),一套服务于日志异常检测和快速定位的开源思路型方案。
这套东西解决的痛点是:服务一多、日志一杂,出问题靠人肉 grep,耗时且容易漏,想定位根因需要翻遍多台机器,把所有异常堆栈翻完才发现是某一处依赖抖动。rea 做的是把日志采集、清洗、规则检测、错误指纹聚类、根因关联告警串成一条流水线,让“异常浮现”从分钟级别降到秒级别,让“定位根因”从人工翻日志变成自动圈定嫌疑对象。
这篇文章适合正在做日志分析、可观测性建设、或者单纯被“线上查错”折磨的开发、运维、SRE 同学参考。我不会堆架构图,只讲我实际从零搭这套系统时踩过的坑、验证过的取舍,以及可复现的关键步骤。
1. 整体设计与思路拆解
先说说为什么要上 rea,而不是直接套一套重型的商业化可观测平台。我在某公司内部支持中间件团队时,日均日志量大概在 50GB 左右,服务实例约 40 个,不算大,但日志格式极其混乱:有 LaaS 的、有用 JSON 的、有纯文本拼 key=value 的,甚至有把整个调用链打进一行里的。如果直接上一套重型全家桶,部署成本、存储成本、学习成本都会让团队放弃使用。
rea 的定位从一开始就是“轻、准、快”。我们不追求把全部日志都永久存起来做全量检索,而是做两件事:实时捞异常,自动给异常归类,并把根因信息带出来。原始日志只做短期缓冲,异常的上下文才做结构化存储。这样,存储成本大幅下降,规则可以快速迭代,告警效果也直接可见。
1.1 为什么项目代号叫“rea”
“rea”内部含义是:Reduce Error Ambiguity——减少错误的不确定性。它要解决的核心问题是“错误”(error)在日志里有多义性。同一个异常类型,可能因为 host 不同、时间不同、message 里附带参数不同,被当成好几个不同问题;反过来,多个完全不同的异常,又可能因为包含相同关键字被误合成一类。
于是 rea 的核心设计原则就是:先规范化,再指纹化。规范化解决“同义不同形”的问题,指纹化解决“同根不同相”的问题。这个思想贯穿了后续的规则引擎和根因分析模块。日志只要通过清洗层,就会被转成统一事件结构,再通过错误指纹算法进行归一。
1.2 流水线式架构:采集、缓冲、清洗、判活
rea 整体是一条数据流水线,每个环节都允许独立重启和降级。我实际用的架构是四段式:
- 采集端:每台宿主机上都部署一个轻量采集代理,专门做 tail 和转发,不承担任何解析逻辑。它只负责按行读日志,把整行文本塞给下游。
- 缓冲层:采集端和清洗端之间放一个本地缓冲队列。作用就两个:抗下游抖动、削峰。业务高峰日志量暴涨时,清洗端来不及处理,队列能兜住,避免日志丢失。
- 清洗端:核心逻辑所在。它拿到原始行之后,做编码归一、格式识别、字段抽取、时区统一,最终产出一条 schema 化的日志事件。
- 判活与存储端:规则引擎接收清洗后的事件,命中规则的就进入“异常事件池”,再由指纹模块来聚合成“问题单”,并触发告警。
为什么清洗端不在采集端做?因为采集端是部署在业务机器上的,资源敏感,一旦做了解析、规则判断,很容易成为业务容器的性能噪音。清洗端独立出来了,规则更新也能热加载,不会影响采集链路。最初我也图省事在采集端加了解析,结果某次日志多打了一个字段导致采集端 CPU 飙升,直接影响业务容器,后来坚决把解析逻辑全部下沉。
1.3 技术选型背后的取舍
选型一定要看团队的上下文。rea 这套系统,我在单机模拟和生产集群两种环境都验证过:
- 采集端与清洗端通信,统一走 JSON Lines 协议,而不是自研二进制协议。好处是排查链路问题时每一跳都能直接打印一条数据出来看,调试成本极低。
- 事件存储用列式存储,按小时分桶,字段只保留核心 20 个左右,message 全文内容压缩后放进单独列,支持按 trace 召回。
- 规则判断用“表达式树”实现,不引入重量级规则引擎。因为我们的阈值、正则、滑动窗口逻辑其实都可以用几类算子表达,自己维护一个轻量引擎反而灵活。
- 告警通知做成可插拔接口,先接内部消息机器人,后续再扩展电话、短信通道。
一个很重要的取舍是“实时性做到什么程度”。rea 最初定的目标是“从日志产生到异常事件可见”在 10 秒以内,而不是毫秒级。因为日志分析场景里的“实时”是业务语义上的实时,不是流计算竞赛里的实时。我把窗口设成了 10 秒,让批量吞吐更稳,也大幅降低了实现复杂度。实际线上跑下来,p99 在 7 秒左右,完全够用。
2. 核心细节解析与实操要点
如果你只是想把日志收到一个平台里再搜索,那不需要 rea。rea 真正值钱的地方是后面的两个环节:清洗标准化和异常指纹聚类。这两个环节做好了,后面告警的准确率才会高;做不好,就是天天狼来了。
2.1 日志标准化:核心字段固定,扩展字段保留
日志标准化最怕的是“什么字段都想格式化”。很多团队会试图把所有日志都整理成一个超大的统一 JSON,结果每接一个新服务就要改一遍 schema,维护成本极高。rea 的思路是:核心字段必须固定,扩展字段用 KV 保留。
核心字段我固定为:timestamp、level、service、module、host、trace_id、error_type、message、ext_kvs。
其中 timestamp 统一转成毫秒级 Unix 时间戳,level 统一成 DEBUG/INFO/WARN/ERROR/FATAL 五档,service 和 module 由采集路径映射。trace_id 是从日志文本里通过正则抽取的,抽不到就置空。error_type 由规则引擎在判定异常时补充,而不是在清洗阶段猜。
这块最值得强调的是时区统一。很多服务的容器时区没设好,日志打印时间有的是 CST 有的是 UTC,如果你不在清洗端统一换算成 Unix 时间戳,后面做时间窗口、同比环比、按分钟聚合全部会乱套。我吃过这个亏:有个服务日志时间和实际时间差了 8 小时,导致告警里的“最近 5 分钟”实际上框的是未来时间,定位问题偏了快两个小时。
2.2 错误指纹:异常聚类的最关键技术
假设线上同时出现 500 个“连接超时”错误,但 message 各不相同:有的带 IP、有的带超时时间、有的带服务名。如果你直接按 message 全文去重,会得到几百个不同事件;如果你按“异常类名”去重,又可能把完全不相关的错误糊在一起。
rea 的错误指纹算法可以理解为“三级收敛”:
第一级:取 error_type + 堆栈第一行的方法签名作为主键。比如 “Java 连接超时 + com.xxx.rpc.RemoteInvoke.doInvoke”,这就是一个主指纹。它表达的是“哪个组件在什么操作上失败了”。
第二级:把 message 中的 IP、端口、时间戳、数字 ID、UUID 全部替换成占位符,再参与聚类。这样做是为了把“同一类错误、不同参数”归并,同时避免“不同错误、相同文案”的误并。
第三级:关联 trace_id。如果多条异常属于同一个 trace_id,它们就会被提升为一个“一级事件”,直接作为一条调用链的根因候选。这个信息在定位多服务问题时非常关键,单靠日志文本很难还原。
实际写的时候,指纹就是一组字符串字段拼接后取哈希:service + error_type + normalized_message + 堆栈摘要。哈希值相同的事件归入同一个“问题单”。我们按天对每个问题单计数,观察频次曲线,持续高频上涨的就是需要立刻处理的。
2.3 规则引擎:静态规则与动态基线配合
规则引擎是 rea 把日志变成告警的开关。没经验的人容易把规则写成“出现 ERROR 就告警”,然后被日志里所有正常 ERROR 淹没。rea 的做法是把规则分成三类:
- 关键字/正则规则:常见的“OOM”“NullPointerException”“Timed out”“Connection refused”这些直接命中即异常,但告警级别低,只记录不出通知。
- 阈值规则:对某个 service + error_type 组合,在滑动窗口内统计出现次数,超过阈值才真正告警。阈值设定不能一刀切,要按服务的正常水位来算。
- 动态基线规则:用过去 14 天同时间段的数据做基线,当当前窗口统计值超过基线 3 倍标准差时告警。这个规则是为了抓“数量异常涨”的场景,专门防那种低频但重要的错误。
三者的触发优先级:关键字规则先打标,阈值规则决定出通知,动态基线规则决定升级通知。打个比方,就像一个安检系统:先看你是不是名单里的人(规则命中),再看你出现的频率是否异常(阈值),最后看是不是突然成群出现(基线)。这套设计上线后,告警准确率从最初的 30% 提到约 85%。
2.4 实操中的几个关键遗憾
要提醒你的是,规则引擎不是“配一次就完事”的。日志格式一变、业务量级一涨,规则参数就要跟着调。我们当时犯过一个错:某核心服务大促流量翻倍,错误总量其实是正常的按比例上涨,但固定阈值规则直接告警,导致值班组半夜爬起来处理了一个“假故障”。后来给所有阈值规则都加了“同比放行策略”:当错误上涨幅度不超过请求量上涨幅度时,压级处理。
这也是为什么我强烈建议你一定要给规则引擎做“历史回放”能力。用昨天一整天的日志重新跑一遍今天的规则集,看会触发多少条告警,再人工判断哪些是误报。我们把“回放拦截率”作为规则上线的前置卡点:回放时误报率超过 20% 的规则不允许发布。
3. 实操过程与核心环节实现
这一部分我把 rea 最小可运行版本的单机搭建过程写出来。你不需要一上来就上集群,先用一台机器把链路跑通,然后再横向扩展。我用的环境是 Linux + Docker Compose + Python 3 和一个轻量的列式存储组件。为避免被具体实现细节绑架,这里只贴关键配置和思路。
3.1 环境准备与目录规划
模拟环境下,我会准备三个目录:input_logs(放原始日志)、parsed_events(清洗后的结构化事件,按小时分目录)、alerts(告警产出)。目录规划好了,整个调试链路就特别清晰:你可以随时看某一环节的输入输出长什么样。
链路中的组件我用 Docker Compose 编排成三个服务:collector(采集)、cleaner(清洗)、analyzer(规则引擎与指纹模块)。无论生产环境规模多大,这三个角色的边界都不要破。
3.2 采集端配置与原始日志接入
采集端我直接写了一个简单的 tail 脚本,实时读取 input_logs 下的 app.log,按行输出为 JSON:
tail -F /var/log/input_logs/app.log | while read line; do printf '{"ts":"%s","raw":"%s"}\n' "$(date +%s%3N)" "$line" done生产环境不会这么写,会用带 buffer 的采集代理,但这个简版能让你理解采集端只做“搬运不做判断”。消息格式只有两个字段:ts 和 raw。所有解析逻辑交给 cleaner。
3.3 清洗端:正则抽取与字段收敛
清洗端拿到一条 raw 后,做三件事:
第一,识别日志格式。我先定义了几套解析模板,例如 JSON 日志、K8s 日志、常见 Java 堆栈日志。识别方式是对开头 200 个字符做快速嗅探:如果以{开头就是 JSON,否则按文本模板走。
第二,抽取核心字段。对文本日志,用一组命名正则提取 timestamp、level、trace_id 和 message:
pattern = re.compile( r'^(?P<ts>\S+ \S+)\s+' r'(?P<level>DEBUG|INFO|WARN|ERROR|FATAL)\s+' r'\[(?P<trace_id>[^\]]*)\]\s+' r'(?P<message>.*)$' )抽不到 trace_id 的,就留空并在 ext_kvs 里标记 needed_flag。有意思的是,后续我们做过统计:根因定位耗时和 trace_id 缺失率高度相关,trace_id 缺失越多的服务,平均定位时间越长。
第三,做关键词归一。比如把多个空格合并,把异常堆栈里的行号去掉,把数字替换成{num}。这套“脱敏归一”后的 message 会直接参与指纹计算。
3.4 规则引擎核心代码示意
规则引擎我用一个表达式来判定:每个规则就是条件 + 动作。条件支持 in、regex、count_window、baseline_ratio 四种操作。下面是个示例规则定义:
def rule_conn_timeout(event): if event.error_type != "CONN_TIMEOUT": return False window_count = sliding_window_count(event.service, event.error_type, window=300) if window_count < 50: return False if not exceed_baseline(event.service, event.error_type, ratio=3.0): return "WARNING" return "CRITICAL"说明一下这里的两个参数:window=300 表示在 5 分钟内统计;baseline_ratio=3.0 表示当前窗口计数要超过历史同期均值的三倍才升级。这两个参数不建议一开始就设得很严,先放宽让告警多产生一点,然后每天根据回放结果收紧。
处理后的异常事件进入问题单聚合器,按指纹哈希分桶。同指纹事件只更新计数、首末时间和关联的 trace_id 列表,不新增记录。等到窗口结束时,问题单才正式落库并触发告警。
3.5 从模拟注入到效果验证
为了验证链路,我写了一个脚本向 input_logs/app.log 注入 500 条模拟错误日志,包含真实的服务名和随机 IP,并在 2 分钟内分三批注入。rea 跑完后的结果是:清洗端识别出 481 条事件,规则引擎命中 468 条,指纹聚类后归并为 3 个问题单,每个问题单都带上了首次出现时间、计数和涉及的 trace_id 列表。
这里要特别看一个参数:这 481 条事件里,关键字规则命中率其实是 97%,但阈值规则只对其中 2 个问题单触发告警,第 3 个问题单因为频次低于阈值只是记录。这个设计是故意的:低频但严重的错误也要留痕,只是不能大半夜把值班人吵醒。
整体跑完,从日志写入到问题单落库,平均耗时 8.6 秒。当然这是在单机模拟环境,生产环境会把 collector 和 cleaner 分开部署,吞吐会再涨一个量级。
4. 常见问题与排查技巧实录
任何日志分析系统,上线三个月内一定会遇到下面这几类问题。我把典型场景、现象、原因和解决办法整理成一个速查表,也都是我亲自排查过的真实案例。
| 问题现象 | 根本原因 | 排查与解决思路 |
|---|---|---|
| 清洗端解析命中率只有 60%,大量日志落入 unparsed | 正则模板顺序不对,被误判格式 | 给解析模板加权重和 fallback 标记;定期统计 unparsed 占比,超过 10% 告警 |
| 同类错误被拆成几百个问题单 | message 里的 IP、端口、实例 ID 没有被归一 | 检查归一化正则,确保对 IP、UUID、数字 ID 都做了替换;在指纹生成前统一走 normalize_message 函数 |
| 某服务固定阈值频繁误报 | 业务量涨导致错误总数同步上涨,但占比正常 | 阈值规则加上同比放行,或改为动态基线方式 |
| 告警重复轰炸,同一问题 10 分钟告警 30 次 | 缺少告警聚合与去重 | 按指纹 + 时间窗口聚合,同一问题单仅首次触发通知,后续只在问题单状态变化时通知 |
| 时间窗口判定的“最近 5 分钟”与实际不符 | 部分日志时间戳是本地时间,未统一时区 | 清洗端强制将 timestamp 转为毫秒级 Unix 时间戳;对无年份或无法解析的时间样本打标丢弃 |
| 存储增长过快,一周吃掉 200GB | 把清洗后的全量 JSON 都落库了 | 原始日志只保留 6 小时;异常事件保留 14 天;特征聚合数据保留 30 天以上 |
里面最隐蔽的坑是第一行的“unparsed 日志”问题。因为日志格式永远在变,某天一个服务升级了日志打印框架,老正则全部失效,如果解析命中率没有监控,规则引擎就像瞎了一样。我在 rea 里加了解析命中率看板,低于 90% 自动高亮告警,这样至少能在业务抱怨“告警变少”之前发现问题。
另一个实操技巧:对 message 做正则抽取时,务必用编译后的正则对象,不要每次实时 re.compile。在日志量大时,正则编译的开销会被放大几十倍。我用 Python 的re.compile提前编译并缓存 200 多个模板,解析吞吐从每秒 800 条提升到 2500 条。
5. 扩展思路与个人使用体会
rea 这套思路不只能用在日志上。后面我把同一套“清洗层 + 指纹层 + 规则层”的代码复用到了调用链异常分析里:把分布式 trace 中每个 span 的 status 信息作为输入,清洗抽取出 service、operation、error_code,指纹改成 “service + operation + error_code + 相邻 span 特征”,规则层用同样的滑动窗口和基线告警。这样连日志里没有显式打印的错误,也能被快速圈出来。整套扩展大概花了一周时间,无非是换了输入源和几个解析模板,架构完全没动。
我在实际使用中最大的体会是:做日志分析系统,最难的其实不是写规则、不是调阈值,而是建立“对异常数据形态的敏感度”。你摸清自己的系统在什么时段、什么负载下会打印什么类型的日志,才能真正定义出有效的错误指纹和基线。规则和算法都是工具,判断力来自一次次回放和历史问题复盘。
最后分享一个事实:一开始我们过于纠结“错误指纹”算法要用多复杂的聚类模型,后来发现先通过“error_type + 归一化 message + 堆栈摘要”三级拼接做哈希,就能解决 80% 的聚类问题。朴素的做法未必不优雅,关键是先把链路跑起来,用真实数据校验,再逐步迭代。
这套 rea 的完整实现并不复杂,单体代码量也不大,但链路中每一层都有值得打磨的地方。如果你正在被日志查询慢、告警不准、根因定位困难这三件事困扰,不妨也照着四段流水线的思路,先搭一个最小闭环,再逐步完善。踩完这些坑之后你会越来越认同一个观点:好的可观测性工具,不是把所有数据都堆给你看,而是替你完成第一轮减法——在正确的时间,用正确的粒度,把可疑的东西摆到你面前。