正则表达式的性能问题既可能表现为对象分配和重复编译,也可能因为灾难性回溯演变成拒绝服务风险。优化时不仅要比较正常输入的吞吐量,还要考虑异常输入的最坏执行时间。

1. 🧭 先判断是否真的需要正则表达式

正则表达式适合描述具有一定结构的文本模式,但简单查找和解析通常有更直接的 API。

需求优先考虑
判断固定前缀或后缀StartsWithEndsWith
查找固定文本ContainsIndexOf
查找多个固定字符SearchValues<T>IndexOfAny
解析数字、日期、枚举TryParse
按简单分隔符切分Span<T>IndexOfAny
匹配复杂文本结构正则表达式

如果规则只是“是否包含字符 :”或“是否以 .json 结尾”,使用正则只会增加模式解析、匹配引擎和结果对象等额外成本。

2. 🧬 固定模式优先使用 GeneratedRegex

模式和选项在编译期已经确定时,.NET 7+ 可以通过源生成器生成专用匹配代码:

using System.Text.RegularExpressions;

public static partial class Validators
{
    [GeneratedRegex(
        @"\A[a-z0-9_-]{1,64}\z",
        RegexOptions.CultureInvariant,
        matchTimeoutMilliseconds: 100)]
    public static partial Regex Slug();
}

调用时直接复用生成的实例:

bool valid = Validators.Slug().IsMatch(input);

GeneratedRegex 的主要优势包括:

  • 避免在运行时解析和编译固定模式。
  • 具有类似 RegexOptions.Compiled 的匹配吞吐量。
  • 生成的代码可以查看和调试。
  • 更适合 Native AOT 和应用裁剪。
  • 实例由生成代码缓存,不需要自己维护静态字段。

源生成器要求模式、选项和超时参数在编译期可确定。模式来自配置或用户输入时,仍然需要在运行时创建 Regex

3. ♻️ 不要在高频路径重复创建 Regex

下面的代码每次调用都会创建新的正则实例:

static bool IsValid(string input) =>
    new Regex(@"\A[a-z0-9_-]{1,64}\z").IsMatch(input);

固定模式应使用 GeneratedRegex 或缓存的静态实例。运行时模式也应在确定会重复使用时复用:

private static readonly Regex SlugRegex = new(
    @"\A[a-z0-9_-]{1,64}\z",
    RegexOptions.CultureInvariant,
    TimeSpan.FromMilliseconds(100));

动态模式数量没有上限时,不要简单存入永久 Dictionary。攻击者可能不断提交不同模式,使缓存持续增长。此时需要限制模式来源、缓存容量和过期策略。

4. ⚙️ 根据场景选择执行模式

.NET 正则表达式主要有以下几种执行方式:

场景推荐方式
固定模式并频繁执行GeneratedRegex
运行时模式,只执行少量次数默认解释执行
运行时模式,会长期频繁复用考虑 RegexOptions.Compiled
需要限制最坏匹配时间RegexOptions.NonBacktracking 或超时

RegexOptions.Compiled 会在运行时生成匹配代码,能够提高重复匹配的吞吐量,同时也会增加创建时间和代码内存。只执行一次或调用次数很少的表达式,编译成本可能高于匹配收益。

不要同时期待 CompiledNonBacktracking 的收益。非回溯模式使用另一套匹配引擎,Compiled 会被忽略;源生成器遇到 NonBacktracking 时,也会退回到缓存普通 Regex 实例。

5. ⏱️ 不可信输入必须设置超时

默认情况下,正则匹配可能没有有限超时。处理用户输入、上传文件、日志或网络报文时,应显式限制匹配时间:

private static readonly Regex InputRegex = new(
    pattern,
    RegexOptions.CultureInvariant,
    TimeSpan.FromMilliseconds(100));

超过限制后会抛出 RegexMatchTimeoutException

try
{
    return InputRegex.IsMatch(input);
}
catch (RegexMatchTimeoutException)
{
    return false;
}

超时不是普通业务分支,也不能解决表达式本身的结构问题。捕获异常后,不要立即使用相同模式和输入无限重试。除了匹配超时,还应限制输入长度和动态模式长度。

6. 💥 避免灾难性回溯

传统正则引擎在尝试失败时会回到之前的位置,改用另一条匹配路径。表达式包含嵌套且含义重叠的量词时,尝试路径可能随输入长度指数增长。

典型问题模式:

\A(a+)+\z

当输入由大量 a 和一个无法匹配的尾字符组成时,引擎可能尝试大量不同的分组方式。

可以从以下方面改进:

  • 避免嵌套且能匹配相同内容的量词。
  • 使用更具体的字符类代替 .
  • 给量词设置合理上限,例如 {1,64}
  • 让确定性高的文字和条件尽早出现。
  • 在语义允许时使用原子组 (?>...) 阻止回溯。
  • 对兼容的模式考虑使用非回溯引擎。

修改回溯行为可能改变匹配结果,尤其是原子组和分支顺序。不能只为了速度机械替换,必须使用正常输入和边界输入验证语义。

7. 🛡️ 使用 NonBacktracking 限制最坏时间

.NET 7 引入了 RegexOptions.NonBacktracking。对于支持的模式,它能保证匹配时间相对于输入长度呈线性增长,避免灾难性回溯。

private static readonly Regex SafeRegex = new(
    @"\A[a-z0-9_-]{1,64}\z",
    RegexOptions.CultureInvariant |
    RegexOptions.NonBacktracking,
    TimeSpan.FromMilliseconds(100));

非回溯引擎并不是所有输入上都更快,它的价值主要是性能更可预测。它也不支持反向引用、环视和部分依赖回溯的高级结构,因此需要先确认模式是否兼容。

即使使用非回溯引擎,仍应限制超长输入。线性复杂度不代表处理任意长度的数据都没有成本。

8. 🎯 只获取真正需要的结果

只需要判断是否匹配时,使用 IsMatch

bool valid = regex.IsMatch(input);

如果调用 MatchMatches,引擎还需要构造匹配结果并维护分组和捕获信息。只需要真假时,这些对象没有价值。

同样,应根据需求选择 API:

  • 只判断是否存在:IsMatch
  • 只需要第一个匹配及其分组:Match
  • 替换文本:Replace
  • 拆分文本:Split,但要关注产生的大量字符串和数组。
  • 遍历位置和长度:优先考虑 EnumerateMatches

9. 🚄 使用 Span 枚举匹配

现代 .NET 可以直接在 ReadOnlySpan<char> 上枚举匹配,避免创建 MatchCollection 和多个 Match 对象:

ReadOnlySpan<char> input = text;

foreach (var match in regex.EnumerateMatches(input))
{
    ReadOnlySpan<char> value = input.Slice(
        match.Index,
        match.Length);

    Process(value);
}

这种方式适合只需要匹配位置、长度或原始切片的高频解析场景。Span 不能跨越 awaityield 或当前生命周期保存;如果最终仍要为每个结果调用 ToString(),字符串分配依然存在。

10. 🧩 避免不必要的捕获组

普通圆括号会创建捕获组,并记录对应的捕获结果:

(https?|ftp)://

如果括号只是为了控制分支或量词,可以使用非捕获组:

(?:https?|ftp)://

大量分组都不需要捕获时,可以使用 RegexOptions.ExplicitCapture,只有显式命名的组才会记录结果。

捕获组在同一组被量词重复匹配时,还可能产生多个 Capture。模式只用于验证时,应尽量避免保存不需要的分组信息。

11. 🚪 让不匹配的输入尽早失败

高性能模式不仅要让正确输入匹配得快,还要让错误输入尽早退出。

11.1 使用准确的边界

完整字符串验证可以使用 \A\z

\A[A-Z]{2}-\d{6}\z

它们明确表示整个输入的开始和结束,不受多行模式影响。^$ 更适合行边界场景,其中 $ 还可能匹配最终换行符之前的位置。

11.2 缩小匹配范围

过于宽泛的写法:

.*ERROR.*

如果只是查找固定文本,直接使用 Contains("ERROR", StringComparison.Ordinal) 更清晰。如果确实需要正则,应尽量使用明确前缀、字符类和长度限制,减少引擎需要尝试的路径。

11.3 调整分支顺序

传统回溯引擎通常按从左到右的顺序尝试分支。高频且容易成功的分支放在前面,有时可以减少尝试次数。不过,现代运行时也会分析公共前缀并生成搜索优化,因此最终仍应通过基准测试确认。

12. 🌐 明确大小写与区域性规则

RegexOptions.IgnoreCase 的行为可能受到区域性影响。处理协议、标识符、文件格式等机器可读文本时,通常应组合 RegexOptions.CultureInvariant

RegexOptions options =
    RegexOptions.IgnoreCase |
    RegexOptions.CultureInvariant;

面向自然语言的匹配则需要根据具体业务选择文化规则。性能优化不能以改变字符串比较语义为代价。

13. 📏 同时测量正常输入和恶意输入

正则基准测试至少应覆盖以下数据:

  • 能够快速匹配的常见输入。
  • 在开头就不匹配的输入。
  • 接近末尾才失败的输入。
  • 达到允许长度上限的输入。
  • 专门触发大量回溯的输入。

除了平均耗时,还应观察内存分配、首次调用成本和最坏耗时。GeneratedRegexCompiled、解释执行和非回溯模式的表现会随模式与输入变化,不能只用一个短字符串得出结论。

14. ✅ 正则表达式优化顺序

遇到正则性能问题时,可以按下面的顺序检查:

  1. 是否可以改用字符串、Span 或 TryParse 等更简单的 API。
  2. 固定模式是否可以使用 GeneratedRegex
  3. Regex 实例是否被重复创建。
  4. 不可信输入是否设置了超时和长度限制。
  5. 模式是否存在嵌套量词和灾难性回溯。
  6. 是否适合使用 RegexOptions.NonBacktracking
  7. 是否创建了不需要的 Match、分组和 Capture。
  8. 遍历匹配时是否可以使用 EnumerateMatches 和 Span。
  9. 最后通过正常输入与最坏输入的基准测试验证收益。

正则表达式优化的目标不只是让常见输入更快,还要让异常输入的成本可控。固定模式优先源生成,不可信输入设置超时,复杂模式重点检查回溯,这三点通常最值得优先落地。