从 grep 到 ripgrep:终端代码搜索的演进与权衡
在 Unix 哲学里,grep 是那种几乎不需要解释的积木。它的名字源自 ed 文本编辑器里的命令 g/re/p(global regular expression print),设计目的很单纯:接收输入流或文件,逐行进行正则表达式匹配,然后输出命中的内容。半个世纪以来,无论是排查系统日志、在管道中过滤进程列表,还是在 Shell 脚本中做格式校验,grep 一直在那里。
但如果你在一个现代的前端或后端工程根目录下运行过递归搜索,很可能遇到过相似的尴尬场景:
grep -rn "getUserProfile" .终端在卡顿了几秒甚至几十秒后,倾泻出数万行来自 node_modules、打包产物 dist/、或者 .git/ 内部打包对象的二进制碎片。为了得到真正想要的那两行业务代码,你不得不追加冗长的过滤参数:
grep -rn --exclude-dir={node_modules,.git,dist} "getUserProfile" src/这就是开发体验出现断层的地方。grep 诞生在一个文件组织相对扁平、文本即一切的年代;而今天我们的开发环境充满了庞大的依赖树、构建缓存、版本控制元数据和深层目录。
正是在这一背景下,出现了一系列专为代码仓库打造的搜索工具。从 ack、The Silver Searcher(ag),再到由 Andrew Gallant(BurntSushi)用 Rust 编写的 ripgrep(命令名 rg),终端代码搜索的默认行为与性能边界被重新定义。
默认行为的转变:尊重工程约定
很多人初次接触 ripgrep 时,最直观的震撼是“它怎么这么快”。但只要仔细拆解日常场景就会发现,它快的第一原因往往不是算法有多神化,而是它默认跳过了那 90% 你根本不想搜的内容。
在任何 Git 仓库中,执行:
rg "getUserProfile"你不需要手动加上 -r(递归),也不需要手动告诉它别搜构建目录或依赖。ripgrep 默认具备以下几项开箱即用的行为:
- 自动递归:不带文件路径参数时,默认从当前目录开始递归向下扫描。
- 遵守忽略规则:原生解析
.gitignore、.ignore和.rgignore,自动避开项目显式忽略的文件与文件夹。 - 跳过隐藏与二进制文件:默认略过以
.开头的隐藏文件(例如.git/)以及检测出的二进制文件。 - 终端友好:在交互式终端中默认启用高亮、文件名分组并附带行号;重定向至管道时则自动退回为纯文本单行格式。
这并不意味着 ripgrep 把内容藏起来了。它通过设计紧凑的 -u(unrestricted)参数层级提供了清晰的逃生通道:
rg pattern:默认过滤(跳过 ignore 规则、跳过隐藏文件、跳过二进制)。rg -u pattern:搜索隐藏文件(但仍遵守 ignore 规则)。rg -uu pattern:搜索隐藏文件并忽略 ignore 规则(即搜遍几乎所有文本文件)。rg -uuu pattern:搜索所有内容,包括将二进制文件视作文本一并搜索。
这种分级把“程序员在仓库里的直觉”做成了默认行为,同时把“系统级的穷举搜索”留为可组合的选项。相比每次在 grep 后面手动拼接 --exclude-dir,这种工效学的改善在日常开发中节省了大量注意力。
速度从何而来:不仅是多线程
即便在排除了 .gitignore 和庞大依赖之后,在纯文本的大规模匹配上,ripgrep 依然展现出了极高的吞吐能力。这得益于它在系统底层与文本处理架构上的几项关键设计:
1. 投机式的多线程目录遍历
传统的递归工具(包括简单的脚本包装)往往采用单线程遍历目录,或者在找到文件列表后再批量喂给搜索进程。ripgrep 实现了一个高度并发的目录遍历库(ignore crate),多个工作线程一边并发遍历文件系统树、评估复杂的 gitignore 规则,一边直接将准备好的文件描述符递交给搜索管道,让磁盘 I/O 调度和 CPU 解码保持高度重叠。
2. 正则引擎与 SIMD 字符跳跃
在单文件内容匹配阶段,真正的开销并不总是完整的正则表达式状态机演算。许多查询虽然写成了正则表达式,但其内部包含固定的字面量前缀或子串(例如 fn handle_[a-z]+ 中的 handle_)。
ripgrep 所依托的 Rust regex 和 aho-corasick 底层,利用了 SIMD(单指令多数据流)向量指令(如 AVX2、NEON)。它能在一个 CPU 周期内扫描数十个字节,极快地在内存块中寻找可能的字面量候选点,只有命中候选点时才触发更昂贵的状态机转移。配合内建的行换行符(\n)快速扫描,使得整行文本的吞吐速率非常接近内存带宽上限。
3. 正确且快速的 Unicode 处理
GNU grep 在设置了 UTF-8 语言环境(如 LC_ALL=en_US.UTF-8)时,性能经常会出现明显下滑,部分原因在于严苛的字符边界验证逻辑;而如果退回 LC_ALL=C,又会丢失对多字节字符和 Unicode 大小写折叠的正确支持。
ripgrep 从诞生之初就把 UTF-8 作为一等公民。它的正则引擎在字节层面直接构建匹配 UTF-8 编码序列的自动机,既保证了完整的 Unicode 属性支持,又避免了反复在字符(Char)与原始字节之间做昂贵的编解码转换。
常用命令对照
如果习惯了 grep 的传统参数,迁移到 ripgrep 几乎没有心智负担,但也有少量语法差异值得留意:
| 意图 | grep | ripgrep (rg) | 说明 |
|---|---|---|---|
| 基础递归搜索 | grep -rn "foo" . | rg "foo" | rg 默认递归并展示行号 |
| 忽略大小写 | grep -ri "foo" . | rg -i "foo" 或 rg -S "foo" | -S(smart case)在全小写时忽略,有大写时区分 |
| 全词匹配 | grep -rw "foo" . | rg -w "foo" | 语义一致,匹配单词边界 \b |
| 反向匹配 | grep -rv "foo" . | rg -v "foo" | 打印不包含模式的行 |
| 仅列出文件名 | grep -rl "foo" . | rg -l "foo" | 仅输出有匹配的文件路径 |
| 指定文件类型 | grep -rn --include="*.ts" "foo" | rg -t ts "foo" | rg 内置了常见类型预设,也可以用 -g "*.ts" 通配 |
| 跨多行匹配 | 难(需借助 pcregrep 或 tr) | rg -U --multiline "foo\nbar" | rg 原生支持跨行模式 |
| 统计匹配行数 | grep -rc "foo" . | rg -c "foo" | 输出各文件的匹配计数 |
一个特别好用的细节是 -t / -T(type 过滤)。不需要手写长通配符,rg -t rust "fn main" 就会自动涵盖 *.rs 文件;rg -T test "config" 则会排除测试相关的文件。你可以通过 rg --type-list 查看它支持的数十种预置语言规则。
什么时候依然需要 grep?
既然 ripgrep 在代码仓库和性能上有如此多优势,grep 会被完全淘汰吗?答案显然是否定的。
第一是普遍存在性(Ubiquity)。 无论你登录到一台最小化安装的 Alpine Linux 容器、嵌入式路由器、旧款 FreeBSD 机器,还是刚刚初始化的云主机,grep 永远都在那里,属于 POSIX 标准的核心组件。在编写通用的基础设施脚本、CI 镜像初始化逻辑或跨平台分发工具时,依赖 grep 意味着零额外依赖。
第二是单纯的流式处理与管道哲学。 当输入来源不是文件夹,而是管道重定向过来的纯文本流时:
docker logs api-server | grep -E "ERROR|WARN"这种场景下没有文件树需要遍历,没有 .gitignore 需要计算,也没有多核并行分治的发挥空间。grep 的极简与轻巧依然是管道中极其可靠的选择。
第三是高级正则表达式方言。
默认情况下,ripgrep 使用基于有限状态自动机(DFA/NFA)的正则引擎,能够保证最坏情况下的线性时间复杂度 $O(mn)$,从而避免正则表达式的灾难性回溯。但也正因如此,它默认不支持环视(lookaround)和反向引用(backreference)。虽然 ripgrep 提供了 -P(--pcre2)选项来切换到 PCRE2 引擎,但如果原本就有复杂的 GNU 正则或 Perl 正则脚本,直接使用熟悉的环境往往更稳妥。
小结
从 grep 到 ripgrep,并不是一个简单的“新语言重写旧工具”的故事。
它代表的是命令行工具在特定垂直领域内的自我进化:grep 坚守着通用文本流过滤的简单与基石地位;而 ripgrep 则精准切中了现代开发者在复杂代码仓库中的真实痛点,通过合理的工程约定、现代并发模型与 SIMD 优化,把“在代码里找东西”这件高频小事做到了极致流畅。
在终端里保留它们两个:写系统运维脚本与简单管道时随手用 grep,而在自己的代码仓库里定位问题时,敲下 rg。

