一条看着人畜无害的正则,喂给某条特定输入,能让一个 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 再重建),所以你在工具里写出灾难性正则只会看到”超时”,不会卡死浏览器。但这只保护调试体验,不是你的线上防线。生产必须自己叠两层:
- 执行超时——Node 把正则放
worker_threads子线程 +AbortController/ 超时 terminate;Python 用signal.alarm或子进程超时。 - 降级返回——超时或异常时返回”原值 / 校验不通过”,而不是抛未捕获异常或返回空。
纵深防御:能换非回溯引擎就换——Go 的 regexp、Rust 的 regex 基于 RE2,线性时间、天然免疫 ReDoS。尤其当 pattern 由用户输入(允许用户填正则的搜索框)时,要么白名单要么上 RE2。
正则性能问题不靠”看起来还行”判断,靠 Bench 实测 + 生产兜底。工具帮你在上线前把炸弹拆掉,剩下的纵深防护,留给架构。