正则表达式为什么会卡死?
量词套在已量化的分组上,匹配失败时要试的路径随长度翻倍。
因为主流的正则引擎是「带回溯」的,遇到失败要回头换一条路重试。当一个模式可以用多种方式去匹配同一段文字时,引擎会挨个尝试这些可能;如果最终整体匹配失败,它必须把所有可能都试一遍才能确定「确实匹配不上」。某些写法下,可能的切分方式数量随输入长度呈指数增长,于是一段短短几十个字符的文本就能让它算几分钟。
典型的危险形状是「量词套在本身已经量化的分组上」,比如 ^(a+)+$:内层加号可以取一个 a、两个 a、三个 a,外层加号再把这些选择任意重复。给一串 a 后面跟一个不匹配的字符,引擎就得枚举所有切分方式才能判定失败。有意思的是,能匹配的输入永远是快的——爆炸只发生在「差一点就匹配」的输入上,而这恰好是攻击者会发来的、测试用例里永远不会出现的字符串。
一个在测试里跑得好好的正则,上了线偶尔会让页面转圈、把服务卡住几秒甚至更久。日志上看不出异常,CPU 却被打满。这类问题有个名字叫灾难性回溯,也叫正则拒绝服务。
它的机制并不神秘,只是反直觉:问题不出在匹配成功的时候,而出在匹配失败的时候。理解这一点,就能写出既正确又不会爆炸的模式。
1危险形状对照
这些写法看起来很自然,但都能让引擎的尝试次数爆炸。
| 危险写法 | 问题在哪 | 等价的安全写法 |
|---|---|---|
| ^(a+)+$ | 量词套在已量化的分组上 | ^a+$ |
| ^(\w+\s?)+$ | 分组能匹配「带空格」或「不带空格」,长句有指数种切分 | ^\w+(?:\s\w+)*$ |
| (a|a)+ | 两个分支能匹配同样的文本 | 合并成一个分支 a |
| (\d+|\s)+ | 两个分支覆盖的字符集容易重叠 | 用字符类 (\d|\s) 或拆开处理 |
| ^.*_.*$ | 点号能跨过分隔符,回退时反复重试 | 换成 [^_]*_.* |
识别口诀:凡是量词套量词、或者并列分支能匹配同一段内容,就先假设它危险,再想办法让每段输入只有一种切分方式。
2四条反直觉的事实
这些细节决定了你会不会白费力气。
- 能匹配就一直很快
- 爆炸只发生在近似匹配的输入上,所以本地测试用正常数据永远测不出来。
- 改懒惰量词没用
- 把加号改成懒惰加号,路径数量一样多,只是尝试顺序反了,照样卡。
- 加首尾锚点常常更糟
- 锚点要求引擎「证明没有任何切分可行」,而不是第一个局部失败就放弃,于是把最坏情况逼了出来。
- 长度限制很有效
- 指数增长的底数再大,输入被截断到十几个字符时也快得看不出来;先限制输入长度,能挡住绝大多数攻击。
3按优先级修
从「改模式」开始,越往后越像兜底。
| 手段 | 做法 | 代价 |
|---|---|---|
| 重写模式 | 让各分支互斥、每段输入只有一种匹配方式 | 需要真正理解模式语义,是唯一根治的做法 |
| 限制输入长度 | 处理前先截断到业务实际需要的长度 | 极短,几乎没有副作用 |
| 换无回溯引擎 | 使用保证线性时间的引擎(如 RE2 系) | 会失去反向引用等特性 |
| 设置匹配超时 | 把匹配放到能被打断的执行单元里,超时即终止 | 只能止损,不能修好 |
| 升级依赖 | 排除已知存在此类问题的库版本 | 属于间接防护,仍需自查自有模式 |
真实世界里这已经造成过大面积故障:有的平台因为一段「去首尾空白」的正则遇上两万个连续空白字符,停机了半个多小时。
顺着这个问题往下用
全部工具总览 →相关疑问
为什么我的正则只在某些数据上慢?
因为慢的条件是「匹配失败」,而且输入长得像目标、但结尾差一点。这种输入在正常业务数据里很少见,所以测试时一切正常。用带不匹配尾巴的长字符串测一遍,就能复现。
换成懒惰量词有用吗?
没用。懒惰量词只改变尝试顺序,不减少可能路径的数量,遇到会爆炸的模式一样慢。真正的解法是让模式变得无歧义,或者限制输入长度。
加首尾锚点不是能提高性能吗?
在简单模式上通常有益,但在嵌套量词这类危险形状上反而更差。锚点要求引擎穷举所有切分方式、确认全都失败,才肯判定不匹配,正好把最坏情况逼出来。
怎么提前发现这种问题?
静态检查能标出大部分危险形状:嵌套量词、能匹配同一段内容的重叠分支。把这类检查加入代码审查流程,再配合限制输入长度,就已经挡掉绝大多数风险。
继续看:相关问题与资料
全部问题解答 →内容说明:本页针对具体使用问题作答,方法步骤与数值由本站自行整理与复算,事实性内容以对应官方规范与文档为准。内容最后核对日期:2026-10-09。若发现错误,欢迎通过页脚反馈入口告知。
速查在线工具箱