正则为什么会把 CPU 跑满:灾难性回溯 (ReDoS) 的识别、Bench 基准测试与超时兜底

· 约 4 分钟 🔍 Regex Pro

一条看着人畜无害的正则,喂给某条特定输入,能让一个 CPU 核心烧到 100%、卡死几秒甚至几分钟——这就是 ReDoS(正则表达式拒绝服务)。它的可怕之处在于:代码评审时根本看不出问题,单元测试也常常跑得飞快,只有那条精心构造(或恰好撞上)的输入才会引爆。这篇讲清成因、怎么用 Regex Pro 的 ⏱ Bench 实测、以及上线前必须叠的兜底。

为什么是”指数级”慢

JS、Java、Python 默认用的都是回溯型正则引擎:匹配走不通时,它会退回去换一种拆分方式重试。问题出在”同一段文本能被多种方式重复匹配”时——拆分组合的数量随输入长度指数爆炸

经典炸弹 (a+)+$ 匹配 aaaa…aaab

  • 那串 a 既能被内层 a+ 吃掉,又能被外层 + 再切一刀;
  • 引擎要试遍”把 N 个 a 切成若干段”的每一种组合,是 2^N 量级
  • 末尾的 b$ 永远失配,于是引擎把所有组合跑满才肯认输。

致命的不是匹配成功,是匹配失败那条路。 成功往往一击即中,失败才会触发穷举——这正是攻击者构造 ReDoS 输入的着力点。

三类高危长相

模式例子为什么危险
嵌套量词(a+)+(\d+)*(.*)*量词套量词、内层能匹配多字符,组合爆炸
重叠交替 + 量词(a|a)*(\w|\d)+分支能匹配同一字符,每个字符两条路
相邻贪婪通配.*.*\s*\S*\s*两个不限长量词争抢同一段文本

对照安全例:a+ 安全、(?:abc)+ 安全(每次必吃固定 3 字符、无歧义)。判断口诀:量词嵌套量词 + 内层能吃同一批字符 + 有可能失配的结尾 = 指数回溯。

用 ⏱ Bench 实测,而不是凭感觉

点 Regex Pro 状态栏的 ⏱ Bench,它会拿当前 pattern + 当前测试文本,在 500ms 预算内反复扫描,输出单次耗时的分位数与吞吐:

指标怎么读
p50典型耗时(中位数)
p95 / p99最坏尾部——ReDoS 的标志是 p99 ≫ p50
iterations预算内跑了几次;个位数说明一次扫描就快用光 500ms
bytesPerSec吞吐;线性安全的正则稳定,危险正则随输入变长骤降

测法是关键:别用能匹配的样本——那跑得飞快、掩盖问题。要专门喂”接近匹配但最终失配”的输入(一长串 a 接一个 X),逐步加长,看耗时是不是翻倍增长。Bench 在 Web Worker 里跑,即便真卡死也只是 worker 超时重建,页面不冻。

怎么改写才能根治

按通用性从高到低:

1. 具体字符类替代通配(最常用、最有效)

危险:  <(.*)>      在 <a><b> 上回溯 + 贪婪双重问题
安全:  <([^>]*)>   遇到 > 立刻停,不给引擎回头机会

2. 拍平多余的嵌套量词

危险:  (a+)+       想表达"一个或多个 a"
安全:  a+          外层那层量词纯属多余

3. 锚定 + 限长:加 ^ ${1,N} 上界,把搜索空间框死。

4. 原子组 / 占有量词(注意引擎差异)

(?>a+)+     原子组:已匹配部分不回退
a++  a*+    占有量词

⚠️ JavaScript 直到 ES2024+ 才支持 (?>...) 和占有量词,老环境用不了;Java 一直支持。所以浏览器里优先用第 1、2 招——它们跨引擎通用。

改完务必回 Bench 用同一条失配输入复测,确认 bytesPerSec 稳定、耗时不再随长度爆炸。

工具调优 ≠ 生产防护

Regex Pro 在 Worker 里跑匹配、设了约 4.5 秒超时(超时就 terminate 掉 worker 再重建),所以你在工具里写出灾难性正则只会看到”超时”,不会卡死浏览器。但这只保护调试体验,不是你的线上防线。生产必须自己叠两层:

  1. 执行超时——Node 把正则放 worker_threads 子线程 + AbortController / 超时 terminate;Python 用 signal.alarm 或子进程超时。
  2. 降级返回——超时或异常时返回”原值 / 校验不通过”,而不是抛未捕获异常或返回空。

纵深防御:能换非回溯引擎就换——Go 的 regexp、Rust 的 regex 基于 RE2,线性时间、天然免疫 ReDoS。尤其当 pattern 由用户输入(允许用户填正则的搜索框)时,要么白名单要么上 RE2。

正则性能问题不靠”看起来还行”判断,靠 Bench 实测 + 生产兜底。工具帮你在上线前把炸弹拆掉,剩下的纵深防护,留给架构。

❓ 常见问题

灾难性回溯到底怎么发生的?为什么慢得是指数级而不是慢一点?

根因是回溯型引擎(JS / Java / Python 默认都是)在匹配失败时会穷举所有可能的拆分方式。当正则里有"一段文本能被多种方式重复匹配"时,拆分方式的数量随输入长度指数爆炸。经典案例 (a+)+$ 去匹配 aaaaaaaaaaaaaaaaaaaaba 串既可以被内层 a+ 吃、又可以被外层 + 再分,引擎要试遍每一种"把 N 个 a 切成若干段"的组合,是 2^N 量级;末尾的 b$ 永远失配,于是引擎把所有组合跑满才认输。直觉判断:只要存在 (X+)+(X*)*(X+)* 这种"量词套量词、且 X 能匹配同一批字符"的结构,再配一个会失配的结尾,就具备指数回溯的条件。一次成功匹配可能很快,致命的是不匹配的那条输入——这正是攻击者构造 ReDoS 的入口。

哪些正则长相要警惕?我怎么一眼看出有 ReDoS 风险?

盯三类结构:(1) 嵌套量词 (a+)+(\d+)*(.*)*——量词套量词且内层能匹配多字符,最危险;(2) 重叠的交替分支配量词 (a|a)*(\w|\d)+——分支之间能匹配同一个字符,引擎得对每个字符试两条路;(3) 相邻的贪婪通配 .*.*\s*\S*\s* 这类"两个不限长量词争抢同一段文本"。反例对照——(a+)+ 危险,但 a+ 安全、(?:abc)+ 安全(每次必吃固定 3 字符、无歧义)。把可疑 pattern 贴进 Regex Pro 开「解释」面板,AST 会把 .*a+ 这类标成"贪婪、可能回溯",看到量词嵌套量词就该改写。最稳的验证是下一条说的 ⏱ Bench 实测。

Regex Pro 的 ⏱ Bench 怎么用?输出的 p50 / p95 / bytesPerSec 怎么读?

点状态栏 ⏱ Bench,工具会拿当前 pattern + 当前测试文本在 500ms 预算内反复跑匹配,统计单次扫描耗时的分位数。读数:(1) p50(中位耗时) 看典型情况;(2) p95 / p99 看最坏尾部——ReDoS 的特征是 p99 远高于 p50,或者干脆只跑得了一两次(iterations 极少)就用光预算;(3) bytesPerSec(吞吐) 衡量"每秒能扫多少字节",线性安全的正则这个值稳定,危险正则会随输入变长骤降。关键测法:别只用能匹配的样本测,要专门构造"接近匹配但最终失配"的输入(如一长串 a 后面接个 b),这才是回溯炸弹的引信。Bench 跑不动 / 耗时随输入翻倍增长,就是确凿信号。注意 Bench 在 Web Worker 里跑,真卡死了也只是 worker 超时被重建,不会冻住整个页面。

怎么改写才能消除回溯?原子组和占有量词在浏览器里能用吗?

从根上消歧义,四条路:(1) 用具体字符类替代通配 —— 把 <(.*)> 改成 <([^>]*)>[^>] 一旦遇到 > 就停,不给引擎回头的机会,这是最常用也最有效的一招;(2) 把嵌套量词拍平 —— (a+)+ 想表达的若是"一个或多个 a",直接写 a+ 即可,外层那层量词纯属多余;(3) 锚定 + 限定长度 —— 加 ^ ${1,N} 上界,把搜索空间框死;(4) 原子组 (?>...) / 占有量词 a++ a*+ —— 让已匹配的部分"不回退",从语言层面切断回溯。但要注意引擎差异:Java 支持 (?>...)a++JavaScript 直到 ES2024+ 才支持 (?>...) 和占有量词,老环境不可用,所以浏览器里优先用"具体字符类 + 拍平嵌套"这条最通用的路。改完务必回 Bench 用同一条失配输入复测。

调好了不卡,上线还需要做什么?Regex Pro 的超时能代替生产防护吗?

不能,工具的超时只保护调试体验,不是你的线上防线。 Regex Pro 在 Web Worker 里跑匹配并设了约 4.5 秒超时——超时就 terminate 掉 worker 再重建,所以你在工具里写出灾难性正则只会看到"超时"而不会卡死浏览器。但生产环境必须自己叠两层:(1) 执行超时 —— Node 用 worker_threads 把正则放子线程 + AbortController / 超时 terminate;Python 用 signal.alarm 或子进程超时;别让一条用户输入的待匹配文本把主线程占满。(2) 降级返回 —— 超时或异常时返回"原值 / 校验不通过"而非抛未捕获异常或返回空。额外纵深:能用非回溯引擎就用(Go 的 regexp、Rust 的 regex 基于 RE2,线性时间、天然免疫 ReDoS);用户可控的 pattern(搜索框允许用户输正则)务必白名单或换 RE2。工具调优 + 生产兜底,两道防线缺一不可。

🔍 打开 Regex Pro 对标 regex101·pattern 语法着色·AST 中文解释·命名组捕获/回引·替换预览·大文本 grep 模式·Web Worker 超时保护·本地运行

📖 同一工具的其他教程