正则表达式从零到精通:5 大场景实战与灾难性回溯陷阱
^\d{3}-\d{4}$ 是匹配电话号码,^(a+)+$ 看起来人畜无害——但后者在某些输入下会让你的服务器跑到天荒地老。
正则表达式是几乎每个开发者每天都在用的工具,但也是最常被滥用、最容易出性能事故的工具。Cloudflare 在 2019 年 7 月 2 日的全球宕机,根因就是一条正则表达式触发了 CPU 100% 跑满——影响 30 分钟,损失难以估量。
这篇文章从语法基础讲起,覆盖 5 个最常见的实战场景、不同语言/引擎的差异,以及怎么避开正则世界最大的雷——灾难性回溯。
基础语法速查
字符类
| 写法 | 含义 |
|---|---|
. | 任意字符(默认不含换行) |
\d | 数字,等同 [0-9] |
\D | 非数字 |
\w | 字母数字下划线 |
\W | 非字母数字下划线 |
\s | 空白字符(空格/制表/换行) |
\S | 非空白字符 |
[abc] | a/b/c 任一个 |
[^abc] | 不是 a/b/c |
[a-z] | a 到 z 任一个 |
量词
| 写法 | 含义 |
|---|---|
* | 0 次或多次 |
+ | 1 次或多次 |
? | 0 次或 1 次 |
{n} | 恰好 n 次 |
{n,} | 至少 n 次 |
{n,m} | n 到 m 次 |
锚点
| 写法 | 含义 |
|---|---|
^ | 行/字符串开头 |
$ | 行/字符串结尾 |
\b | 单词边界 |
\B | 非单词边界 |
分组与捕获
| 写法 | 含义 |
|---|---|
(abc) | 捕获组 |
(?:abc) | 非捕获组 |
(?<name>abc) | 命名捕获组 |
\1 \2 | 反向引用 |
贪婪 vs 懒惰:很多人栽过的坑
默认所有量词都是贪婪的——尽可能多匹配。在后面加 ? 变成懒惰——尽可能少匹配。
经典例子:提取 HTML 标签内容
字符串:<b>hello</b> <i>world</i>
| 量词 | 贪婪 | 懒惰 |
|---|---|---|
* | * | *? |
+ | + | +? |
? | ? | ?? |
{n,m} | {n,m} | {n,m}? |
真实事故:HTML 解析正则用 <.+> 匹配标签,结果在 <a href="/foo">bar</a> 这种长字符串里直接匹配到 </a> 才停——日志解析、模板替换的 bug 经常源于此。
5 大实战场景
场景 1:邮箱
最常被滥用的正则。先说一个真相——RFC 5322 完整定义的邮箱正则有几百行长。生产中用简化版即可:
覆盖 99% 实际场景。不要去拼"完美"邮箱正则——浪费时间还容易漏;真要严格校验,发一封验证邮件最可靠。
场景 2:手机号(中国大陆)
1开头- 第二位是 3-9(覆盖所有运营商号段)
- 后面 9 位数字
别用 ^1\d{10}$——会让 11/12 开头这种不存在的号段通过。
场景 3:URL
匹配 http/https,允许端口和路径。如果是要从一段文本里提取 URL:
注意把常见结束符 < > " ' 和右括号排除掉。
场景 4:IPv4 地址
简单版(够用):
严格版(每段 0-255):
严格版会拒绝 999.999.999.999,简单版不会——按需选择。
场景 5:从日志行提取字段
Nginx access log 标准格式:
提取 IP、时间、方法、路径、状态码:
用命名捕获组(?<name>)让代码可读:
灾难性回溯:你必须懂的性能炸弹
问题示例
正则:^(a+)+$,输入:aaaaaaaaaaaaaaaaaaaaaaaa!(24 个 a 加一个感叹号)
看起来无害,但匹配会进入指数级回溯——每个 a 都有"属于内层 a+ 还是属于外层重复"两种选择,引擎尝试所有组合。Python 3.13 单线程实测(结果因机器和引擎而异):
| 输入长度 | 匹配时间 |
|---|---|
| 20 个 a | ~80 ms |
| 22 个 a | ~300 ms |
| 24 个 a | ~1.3 秒 |
| 28 个 a | ~21 秒 |
| 32 个 a | 数分钟级 |
| 40+ 个 a | 小时起 |
这就是 ReDoS(Regular Expression Denial of Service)攻击——攻击者构造特殊输入让你的服务卡死。
Cloudflare 的真实事故
2019 年 7 月 2 日,Cloudflare 推送了一条新的 WAF 规则,里面有:
中间嵌套的 .*.*=.* 在某些输入上触发了灾难性回溯,全球节点 CPU 跑满,服务中断 27 分钟。事后 Cloudflare 给出的根因分析点名了这条正则。
识别危险模式
只要正则里有嵌套量词(量词包着量词),就要警惕:
通用规则:多种方式能匹配同一段输入的正则容易出灾难性回溯。
防御手段
1. 用懒惰量词
(a+?)+ 比 (a+)+ 安全得多(但不是万能)。
2. 用占有性量词(Possessive Quantifier)
a*+ 表示"匹配后不允许回溯",从根本上消除指数级搜索:
支持的语言:Java、PCRE、Ruby。JavaScript 不支持。
3. 用原子组
(?>a+) 同样禁止回溯:
支持的语言:Java、PCRE、Python 3.11+、Ruby。
4. 切换到 RE2 引擎(最彻底)
Google 的 RE2 引擎用有限状态机实现,不支持回溯,所有匹配在线性时间内完成——没有 ReDoS 的可能。代价是不支持反向引用和环视。
- Go 的
regexp包基于 RE2 - Python 可用
google-re2包 - Cloudflare 在事故后就切到了 RE2
不同语言/引擎的方言差异
| 语言/引擎 | 引擎类型 | 是否回溯 | 反向引用 | 环视 |
|---|---|---|---|---|
| JavaScript (V8) | 回溯 NFA | 是 | 支持 | 支持(ES2018+) |
Python re | 回溯 NFA | 是 | 支持 | 支持 |
Python regex(第三方) | 回溯 NFA | 是 | 支持 | 支持,更多特性 |
| Java | 回溯 NFA | 是 | 支持 | 支持 |
| PCRE / PHP | 回溯 NFA | 是 | 支持 | 支持 |
| Ruby | 回溯 NFA(Oniguruma) | 是 | 支持 | 支持 |
Go regexp | RE2 DFA | 否 | 不支持 | 不支持 |
Rust regex | DFA/NFA 混合 | 否 | 不支持 | 部分支持 |
结论:在 JS/Python/Java 等回溯引擎里用复杂正则前一定要测压力;性能敏感场景考虑 RE2/Rust regex。
调试技巧
用工具试错
正则不要靠脑补,写一条试一条:
- 站内 正则测试器 实时高亮匹配
- VS Code 的查找替换框就是个迷你正则环境
- 命令行:
echo "string" | grep -E "pattern"
拆分长正则
写出来后觉得不可读?用 verbose 模式 加注释:
Python re.VERBOSE / Perl /x / Java Pattern.COMMENTS 都支持。
用 re.DEBUG 看编译树
5 条实用建议
- 能不用正则就不用——
str.startswith()/str.contains()更快更可读 - 优先非捕获组
(?:...)——不需要捕获时省内存 - 锚定开头结尾
^/$——避免引擎扫描整段字符串 - 测试边界用例——空字符串、超长字符串、特殊字符
- 写完用工具复盘——肉眼看不出灾难性回溯,工具能
总结
正则是把双刃剑——5 分钟能解决一个文本处理问题,也能因为一行 (a+)+ 让你的服务 down 半小时。
关键心法:
- 语法是基础:字符类、量词、锚点、分组烂熟于心
- 场景套模板:邮箱、手机、URL、IP、日志解析有现成模式
- 小心嵌套量词:
(x+)+(x|x)+这类是 ReDoS 雷区 - 性能敏感选 RE2:Go/Rust 用线性时间引擎从根上避坑
- 写完一定测:用站内的 正则测试器 跑边界用例
记住 Cloudflare 那 27 分钟——你不想成为下一个事后报告里的主角。