
前阵子处理线上故障的时候我发现自己至少花了半个下午在干一件重复的事从一堆日志文件里找出某类报错、提取时间点、数出现次数、再拿到表格里做统计。刚开始还会老老实实写临时脚本脚本长了之后每次都要改路径、改正则最后干脆起了个项目名字就叫 rea取 Read、Extract、Act 三个动作的首字母。它是一个面向日志和文本的本地抽取与聚合小工具喂给它规则它负责从原始文本里定位字段、变成结构化记录再按你指定的方式输出或通知。如果你也是那种不想为一个统计需求就部署一套重量级日志平台的人这个思路应该对你有用。下面我把整个项目的来龙去脉、规则设计、核心代码、实战配置和踩坑记录都整理出来内容偏实操尽量把每一步为什么这么做讲清楚。1. REA 的项目背景为什么我会自己写一个日志抽取工具1.1 现成方案不是不行而是不匹配刚开始我考虑过直接上那套常见的日志采集加检索平台但实际算了一笔账只是想盯两三个应用日志、统计几类错误码、偶尔把告警推到群里却要维护一套服务端加一堆采集器配置和磁盘开销都不小。更重要的是这类平台擅长的是“全文检索”和“事后查询”而我要的是“实时匹配规则、抽取字段、聚合上报”两件事看着接近侧重点完全不一样。REA 的思路更像是“一把趁手的瑞士军刀”。它不负责存大量历史日志也不做分布式查询。它只做一件事把日志流变成结构化事件流然后按规则消费。就像我们需要一把螺丝刀没必要把整个工具箱拖到现场。明确边界之后设计就变得很简单了。1.2 三条设计原则REA 的设计遵循三个原则规则和代码分离。新增一个日志场景只需要写一条 TOML 规则不用改 Python 代码。输入、解析、输出三段式。日志源只管读规则引擎只管匹配输出模块只管推送到目的地。这样单个模块可以独立测试。先有“行”再有“上下文”。默认按行解析遇到多行堆栈时通过上下文缓存补充而不是一开始就做复杂的状态机。这三个原则让我在后续扩展中几乎没有推翻过重来。尤其是规则和代码分离省掉了大量“求你啊改个匹配条件”的沟通成本。同样一段日志给不同人维护只需要看配置文件就能明白规律。1.3 选型标准库优先依赖尽量为零实现语言我选了 Python 3。原因很直接正则支持完善、文本处理顺手、标准库里有argparse、tomllib、urllib、json一个第三方依赖都不加就能跑起来。有人问为什么不用 Go 或 Rust理由是单文件分发确实诱人但我的核心诉求是“快速改规则、快速验证”Python 的迭代速度更适合。性能不够时再考虑把规则匹配部分单独用编译型语言重写。为什么不引入pandas、requests这类常用库因为 REA 的输入是文本流不是表格没必要为了几个统计函数背一个庞大的依赖树。Webhook 推送用内置urllib就能实现代码量不超过四十行维护成本反而低。2. 规则文件怎么写从一行日志到结构化记录2.1 我为什么用 TOML 做规则格式REA 的规则文件第一版用的是 JSON后来换成 TOML。原因很朴素JSON 不支持注释写规则的人想在某个正则旁边加一句“这段是兼容老格式的”居然还要另开一个字段存注释。TOML 天然支持注释嵌套表也够用Python 3.11 之后标准库直接提供tomllib连依赖都不用装。下面是一份典型规则的骨架[rules.access_log] enabled true pattern ^(?Pip\d\.\d\.\d\.\d) - - \[(?Ptime[^]])\] (?Pmethod\w) (?Ppath[^]) (?Pstatus\d{3}) (?Pbytes\d|-) mode first fields [ip, time, method, path, status, bytes]这里用三引号把正则包起来是为了避免正则里的反斜杠和引号互相打架。mode first表示一行日志只归属第一个命中的规则这是大多数场景的默认行为。如果某些日志行需要同时打上多个标签可以改成mode all。2.2 命名分组与字段归一化正则里用(?Pname...)命名分组匹配结果会自动变成结构化字段这一步是 REA 的核心。不需要在代码里写group(1)、group(2)这种容易错位的取值方式而是直接用字段名访问。比如上面那条规则解析完就得到{ip: 192.168.1.10, method: GET, status: 200}。字段归一化要做两件事类型转换和字段别名。类型转换比较好理解bytes字段在原始日志里可能是-应该转成 0 而不是报错。字段别名是把不同业务日志里的“同一个含义”统一起来比如 A 系统叫user_idB 系统叫uid在输出给下游时统一成user_id。REA 在规则里支持alias配置命中后将原始字段映射成标准字段名统计模块就不会被命名差异搞乱。2.3 规则匹配顺序和回退行为多个规则之间是顺序匹配的。REA 会按照配置顺序逐条尝试一旦某条规则命中就停止继续匹配除非mode all。这个顺序很重要你应该把最具体的规则写在前面把兜底规则写在最后。比如先匹配“支付超时”的完整格式再匹配“所有 ERROR 级日志”。如果所有规则都没命中默认行为是把原始行原样输出到missed通道方便你之后补充规则。这个设计救过我很多次不然未匹配日志会被静默吞掉等你发现时已经丢了一大批数据。REA 还允许给每条规则设置sample_lines在启动时用这些样例自动验证正则是否能命中规则写坏了能立刻发现。3. 核心模块的实操实现3.1 项目目录结构REA 的实际目录结构不长我保持了单机小工具的克制rea/ ├── rea.py # 入口负责参数解析和调度 ├── tailer.py # 文件跟踪模块模拟 tail -f ├── parser.py # 规则加载与正则匹配 ├── output.py # 输出与通知模块 ├── aggregator.py # 滑动窗口统计 ├── rules.toml # 用户规则文件 └── state/ └── offsets.json # 保存每个文件的最新读取位置核心其实只有tailer.py和parser.py其他模块都是围绕这两个核心打辅助。入口程序启动后做四件事读配置、恢复文件读取位置、注册输出管道、进入事件循环。事件循环收到一行日志就交给 parser 处理处理完把结构化记录推向 aggregator 或 output。3.2 给 Python 加一个 tail -f文件跟踪实现监控文件新增内容最直接的想法是每秒钟读一次文件末尾。但直接os.popen(tail -f)在 Windows 上不友好而且每次生成子进程的成本不低。REA 用 Python 内置的文件对象实现了一个简化版tail -fimport os import time def follow(path, offsetNone, encodingutf-8): with open(path, r, encodingencoding, errorsreplace) as f: if offset is None: f.seek(0, os.SEEK_END) else: f.seek(offset) while True: line f.readline() if line: yield line, f.tell() else: time.sleep(0.2)这段代码的理解难点在于yield line, f.tell()。f.tell()返回当前文件指针位置REA 会把指针位置记录到state/offsets.json。下次重启时直接从上次位置继续读而不是从头读一遍。这就是“断点续读”的基础。真正生产环境还得处理文件轮转。日志按天切割后同一个路径可能对应新的 inode。REA 的做法是记录文件的st_ino和st_size一旦发现 inode 变了就认为文件被轮转立即把 offset 重置成 0 重新开始读新文件。这个细节会在后面踩坑部分详细展开。3.3 规则匹配器先过滤再匹配一条一行几百字节的日志如果每条规则都跑一遍完整正则日志量大的时候 CPU 会直接飙上去。REA 的 parser 做了一层快速预筛如果规则定义了quick ERROR那么这一行必须包含ERROR字符串才可能进入完整正则匹配如果规则定义了quick_re ^\\d{4}-\\d{2}-\\d{2}就会先判断行首是否像时间戳。预筛逻辑看起来不起眼实际上能把匹配开销降低一个数量级。举个具体数据某业务日志每小时约 50 万行其中只有 3 万行包含ERROR字样。给第一条规则加上quick ERROR后其余 47 万行在正则引擎之前就被跳过了运行时的 CPU 占用从 35% 降到 8% 以内。3.4 输出管线和状态保存REA 默认输出是 JSON Lines也就是每行一个 JSON 对象方便后续用其他工具处理。结构化的记录会包含统一的元信息{rule: access_log, ts: 2025-03-23T10:15:3008:00, fields: {ip: 192.168.1.10}, raw_length: 87}输出模块支持三种目的地stdout直接打印到控制台适合调试。文件写入指定的.jsonl文件适合长期采集。Webhook通过 POST 推送到外部系统适合对接即时通知。用标准库urllib发送 Webhook 的代码很简单核心就是构造一个 JSON POST 请求。如果推送失败REA 会把失败的记录写到本地待重试队列而不是直接丢弃。这里有一个小设计重试队列最多保留 1000 条超过就往drop.log里记时间戳和原始行避免内存和磁盘被无限占满。状态保存是另一个容易被忽视的点。REA 每隔 5 秒或者每读到 1000 行就做一次 offset 持久化。为什么不是每次读取都写因为磁盘 IO 太频繁会影响日志采集本身。自己写工具的最大好处就是可以对这种细节做权衡而不是被框架规定死。4. 两个实战配置日志解析的真实落地4.1 场景一Web 访问日志统计第一个场景是从某常见 Web 服务器的访问日志里统计接口调用量。首先写规则[rules.web_access] enabled true quick pattern ^(?Pip[^ ]) - - \[(?Ptime[^]])\] (?Pmethod\w) (?Ppath[^]) (?Pstatus\d{3}) (?Pbytes\d|-) mode first然后在 REA 命令里加上聚合指令按分钟统计每个status和path的组合出现次数。输出的效果类似2025-03-23 10:15:00 status200 path/api/health count320 2025-03-23 10:15:00 status500 path/api/order count2这个功能让线上接口监控从“发现日志堆积了”变成“每分钟都能看到对应指标”。我在实际使用时会把输出接上 Webhook让 500 比例一旦超过阈值就自动通知。聚合的逻辑由aggregator.py完成滑动窗口默认一分钟窗口大小和触发阈值都在规则文件里配置。4.2 场景二业务异常日志聚合告警第二个场景是提取业务日志中的异常码和耗时并在异常码连续出现 N 次时触发告警。先定义规则[rules.biz_error] enabled true quick ERROR pattern ^(?Ptime\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s\[(?PlevelERROR)\]\s\((?Pcode[A-Z]\d)\)\s(?Pmsg.) mode first告警条件配置文件里写得很直白[aggregate.biz_error] window_seconds 60 threshold 5 notify webhook意思是 60 秒内同一code出现 5 次就推送一次通知。这里要注意告警通知不能每条都推否则错误多时会变成通知轰炸。REA 的自带聚合器做了“闹钟复位”逻辑同一告警触发后进入 10 分钟静默期除非错误级别更高。这个机制是我在实际运维中被反复打扰之后加上的。4.3 常用命令行操作REA 的入口参数不多基本是够用就行python rea.py --config rules.toml --input ./logs/ --watch python rea.py --config rules.toml --input ./logs/app.log --dry-run python rea.py --config rules.toml --input ./logs/ --since 2025-03-23--watch表示持续跟踪文件新增内容--dry-run只解析不输出、不推送适合验证正则--since可以指定只处理某时间点之后的日志方便做历史回放。我建议第一次用任何一个新规则时都先用--dry-run跑一小段再正式让工具进入--watch模式能省掉不少蜜蜂在日志里找半天才发现规则写错的时间。5. 真实环境最容易踩的五个坑5.1 logrotate 后的重复读或漏读日志切割是 REA 最容易踩的坑之一。系统日志工具按天把旧日志改名新建同名文件如果 REA 还拿着旧文件的 offset 继续读就会在旧文件末尾空转新文件则完全不被读取。反过来如果不小心在轮转后重置 offset就会把新文件从头读一遍造成重复统计。解决方法是同时检查文件的 inode 和文件大小。REA 在保存 offset 时会连带保存当时的st_ino。每次读取前先os.stat(path)如果 inode 变化说明这个路径已经对应了新文件立刻把 offset 置为 0。如果 inode 没变但文件大小变小也说明发生了截断同样需要重置。这个逻辑不复杂却没有现成库能直接帮你处理必须自己实现在文件跟踪模块里。5.2 多行堆栈追踪解析不全Java 和 Python 的异常堆栈经常跨好几行按行解析会让堆栈碎片变成多条无意义记录。REA 的处理方式是“当前待定记录”机制。如果一行日志命中了某个规则的开始标记就把它作为新记录如果后续连续几行都没有命中任何规则就把它们追加到当前记录的消息字段里直到出现下一个匹配行。这个机制要注意边界条件如果两个不同异常的堆栈之间只有空行REA 会把空行也吞进上一条记录。我在规则里加了一个stack_flush_keywords配置当遇到“Caused by:”或“Traceback (most recent call last):”等关键字时强制中断当前记录。实际效果还不错牺牲了一点灵活性换来了可预期的行为。5.3 正则回溯把 CPU 打满有一个教训值得单独说。某次我把一条访问日志的 path 提取正则写成(?Ppath.*?)当天线上流量一上来单核 CPU 直接打满。问题出在.*?和后面的引号组合上当一整行带宽满日志没有出现预期结束符时正则引擎会在超长字符串上反复回溯尝试性能退化非常严重。REA 的应对策略有两个第一快速预筛能在进入完整正则前排除的大概率不匹配第二对进入正则解析的行做长度限制默认单行最长 8192 字节超过长度直接截断并打上truncated标记。给业务日志设置一个合理长度上限比在正则里小心翼翼地写惰性匹配要可靠得多。5.4 时区与时间戳解析不同系统的日志时间格式五花八门有的带时区偏移有的不带有的年份是两位数。REA 在解析时间字段时不会自动猜测。规则中必须显式写time_format或timezone例如time_format %Y-%m-%d %H:%M:%S timezone Asia/Shanghai有一个很隐蔽的坑如果原始日志本身没有时区信息但服务器运行在 UTC而你的告警脚本运行在本地时区那么统计结果会差 8 个小时。REA 的解决办法是所有解析后的时间统一转换成带时区的 ISO 8601 格式存到ts字段要不要转回本地时区由输出模块决定。这样数据内部保持一致不会因为消费方不同而各算各的。5.5 中文乱码与超长行截断业务日志不一定是 UTF-8 编码尤其是老系统对接 Windows 服务时经常碰到 GBK 或 GB18030。REA 在follow()里带了一个errorsreplace参数读到的非法字节会替换成特殊符号而不是让程序崩溃。规则文件里可以配置input_encoding gbk读日志时用对应编码解码。超长行截断则要小心别把一条完整的 JSON 日志拦腰斩断。如果业务日志本身就是一个很长的 JSON建议先把max_line_bytes调大一些并让截断逻辑只在行首出现规则命中标记时才生效。简单来说REA 的截断策略是“先看行首再决定是否长线处理”避免所有长行都无脑截断。5.6 调试技巧--explain 模式正则写多了一定会遇到“为什么这条日志没命中”的问题。REA 提供了一个--explain参数运行后会对输入的每条样例日志输出一个命中诊断表内容包括每条规则是否尝试、快速预筛是否通过、完整正则是否匹配、耗时多少毫秒。看这个表比盲改正则高效得多。比如有段时间我发现访问日志的正则明明能命中样例但采集结果总是不对。用--explain一查原来样例末尾带了一个看不见的\r回车符正则里没加\s*?结尾导致匹配失败。这种字符问题靠肉眼几乎不可能发现但结构化输出能立刻定位。6. REA 的边界与我的使用心得6.1 什么时候不该用 REAREA 是一个轻量工具它不负责存储和长期归档。如果你需要保留三个月以上的日志、做复杂的关联查询、或者有多个团队共用一套采集平台那还是应该考虑完整的日志系统。我在项目文档里明确写了一句话“REA 是日志消费的入口不是日志平台的全部。”承认工具的边界才能把它用在最合适的位置。另外REA 的单机模式不适合做高可用。如果监控进程挂了offset 可能停留在旧位置重启后虽然不会丢数据但会有一段采集空窗期。对于非核心场景这个缺点可以接受对于核心交易链路我建议还是用成熟的采集方案。6.2 后续扩展插件化正则适合解决 80% 的日志解析剩下 20% 需要做语义判断或调用外部数据。REA 在设计时留了一个插件接口规则里可以声明type plugin指定一个 Python 函数名该函数接收到原始行和已有字段可以返回修改后的字段集。这样就能在解析阶段调用本地的算法函数做简单的敏感信息识别或调用其他 API 做增强。插件接口我至今只用了两个场景一个是从 URL 里剥离查询参数另一个是识别容器日志中的自动重启标记。插件化真正的价值不是增加功能而是让核心代码保持精简把“非通用逻辑”挡在规则层之外。6.3 一点个人体会从这个项目里收获最大的不是代码量而是对“日志处理”这件事的重新认识。以前总觉得日志分析要基于大数据平台后来发现自己大部分需求不过是“读懂特定格式、抽字段、做阈值判断”。一个百来行的小工具反而让这些事变得顺手。如果你也想做类似的东西我的建议是先不要急着写代码花一天时间把自己真正的日志需求列出来看看其中有多少是正则能解决的。REA 这个名字也许并不出名但它证明了一个贴合真实使用节奏的小工具比一套复杂系统更能坚持用下去。