正则表达式:又爱又恨

作者:晚风信箱 发布时间: 2025-10-05 阅读量:79 评论数:0

我电脑上有个文件叫 regex.txt,从 2016 年记到现在,一共 73 条。每条上面都有一行注释写着这玩意儿是干什么的。这个文件的存在本身就说明了一切——正则是我唯一需要给自己写备忘录的语言。

先说爱

爱它的时刻通常长这样:某天下午,产品拿来一个 40 万行的日志文件,说想知道有多少个用户在支付页面停留超过 30 秒。

如果写程序,要读文件、解析时间、分组、聚合,怎么也得二十分钟。我用一行搞定了:

grep -oE 'uid=[0-9]+ .*stay=([3-9][0-9]|[0-9]{3,})' app.log | wc -l

17 秒出结果。这种时候正则给人的爽感是别的东西替代不了的:你的意图和你敲的字符之间几乎没有距离。

另一个我常用的场景是批量改代码。有一次要把 300 多处 logger.info("xxx: " + var) 改成结构化日志,我在编辑器里用捕获组配替换,十分钟改完,diff 干干净净。手改的话得改一天,而且必然漏掉几处。

再说恨

第一次:CPU 打满

2018 年,我们一个日志清洗服务在半夜 CPU 打到 100%,持续了 40 分钟。定位下来是一条正则,形状大概是这样:

^(\s*\w+\s*)+:.*$

问题出在 (\s*\w+\s*)+:内层的 \s* 和外层的 + 存在大量等价的匹配划分方式。正常的行毫秒级返回,但那天来了一行 78 个字符的、结尾没有冒号的日志,回溯路径直接指数爆炸。这就是所谓的灾难性回溯。

修法很简单,把 \s* 换成不产生歧义的写法,再给整条加个长度前置检查。但我从那天起养成了一个习惯:任何要跑在热路径上的正则,我都会先构造几条恶意输入压一遍。

第二次:贪婪毁掉的一天

写 HTML 抽取的时候用了 <div class="item">(.*)</div>。测试数据里只有一个 item,跑得好好的。上线后发现每页只抽出一条记录,而且内容长得离谱——.* 一路贪婪吃到了最后一个 </div>

改成 (.*?) 之后好了一半,然后遇到嵌套 div,又错了。这次我认了,换成了 HTML 解析器。

第三次:三个月后我看不懂自己写的

这是最让人挫败的一种恨。我写过一条邮箱加域名白名单的正则,138 个字符,一堆分组和断言。三个月后要加一个域名,我盯了十分钟没敢动,最后重写了一条。

我现在给自己定的规矩

  1. 超过 50 个字符就必须换行加注释。Python 用 re.VERBOSE,其他语言用字符串拼接,每段后面写清楚匹配什么。
  2. 结构化数据不用正则。HTML、JSON、CSV、URL,一律用对应的解析器。正则只用来处理真正非结构化的文本。
  3. 热路径正则预编译,并且写单元测试。我的测试里必须有三类样本:正常、边界、恶意长输入。
  4. 能拆就拆。与其写一条巨型正则,不如写三条小的然后用代码串起来。可读性和可调试性都好太多。
  5. 用可视化工具画一遍。那种能把正则渲染成铁路图的网站,帮我抓出过至少五个逻辑错误。

一点反思

我以前觉得,能写出复杂正则是一种炫技。现在觉得刚好相反:能写出复杂正则说明你偷懒了,你把本该显式表达的逻辑压进了一行密码一样的字符串里。

正则是一把很快的刀,但它没有刀柄。你握得越紧,割到自己的可能性越大。

那个 regex.txt 我还在维护。最近新增的一条是匹配 ISO 8601 时间的,我在注释里写了一句:「如果你在用它做时间解析,停下,去用标准库。」

评论