ripgrep 15.0 是一次安静的大版本:没有新子命令,没有重写引擎,release notes 通篇是 bug 修复和"轻微性能改进"。但把 CHANGELOG 里那三十几个 issue 号点开,会发现三个值得单独讲的修复。它们分别踩在正则引擎、行缓冲读取、glob 解析器三个完全不同的层次上,每一个都是"症状在上层,根因在底层"的典型。

代码全部来自 BurntSushi/ripgrep 的 15.0.0 tag。

一、14 GB 峰值内存:一个 (?:...) 引发的血案

issue #2750 的标题很直白:rg --files --ignore-file ~/.ultimate-gitignore 吃掉 7.1 GiB 内存然后被 OOM killer 干掉。语料只有两个文件和 .git 目录,但 ignore 文件有 16332 行、9298 条规则。

作者 BurntSushi 的定位给出了关键对照组,同一份 ignore 文件、同一个 /dev/null:

$ time rg-13.0.0 --ignore-file .ultimate-gitignore /dev/null
real    0.143
maxmem  49 MB

$ time rg-14.1.0 --ignore-file .ultimate-gitignore /dev/null
real    1.445
maxmem  14238 MB

13 到 14 之间,regex crate 换了引擎。

9298 条 ignore 规则会被 globset 编译成一条巨大的正则,每条规则是一个 (?:...) 非捕获分组,全部用 | 串起来。问题出在 regex-automata 的 PikeVM:它为每个正则预先构造执行所需的状态缓存,而缓存大小与分组数量成正比。ripgrep 的搜索路径几乎从不走 PikeVM,lazy DFA 能处理绝大多数情况,但缓存是在构造时就分配的,不管用不用得上。

修复在 rust-lang/regex#1302,标题 automata: make PikeVM cache initialization lazy,改动只有 +23 -36:

Prior to the advent of regex-automata, the PikeVM would decide how much space it needed at the beginning of every search. In regex-automata, ... we create the caches for each regex engine eagerly. So even if we never call the PikeVM ..., we end up paying for the memory of its cache.

改成懒初始化之后,内存回到 regex 1.7.3 以下的水位。

这里有个值得抄走的教训:性能回归不一定出在热路径上。ripgrep 14 的搜索速度其实更快,慢的是"根本没被调用的代码"按时序付了预分配的钱。我在自己的工具链里也踩过类似的坑,一个 CLI 启动要 300ms,最后查出来是 --help 路径里提前编译了全部子命令的 clap 结构。预分配和懒加载的边界得按"实际调用概率"划,不能按"代码组织方便"划。

二、-A 让 stdin 搜索变成指数级:一次 read 的系统调用差异

issue #3184 报告说 -A(after-context)值越大,rg 越慢。BurntSushi 第一次没复现出来,因为他是拿显式文件路径测的:

$ time rg -c ZQZQZQZQZQ bigger.txt
real 0.645    maxmem 7734 MB

$ time rg -c ZQZQZQZQZQ bigger.txt -A999999
real 0.645    maxmem 7734 MB

换成管道喂 stdin,同样的数据和参数,时间立刻失控:

$ cat bigger.txt | (time rg ZQZQZQZQZQ -A999) | wc -l
real 2.009    maxmem 29 MB

$ cat bigger.txt | (time rg ZQZQZQZQZQ -A9999) | wc -l
real 10.459   maxmem 29 MB

-A 从 999 加到 9999,时间从 2 秒跳到 10.5 秒。管道下内存只有 29 MB,不是内存问题,是 CPU 空转。

真相在 crates/searcher/src/line_buffer.rs 的 fill()。核心缓冲容量是 64 KB(DEFAULT_BUFFER_CAPACITY = 64 * (1 << 10)),修复前每次 fill() 只做一次 read:

loop {
    self.ensure_capacity()?;
    let readlen = rdr.read(self.free_buffer().as_bytes_mut())?;
    if readlen == 0 {
        self.last_lineterm = self.end;
        return Ok(!self.buffer().is_empty());
    }
    let oldend = self.end;
    self.end += readlen;
    ...
    if let Some(i) = newbytes.rfind_byte(self.config.lineterm) {
        self.last_lineterm = oldend + i + 1;
        return Ok(true);
    }
}

读普通文件时,内核乐于一次填满整个 64 KB 缓冲。从管道读时,Linux 上单次 read 往往只返回一小块。同样要把 -A9999 需要的上下文窗口填满,走 stdin 需要的 fill() 调用次数多一个数量级,每次调用还要重新 rfind_byte 扫一遍行尾。

我把这个放大机理单独抽出来跑了一遍(单次 read 上限 64 KB、缓冲上限 256 KB):

requested 64K  in one fill() -> reads = 1
requested 512K in one fill() -> reads = 4
requested 4M   in one fill() -> reads = 4
requested 64M  in one fill() -> reads = 4

reads 是 fill() 的调用次数。-A 越大需要填的字节越多,调用次数线性增长,每次调用又附一次全缓冲扫描,两者相乘就是用户感知到的"指数级变慢"。

两处修复(PR #3185,+22 -11,4 个文件)。第一,fill() 里改成内层 while 循环,一次填满缓冲再返回:

let oldend = self.end;
while !self.free_buffer().is_empty() {
    let readlen = rdr.read(self.free_buffer())?;
    if readlen == 0 { break; }
    self.end += readlen;
}

我用 Python 复刻了这个改动,同样的读取上限下 reads 从按需线性增长变成常数 4 次,填满就停。

第二处在 crates/searcher/src/searcher/core.rs,限制 preceding() 的搜索窗口:

// ... however, we only need to find the N preceding lines based
// on before context. We can skip this (potentially costly, for
// large values of N) step when before_context==0.
let context_start = lines::preceding(
    buf,
    self.config.line_term.as_byte(),
    self.config.before_context,   // 原来是 max_context()
);

原来传 max_context(),也就是 before + after 取大。只用 -A 时 before_context == 0,代码照样去缓冲里倒着找 N 行前面的位置,一个完全无用的 O(N) 扫描。改成只按 before_context 算,-A 场景直接跳过。

顺带说,GNU grep 在 -B 上有同样的毛病,而且更慢。这类上下文窗口回溯的性能陷阱跟语言无关,跟"有没有人认真区分 before 和 after 的语义差异"有关。

实用结论:在管道 + 大 -A 做日志分析的话,15.0 值得升级。但 15.1.0 紧接着修了这次改动带来的 --line-buffered 回归(#3194,输出延迟、tail -f 行为异常),真要用直接上 15.2.0。

三、gitignore 匹配:四个互相独立的错误

ignore crate 这一轮修了 4 个匹配错误,都是"规则该生效却没生效":

issue 症状 根因
#829 / #2731 / #3076 传目录作参数时 .gitignore 不生效 参数路径没走父目录的 ignore 匹配链
#2750 大 gitignore 文件内存爆炸 regex 引擎 eager cache(见第一节)
#3173 . 作参数时,祖先 .ignore 白名单的隐藏文件搜不到 隐藏文件过滤先于白名单求值
#3179 core.excludesfile 里的 /.venv 这类 rooted 模式失效 绝对路径搜索时全局 gitignore 没做根目录归一化

3179 尤其隐蔽,最初是在 helix 编辑器里发现的,因为 helix 的文件选择器直接用 ignore crate 的 WalkBuilder。全局 gitignore 里写 /.venv(只在仓库根忽略),命令行 rg 下工作正常,一换到"给绝对路径"就失效,因为 rooted 模式得先知道根在哪,绝对路径把根信息抹掉了。

同版本还加了个小功能:.jj 目录现在被当作 git 仓库对待(PR #2842)。用 Jujutsu 的人终于不用手动 --no-ignore。

四、glob 支持嵌套花括号

PR #3048(+46 -30)实用性很高。以前想同时选中 node_modules 下的 ts/js 和 crates 下的 rs/toml,得写两条 -g。现在一条就够:

rg --files -g '**/{node_modules/**/*/{ts,js},crates/**/*.{rs,toml}}'

我在本地跑了几组对照,目录里只有 a.rs b.py c.txt sub/d.rs sub/e.py:

$ rg --files -g '*.{rs,py}' .
./a.rs ./b.py ./sub/d.rs ./sub/e.py          # 4 个文件,正确

$ rg --files -g '*.{rs,{py,txt}}' .
./a.rs ./b.py ./c.txt ./sub/d.rs ./sub/e.py  # 三层嵌套,正确

$ rg --files -g '*.{rs,py' .
rg: error parsing glob '*.{rs,py': unclosed alternate group;
    missing '}' (maybe escape '{' with '[{]'?)
                                             # exit 2,报错信息相当友好

最后一条是我最想强调的:glob 的逗号只有落在 {} 里才是分隔符。看 glob.rs 的 parse_comma() 就明白:

fn parse_comma(&mut self) -> Result<(), Error> {
    // If we aren't inside a group alternation, then don't
    // treat commas specially.
    if self.alternates_stack.is_empty() {
        self.push_token(Token::Literal(','))
    } else {
        Ok(self.branches.push(Tokens::default()))
    }
}

嵌套的实现是把"当前分支栈"做成 Vec<Tokens>:{ 压入新分支、} 弹栈合并成 Token::Alternates、, 在当前层切分支。解析完如果 branches.len() > 1,说明有没闭合的 {,直接抛 UnclosedAlternates。结构和手写表达式解析器没有区别,改动量自然小。

五、顺手能用的组合:-r 和 --json 一起用

15 版让 --replace 的替换结果直接进 JSON 的 submatch 对象(#1872)。实测:

$ rg -r 'BAZ' --json foo r.txt
{"type":"match","data":{"path":{"text":"r.txt"},"lines":{"text":"foo\n"},
 "line_number":1,"absolute_offset":0,
 "submatches":[{"match":{"text":"foo"},
                "replacement":{"text":"BAZ"},"start":0,"end":3}]}}

以前要自己按 start/end 切片再拼接,现在引擎替你算好。写代码审计工具、批量重命名脚本的时候,这条能省掉一整段字符串处理逻辑。

我的做法

配置里三件事。

~/.config/ripgrep/config 只放默认行为,不放一次性开关。--type-add 这类要长期生效的才写进去:

--smart-case
--type-add=crack:*.{hash,pot,rule}
--type-add=log:*.{log,out,err}

大 -A 加管道的场景用 --stats 观察是否还在空转。bytes_searched 和 elapsed 的比例异常(只搜了 2 MB 却花了 10 秒),基本就是缓冲没填满在反复扫描。

第三件事是这次读源码最大的收获。ripgrep 的整个 15.0 大版本几乎没有用户可见的新功能,全部产出是把上一版埋下的坑填掉。一个维护了十几年的工具还在用这种密度修 bug,这比任何新特性都说明问题。工具的可靠性不是发布时声明的,是 issue 号一条条攒出来的。