半夜两点,我对着一个 1.8GB 的访问日志发呆。我要在几十万条请求里,把某次超时链路涉及的所有记录捞出来,按请求 ID 拼成完整时间线,再看哪个环节耗时异常。第一反应是 grep,grep 也确实把行筛出来了,但筛完我还得提取参数、转换时间、按 ID 分组、再做聚合计算——本来一条命令能说清的逻辑,被拆成七八个管道片段,每次复用都要翻旧命令出来改。这种状态持续了大概两周,我彻底忍不了,决定写一个专门干这件事的小工具。它叫 rea。
rea 是我最近业余时间在维护的一个命令行日志文本分析小工具,名字没什么讲究,键盘上顺手敲出的三个字母,含义就当是 Roughly Extract Anything,意思是不管什么格式的日志或文本,先把里面的字段抽出来再说。它的定位很窄:从日志里定位目标行、提取结构化字段、过滤聚合、再以合适的格式输出。适合每天要翻日志、查线上问题、处理半结构化文本的研发和运维朋友用。这篇文章我会把这套工具的来龙去脉、核心设计、实现细节和踩过的坑完整写出来,算是一份从需求到落地的全记录。
1. 为什么放着 grep、awk 不用,偏要自己写 rea
1.1 不是 grep 不行,是把复杂的逻辑拼成命令太累
grep 是日志分析的第一块敲门砖,这点没人反对。但实际排查场景里,很少停留在"找到包含某关键词的行"。更常见的是这样:统计每种返回码的数量、把所有超过 500ms 的请求按接口分组、把某个请求 ID 关联的所有服务日志按时间拼起来。
拿第一种场景举例,传统做法大概是:
grep "HTTP/1.1" access.log | awk '{print $9}' | sort | uniq -c | sort -rn这行命令本身不长,但每一次都要重复造轮子。返回码在第 9 列,这个位置靠数出来的,一旦前面某个字段加了空格,列号全变,命令立刻失效。更麻烦的是,如果我想让"统计返回码分布"这件事变成一个可复用的能力,grep、awk、sort 这串命令没法优雅地封装。写成 shell 函数吧,参数传参、默认值、输出格式都要自己处理,维护成本和直接写个小工具差不了太多。
1.2 awk 的字段心智模型,和日志分析的真实场景有错位
awk 确实强大,但它的核心抽象是"文本行 + 固定分隔符 + 列号"。日志虽然看起来是"空格分隔的若干列",实际却是半结构化文本:有的是key=value,有的是 JSON,有的是[时间] 级别 消息的前缀结构,同一行里还会有带空格的消息体。用列号去描述这些字段,本质上是在跟文本的物理布局搏斗,而不是跟字段语义搏斗。
举个例子,一条常见日志:
2025-02-01 10:00:00.123 INFO request_id=abc123 user_id=88 event=order_created cost_ms=45用 awk 取 request_id 和 cost_ms,得先知道它们在哪个列。而如果哪天日志里插了一个新字段,所有列号全部后移,排查脚本当场报废。我想要的是一个能直接说"我要 request_id 和 cost_ms"的抽象,而不是"第 6 列和第 9 列"。
1.3 重新发明轮子的边界:只做日志分析中最常被重复的那部分
写 rea 之前,我给自己定了一条纪律:只做文本分析领域里最常被重复、又最容易被写错的那部分。具体来说就是四条能力——定位行、提取字段、过滤计算、格式化输出。其他功能全部砍掉,不做图表、不做告警、不做任务调度。
grep 解决的是"哪一行出现了关键词",rea 解决的是"这个关键词所在的行里,结构化的字段到底是什么,怎么参与统计和过滤"。两者的关系不是替代,而是互补。实际上我现在排查问题时,经常是先 grep 缩小范围,再把命中的几十行交给 rea 做字段提取和聚合。
提示:任何自研工具都应该先问一句"我要解决的问题,现有工具的抽象是否匹配"。列号匹配不上字段语义,这就是 rea 出现的合理理由,而不是"我比 grep 强"。
2. rea 的核心设计:把文本分析变成一条四段式流水线
2.1 定位、过滤、提取、输出,四个阶段各干各的事
rea 的命令行使用方式围绕一条流水线展开:先按模板匹配出感兴趣的行,再对提取出的字段做条件过滤,然后决定输出哪些字段、以什么格式展示。四个阶段可以自由组合,也可以只用到其中一部分。
命令形式上,我尽量让它自然:
rea match '{{remote_addr}} - - [{{time_local}}] "{{method}} {{uri}} {{protocol}}" {{status}} {{bytes}}' \ --where 'status > 500' \ --fields method,uri,status,cost_ms \ --out table这句话读起来就像一个简化的 SQL:从日志匹配特定行,筛选状态码大于 500 的记录,取出请求方法、URI、状态码和耗时字段,以表格展示。设计的时候我反复问自己:用户在排查问题时脑子里是怎么想的?大部分是从"长什么样"出发,而不是从"正则怎么写"出发。所以我把"长什么样"直接作为第一配置项。
2.2 模板匹配优先于正则:让匹配规则可读、可维护
rea 的核心创新点不是新的正则引擎,而是把正则包装成"模板"。用户不用写\S+、[\d.:/-]+这种符号,而是用{{字段名}}占位,背后自动生成对应的正则片段。
比如要匹配一条 Nginx 访问日志,我只需要写:
{{remote_addr}} - - [{{time_local}}] "{{method}} {{uri}} {{protocol}}" {{status}} {{bytes}}而不是:
^(\S+) - - \[([^\]]+)\] "(\S+) (\S+) (\S+)" (\d+) (\d+)$前者的字段名本身就是文档,后者如果不加注释,一周之后没人看得懂。我把这个思路称为"用样例说话":找一条真实日志,把可变部分换成语义占位符,这就是规则。
实现上,每个字段会根据名字和可选的类型推导出正则片段:默认类型是"非空白连续串",对应\S+;声明了{{time_local:datetime}}就对应一个宽松的时间戳匹配;{{body:quoted}}对应带引号的字符串。这样既能保留正则的威力,又让 90% 的场景不需要手写正则。
2.3 流式处理:日志多大,内存占用都稳在一个低水位
日志文件动辄几个 GB,第一版的原型我曾经图省事直接readFileSync,结果跑一个 3GB 文件直接吃满内存,机器卡到鼠标都动不了。后来老老实实改成流式处理。
rea 的处理模型是逐行读入,逐行匹配,逐行输出。读入一行、处理、丢掉这一行的引用,再读下一行。内存在任意时刻只需要保留少量缓冲和当前行的字段对象。在处理一个 5GB 日志时,rea 进程的内存常驻维持在 300MB 以内,大部分还被输出缓冲占据,解析本身的内存开销非常低。这也带来了一个额外好处:rea 可以直接配合tail -f消费实时日志,不用等文件写完。
tail -f app.log | rea match '{{time}} {{level}} msg={{message}}' --fields time,level,message2.4 输入输出做成可插拔:从成堆的文件到结构化数据
第一版 rea 只支持读普通文本文件,后来我很快加了 stdin 支持,因为 tail 管道和日志采集器都要从 stdin 吃数据。再后来发现线上日志经常是.gz压缩状态,于是又加了自动识别 gzip 的逻辑,文件路径以.gz结尾就直接走压缩流解压。这几个输入源对外暴露的接口完全一致,解析逻辑无感知。
输出端则维持三种格式:table 适合人看,json 适合给下游工具消费,csv 适合丢进电子表格或再交给 sort、awk 继续处理。多加一种格式的成本,远低于在页面上手抄数据。
默认输出 json 而不是 table这条是我个人的偏好:排查问题时,人眼读十行表格有用,但一旦数据量超过 50 行,机器可读的中间格式远比对齐的表格有价值。所以我让 json 成为默认输出,想看表格时用--out table显式指定。
3. 从想法到可用:rea 的实现要点与参数取舍
3.1 为什么选 JavaScript 作为实现语言
工具最终是用 JavaScript 写的。原因很实际:运行环境通用,一台机器只要有浏览器生态配套的运行时环境就一定能跑,不需要编译;JSON 是它的原生数据格式,配置解析零成本;它的流式接口写这种逐行处理的逻辑很顺手。
用 Go 或 Rust 重新实现一遍性能当然更好,但对一个日志分析小工具来说性价比不高。rea 的瓶颈在正则匹配和 I/O,而这部分 JavaScript 的底层已经做得很成熟。实践下来,单线程处理 500MB 的日志,耗时大约在一个可接受的范围,对于排查问题来说完全够用。
3.2 模板编译成命名捕获组的核心代码逻辑
既然模板是核心抽象,那模板编译就是最核心的代码模块。逻辑不复杂:遍历模板字符串,找到{{fieldName}}占位符,把它替换成带类型推导的正则片段,同时记录字段名。
function compileTemplate(template) { const fields = []; let pattern = '^'; let rest = template; while (rest.length > 0) { const index = rest.indexOf('{{'); if (index === -1) { pattern += escapeRegExp(rest); break; } pattern += escapeRegExp(rest.slice(0, index)); rest = rest.slice(index + 2); const endIndex = rest.indexOf('}}'); if (endIndex === -1) { throw new Error('模板缺少闭合 }}'); } const token = rest.slice(0, endIndex).trim(); rest = rest.slice(endIndex + 2); let [name, type = 'string'] = token.split(':'); const fieldPattern = getPatternForType(type.trim()); fields.push(name.trim()); pattern += `(?<${name.trim()}>${fieldPattern})`; } return { regex: new RegExp(pattern), fields }; } function getPatternForType(type) { const patterns = { string: '\\S+', quoted: '"([^"]*)"', number: '\\d+', word: '\\w+', datetime: '\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?', ip: '(?:\\d{1,3}\\.){3}\\d{1,3}' }; return patterns[type] || patterns.string; }这段代码刻意保持简单,真正落地时还要处理嵌套占位符、字段名冲突、命名捕获组在旧环境不可用等问题。核心思想是:把用户友好的模板概念,编译成引擎友好的正则表达,一次编译多次匹配,性能开销只在启动阶段。
3.3 性能优化的几个心得:回溯、锚定和尽量少吃字符
rea 的性能优化踩过几个实实在在的坑,分享出来比结论更有价值。
第一个坑是回溯。只要模板里有任意字段用了过于宽泛的匹配规则,比如{{anything:.*}}去匹配整行剩余内容,遇到没有匹配的行时,正则会尝试无数种切分方式,最坏情况下能把 CPU 跑满。我在设计默认字段类型时,故意让string类型映射到\S+而不是.*,就是为了避免跨空白匹配带来的回溯爆炸。用户写模板时,如果某个字段后面有明确分隔符,正则引擎很快就能收敛;一旦用.*吃到行尾,失败检测的成本陡增。
第二个坑是不加锚定。编译模板时我强制给正则加上^和$,让整行必须完全匹配。这不仅是语义正确的问题,也是性能问题:没有锚定的话,正则会从行的每一个位置开始尝试匹配,浪费大量无谓扫描。锚定之后,不匹配的行会在极短时间内失败。
第三个坑是字符串切片的时机。早期实现里,我会对匹配的行先按空格 split 一次,再做字段提取,等于多做了一次全量切分。后来改成只用命名捕获组直接定位字段子串,需要哪个字段就取哪个字段,省掉大量中间结果,处理耗时下降了约 30%。
3.4 输出格式的细节,坑比想象的深
JSON 输出看似简单,实际有个字段类型问题。日志里提取出来的字段全是字符串,但用户希望status是数字、cost_ms是数字、time是时间对象。rea 的配置里允许给字段声明类型,输出前做一次类型转换;转换失败时保留原始字符串并给出一条 warning,而不是默默丢弃。
CSV 的坑在转义。字段值里如果包含逗号、引号和换行,不处理就会破坏列结构。我实现了一个标准 CSV 转义函数:字段含逗号、引号、换行时,用双引号包裹,内部的引号再翻倍转义。这个逻辑看起来简单,但少了它,导出的表格在电子表格软件里必然错列。
表格输出也有细节:列宽由该列最大内容长度决定,中文和英文的显示宽度不同,纯靠length计算会不对齐。实际实现里,我用了一个简单的显示宽度函数,把中文字符按双宽度计算,才让表格在终端里像样。
4. 偶遇真实日志时的三个隐藏坑
4.1 文件编码:BOM、GBK 与 UTF-8 混在一堆文件里
开发阶段我用的都是 UTF-8 编码、无 BOM 的干净测试文件,直到某个环境导出的日志给我上了一课。
第一个问题是 BOM。Windows 下生成的日志文件,开头会带一个不可见的\uFEFF字符。rea 读第一行时,模板里的^锚点无法匹配这个隐藏字符,导致第一条日志永远解析失败。这不是大问题,但非常隐蔽,你只会发现整个文件第一条记录神秘消失。修复方案很直接:在流开始阶段读取前三个字节,如果是 BOM 就剥掉。
第二个问题是 GBK。很多老系统的日志默认 GBK 编码,尤其日志里包含中文环境名、中文错误消息时,不处理直接按 UTF-8 解码,输出全是乱码。我的做法是为rea match增加一个--encoding参数,底层交给通用的转码模块处理。纯 ASCII 字段其实不受影响,但消息体这种自由文本就依赖编码正确。
提示:处理外部日志前,先花十秒确认文件编码。或者干脆在命令里显式加
--encoding utf8/--encoding gbk,避免环境不同导致的意外差异。
4.2 多行日志:真正难的不是字段,是拼接规则
日志领域最经典的坑之一就是多行日志。一个 Java 异常堆栈可以跨十几行,每行开头是空格和at关键字;一个多行 JSON 日志中间会夹杂大括号嵌套。如果只按行匹配,规则永远匹配不上"一条完整日志"。
rea 引入了一个很朴素的拼接策略:--join-mode。默认是line,按行处理。处理异常堆栈时切换到stack模式,它的判断逻辑是:如果当前行以若干空格和at开头,就把它作为上一行日志的附加内容,不做匹配、不额外输出,只在需要时把完整原文暴露给输出端。
实现这个逻辑要注意一个细节:拼接不能无限往内存里堆,必须给附加行数量设上限,比如最多拼接 200 行,超过就强制切开,防止个别异常日志拖垮整体内存。
4.3 字段漂移:不同日志源的格式,比想象中碎
你以为同一类日志的格式是固定的,直到你把 rea 从一个环境挪到另一个环境。某些服务日志字段名是下划线命名,某些是驼峰命名;某些环境多一个thread_id字段,某些环境整个字段缺失。如果规则写死thread_id,另一环境匹配直接失败。
我在配置里引入了两个解决机制。第一个是字段别名:
rules: - name: app_log match: '{{time}} {{level}} {{msg:body}}' aliases: thread: thread_id defaults: thread_id: '-'第二个是"未匹配行不留死角":rea 默认把匹配失败的行单独写入--unmatched文件,而不是直接丢弃。这样字段漂移发生时,你能第一时间从 unmatched 文件里看到新格式,而不是在数据分析结果里发现某类数据凭空变少。
5. rea 现在的真实用法:命令速查与工作流衔接
5.1 最常用的几条命令
我这几个月用下来,高频命令基本就这五类,列成表格方便直接抄:
| 场景 | 命令 | 说明 |
|---|---|---|
| 提取指定字段 | rea match '{{time}} {{level}} msg={{msg}}' --fields time,level,msg --out table | 从日志里抽出关键字段 |
| 条件过滤 | rea match '{{t}} cost={{cost}}' --where 'cost > 1000' --out json | 筛选耗时超阈值记录 |
| 分组统计 | rea match '{{t}} url={{url}} status={{status}}' --fields url --count | 统计接口访问次数 |
| 实时追踪 | `tail -f app.log | rea match 'err={{message}}' --fields message` |
| 排查未匹配 | rea match '...' --unmatched unmatched.log | 观察日志格式漂移 |
5.2 和其他命令联合作战
rea 从不打算替代整个 Unix 工具箱,它产出的是中间格式,下游用什么都行。我最喜欢的组合是把 filter 和 sort 交给系统命令,rea 专注做"格式解析"这一件小事。
# 按耗时从高到低排列超时请求 rea match '{{time}} cost={{cost}} api={{path}}' --where 'cost > 500' --out csv \ | sort -t, -k2 -rn # 从 1GB 日志中筛出错误级别记录,按接口聚合 rea match '{{date}} {{level}} api={{path}}' --where 'level == ERROR' --fields path --count另一个实用技巧是把 rea 的输出格式改为编辑器能识别的定位格式:
rea match '{{time}} {{level}} file={{file}}:{{line}} {{msg}}' \ --fields file,line,msg \ --out template --template '{file}:{line}: {msg}'这样输出的每一行都带上文件路径和行号,在大多数编辑器里可以直接跳转到对应源码位置。这个功能是我在排查一个告警脚本时临时加上的,后来成了我最常用的隐藏功能。
5.3 下一步想扩展的方向
rea 现在最缺的是多规则联合匹配。真实排查场景里,访问日志、业务日志、错误日志往往分散在不同文件,如果能把多个规则包组织成一次任务,一次输入、多条输出,会更贴近实际工作流。
另一个方向是聚合计算的增强。目前只有计数,我想加入avg、sum、p50、p90这类简单统计,配合--group-by字段,让"查询日志分布"这个高频需求一条命令完成。到时候最常用的命令大概会变成这样:
rea match '{{time}} api={{path}} cost={{cost}}' \ --group-by path \ --agg avg(cost),p90(cost),count当然这只是规划,具体什么时候写还得看下次排查看日志的时候有多痛。
我在实际使用 rea 的过程中,最深的体会是一个工具能长期活下去,靠的往往不是功能多,而是边界清楚。rea 从头到尾只做"把文本中的字段结构化"这一件事,所以它体量小、易维护、敢重构。也许你不需要照抄 rea 的任何代码,但如果能从这篇文章里带走一点东西,我的建议是:如果某个临时脚本被你反复复制粘贴超过三次,就把它从一个"一次性命令"升级成"一个有名字的工具",给它一个明确的输入输出契约,而不是继续往 shell 历史里堆一摞谁也看不懂的长命令。