正则表达式实战模式:锚点、量词、捕获组

2026-04-13发布 8分钟阅读

摘要 (TL;DR)

2019 年 7 月 2 日,Cloudflare 因为一条单独的正则——WAF 新规则里形如 .*(?:.*=.*) 这种几乎匹配但失败的模式——让全球流量在 27 分钟内掉了将近 50%。PCRE 的回溯把一个 CPU 核钉到 100%,所有请求堵在它后面。这个事故对正则给出最诚实的教训是:正则会隐藏自己的代价。平时它小、快、迷人;某一行错的输入就能让一个系统停摆。所以这篇文章语气稍微带点怀疑。日常正则工作大多建立在几样部件上:把模式钉在开头、结尾或单词边界的锚点;描述”这些字符之一”的字符类;说”几次”的量词;让你捕获、引用、或并列选项的。这些用对了,常见问题——粗略校验邮箱、从日志行抽字段、规整电话号——就能写得短又可读。用错了,你的模式要么匹配太多要么太少,或者像 Cloudflare 那次让引擎停转。别用正则解析 HTML、JSON、XML——正则语言无法描述平衡嵌套。引擎之间也有差别:ECMAScript 正则、PCRE(Perl/PHP)、Python 的 re、Oniguruma(Ruby 与 Rust 的 onig crate)、Go 的 RE2 各有微妙差异。Cloudflare 那次事故只发生在 PCRE 上;用 RE2 它会以线性时间完成。

背景与概念

正则表达式是匹配字符串的紧凑文法。最常用的几种部件值得明确命名。

锚点不消耗字符——它断言位置^ 是输入起点(在 multiline 模式下是行起点),$ 是终点,\b 是单词边界(\w 与非 \w 的转换)。无锚点的 abc 会匹配长字符串里的任何位置;带锚点 ^abc$ 只匹配字面整串 abc\babc\b 只匹配作为独立单词的 abc

字符类描述一个字符属于某集合。[abc]abc 之一,[a-z] 是任意小写字母,[^0-9] 是”任何不是数字的字符”。常用情况有简写:\d(数字)、\w(单词字符——字母、数字、下划线)、\s(空白)。大写 \D\W\S 是反集。. 默认匹配除换行外的任意字符;s(dotall)旗标改这一行为。

量词附在前一 token 上,规定重复次数。* 是 0 次以上,+ 是 1 次以上,? 是 0 或 1,{n,m} 是”n 到 m 次”(任一边界可省略)。默认是贪婪(greedy):尽可能多匹配,只有在后面失败时才一格一格回退。加 ? 变成懒惰(lazy)——*?+???——尽可能少匹配,只有被强制时才扩展。一些引擎支持占有(possessive)量词(*+++),贪婪匹配但失败也不回退,能在病态输入里阻止灾难性回溯——这正是 Cloudflare 那次事故里缺失的工具。

包住子模式。(pattern)捕获组,按左括号顺序从 1 编号,模式内可用 \1 引用,宿主语言里可用索引访问。(?<name>pattern)命名捕获(?:pattern)非捕获组,纯粹用来选择((?:cat|dog))或量词分组而不记录匹配。组里的 | 是替选(alternation)。

最后,旗标改引擎行为:i 不区分大小写、m 多行(^ $ 在行边界匹配)、s dotall、u Unicode。在 JavaScript 里 u 旗标还启用 Unicode 属性转义,比如 \p{L} 匹配”任何字母”。

对比与数据

量词贪婪(默认)懒惰(? 后缀)占有(支持时)
* / *? / *+匹配尽量多,失败回退匹配尽量少,强制时扩展匹配尽量多,不回退
+ / +? / ++1 次以上,贪婪1 次以上,懒惰1 次以上,不回退
? / ?? / ?+0–1,偏 10–1,偏 00–1,不回退
{n,m} / {n,m}? / {n,m}+范围,贪婪范围,懒惰范围,不回退

贪婪是默认是因为大多数情况下它就是对的。懒惰需要用到的场合是”后面模式”本身太宽容。从 HTML 摘录里取出 <b>...</b> 一对要写 <b>(.*?)</b>,否则贪婪 .* 可能吞掉多个标签(即便如此也别用正则解析真实 HTML)。占有量词与原子组 (?>...)几乎匹配但失败的模式上能切断指数级回溯路径。PCRE、Java、Oniguruma 支持它们;ECMAScript 与 Python re 历史上不支持,不过 Python 3.11 给 re 模块加了原子组。

实战场景

**场景 1 — 务实的邮箱匹配。**RFC 5322 的完整邮件文法允许注释、引用的本地部分、嵌套 IP 字面量,覆盖全部的正则臭名昭著地巨大(最有名的尝试 6,425 字符),就算这样也不是真正的解析器。我在生产里几乎一直用 ^[^\s@]+@[^\s@]+\.[^\s@]+$——“非空、无空白、@ 不在错位置、域名至少一个点”——能挡住明显的笔误,但不假装在做完整校验。真正确认地址只能发一封信去。形状用正则,存在用邮件。

场景 2 — 含国际格式的电话号。+82 2-1234-5678(02) 1234-567882-2-1234-5678 描述的是同一个韩国首尔号码。^\+?\d{1,3}[-\s().]*\d{1,4}[-\s().]*\d{3,4}[-\s().]*\d{3,4}$ 这种模式接受常见标点,再用归一化步骤把标点剥成只剩数字的规范形式。任何严肃用途——路由、存储、拨号——请用 Google 的 libphonenumber,不要自己写。我见过这话题最快的会议结论是”我们不用正则做”,那天给团队省了一周边界 case bug。正则的角色是”看起来像不像电话号”的表面检查

场景 3 — 从日志行里抽字段。2026-04-13T02:11:05Z 192.0.2.42 "GET /search?q=foo HTTP/1.1" 200 1534 这样的行可以用一条模式拆分:^(?<ts>\S+)\s+(?<ip>\S+)\s+"(?<method>\w+)\s+(?<path>\S+)\s+\S+"\s+(?<status>\d+)\s+(?<bytes>\d+)$。命名组在这里有价值:得到的匹配对象像字典一样,每个字段按名字取出。日志格式变了时,这条模式本身就是它的文档。

常见误解

**“正则能完整校验邮箱。“**只能校验形状。RFC 5322 复杂到无法合理编码进正则——即便编码了,“形状有效”也不等于”邮箱存在”。业界标准是”简单正则 + 验证邮件”。

**“贪婪总是比懒惰慢。“**未必。如果量词的子模式很受限,贪婪匹配可以一次长前进结束,反而更快。懒惰胜出的场合是后面模式锚定了匹配,比如 <b>(.*?)</b>。在反射性地加 ? 之前先用真实输入做基准。

**“所有正则引擎一样。“**不一样。ECMAScript 正则没有占有量词与原子组(现代引擎的 v 旗标补上一些缺口但不是这两个),Python re 有自己的 Unicode 属性集,PCRE 支持反向引用与递归模式,Oniguruma(Ruby 与 Rust onig crate)又是另一个方言。Go 的 RE2 放弃反向引用与 lookaround,换得运行时间在输入长度上线性——正是 Cloudflare 事故后评估迁移的引擎。从 Perl 教程复制来的模式在 JavaScript 里跑不了是常事,反过来也一样。

“正则可以解析 HTML(或 JSON、XML)。“不行,因为正则语言无法描述平衡嵌套。正则可以从结构化文本里抽出特定、定义良好的子模式——比如某个属性值——但无法正确解析整棵树。嵌套格式要用专门解析器(DOMParserJSON.parse、XML 库、CSV 读取器)。Stack Overflow 的”正则 vs HTML”传说(2009 年那个回答)是警示而不是辩论。

决策清单

  1. **输入是什么?反例是什么?**两个都在写模式之前写下来。
  2. **数据是嵌套或递归结构吗?**是 → 用解析器。正则是错工具。
  3. **目标是哪个引擎?**JS、Python、Go、PCRE 在 lookaround、反向引用、Unicode 上都有差异。
  4. **需要匹配本身还是 yes/no?**只为选择或量词分组的,用非捕获 (?:...)
  5. **模式来自用户输入或作用于不可信输入吗?**用原子组、超时、或 RE2 这种线性时间引擎防灾难性回溯。否则你就会变成 Cloudflare。
  6. **后面有文本归一化吗?**别把所有东西塞进一个巨大模式,把简单形状检查和轻量后处理拆开。
  7. 正则有文档吗?x(extended)模式的注释或上方一行说明,对下一个读它的人是廉价保险。

相关工具

Patrache Studio 正则测试器 在浏览器里对样例输入跑模式,行内显示捕获,比来回切标签更快。如果要匹配的字符串本身是结构化的(带 JSON 载荷的日志行、API 响应),把正则与 JSON 格式化、校验、JSON Schema 在实战中的差异 组合——抽出来的部分用真正的解析器校验,而不是再写一条正则。正则常见目标之一是 URL 里嵌入的 UUID,UUID v1、v4、v7 对比与数据库主键设计 解释为什么同样 36 字符按版本位不同会有不同含义。

参考资料