grep -o 在一行 160 万字符的文件上跑了 14 秒:一次正则回溯排查
线上排查时遇到过一次很反直觉的性能问题:同样一个 1.9MB 的压缩 JS 文件,grep -c 秒回,grep -bo 也是毫秒级,换成 grep -oE '.{0,40}关键字.{0,40}' 却要等十几秒才出第一行。机器不慢,磁盘不慢,慢的是正则本身。
现象
目标是从一份构建产物里把某个字符串前后的上下文捞出来。文件 1.9MB,只有两行——压缩后的 Next.js chunk 就是这样,一行一百六十万字符,另一行三十万。
命令是常见的那一套:
grep -oE '.{0,40}TARGETKEYWORD.{0,40}' bundle.js
结果是光标闪了十几秒,什么都没打印。第一反应是 IO 或者内存,但同一个文件用别的命令都很快,问题不在文件。
把变量拆开
为了排除干扰,我构造了一个可复现的最小样本:一个 1.9MB 的文件,一行 160 万字符,关键字埋在偏移 120 万的位置。然后逐项对比。
| 命令 | 耗时 |
|---|---|
grep -oE '.{0,40}KW.{0,40}' 输出到文件 |
14285 ms |
同上,输出到 /dev/null |
24 ms |
LC_ALL=C grep -oE '.{0,40}KW.{0,40}' |
524 ms |
rg -o '.{0,40}KW.{0,40}' |
21 ms |
grep -boE 'KW' |
17 ms |
grep -oE 'KW'(不带窗口) |
16 ms |
| 同样 1.9MB 但切成 3.6 万短行,带窗口 | 13 ms |
七组数据把三个变量摘了出来。
行长是第一位的。 文件总大小一样,切成短行之后 13ms 就出来了,比原来快三个数量级。也就是说代价跟「单行长度」有关,跟文件大小基本无关。
-o 的窗口是第二个。 同一个长行,去掉 .{0,40} 只要 16ms。窗口让匹配的起点可以在 40 个不同位置上,而 -o 要求输出的是完整匹配文本,不是「这一行命中了」这种布尔值。
locale 是第三个。 加 LC_ALL=C 从 14 秒降到 524ms。
最反直觉的一条:输出到 /dev/null 就不慢
这组数据里最值得说的是第二行。同一条命令,唯一的区别是把 stdout 接到 /dev/null,耗时从 14285ms 掉到 24ms,快了将近 600 倍。
原因不难理解:结果没人要的时候,算匹配边界这件事就没有意义。-o 的昂贵部分恰恰在于「这个匹配到底从哪开始、到哪结束」,而不是「这一行有没有命中」。命中判定由 DFA 线性扫一遍就够,边界计算则要围绕每个候选起点反复回退试探。
这个特性有个副作用:用 >/dev/null 来测速会得出完全相反的结论。我曾经在压测里加过 >/dev/null,六秒多的命令看起来只要 45 秒之外的另一个数量级,结论直接翻转。测 grep 性能时不要为了「不让它刷屏」而丢掉输出,日志落文件再统计才是真的。
根因
-o 的语义是「只输出匹配到的部分」,实现上必须为每一个匹配算出精确的起止位置。.{0,40} 这种有界重复给了匹配 40 个可能的起点,最左最长的语义要求把这 40 个候选都试过才能确定哪一个最长。在一行 160 万字符上,这个「试」的成本随行长线性放大,再乘上窗口宽度。
多字节 locale 又在这上面叠了一层:UTF-8 下每个字节都要参与「是否合法多字节序列起始」的判定,字符类被展开成字节区间,. 也不等于一个字节。这也是为什么 LC_ALL=C 能把时间砍掉 96%——它把匹配退化成纯字节比较。
处理
四条路子,按推荐顺序:
一、换 ripgrep。 rg -o 在这份样本上 21ms,比 GNU grep 快 680 倍。ripgrep 默认按行流式处理,正则引擎对长行也友好,日常检索直接替代 grep 就行。
二、加 LC_ALL=C。 不改命令、不装新工具,524ms 已经可用。前提是关键字是纯 ASCII,涉及中文匹配时不能这么用。
三、用字节偏移再切片。 grep -boE 'KW' 只输出偏移量,17ms 拿到位置,再用 dd 从偏移附近切一段出来看上下文。适合不想引入新依赖、又需要精确位置的场景。
四、先还原源码。 压缩产物有 source map 的话直接还原,没有就先 npx js-beautify 展开再按行 grep。治标但有效,尤其是要反复读同一份产物的时候。
结论
grep -o慢在算匹配边界,不在扫文件;行长和窗口宽度是主要变量- 同一份文件,
grep -c快不代表grep -o也快 LC_ALL=C能砍掉九成以上开销,rg能砍掉三个数量级- 不要用
>/dev/null测 grep 性能,它会跳过最贵的那部分
顺带一条通用经验:所有可能跑很久的命令都套一层 timeout。这次排查里我有一次十分钟没等到输出,进程还挂着占 CPU,加超时之后再出问题至少不会拖住整个会话。
- 点赞
- 收藏
- 关注作者
评论(0)