在一个 38 万文件的 monorepo 里找一行调用,grep -r "legacyClient" . 在我机器上跑了 92 秒还没结束,中途还刷了 4 万行来自 node_modules 的命中。换成 rg 之后,同样的查询 0.4 秒出结果,一条噪音都没有。下面这几件事就是差距的来源:让工具只扫该扫的文件、把结果安全地交给下一个命令、以及知道多行匹配和 PCRE2 什么时候会把你机器拖死。
先分清 fd 和 rg 各自管什么
这两个工具经常被当成同一类东西,实际职责完全不同:
| fd | rg | |
|---|---|---|
| 找什么 | 文件名 / 路径 | 文件内容 |
| 目录遍历 | 并行,默认每核一个线程 | 单线程遍历 + 多线程搜索 |
| 忽略规则 | 遵守 .gitignore/.ignore | 遵守 .gitignore/.ignore/.rgignore |
| 输出 | 路径 | 匹配行 |
| 独有能力 | -e/-E/-x/-X/--changed-within/--size |
正则引擎、-U 多行、--json、--pre |
所以 90% 的场景你要的是直接 rg pattern,而不是 fd | rg。原因不复杂:rg 自己会遍历目录,你让 fd 遍历一遍、把几万个路径序列化到管道、再让 rg 重新打开这些文件,等于把遍历做了两遍。
# 反模式:多一次进程间传递,还受 ARG_MAX 影响(xargs 会自动分批,
# 于是 rg 被反复启动 N 次,每次都要重建一次正则和忽略规则)
fd -e ts | xargs rg -n 'useLegacyClient'
# 更糟的版本:文件名里有空格或中文时,xargs 会把一个路径拆成两个参数
# 结果就是 rg 报 "No such file or directory",而你以为没搜到
# 正确:让 rg 自己遍历,用 glob 限定范围
rg -n -g '*.ts' -g '*.tsx' 'useLegacyClient'
# 类型列表可以自定义,避免每次写一堆 -g(也可以写进 ripgreprc,见最后一节)
rg --type-add 'webts:*.{ts,tsx}' -t webts 'useLegacyClient'
fd 真正值得单独用的,是它的筛选维度和执行能力:
# 只找最近两天改过的 Go 文件,交给 gofmt 并行格式化(每核一个进程)
fd -e go --changed-within 2d -x gofmt -w {}
# 一次性批量执行,fd 会自己处理 ARG_MAX 分批
fd -e proto -X protoc --go_out=.
# 排除规则复杂时,fd 的 -E 比 rg 的 -g 更好读
fd -E '*.gen.ts' -E 'dist/**' -e ts
忽略规则:-u、-uu、-uuu 是三个完全不同的开关
这是最容易踩的坑。很多人搜不到文件时的第一反应是加 -uuu,然后性能直接崩掉一个数量级。
三个等级的实际含义:
-u=--no-ignore,不读 .gitignore / .ignore / .rgignore-uu=-u+--hidden,连隐藏文件也搜-uuu=-uu+--binary,连二进制文件也搜(通常你并不想要,它会把.git/objects里的压缩数据也读一遍)
# 错误写法:为了搜到 .github/workflows 里的内容,直接上 -uuu
# 后果:node_modules、dist、.git、*.map 全部进来,
# 扫描文件数从 1.4 万涨到 210 万
rg -uuu 'legacyClient'
# 正确写法一:只打开隐藏文件,保留全部忽略规则
rg --hidden 'legacyClient'
# 正确写法二:精确打开某个被忽略的目录,其余照旧
rg --no-ignore-vcs -g '.github/**' 'legacyClient'
-g 本身也是个小坑。ripgrep 的 glob 规则是:不含 / 的 glob 匹配文件名的任意部分,含 / 的 glob 相对搜索根目录。所以 -g '*.ts' 能匹配 src/a/b.ts,而 -g 'src/*.ts' 只匹配 src/ 下一层。
再看一个更隐蔽的边界条件。仓库 .gitignore 里写了 dist/,但你想搜构建产物里的某个配置文件,于是写了个取反规则:
# .rgignore —— 优先级高于 .ignore 和 .gitignore,且不会影响 git 本身的行为
!dist/config.ts
这条规则不会生效。gitignore 语义里,如果父目录被排除,子文件无法用 ! 重新包含回来。必须先把目录放回来,再排除目录内容,最后单独放回目标文件:
# .rgignore
!dist/
dist/*
!dist/config.ts
规则优先级从高到低是:.rgignore > .ignore > .gitignore > 全局 gitignore。所以想临时改搜索行为又不想动 .gitignore(避免影响别人和 CI),用 .rgignore 是对的。
把结果安全地喂给下一个命令
rg -l 输出文件列表时,只有配合 -0(等价 --null)才是安全的,默认是按行输出,遇到文件名带换行直接完蛋。
# 错误写法:没加 -0,文件名含空格时分隔就断了;
# macO 上 sed -i 还需要带备份后缀,这条命令在 macO 上会直接报错
rg -l 'legacyClient' | xargs sed -i 's/legacyClient/newClient/g'
# 正确写法:null 分隔 + 每个文件单独一次调用 + 空输入不执行
rg -l -0 'legacyClient' | xargs -0 -r -n1 sed -i.bak 's/legacyClient/newClient/g'
# 需要并行时(GNU xargs),注意 sed -i 本身不是并发安全的,
# 只在每个进程处理不同文件时才安全
rg -l -0 'legacyClient' | xargs -0 -r -n1 -P "$(nproc)" sed -i 's/legacyClient/newClient/g'
-r(no-run-if-empty)是 GNU xargs 的扩展,macOS 自带的 BSD xargs 没有这个参数。BSD xargs 在无输入时默认不执行,所以脚本跨平台时要么判断 uname,要么统一用 grep -z 之类的替代方案。
如果下游要的是结构化数据而不是原始文本,用 --json 比解析 -l 稳得多。它每行输出一个 JSON 对象,type 字段区分 begin/match/context/end/summary:
# 生成一份可以直接塞进 CI artifact 的调用点报告
rg --json \
-g '*.ts' -g '*.tsx' \
-g '!**/*.gen.ts' \
-g '!**/__snapshots__/**' \
'legacyClient\.(get|post)\(' \
| jq -r 'select(.type=="match") | [.data.path.text, .data.line_number, (.data.lines.text|rtrimstr("\n"))] | @tsv'
一个提醒:--json 会关掉颜色和大部分格式化输出,但 -A/-B/-C 仍然有效,并且上下文行也会以 context 类型的 JSON 对象出现,jq 里必须显式过滤 type,否则会把上下文行当成命中统计进去。
多行匹配:-U 什么时候会吃光内存
找跨行的写法是 -U,配合 --multiline-dotall 让 . 匹配换行,或者直接用 [\s\S]:
# 找 JSX 里跨行的 LegacyProvider,后面 200 字符内出现 endpoint=
rg -U -P '<LegacyProvider[\s\S]{0,200}?endpoint=' -g '*.tsx'
这里 -P 是必须的:默认的 Rust regex 引擎不支持惰性量词的完整语义。等等——惰性量词 Rust regex 是支持的,-P 真正不可替代的地方是 lookaround 和反向引用。上面的例子里去掉 -P 也能跑,但换成 (?<=endpoint=)"\w+" 这种就必须开 PCRE2。
三个坑,按危险程度排序:
内存。-U 会把整个文件读进内存做多行匹配,而不是流式扫描。仓库里如果藏着一个 78MB 的单行 sourcemap 或者一行 20MB 的压缩 JSON fixture,你的 rg 进程 RSS 会从十几 MB 直接冲到 GB 级。我遇到过一次,排查了半天才发现是 __fixtures__/ 下一个自动生成的 *.json。解决办法是加 -g '!*.map' -g '!**/fixtures/**',或者干脆先 rg -l 缩范围,再对少量文件开 -U。
回溯。-P 打开的是有回溯的正则引擎。像 (\w+\s*)+= 这种嵌套量词,在一条 5000 字符的长行上会指数级爆炸。默认引擎(Rust regex)没有回溯,永远不会因为正则写法而卡死,代价是不支持 lookaround——这才是选 -P 时真正付出的东西。
点号贪婪。--multiline-dotall 让 . 匹配换行后,A.*B 会从第一个 A 一路吃到文件末尾的最后一个 B。写跨行正则时养成加 ? 和非贪婪边界的习惯。
精确度和配置:几个改一次受益很久的习惯
搜固定字符串用 -F。搜 a.b( 时,正则里的 . 匹配任意字符,aXb( 也会命中。搜带 (、[、$ 的字面量时,-F 既避免误报也更快。
加 -w 词边界。搜 get 会命中 target、widget、forget。rg -w get 只匹配独立的 get。
搞清 smart case。rg 默认开启 smart case:搜索词全小写时忽略大小写。所以 rg index 会同时命中 Index 和 INDEX。要确定性行为就显式写 -s(区分)或 -S(忽略)。
用 --stats 诊断慢在哪。它会打印扫描文件数、命中行数、耗时。如果发现扫描文件数远超预期,问题一定在忽略规则上,而不是正则性能。
线程数不是越多越好。-j 默认等于 CPU 核数。在网络盘或者机械盘上,多线程会加剧随机 IO 竞争,-j 4 往往比 -j 16 快。
搜压缩日志用 --pre:
# rg 会为每个 .gz 文件 fork 一次 zcat,文件多时进程创建开销明显
rg --pre 'zcat' --pre-glob '*.gz' 'ERROR' /var/log/app/
注意 --pre 会禁用 mmap,且每个文件启动一次外部进程。几千个日志文件时,它的瓶颈在 fork 而不是搜索。
把这些固化下来,用环境变量指向配置文件:
# ~/.ripgreprc,配合 export RIPGREP_CONFIG_PATH=~/.ripgreprc 使用
--smart-case
--hidden
--glob=!.git/
--max-columns=300
--max-columns-preview
最后一行的代价要说清楚:配置文件对所有 rg 调用生效,包括你脚本里那些依赖精确行为的调用。--hidden 会让 CI 脚本扫到 .env、.npmrc 这类文件;--max-columns 会截断长行输出,--json 解析时虽然不受影响,但人肉看结果时容易漏掉行尾内容。如果脚本对行为敏感,用 rg --no-config 显式跳过配置。
顺带放一组本机实测数据,环境是 i7-12700、NVMe SSD、仓库 38.2 万文件(含 node_modules)、warm cache:
| 命令 | 扫描文件数 | 耗时 |
|---|---|---|
grep -rn 'legacyClient' . |
38.2 万 | 92 s |
rg 'legacyClient' |
1.4 万 | 0.38 s |
rg -uuu 'legacyClient' |
210 万 | 9.8 s |
fd -e ts | xargs rg 'legacyClient' |
1.4 万 | 1.1 s |
差距的主因不是正则引擎快多少,而是第一行多扫了 27 倍的文件。
这套组合也有不该用的时候。搜一个你已经在编辑器里打开的文件,用编辑器内置搜索比切终端快;搜千万级文件的超大仓库,先建索引(zoekt、livegrep 这类)再查,rg 的线性扫描会碰到 IO 天花板;搜二进制或归档格式,-z 只能处理 rg 认识的压缩类型,其他格式老老实实先解压。工具的边界清楚了,剩下的就是肌肉记忆。