← 返回文章列表

正则的灾难性回溯:一个能拖垮服务的写法

正则避坑

问题长这样

(a+)+$ 这类嵌套量词遇到 aaaa...! 时,引擎会反复回溯,耗时随长度指数增长,把 CPU 打满。

危险信号

  • 量词套量词:(a+)+、(a*)*;
  • 多个分支都能匹配空:(a|ab)* 配长串;
  • 在不可信输入上跑复杂正则。

怎么规避

  1. 改用确定型写法:用具体字符类、避免嵌套量词;
  2. 给正则加超时:很多引擎支持匹配超时,防止单条请求卡死;
  3. 先用长度 / 格式粗筛:再上正则,缩小攻击面。

把正则加固到可上线

  1. 加超时或隔离:能用带超时的库就用;否则把匹配放进子进程并设硬超时,防止单条请求拖垮进程;
  2. 改用线性引擎:Go 的 RE2、Rust 的 regex 用自动机实现,最坏情况线性,处理不可信输入时应优先选择;
  3. 用原子组或占有量词:支持 (?>…) / *+ 的引擎可禁止回退,把指数变成线性;
  4. 先做廉价校验:长度上限、字符集白名单、必须包含的关键字符,先用普通字符串操作挡掉大部分输入;
  5. 预编译并复用:正则编译本身有开销,请求路径上应预编译;同时避免在循环里重复构造。

写正则的几条纪律

  • 避免量词嵌套:(a+)+、(w*)* 一律改写为不嵌套的等价形式;
  • 用具体字符类替代通配:[a-z0-9] 比 . 更可预测,也更快;
  • 锚定边界:能加 ^ / $ 就加,减少无谓的搜索尝试;
  • 宁可多个简单正则:把复杂表达式拆成几个顺序判断,既好读也避免回溯叠加。

上线前的验证方法

用三类输入做基准测试:最短合法串、最长合法串、以及“几乎匹配”的对抗串(如 a 重复 30 次后跟一个不合法的字符)。若耗时随长度呈指数增长,就必须改写或加超时,而不是靠“线上没出事”侥幸上线。

哪些语言与引擎默认安全

  • 线性引擎:Go 的 regexp(RE2 语义)、Rust 的 regex、以及多数 grep 实现不会回溯,最坏情况线性;
  • 回溯引擎:PCRE、Java、Python re、JavaScript 正则都是回溯型,危险模式会指数爆炸;
  • 部分语言提供安全模式:如 Java 可通过独立线程加超时中断,Node 可把匹配放进 worker 并设时限;
  • 注意库差异:同一语言的不同库(如某些第三方实现)行为可能不同,引入前确认其引擎类型。

监控与止损

上线后应对匹配耗时加埋点:当 P99 超过阈值或出现单个请求耗时异常放大时立即告警。若已发生拒绝服务,最快的止损是先在网关按输入长度拦截,再回头改写正则,而不是临时加机器。