贪婪、懒惰与灾难回溯:解释大多数正则 Bug 的三种行为

正则匹配多了、匹配少了、偶尔永远跑不完——三种症状对应贪婪、懒惰与灾难回溯。讲清回溯怎么发生、如何一眼认出危险写法、以及三条上线前的自查清单。

约 6 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

一句话分清三种行为

贪婪(默认):量词尽量多吃,吃多了往回吐;懒惰(量词后加 ?):尽量少吃,不够再往下吃;灾难回溯:不是新的匹配模式,而是贪婪与懒惰在「多条路都走不通」时的代价——回溯次数随输入长度指数增长,CPU 直接打满。

贪婪:吃到最后一个引号才停
/".*"/  匹配  "a" and "b"
→ 得到 "a" and "b" 整段(从第一个引号吃到最后一个)
懒惰:碰到第一个引号就收
/".*?"/ 匹配  "a" and "b"
→ 得到 "a" 与 "b" 两段

回溯是怎么发生的:正则引擎的「试错 rewind」

引擎遇到量词会先按贪婪吃满,失败后逐字符往回吐、换一条路再试——每吐一个字符就是一次回溯。正常情况下回溯次数是线性的;但当两个量词能对同一段文字有多种分法(嵌套量词,如 (\d+)+),同一个位置会被「不同的分法」重复尝试,次数就从线性变指数。

灾难回溯:一行正则拖垮一台机器

识别危险写法的口诀:两组量词嵌套、中间没有明确的分隔符。常见高危:(\w+)+、(\d|\d)+、(.*)*Q。这类正则打在「正常输入」上一切正常,攻击者构造一个「差一点就能匹配」的输入就能触发——这就是 ReDoS 攻击的原理。

✗ 危险写法:嵌套量词 + 整体不匹配
/^(\d+)+$/  匹配  "1111111111111111111111111b"
→ b 无法匹配,引擎在 25 个 1 的所有分法间回溯
→ 约 800 万次尝试,输入再长一位就翻倍
✓ 修复:消灭歧义分法
/^\d+$/          改写一:整体一个量词,没有嵌套
/^(?>\d+)/       改写二:原子组(占有量词同理),禁止交还已吃字符

上线前的三条自查清单

  1. 量词嵌套了吗?(X+)+、(X*)*、(X{2,})* 一律先改写:能合并成一个量词就合并,不能就用原子组 / 占有量词((?>...)、++、*+)。
  2. 「.*」前面有锚点或明确边界吗?裸奔的 .* 既是性能黑洞也是误匹配之源:优先用否定类([^"]*)代替。
  3. 最坏情况试过吗?拿一段「接近匹配但最终失败」的输入跑一遍——线性回溯毫秒级完成,指数回溯立刻卡住,一试便知。

修不动时换思路:不是所有字符串问题都该用正则

  • 解析嵌套结构(HTML、JSON、括号嵌套)→ 正则表达不了递归(多数引擎),用真正的解析器。
  • 从大文本里抽字段 → 先用明确的分隔符或锚点切小块,再对小块用正则,回溯范围骤减。
  • 校验格式 → 优先匹配「合法长什么样」(白名单),而不是「非法长什么样」;白名单天然少回溯。
  • 引擎层面:Go(RE2)与 Rust(regex crate)压根不带回溯,天然免疫 ReDoS,跨语言选型时值得考虑。

常见问题

正则在线上突然卡死,怎么快速定位是哪一条?

抓一份 CPU 采样(或线程栈 dump),栈停在正则执行处即可锁定表达式;然后用 正则测试器复现——喂一段逐步加长的输入,耗时呈平方或指数增长的就是它。修复优先级:消灭嵌套量词 > 加原子组 > 缩小匹配范围。

懒惰量词比贪婪快吗?

不一定。懒惰多用于「少匹配」的正确性需求,性能上它同样要回溯(方向相反)。真正的性能差异来自「能否一步到位」:用否定类 [^x] 一步吃到边界,通常比任何量词加懒惰标记都快。

原子组和占有量词是什么?

同一思想的两种写法:匹配过的字符绝不交还(禁止回溯到组内)。代价是牺牲一部分匹配能力(某些原本能匹配的输入现在匹配失败),收益是彻底消灭指数回溯。JS 从 ES2018 起支持,Go、PCRE、Java 也都可用。

正则回溯能彻底关掉吗?

多数引擎做不到全局关闭,但有等效手段:① 用原子组 / 占有量词包住高危段;② 选不带回溯的引擎(Go 的 RE2、Rust 的 regex crate)跑用户可输入的正则;③ 对外暴露正则功能时加输入长度上限与超时熔断——三层里做任意一层,ReDoS 就打不进来。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件