SerpApi

Fast(er) regular expression engines in Ruby

本文对替代的正则表达式引擎进行了性能比较,这些引擎可能会(也可能不会)加速你的 Ruby 代码。

Dmytro Horoshko

2025年5月1日 • 16 分钟阅读

目录:

  1. Introduction
  2. Contenders 1. re2 2. rust/regex 3. pcre2
  3. Benchmarks 1. Literal 2. Literal with alternation 3. Date 4. Cloudflare ReDoS 5. Words 6. Bounded repeat 7. Noseyparker
  4. Engine limitations
  5. 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 变体。 具体来说,那些已使用 countcount-spans 模型验证的变体。

以下结果是使用以下方式收集的:

包含 haystack 数据和原始基准测试结果(包括 macOS / M4 Max 结果)的基准测试脚本可在 GitHub 上找到。

Literal

rebar

这组基准测试测量简单的文字正则表达式模式。 它主要用于演示两件事。 首先是正则表达式引擎是否通过识别模式只是一个文字来进行一些最基本形式的优化,并且可能不需要完整的正则表达式引擎。 事实上,在这种情况下天真地使用正则表达式引擎可能会产生比大多数正则表达式引擎差得多的测量结果。 其次是简单文字搜索的性能如何随大小写不敏感和 Unicode 的变化而变化。 也就是说,在 ASCII 文本上运行良好的子字符串搜索算法不一定也能在包含许多非 ASCII 代码点的 UTF-8 上运行良好。 对于不区分大小写的搜索尤其如此。

使用包含最少数量的德语单词(即带有变音符号的 Unicode 字符)的英语 haystack,rust/regex 处于领先地位,而 re2ruby 并没有相差太远。

-- [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/regexre2,然后是 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} 匹配 345 个连续的 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