本文首发于 CSDN,转载请注明出处。
先说结论:正则写不对,八成不是语法记错,而是没搞清引擎类型。回溯型引擎(PCRE、Python、Java、Perl)靠"试错回退"工作,写不好会指数级变慢;自动机型引擎(RE2)不回溯、时间线性,但砍掉了反向引用和环视。同一个模式在两个引擎上,行为可能完全不同。
三种量词的本质区别是什么?
量词默认是贪婪的。PCRE2 官方的定义:
By default, quantifiers are “greedy”, that is, they match as much as possible (up to the maximum number of permitted repetitions), without causing the rest of the pattern to fail.
注意后半句"在不导致剩余模式匹配失败的前提下"——这是理解回溯的入口。贪婪不是"无脑吃光",而是"先吃光,不行再吐出来"。
懒惰(非贪婪)正好相反。Python 官方文档的表述:
Adding ? after the quantifier makes it perform the match in non-greedy or minimal fashion; as few characters as possible will be matched.
第三类是占有量词,写法是量词后再加一个+(*+、++、?+、{n,m}+)。它的性质是 PCRE2 对原子组的简化写法:
when the contents of an atomic group is just a single repeated item … a simpler notation, called a “possessive quantifier” can be used.
而原子组(?>...)的作用是立即丢弃组内已经产生的所有回退点——PCRE2 的说法是:
This kind of parenthesized group “locks up” the part of the pattern it contains.
Perl 官方干脆把两者说成同一件事:
possessive quantifiers are just syntactic sugar for that construct [independent subexpression (?>pattern)]
| 量词类型 | 写法 | 行为 | 触发回溯 |
|---|---|---|---|
| 贪婪 | .*a+ | 先尽量多吃,不行再吐 | 是 |
| 懒惰 | .*?a+? | 先尽量少吃,不行再吃 | 是 |
| 占有 | .*+a++ | 吃定就不吐 | 否 |
"不吐"意味着不会回溯,也就意味着不会因为回溯而变慢。代价是匹配能力弱了一点——占有量词匹配失败就真的失败了,不会退回去换一种分法。
用法判断很简单:如果重复部分之后没有依赖它内部切分的内容,用占有量词。比如"[^"]*+"匹配一段引号内容,后面的内容不依赖引号内怎么切,用占有就比贪婪快,而且没有回溯风险。
回溯是怎么吃掉 CPU 的?
回溯的机制,可以用 Python 官方文档里对原子组的解释来反证:
once exited, the expression, known as an atomic group, has thrown away all stack points within itself.
“丢弃了内部所有的栈点”——反过来说,非原子组会保留这些栈点。引擎在匹配时把每个可能的分叉点压入栈,失败就弹出一个换个方案重试。栈点越多、重试越多,最坏情况下就是指数级。
Perl 官方对这种行为的描述只有一句,但足够直白:
Normally when a quantified subpattern does not allow the rest of the overall pattern to match, Perl will backtrack.
代价有多大?Russ Cox 在那篇被引用最多的正则文章(《Regular Expression Matching Can Be Simple And Fast》)里给了一个量级:
the Thompson NFA handles a 100-character string in under 200 microseconds, while Perl would require over 10^15 years.
同一个 100 字符的字符串,自动机实现 200 微秒,回溯实现要 10^15 年。这个对比虽然极端,但它说明的不是"慢几倍",而是"量级上的崩溃"。
被举得最多的灾难性回溯例子是^(a+)+$。OWASP 的 ReDoS 页面里给了它为什么爆炸的量化说明:
For the input aaaaX there are 16 possible paths … but for aaaaaaaaaaaaaaaaX there are 65536 possible paths, and the number is double for each additional a.
每多一个字符,路径数翻倍。输入 30 个 a,路径数就是 2^30 级别。这种模式在正常输入下跑得飞快,遇到一个精心构造的字符串就能把 CPU 打满——这正是 ReDoS(正则拒绝服务)攻击的原理。
需要说明出处:^(a+)+$这个具体例子出自OWASP(安全视角),而理论依据出自 Russ Cox。PCRE2 和 Perl 的官方文档里并没有给这个例子。引用时最好分清。
**怎么判断自己的正则有没有这个风险?**看嵌套量词:(a+)+、(a*)*、(a|a)+这类"量词套量词、且内部可选分支能匹配相同字符"的结构就是高危。修法是改写结构(比如用a+替代(a+)+)或换成占有量词。
RE2 走的是另一条路
Google 的 RE2 用的是自动机方案,官方 README 里对它的定位说得非常明确:
Safety is RE2’s primary goal. … One of its primary guarantees is that the match time is linear in the length of the input string.
匹配时间与输入长度线性——这是它的核心承诺。因为线性,所以不存在"某个输入把 CPU 打满"这种情况,可以安全地处理来自不可信用户的正则表达式。
它和回溯引擎的差别,官方用了一个很形象的对比:
In a sense, RE2 is pessimistic where a backtracking engine is optimistic: A backtracking engine tests each alternative sequentially, making it fast when the first alternative is common. By contrast RE2 evaluates all alternatives in parallel, avoiding the performance penalty for the last alternative, at the cost of some overhead.
回溯引擎是乐观的:赌第一个分支就命中,赌对了极快,赌错了极慢。RE2 是悲观的:所有分支并行推进,没有"赌错"这回事,代价是常态下有额外开销。
所以 RE2 不是"更快的正则引擎"——官方自己说了:
It is not a goal to be faster than all other engines under all circumstances.
它的目标是安全和可预测,不是绝对速度。常见模式上它可能比 PCRE 慢,但永远不会爆炸。
代价是砍功能。官方明确列出:
As a matter of principle, RE2 does not support constructs for which only backtracking solutions are known to exist. Thus, backreferences and look-around assertions are not supported.
反向引用和环视(look-around)不支持。没有这两样,很多现有正则直接无法迁移。所以"用 RE2 替代 PCRE"不是个普遍的替换方案,得看你的模式库用没用这些特性。
零宽断言最容易踩哪个坑?
零宽断言分四种:正向先行(?=...)、负向先行(?!...)、正向后行(?<=...)、负向后行(?<!...)。
"零宽"的意思是它只判断条件、不消耗字符。Java 官方 javadoc 的说法是 “zero-width positive lookahead / lookbehind”——匹配位置不前进,所以可以多个断言串在同一个位置。
真正容易踩的坑是后行断言的长度限制,各引擎的支持情况差别很大:
| 引擎 | 后行断言是否支持变长 | 依据 |
|---|---|---|
Pythonre | 不支持,必须定长 | 官方文档:abc或 `a |
| PCRE2 | 10.43 起支持,之前只支持各分支定长 | 官方文档:10.43 起(?<=colou?r)可用 |
| JavaScript | 允许无界(ES2018 起) | TC39 提案 Stage 4,Chrome 62 首发 |
| Java | 官方 javadoc 未明文说明长度限制 | 未找到官方依据 |
Python 的限制是最硬的,官方原文:
The contained pattern must only match strings of some fixed length, meaning that abc or a|b are allowed, but a* and a{3,4} are not.
所以在 Python 里写(?<=\d+)会直接报错。想用变长的后行断言,得换第三方regex模块(注意这是第三方包,不是标准库)。
PCRE2 的时间线也值得记一下:10.43 是分界点。在这个版本之前,(?<=colou?r)这种写法是不被支持的。如果你的环境是较老的 PCRE2,迁移正则时会遇到"写法没错但引擎不认"的情况。
JavaScript 这边,规范层面早就允许无界后行断言(TC39 提案 Stage 4),但主流浏览器全面可用是 2023 年 3 月(MDN 的 Baseline 口径)。也就是说如果你的兼容基线比较老,这段代码在某些浏览器上会直接抛语法错误——正则的兼容性问题不是运行时报错,是整个脚本解析失败,这一点比普通 API 兼容要严重。
引擎会偷偷帮你优化正则吗?
一个不太为人所知但很有用的机制。PCRE2 会把某些贪婪量词自动编译成占有形式:
PCRE2’s “auto-possessification” optimization … the pattern “a\d+” is compiled as if it were “a\d++” because there is no point even considering the possibility of backtracking into the repeated digits.
a\d+里的\d+后面没有依赖它的结构,回溯进去毫无意义,所以引擎直接把它当\d++处理。这意味着很多"看起来有回溯风险"的模式,实际上引擎已经优化掉了。
但这个优化不是万能的,也有它自己的坑。官方提到,在不想要这个行为时(比如用 DFA 匹配模式做多次匹配)需要显式关掉:
If you really do want multiple matches in such cases, either use an ungreedy repeat (“a\d+?”) or set the PCRE2_NO_AUTO_POSSESS option
所以遇到"明明写法一样,行为却不一样"的情况,先看看是不是 auto-possessification 在起作用。
四个流传说法,逐个对一下
“正则匹配慢是机器的问题”——不是。Russ Cox 的原文里说得很清楚,回溯实现在最坏情况下需要指数级搜索,这是算法问题,不是 CPU 问题。升级服务器解决不了指数复杂度。
“.*和.*?性能差不多”——不能一概而论。官方文档里没有这个结论。PCRE2 提供 auto-possessification 和 DFA 匹配等多种优化路径,实际性能取决于引擎和模式上下文。安全的说法是"取决于具体模式和引擎,不能一概而论"。
“所有语言的正则语法都一样”——不成立,反例很多:Python 的后行断言必须定长、JS 允许无界;PCRE2 直到 10.43 才把X{,4}解释成量词(官方明确说 “Other regular expression engines may behave either way”);RE2 干脆不支持反向引用和环视。
“RE2 能完全替代 PCRE”——官方明确否认。RE2 README 里写着它"不实现所有 Perl/PCRE 提供的特性",且"原则上不支持只有回溯才能实现的结构"。
一条排查思路
遇到正则问题,按这个顺序定位,比乱改模式快得多:
| 现象 | 可能的原因 |
|---|---|
| 匹配到了不该匹配的 | 贪婪吃多了,试试懒惰或加边界 |
| 贪婪改了还是不匹配 | 后行断言长度超限(看引擎) |
| 小数据没事,大数据卡死 | 嵌套量词导致灾难性回溯 |
| 换个语言就跑不通 | 引擎特性差异(环视/反向引用/变长后行) |
| 明明写了不动 | ()捕获 vs(?:)非捕获、或锚点位置不对 |
最后一条补充个实用技巧:调试正则时先拿一个肯定会失败的输入试一遍。灾难性回溯往往只在"接近匹配但最终失败"的输入上才爆发,正常数据上测不出来。
正则的引擎差异比语法本身更值得记,因为语法错了会报错,引擎差异只会静默地给出不同结果。我现在的做法是把「特性—引擎支持情况—替代写法」整理成一张对照表统一维护,需要的时候用AI 图文同步一次推到几个平台留档,集中排查的那几天靠发文额度提升不用排队,发完再用批量 GEO 检测确认这些内容在 AI 搜索里有没有被引用墨衍会员权益 有需要可以了解。
常见问题
Q:怎么快速判断一个正则有没有 ReDoS 风险?
找"嵌套量词"和"可选分支能匹配同一字符"这两种结构:(a+)+、(a*)*、(a|a)+、(\s*)+。有就重点看。判断标准是能不能构造一个"几乎匹配但最终失败"的输入——如果不能,风险就低。
Q:什么叫"回溯栈点"?
引擎在遇到可选分支时,会把当前位置记下来压栈,作为"这条路走不通时可以回来试另一条"的位置。原子组(?>...)一旦退出就把组内的栈点全丢了,所以组内的分叉不会再被回退试探。这也是它比贪婪量词更"确定"的原因。
Q:Python 里想用变长后行断言怎么办?
两个选择:用第三方regex模块(支持变长后行,但需注意它不是标准库),或者改写思路——把后行断言换成"先匹配前一部分,再在代码里做条件判断"。后一种更可控,也不依赖额外依赖。
Q:为什么同一个正则在不同语言里行为不一样?
因为引擎不同。Java、Python、Perl 走的是回溯路线(特性丰富、可能慢),Go 的标准库regexp和 RE2 走的是自动机路线(线性时间、功能受限)。选语言的时候顺便看下它的正则引擎,比事后调 bug 划算。
Q:(?>和(?=有什么区别?
完全不同。(?=是先行断言——零宽,只判断条件不消耗字符,匹配成功后位置不前进;(?>是原子组——会真实消耗字符,但退出后不给回退机会。一个是"看看",一个是"吃了不吐"。
关于墨衍:如果你也在多个平台发技术文章,值得看看墨衍。三个最常用的权益——发文额度提升(密集更新不再受限)、批量 GEO 检测(一次扫完全部文章的 AI 引用状态)、AI 图文同步(一稿多平台分发)。点这里了解墨衍会员