正则表达式优化 - 避免灾难性回溯

2,777 阅读8分钟

正则表达式系列总结:

前言

最近我在运用正则的过程中,发现了一些让程序变慢的现象,甚至导致了...服务器CPU飙升至100%的问题。 血泪教训不愿再说,引发问题的根本原因还是对正则的运用不够熟练,导致在匹配文本时发生了大量的回溯。

本文主要来总结一下,关于避免正则中灾难性回溯的学习收获。

前置知识

一些需要用到的正则表达式基础知识。

正则引擎

正则表达式的引擎决定了它的工作原理,正则引擎可以分为两大类:

  • DFA(确定型有穷自动机):文本主导。
  • NFA(非确定型有穷自动机):表达式主导,又分为传统型NFA和POSIX NFA。

我们常用的Java、JavaScript、PHP,都属于(传统型)NFA引擎。

判断方法:如果支持非贪婪模式,基本可以确定是传统型NFA。

回溯是NFA引擎最大的特点。因为NFA引擎在匹配时是表达式主导的,某个字符可能会被正则中的不同部分重复检测。如果正则表达式运用不当,会带来非常严重的后果。

量词

javascript MDN文档 - Quantifiers

量词:表示要匹配项(字符或表达式)的数量,也可以理解为重复的次数。

量词的基本形式:

字符含义我的理解
x*将x匹配0次或无穷多次x出现任意次,可以没有
x+将x匹配1次或无穷多次x出现任意次,且必须有
x?将x匹配0次,或1次x有没有都行,有的话就一次
x{n}将x匹配n次x精确出现n次才会匹配
x{n,}将x至少匹配n次x出现次数>n
x{n,m}将x最少匹配n次,最多匹配m次n<x出现次数<m

一个例子:

var r = /^(?:(?:+|00)86)?1\d{10}$/

这是一则宽松匹配手机号的正则表达式,这里使用到了?{n}两种量词,表示在匹配手机号时,+86或0086的前缀有没有都可以,1后面要跟上10位数字。

除了上述的基本用法,量词还有几种模式:

模式描述匹配规则
贪婪模式量词的基础用法,默认使用量词时就是贪婪模式匹配时引擎会选择进行尝试
懒惰模式在量词后再添加一个?,如: *?、+?、??、{n,m}?匹配时引擎会跳过尝试
占有模式在量词后再添加一个+,如: *+、++、?+、{n,m}+和贪婪模式类似,但更为强势,
javascript中目前不支持

多选分支

x|y:匹配x或者y,依靠"|"符号,可以在一个表达式中包含多个不同的子表达式(x和y)

例子:

var r = /gr(a|e)y/

其中,括号可以帮助划定多选分支的作用范围。

断言

JavaScript MDN文档 - Assertions

利用断言可以进行边界判断而不占用任何字符:

  • 向前断言:x(?=y)
  • 向前否定断言:x(?!y)
  • 向后断言:(?<=y)x
  • 向后否定断言:(?<!y)x

调试工具

使用的调试工具:regex101

目前只有在选择PHP语言时能够使用调试功能, 关于PHP(PCRE)和javascript正则的区别:

  • PHP使用的是PCRE正则库,PCRE是一套兼容Perl正则表达式的库,全面仿制了Perl的正则语法和语义。
  • javascript的正则语法借鉴自Perl。

因此,JS和PHP的正则表达式语法大体上是类似的,在一些细节上表现稍有不同,因此可以使用PHP模式的调试功能,然后验证在javascript模式下有没有问题。

: Perl是非常流行、特性十分丰富的正则表达式库。其他语言的开发人员,在某种程度上兼容Perl,开发了各自的正则表达式包。

回溯是什么

如果把正则表达式匹配的过程,想象成一个迷宫游戏:

  • 从起始位置出发
  • 遇到分岔路口,选择其中一条路线
  • 走入死胡同,返回刚才的分岔路口
  • 选择另外一条路线继续走
  • 重复上述步骤,直到找到一条成功的路线,或者尝试所有可能路线后失败

上述步骤中:走入死胡同后返回上一个分岔路口的过程,我理解为和正则中的回溯过程类似。

发生回溯的情况

回溯大多发生在有分岔路口的地方,即遇到需要进行选择的时候。

在正则表达式中,选择会发生在遇到量词多选分支时:

  • 遇到量词:需要选择是否要尝试另一次匹配
  • 遇到多选分支:需要选择尝试哪个分支,同时也会记录下另外的分支(备用状态)稍后尝试

在没有得到正确的匹配结果时,正则会继续尝试直到走完所有可能的分支,所以得出匹配失败的结果可能需要耗费很多的时间。

匹配量词和回溯

贪婪模式中的回溯

由星号或其他贪婪量词限定的部分,不受后面元素的影响。会匹配尽可能多的内容,只有在全局匹配需要的情况下才会被迫交还一些字符。

var s = '<b>HTML</b> and <b>CSS</b> are ...'
var r = /<b>.*<\/b>/

console.log(s.match(r))

表达式中的.匹配除换行符以外的任意字符,*又将任意字符匹配任意次,贪婪模式下.*组合起来会一直匹配到文本的行末尾, 而表达式中.*后边跟随</b>,这时候.*就会往回匹配(发生回溯),直到找到最右边的</b>结束。 匹配的结果为<b>HTML</b> and <b>CSS</b>

调试地址:regex101.com/r/IRZD5U/1

image.png

使用懒惰模式避免回溯

懒惰模式下量词是忽略优先的,它一开始不去匹配任何字符,把控制权交给后面的字符。

把这个例子改成懒惰模式,当.*?后面的字符<无法匹配时,控制权就又交还给它,.匹配字符H后继续选择忽略,重复上面的步骤...直到找到一个<

var s = '<b>HTML</b> and <b>CSS</b> are ...'
var r = /<b>.*?<\/b>/

前面这个例子中,如果改成懒惰模式,就会只匹配<b>HTML</b>了,并且不存在回溯问题。

image.png

使用排除型字符组消除回溯

使用排除型字符组[^x],排除一些不需要匹配的字符,来代替类似 .* 的宽泛匹配方式。 适用于排除单个字符的情况。


var s = '"HTML" and "CSS" in web development...'
var r1 = /".*"/
var r2 = /"[^"]*"/

比较例子中的两个正则表达式,第一个正则使用.*,会存在回溯,且匹配的结果更为宽泛, 第二个正则使用排除型字符组,匹配时不存在回溯,且匹配结果更为精准。

image.png

调试地址:regex101.com/r/OrvljC/1

通过断言实现多字符的排除

由于排除型字符组仅能实现对单字符的排除,如果是连续的多个字符,可以通过断言的方式实现排除。

例子:

var r = /<b>((?!<\/?b>).)*<\/b>/
var s = '<b>HTML<b>(H5)</b> and <b>CSS</b> are ..'

这个例子中,使用了向前否定断言,来实现和排除单字符的排除型字符组类似的效果。

调试地址:regex101.com/r/nCOgUu/1

image.png

表达式图解: image.png

警惕和减少嵌套的量词

嵌套的量词,如 (.+)*,会制造指数级的回溯。

一个例子:

var r = /"([^\\"]+)*"/
var s = 'no \"match\" words'

因为+*组合起来有很多种可能,对于不能得到匹配结果的文本,18个字符在经过307次尝试后才报告失败。如果表达式和文本再复杂一点,回溯次数就更无法控制了。

因此,对于这种嵌套的量词,也要警惕造成过多回溯的问题。

image.png

调试地址:regex101.com/r/6Hsg39/1

利用贪婪模式实现文本的提取

一个例子:

var s = '...class="time">2021-07-01至今(1个月),....'
var s1 = '...class="time">2001-01至2021-12(10年11个月),....'

var r = /time">[\s\S]{1,20}(?<endYear>\S{2})(/

以提取结束日期中的月份,可能的值为至今或形如12的月份,

我的匹配思路:提取时间范围的字符中的最后两个,先定位到特征为class="time">的部分, 然后利用贪婪模式的特点,尽可能多的匹配后面的字符(根据观察进行长度判断,最多匹配20次), 匹配到左括号后,取左括号左边的两个字符。

这里使用贪婪模式是因为,要提取字符的右侧特征更为明显,左侧字符相对不确定,所以先尽可能向右匹配。

但需要注意进行长度判断,如果使用宽泛的.*,虽然写起来方便,但会无限制地向右匹配直到行末尾,造成更多的回溯。

因此,并不是所有的情况都需要使用懒惰模式,而是根据具体情况来选择。

多选分支和回溯

遇到多选分支时,引擎会按照从左到右的顺序检查表达式中的多选分支。

字符组和多选分支:

  • 字符组只进行简单的测试
  • 多选分支则需要在每个位置进行尝试

正则引擎会回溯到存在尚未尝试的多选分支的地方,这个过程会不断重复,直到完成全局匹配,或所有的分支都尝试穷尽为止。

因此,分支越多,可能的回溯次数越多。

可以通过以下方式减少多选分支带来的回溯:

  • 提取多选分支中的必须元素(开头或结尾)例如:/th(is|at)/
  • 尽量减少分支数量

例子:regex101.com/r/0Y0STc/1

back-06.png

注意:多选结构是按序排列的,匹配时正则引擎会按序尝试。 因此要注意避免因分支顺序导致的匹配失败。如例子中的D轮及以上也会被 .*轮 匹配,所以将D轮及以上提前。

更换正则引擎

由于NFA引擎的回溯特征无法避免,还有可能因此而引发ReDoS(Regular Expression Denial of Service, 正则表达式攻击),还有一种终极做法就是换用其他引擎。

Google RE2:一款使用C++编写的正则库:

  • 优点:安全、快速,能避免ReDoS
  • 特性:支持PCRE的大部分语法,有Go、Python、Nodejs等多种语言的实现
  • 缺陷:不能使用回溯、反向引用、断言的特性

RE2在Node.js中的用法和js自身的Regex用法很相似。具体用法参考文档。不过目前的使用倒也不至于更换引擎。

避免灾难性回溯的总结

  • 正确使用贪婪模式和非贪婪模式
  • 不过分依赖.*,通过使用有明显特征的具体字符、字符组代替通配符,来消除某些回溯
  • 复杂情况可以考虑通过断言、固化分组(javascript中暂时不支持)等来解决回溯问题
  • 减少嵌套的量词
  • 减少多选分支数量
  • 使用检测工具进行测试
  • 必要时可以考虑更换正则引擎

其他正则优化措施

  • 使用非捕获型括号:如果不需要引用括号内的文本,请使用非捕获括号,不但能节省捕获的时间,而且会减少回溯使用的状态的数量,从两方面提高速度。
  • 不要滥用括号:在需要的时候再使用括号,其他时候使用括号会阻止某些优化措施。eg: .*(.)*
  • 不要滥用字符组:不使用只包含一个字符的字符组,需要付出处理字符组的代价。
  • 将最可能匹配的多选分支放在前面。

结束语

关于正则表达式的回溯问题,这里记录的内容也只是冰山一角,梳理的过程也是第二次学习,希望读到这里的你也能有一些收获。

最后,如果文中有任何错漏之处,欢迎指正,鞠躬。

参考资料