Ruby 中更快(或最快)的正则表达式引擎
Fast(er) regular expression engines in Ruby
本文对替代的正则表达式引擎进行了性能比较,这些引擎可能会(也可能不会)加速你的 Ruby 代码。
Dmytro Horoshko
2025年5月1日 • 16 分钟阅读
目录:
- Introduction
- Contenders 1. re2 2. rust/regex 3. pcre2
- Benchmarks 1. Literal 2. Literal with alternation 3. Date 4. Cloudflare ReDoS 5. Words 6. Bounded repeat 7. Noseyparker
- Engine limitations
- Conclusions
Introduction
在现代、过度设计和过度混淆的网站中,我们在 SerpApi 面临着从这些网站提取数据日益严峻的挑战。除了通常的 HTML 解析之外,有时我们实际上被迫退回到优秀的旧式正则表达式,例如,用于提取嵌入的 JS 数据。虽然正则表达式可以解决问题,但它们可能会带来成本。
Onigmo 是 Ruby 中默认的正则表达式引擎,虽然在 Ruby 3.2 中进行了大量更新,但仍然存在弱点,这可能会严重影响扫描时间,从而增加我们的搜索请求的延迟。
让我们找出目前有哪些替代方案,以及它们与 Ruby 相比如何。
Contenders
re2
它由 Google 开发,并且广泛使用于各种 Google 产品中。 在底层,它使用他们所谓的“基于 Ken Thompson 的 Plan 9 grep 的即时确定性有限状态自动机算法”。 声明 re2
的设计明确的目标是能够处理来自不受信任源的正则表达式,即能够抵御 ReDoS 攻击。 有维护良好的 Ruby 绑定 gem。
rust/regex
Rust 中的 原生 正则表达式引擎。 根据 rebar,它是总体上最快的引擎之一,并且它使用与 re2
相同的在搜索期间构建 DFA 的方法。 没有最新的、随时可用的 Ruby 绑定,因此我为此比较创建了一个简单的 PoC。
pcre2
由于在许多商业和开源产品中以及在 PHP 和 R 等语言中(它被用作默认引擎)的广泛采用,它是最知名的正则表达式引擎之一。 它支持单独的 JIT 模式,可在大多数情况下显着提高搜索时间。 遗憾的是,Ruby 绑定已过时且无法正常工作。 例如,上述的 JIT 无法使用最新的二进制文件启用,这使得该引擎不值得比较。
Benchmarks
此处呈现的基准测试是 rebar 变体。 具体来说,那些已使用 count 和 count-spans 模型验证的变体。
以下结果是使用以下方式收集的:
ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [x86_64-linux]
re2 (2.15.0)
rust_regexp (0.1.2)
- 具有 4 个 vCPU / 8 GB 的 DigitalOcean CPU 优化 Intel Premium 实例
- Ubuntu 24.04.1 LTS
包含 haystack 数据和原始基准测试结果(包括 macOS / M4 Max 结果)的基准测试脚本可在 GitHub 上找到。
Literal
从 rebar:
这组基准测试测量简单的文字正则表达式模式。 它主要用于演示两件事。 首先是正则表达式引擎是否通过识别模式只是一个文字来进行一些最基本形式的优化,并且可能不需要完整的正则表达式引擎。 事实上,在这种情况下天真地使用正则表达式引擎可能会产生比大多数正则表达式引擎差得多的测量结果。 其次是简单文字搜索的性能如何随大小写不敏感和 Unicode 的变化而变化。 也就是说,在 ASCII 文本上运行良好的子字符串搜索算法不一定也能在包含许多非 ASCII 代码点的 UTF-8 上运行良好。 对于不区分大小写的搜索尤其如此。
使用包含最少数量的德语单词(即带有变音符号的 Unicode 字符)的英语 haystack,rust/regex
处于领先地位,而 re2
和 ruby
并没有相差太远。
-- [literal/sherlock-en]
Calculating -------------------------------------
ruby 2.169k (± 0.8%) i/s (460.97 μs/i) - 10.850k in 5.001876s
re2 2.510k (± 1.5%) i/s (398.37 μs/i) - 12.550k in 5.000721s
rust/regex 12.248k (± 0.4%) i/s (81.65 μs/i) - 61.350k in 5.009252s
Comparison:
rust/regex: 12247.5 i/s
re2: 2510.2 i/s - 4.88x slower
ruby: 2169.3 i/s - 5.65x slower
使用相同的 haystack 但不区分大小写的匹配,ruby
变得明显变慢。
Calculating -------------------------------------
ruby 158.153 (± 1.9%) i/s (6.32 ms/i) - 795.000 in 5.028382s
re2 596.211 (± 0.2%) i/s (1.68 ms/i) - 3.009k in 5.046886s
rust/regex 5.605k (± 0.4%) i/s (178.40 μs/i) - 28.050k in 5.004126s
Comparison:
rust/regex: 5605.5 i/s
re2: 596.2 i/s - 9.40x slower
ruby: 158.2 i/s - 35.44x slower
使用特定于 Unicode 的 haystack,即完全的西里尔文本,re2
突然变得比 ruby
慢。 这种 re2
不“对 Unicode 友好”的趋势将在以后多次遇到。
-- [literal/sherlock-ru]
Calculating -------------------------------------
ruby 1.157k (± 0.8%) i/s (864.47 μs/i) - 5.865k in 5.070396s
re2 288.332 (± 0.3%) i/s (3.47 ms/i) - 1.456k in 5.049813s
rust/regex 6.721k (± 0.5%) i/s (148.79 μs/i) - 34.221k in 5.091769s
Comparison:
rust/regex: 6721.0 i/s
ruby: 1156.8 i/s - 5.81x slower
re2: 288.3 i/s - 23.31x slower
最终,ruby
的糟糕的不区分大小写的性能胜过 re2
的糟糕的 Unicode 性能,因此使用西里尔文本和 i
标志,ruby
再次成为最慢的。
-- [literal/sherlock-casei-ru]
Calculating -------------------------------------
ruby 51.814 (± 0.0%) i/s (19.30 ms/i) - 260.000 in 5.018218s
re2 352.650 (± 0.3%) i/s (2.84 ms/i) - 1.785k in 5.061719s
rust/regex 2.722k (± 0.4%) i/s (367.41 μs/i) - 13.821k in 5.077995s
Comparison:
rust/regex: 2721.8 i/s
re2: 352.6 i/s - 7.72x slower
ruby: 51.8 i/s - 52.53x slower
此基准测试组中的最后一个示例是中国文本,而 re2
排名最后。 但是,与西里尔文本相比,差异并不大。
-- [literal/sherlock-zh]
Calculating -------------------------------------
ruby 6.360k (± 0.4%) i/s (157.23 μs/i) - 32.334k in 5.083835s
re2 2.233k (± 0.3%) i/s (447.77 μs/i) - 11.373k in 5.092542s
rust/regex 30.128k (± 0.3%) i/s (33.19 μs/i) - 151.200k in 5.018621s
Comparison:
rust/regex: 30128.2 i/s
ruby: 6360.2 i/s - 4.74x slower
re2: 2233.3 i/s - 13.49x slower
Literal with alternation
从 rebar:
此组与
literal
类似,但将复杂度从简单的文字扩展到简单的文字的少量交替,包括在适用情况下不区分大小写的变体。 此基准测试在文字优化方面提高了要求。 也就是说,对于正则表达式引擎来说,要优化这种情况,通常需要能够推理出需要匹配来自一组的一个或多个文字的文字优化。 许多正则表达式引擎不能很好地处理这种情况,或者根本不处理。
这组基准测试重用了来自 literal 的 haystack,但具有交替模式。 ruby
中的交替就像不区分大小写一样成为一个弱点。 引擎的位置在所有示例中都是静态的 - rust/regex
、re2
,然后是 ruby
。
-- [literal-alt/sherlock-en]
Calculating -------------------------------------
ruby 175.748 (± 0.0%) i/s (5.69 ms/i) - 884.000 in 5.029956s
re2 552.693 (± 0.7%) i/s (1.81 ms/i) - 2.805k in 5.075420s
rust/regex 6.407k (± 0.4%) i/s (156.08 μs/i) - 32.100k in 5.010115s
Comparison:
rust/regex: 6407.2 i/s
re2: 552.7 i/s - 11.59x slower
ruby: 175.7 i/s - 36.46x slower
-- [literal-alt/sherlock-casei-en]
Calculating -------------------------------------
ruby 83.630 (± 0.0%) i/s (11.96 ms/i) - 424.000 in 5.070034s
re2 550.273 (± 0.4%) i/s (1.82 ms/i) - 2.805k in 5.097541s
rust/regex 2.896k (± 0.5%) i/s (345.30 μs/i) - 14.739k in 5.089453s
Comparison:
rust/regex: 2896.1 i/s
re2: 550.3 i/s - 5.26x slower
ruby: 83.6 i/s - 34.63x slower
-- [literal-alt/sherlock-ru]
Calculating -------------------------------------
ruby 29.989 (± 0.0%) i/s (33.35 ms/i) - 150.000 in 5.001989s
re2 324.299 (± 0.6%) i/s (3.08 ms/i) - 1.632k in 5.032606s
rust/regex 2.292k (± 0.5%) i/s (436.34 μs/i) - 11.679k in 5.096204s
Comparison:
rust/regex: 2291.8 i/s
re2: 324.3 i/s - 7.07x slower
ruby: 30.0 i/s - 76.42x slower
-- [literal-alt/sherlock-casei-ru]
Calculating -------------------------------------
ruby 12.274 (± 0.0%) i/s (81.47 ms/i) - 62.000 in 5.051406s
re2 314.334 (± 0.6%) i/s (3.18 ms/i) - 1.581k in 5.029859s
rust/regex 627.731 (± 0.6%) i/s (1.59 ms/i) - 3.162k in 5.037377s
Comparison:
rust/regex: 627.7 i/s
re2: 314.3 i/s - 2.00x slower
ruby: 12.3 i/s - 51.14x slower
-- [literal-alt/sherlock-zh]
Calculating -------------------------------------
ruby 84.924 (± 0.0%) i/s (11.78 ms/i) - 432.000 in 5.087020s
re2 737.563 (± 0.1%) i/s (1.36 ms/i) - 3.723k in 5.047722s
rust/regex 10.016k (± 0.4%) i/s (99.84 μs/i) - 50.745k in 5.066428s
Comparison:
rust/regex: 10016.1 i/s
re2: 737.6 i/s - 13.58x slower
ruby: 84.9 i/s - 117.94x slower
Date
从 rebar:
这是一个用于从 Python 中编写的 datefinder 项目的非结构化文本中提取日期的大型正则表达式。 正则表达式本身取自 打印 DATES_PATTERN
datefinder
项目中的变量。 然后,我从捕获组中删除了所有名称、不必要的转义并将其折叠为一行(因为并非所有正则表达式引擎都支持详细模式)。 正则表达式更像是分词器,并且datefinder
库尝试将这些标记组合成时间戳。
这是另一个自动机导向引擎优于 ruby
(即回溯器)的示例。
-- [date/ascii]
Calculating -------------------------------------
ruby 0.583 (± 0.0%) i/s (1.71 s/i) - 3.000 in 5.141671s
re2 13.299 (± 0.0%) i/s (75.19 ms/i) - 67.000 in 5.038107s
rust/regex 18.069 (± 0.0%) i/s (55.34 ms/i) - 91.000 in 5.036226s
Comparison:
rust/regex: 18.1 i/s
re2: 13.3 i/s - 1.36x slower
ruby: 0.6 i/s - 30.97x slower
Cloudflare ReDoS
从 rebar:
此基准测试使用一个正则表达式,该正则表达式导致了 Cloudflare 的中断。 此类漏洞通常称为“正则表达式拒绝服务”或简称为“ReDoS”。 它并不总是需要恶意行为者来触发。 由于在使用无界回溯实现时很难推理正则表达式的最坏情况性能,因此它可能完全偶然地发生在有效输入上。
导致中断的特定正则表达式是:
(?:(?:"|'|\]|\}|\\|\d|(?:nan|infinity|true|false|null|undefined|symbol|math)|`|\-|\+)+[)]*;?((?:\s|-|~|!|\{\}|\|\||\+)*.*(?:.*=.*)))
正如 Cloudflare 事后分析中所讨论的,正则表达式中特定的有问题的部分是:
.*(?:.*=.*)
或者更简单地说:
.*.*=.*;
以下是原始正则表达式的结果,以及具有短和长 haystack 的简化变体。
-- [cloudflare-redos/original]
Calculating -------------------------------------
ruby 38.874k (± 0.3%) i/s (25.72 μs/i) - 197.268k in 5.074535s
re2 269.196k (± 0.3%) i/s (3.71 μs/i) - 1.352M in 5.020755s
rust/regex 307.633k (± 0.3%) i/s (3.25 μs/i) - 1.568M in 5.097012s
Comparison:
rust/regex: 307633.0 i/s
re2: 269195.8 i/s - 1.14x slower
ruby: 38874.4 i/s - 7.91x slower
-- [cloudflare-redos/simplified-short]
Calculating -------------------------------------
ruby 118.655k (± 0.4%) i/s (8.43 μs/i) - 596.200k in 5.024702s
re2 275.180k (± 1.8%) i/s (3.63 μs/i) - 1.378M in 5.010738s
rust/regex 2.300M (± 0.4%) i/s (434.81 ns/i) - 11.682M in 5.079644s
Comparison:
rust/regex: 2299833.5 i/s
re2: 275180.0 i/s - 8.36x slower
ruby: 118655.3 i/s - 19.38x slower
-- [cloudflare-redos/simplified-long]
Calculating -------------------------------------
ruby 1.391k (± 1.0%) i/s (719.03 μs/i) - 7.000k in 5.033739s
re2 5.373k (± 0.7%) i/s (186.13 μs/i) - 27.183k in 5.059767s
rust/regex 39.191k (± 1.3%) i/s (25.52 μs/i) - 197.166k in 5.031788s
Comparison:
rust/regex: 39190.6 i/s
re2: 5372.7 i/s - 7.29x slower
ruby: 1390.8 i/s - 28.18x slower
Words
从 rebar:
此基准测试测量正则表达式引擎在 haystack 中查找单词所需的时间。 我们比较一个查找所有单词的正则表达式
\b\w+\b
和另一个仅查找较长单词的正则表达式\b\w{12,}\b
。 在查找所有单词和仅查找长单词之间的分割倾向于突出显示每个正则表达式引擎中匹配的开销。 更快进入和退出其匹配例程的正则表达式引擎在查找所有单词方面比开销更高的正则表达式引擎做得更好。
这组基准测试使用来自 literal 组的英语和西里尔 haystack,但存在一些限制。
\b
匹配器在 re2
中无法识别 Unicode,从而产生略有不同的与英语文本的匹配计数(由于包含了变音符号),并且与西里尔文本的结果完全不同,因此后者已从比较中排除。
强制 re2
匹配 Unicode 字符会使其再次比 ruby
慢。
-- [words/all-english]
Calculating -------------------------------------
ruby 194.375 (± 1.0%) i/s (5.14 ms/i) - 988.000 in 5.083383s
re2 102.214 (± 0.0%) i/s (9.78 ms/i) - 520.000 in 5.087453s
rust/regex 528.470 (± 0.6%) i/s (1.89 ms/i) - 2.652k in 5.018450s
Comparison:
rust/regex: 528.5 i/s
ruby: 194.4 i/s - 2.72x slower
re2: 102.2 i/s - 5.17x slower
匹配长单词可以避免之前使用 Unicode 字符的任何单词,因此 re2
不会受到惩罚。 更重要的是,长有界重复在 re2
中似乎比在 rust/regex
中更有效。
-- [words/long-english]
Calculating -------------------------------------
ruby 351.391 (± 0.6%) i/s (2.85 ms/i) - 1.785k in 5.079936s
re2 6.397k (± 0.5%) i/s (156.33 μs/i) - 32.000k in 5.002787s
rust/regex 852.843 (± 0.2%) i/s (1.17 ms/i) - 4.335k in 5.083041s
Comparison:
re2: 6396.6 i/s
rust/regex: 852.8 i/s - 7.50x slower
ruby: 351.4 i/s - 18.20x slower
Bounded repeat
从 rebar:
这组基准测试测量正则表达式引擎在处理有界重复时的表现。 有界重复是允许匹配最多固定次数的子表达式。 例如,
a{3,5}
匹配3
、4
或5
个连续的a
字符。 与无界重复运算符不同,正则表达式引擎需要某种方法来跟踪边界何时达到其限制。 因此,许多正则表达式引擎会将a{3,5}
转换为aaaa?a?
。 鉴于边界可能远高于5
,并且子表达式可能比单个字符复杂得多,因此有界重复可能会迅速导致底层匹配器的大小膨胀。
使用相对较短的英语字母有界重复,与 ruby
相比,re2
的性能仍然非常好。
-- [bounded-repeat/letters-en]
Calculating -------------------------------------
ruby 160.076 (± 1.2%) i/s (6.25 ms/i) - 816.000 in 5.098412s
re2 694.208 (± 0.9%) i/s (1.44 ms/i) - 3.519k in 5.069443s
rust/regex 2.046k (± 0.3%) i/s (488.76 μs/i) - 10.250k in 5.009798s
Comparison:
rust/regex: 2046.0 i/s
re2: 694.2 i/s - 2.95x slower
ruby: 160.1 i/s - 12.78x slower
但是它再次完全输给了 Unicode。
-- [bounded-repeat/letters-ru]
Calculating -------------------------------------
ruby 84.089 (± 0.0%) i/s (11.89 ms/i) - 424.000 in 5.042387s
re2 16.273 (±18.4%) i/s (61.45 ms/i) - 80.000 in 5.038373s
rust/regex 970.406 (± 0.7%) i/s (1.03 ms/i) - 4.94