引言
正则表达式人人会用,但「为什么有些正则秒回、有些卡死 CPU」很少人讲透。答案在引擎:主流语言用的是带回溯的 NFA 引擎(慢但强大),而 RE2 用的是 DFA 引擎(快但受限)。本文从引擎讲起,把贪婪/懒惰/零宽断言/灾难性回溯一次讲清,并给出可直接照抄的实用模式库。
前置:基本的正则语法(
* + ? {} [] ())。BNF/形式语言基础见 /bnf-backus-naur-form/。
目录
- 1. 正则引擎:DFA 与 NFA 的战争
- 2. 回溯机制:为什么 NFA 慢
- 3. 贪婪 vs 懒惰 vs 占有量词
- 4. 零宽断言:lookahead 与 lookbehind
- 5. 灾难性回溯(ReDoS)与防护
- 6. 跨语言正则差异速查
- 7. 实用模式库:邮箱、URL、IP、数字
- 8. Unicode 正则与旗标
- 9. 正则 vs 解析器:何时该停手
- 10. 调试与速查表
- 延伸阅读
1. 正则引擎:DFA 与 NFA 的战争
正则引擎分两大类,直接决定速度与能力:
| 特性 | DFA(确定性有限自动机) | NFA(非确定性有限自动机) |
|---|---|---|
| 匹配速度 | 线性,与正则无关 | 可能指数级(回溯) |
| 表达能力 | 有限(无反向引用/懒惰量词) | 强大 |
| 内存 | 固定 | 按正则复杂度 |
| 典型实现 | RE2、Go regexp、Rust regex | PCRE、Java/Python/JS/Perl |
DFA 更快的原因:一次扫描文本,每个字符走确定状态,不回头。NFA 更强大的原因:允许「多路猜测」,用回溯试所有可能——能力换性能。
对照表:
| 语言 | 引擎类型 | 反向引用 | 灾难回溯风险 |
|---|---|---|---|
Go regexp | DFA(RE2) | ❌ | 无 |
Rust regex | DFA | ❌ | 无 |
| Java / Python / JS / PHP | NFA(回溯) | ✅ | 有 |
| .NET | NFA + 优化 | ✅ | 有(可调 timeout) |
结论:不受控的正则输入走 DFA 引擎(Go/Rust),需要复杂能力时用 NFA 但加防护(见第 5 节)。
2. 回溯机制:为什么 NFA 慢
NFA 引擎匹配失败时会回退重试,这就是回溯:
匹配 "ababababab" 用 ^(a+)+$
一步步:a+ 贪婪吃掉所有 a,然后 $ 发现后面还有 ab,失败 → 回溯让 a+ 少吃一个 → 再试…… 每次少吃一个都要重新分配外层 + 的迭代,尝试次数随文本长度指数增长。
回溯的方向:
| 量词 | 默认方向 | 效果 |
|---|---|---|
a+ / a* / a{n,} | 贪婪(先吃尽量多) | 失败时逐步吐出 |
a+? / a*? | 懒惰(先吃最少) | 失败时逐步多吃 |
a++ / a*+ | 占有(不退让) | 失败直接整体失败,不回溯 |
心智:回溯是「猜错重来」,量词越多、嵌套越深,猜测组合爆炸。
3. 贪婪 vs 懒惰 vs 占有量词
贪婪(默认)——尽力多吃再吐:
文本: <div>x</div><div>y</div>
<.+> → 匹配到整个 "</div><div>" 最长的尖括号段(吃到最后再吐)
懒惰——尽力少吃再补:
<.+?> → 匹配 "<div>"(最短尖括号段)
占有——吃完不退:
<.++> → 一次吃完,后续不回溯(速度快,但可能匹配不到预期)
| 需求 | 用 |
|---|---|
| 标签/引号内容 | 懒惰 .+? |
| 整行/整块 | 贪婪 .*(但要小心跨界) |
| 性能敏感、无嵌套 | 占有 .*+ 或原子组 (?>...) |
| 分隔符包裹 | [^>]* 优先(见下) |
更优替代——用否定字符类避免回溯:
# 懒人写法(可能灾难回溯)
<(.*?)\1> # 反向引用 + 懒惰
# 推荐写法
<([^>]+)> # 直接排除尖括号,无回溯
黄金法则:能用
[^...]排除,就别用.*?懒惰——排除字符类天然线性。
4. 零宽断言:lookahead 与 lookbehind
断言不消耗字符,只「看位置」:
| 语法 | 名称 | 含义 |
|---|---|---|
(?=x) | 正向先行 | 后面是 x |
(?!x) | 负向先行 | 后面不是 x |
(?<=x) | 正向后行 | 前面是 x |
(?<!x) | 负向后行 | 前面不是 x |
实用示例:
# 千分位:位置前面是数字、后面是 3 的倍数位
\d(?=(\d{3})+$)
# 密码强度:必须同时满足大写/小写/数字
^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$
# 不匹配某关键词的句子
^(?!.*password).*$
# 金额提取(前有 $ 后有数字)
(?<=\$)\d+(\.\d+)?
注意:(?<=...) 变长后行(如 (?<=\w+))在 PCRE/Python 支持,但 JS 老版本不支持——跨端要注意。
5. 灾难性回溯(ReDoS)与防护
ReDoS:恶意输入让 NFA 正则指数回溯,CPU 被打满 → 服务拒绝。
高危模式(看到就警惕):
| 模式 | 为什么危险 |
|---|---|
(a+)+ | 嵌套量词,组合爆炸 |
| `(a | a)+` |
(a*)* | 双层星号 |
\s*\S*.* | 多个重叠宽量词 |
防护手段:
| 手段 | 做法 |
|---|---|
| 改用 DFA 引擎 | Go/Rust regex,天然免疫 |
| 设置超时 | Python regex 库、.NET RegexOptions timeout |
| 简化正则 | 消除嵌套量词、用否定字符类 |
| 输入长度限制 | 先截断/校验长度再匹配 |
| 静态扫描 | r2c / semgrep 检测高危模式 |
| 拒绝用户正则 | 业务正则自己维护 |
测试工具:regex101(显示回溯步骤/耗时)、rxxr2(ReDoS 检测)。
企业级经验:面向互联网的正则一律过 ReDoS 扫描,输入受限、引擎受限、时间受限三选一。
6. 跨语言正则差异速查
| 特性 | PCRE | Python re | JS | Go regexp |
|---|---|---|---|---|
反向引用 \1 | ✅ | ✅ | ✅ | ❌ |
后行断言 (?<=) | ✅ | ✅(定长/变长) | ✅(定长) | ❌ |
原子组 (?>...) | ✅ | ❌(需 regex 库) | ❌ | ❌ |
命名组 (?<n>) | ✅ | ✅ | ✅ | ✅ |
递归 (?R) | ✅ | ❌ | ❌ | ❌ |
占有量词 ++ | ✅ | ❌ | ❌ | ❌ |
惰性量词 +? | ✅ | ✅ | ✅ | ✅ |
转义差异:Python/Go 中 \d 默认 ASCII,要 Unicode 数字加 (?a) 或 (?u) 旗标;JS \d 含 Unicode 数字。
常见坑:$ 在 Python/JS 匹配「字符串尾或换行前」;Go 中 $ 严格匹配文本尾(多用 \z)。
7. 实用模式库:邮箱、URL、IP、数字
直接可用的高防误判模式(生产级):
# 邮箱(务实版,不做完美校验)
^[^\s@]+@[^\s@]+\.[^\s@]+$
# URL(含协议 + 路径)
^https?://[^\s/$.?#].[^\s]*$
# 域名
^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$
# IPv4
^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$
# IPv6(简化版)
^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::1)$
# 数字:整数/小数/千分位/负数
^-?\d+(\.\d+)?$ # 普通
^-?\d{1,3}(,\d{3})*(\.\d+)?$ # 千分位
^[+-]?\d+([.]\d+)?([eE][+-]?\d+)?$ # 科学计数
# 日期 ISO
^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$
# 中文用户名(含中文)
^[一-龥_a-zA-Z0-9]{2,20}$
# 去除 HTML 标签
<[^>]+>
# 提取所有引号内容
"[^"]*"|'[^']*'
8. Unicode 正则与旗标
正则的 Unicode 陷阱:\w、.、\d 的「字符」定义随引擎与旗标变化。
关键旗标:
| 旗标 | 效果 |
|---|---|
i | 忽略大小写 |
m | 多行(^/$ 匹配行首尾) |
s | 点号匹配换行 |
u | Unicode 模式(JS) |
a / u | ASCII / Unicode 预定义类(Python) |
Unicode 属性类(PCRE/现代引擎):
\p{L} # 任意字母
\p{N} # 任意数字
\p{Script=Han} # 汉字
\p{Emoji} # 表情符号
\p{Sc} # 货币符号
陷阱案例:
# JS 想匹配「一个任意字符」,但 `u` 下 `.` 不匹配 emoji(占 2 码元)
# 用 . 前加 u 旗标:/^.$/u 才能匹配 😀
# 组合字符:é 可以是 e + 组合重音,或单个码点 é —— 想匹配「一个用户感知字符」需用 \X
记忆:想「人类可读字符」用
\X(字形簇);想「码点」用.;想「词字符」用\w,但要确认 Unicode 旗标。
9. 正则 vs 解析器:何时该停手
正则的边界:无法可靠处理嵌套结构(JSON、HTML、括号平衡)。
| 任务 | 正确工具 |
|---|---|
| 邮箱/数字/简单模式 | 正则 |
| JSON/XML/HTML | 真正的解析器(json.loads / BeautifulSoup / SAX) |
| 嵌套括号表达式 | 递归下降 / ANTLR / Prisma |
| 语言语法 | 形式文法(BNF/EBNF,见 /bnf-backus-naur-form/) |
| CSV 带引号转义 | 专用 CSV 解析器 |
判断信号:一旦你的正则开始出现「勉强 + 回溯暴增 + 多层 .*」——它其实需要解析器。
经验:HTML 用正则解析是经典翻车现场(
<div>嵌套无法用正则平衡),选型时先问「结构是否嵌套」。
10. 调试与速查表
调试工具:
- regex101.com — 实时解释、回溯可视化、多引擎
- regexcrossword.com — 练习
regextester.com— 快速测试
速查表:
| 需求 | 写法 |
|---|---|
| 数字 | \d+ / [0-9]+ |
| 单词边界 | \b(注意中文无边界) |
| 行首尾 | ^ / $(配合 m) |
| 任意字符 | .(配合 s 含换行) |
| 非贪婪 | *? / +? |
| 捕获组 | (...) |
| 非捕获组 | (?:...) |
| 回溯免疫 | 原子组 (?>...) / 占有量词 |
| Unicode 字母 | \p{L} |
一句话记忆:DFA 快而受限,NFA 强而会回溯;量词嵌套是 ReDoS 的温床,[^...] 排除优先于 .*? 懒惰;有嵌套结构就交给解析器。
延伸阅读
- /bnf-backus-naur-form/ — 形式语言:正则的语言层级位置
- /text-processing-toolkit/ — grep/ripgrep 等命令行正则实战
- /dsl-design/ — 当正则不够时如何设计真正的 DSL
- [[cs-fundamentals]] — 编译原理与形式语言系统学习
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。