title: 正则不是越复杂越好:一次 ReDoS 把接口 P99 从 50ms 打到 30s 的事故,和 3 个引擎真相
tags: 正则表达式, ReDoS, NFA, Java Pattern, 性能优化
description: 从一次正则回溯爆炸拖垮接口的线上事故,讲清 NFA/DFA 引擎差异、回溯灾难的成因,以及在 Java 里如何写"打不爆"的正则。
我们有个商品搜索接口,接收用户手写的关键词做模糊匹配,匹配逻辑里有一句正则:用来校验"关键词里是否含有嵌套的括号表达式"。平时 P99 在 50ms 左右。某天运营在后台导出了一个带几十个左括号的脏数据做测试,接口直接超时,P99 飙到 30 秒,线程池被打满,整个搜索服务雪崩。
事后定位,凶手就是那条正则。它在遇到特定输入时会指数级回溯,专业名词叫 ReDoS(Regular Expression Denial of Service,正则拒绝服务)。这篇文章把正则引擎的底层和这个坑讲透。
Java 用的到底是哪种引擎
先说一个反直觉的事实:大多数开发者以为正则是"确定性的模式匹配",但Java 的java.util.regex是 NFA(非确定有限自动机)引擎,不是 DFA(确定有限自动机)引擎。
区别在哪?
- DFA:匹配时每个字符只读一遍,状态机顺着走,时间复杂度与输入长度线性相关,且绝不会回溯。缺点是不支持反向引用、捕获组这类高级特性。
- NFA:支持捕获组、反向引用、懒惰/贪婪量词,但它的代价是——当一条路径走不通时,引擎会回溯去试另一条路。回溯次数在最坏情况下会随输入长度指数爆炸。
Java 选了 NFA,所以你能写(a+)+、反向引用\1,但也因此继承了回溯爆炸的风险。理解这一点,是看懂 ReDoS 的前提。
灾难现场:那段"看起来人畜无害"的正则
问题正则简化后长这样,用来匹配"被多层括号包裹的内容":
// 危险正则:用于校验 "((...))" 形式的嵌套括号关键词 private static final Pattern NESTED = Pattern.compile("\\(([a-z]+(\\+[a-z]+)*)\\)"); // 触发灾难的输入 public boolean isNestedKeyword(String input) { Matcher m = NESTED.matcher(input); // 1. 编译一次,这里复用没问题 return m.matches(); // 2. 对用户输入调用 matches —— 灾难入口 }逐行解释:第 1 行Pattern.compile放在静态常量里是对的,正则编译很贵,绝不能每次请求都 compile。第 2 行m.matches()是真正的炸弹——当input类似((((((((((((((((((((a这种"大量左括号 + 结尾不匹配"的字符串时,引擎会疯狂回溯。
为什么会爆炸?看正则里的([a-z]+(\+[a-z]+)*)\)。[a-z]+是贪婪的,它会先吃掉所有字母,然后发现后面没有右括号,于是一步步吐回来尝试分组匹配,而外层的(和可能的嵌套又制造了更多分支。输入里每多一个左括号,回溯路径就近似翻倍。20 个左括号可能要试几百万次回溯,30 秒一点都不夸张。
怎么在 Java 里复现并守住这道防线
下面这段代码是我用来验证正则"安不安全"的最小工具——给它一个正则和一个恶意输入,看它多久跑完:
// 正则安全性自检:用带超时的方式限制匹配耗时,避免主线程被卡死 public boolean safeMatch(String regex, String input, long timeoutMs) { Pattern p = Pattern.compile(regex); Matcher m = p.matcher(input); // 1. 不要直接 m.matches(),用有限步数的 find + 手动超时兜底 long deadline = System.nanoTime() + timeoutMs * 1_000_000L; while (m.find()) { // 2. 用 find 而非 matches,只找第一个匹配 if (System.nanoTime() > deadline) { // 3. 超过预算就放弃,判定为可疑正则/输入 throw new RuntimeException("regex timeout, possible ReDoS: " + regex); } // 处理匹配结果... } return m.hitEnd(); }逐行解释:第 3 行用find()而非matches(),matches()要求整串匹配,会逼着引擎尝试所有可能的分割方式,最容易被 ReDoS 打中。第 5 行设了一个 deadline,超时即判定可疑——这是个"止血"手段,真正的解决是改正则本身(见下文)。第 8 行hitEnd()表示输入在匹配中途结束,常用于流式匹配判断是否需要更多数据。
但我要强调:超时只是兜底,不是根治。根治办法是把"指数回溯"结构改写成线性结构。
改法:把贪婪嵌套换成占有量词或重写
最容易被忽视的工具是占有量词(possessive quantifier)++、*+、?+和原子组(?>...)。它们一旦匹配就不回溯,直接斩断回溯爆炸:
// 安全版本:用占有量词杜绝回溯 private static final Pattern SAFE = Pattern.compile("\\(([a-z]++(\\+[a-z]++)*+)\\)"); public boolean isNestedKeywordSafe(String input) { return SAFE.matcher(input).matches(); // 1. [a-z]++ 吃掉后绝不吐回,无回溯分支 }逐行解释:把[a-z]+改成[a-z]++是关键。++是"占有"贪婪——它匹配到尽可能多后,不允许引擎回头重新分配字符给其他分支,于是原本指数级的回溯路径被压成了线性。同样,(\+[a-z]++)*+也用占有量词包住。改完之后,那个 30 个左括号的输入瞬间返回(不匹配),再也不会卡 30 秒。
如果不是简单量词能解决,就要从结构上重写正则,比如用白名单替代复杂的允许嵌套的表达式,或者把"嵌套括号"这种需求交给真正的递归下降解析器,而不是塞进一条正则。
我们线上的 3 个教训
教训一:用户输入永远不可信,尤其是进了正则的。那次事故的根因是"脏数据 + 复杂正则"的组合。任何把用户字符串直接喂给matches()/find()的地方,都应先做长度和字符集白名单过滤。我们后来加了前置校验:关键词长度超 64 或含异常多重复括号的直接拒绝。
教训二:正则越"聪明"越危险。嵌套、多选分支叠加、.*配.*的"夹心"结构,都是 ReDoS 高发区。一条正则如果肉眼读起来要停顿三秒,它大概率有性能雷。我们定了个规范:超过 40 字符或含 2 层以上量词嵌套的正则,必须写单元测试跑恶意输入。
教训三:监控要能抓到"慢匹配"。回溯卡死不会抛异常,只会慢。我们给所有 regex 调用包了一层耗时统计,P99 正则耗时超过 100ms 就告警。那次如果早有这个监控,能在雪崩前 5 分钟就定位。
三个引擎真相,很多人一直搞错
- 真相一:Java 正则不是"慢",是"可能指数慢"。正常输入下
Pattern很快,问题只在特定病态输入。所以你不能靠压测正常数据来排除风险。 - 真相二:预编译不等于安全。很多人知道
Pattern.compile要提出来,但这只解决编译开销,不解决回溯。编译一次照样能回溯到天荒地老。 - 真相三:正则表达式不是图灵完备,但 NFA 回溯能模拟指数时间。别指望"正则一定快"。需要复杂结构匹配时,老老实实写解析代码,比和正则搏斗靠谱。
我的取舍建议
- 校验类需求(邮箱、手机号、订单号):用最简单的字符类 + 锚定,能用
[0-9a-zA-Z]白名单就别用.*。 - 复杂文本解析(日志、模板、DSL):正则只做"粗切分",真正的解析交给状态机或解析库(如 ANTLR),别让一条正则包揽所有逻辑。
- 凡是用到用户输入做
matches/find的服务,给正则调用加超时和输入白名单双保险。
除了防 ReDoS,还有几个 Java 正则特有的坑
坑一:String.matches每次都重新编译。很多人图方便写"123".matches("\\d+"),但String.matches内部每次都Pattern.compile,高频调用下编译开销肉眼可见。正确做法是把 Pattern 提成静态常量,复用 Matcher。
// 反例:热路径里用 String.matches,每次重新编译正则 public boolean isDigitBad(String s) { return s.matches("\\d+"); // 1. 每次调用都 compile 一次,QPS 高时变慢 } // 正例:预编译 + 复用 Matcher private static final Pattern DIGIT = Pattern.compile("\\d+"); public boolean isDigitGood(String s) { return DIGIT.matcher(s).matches(); // 2. 编译一次,反复用;比重新编译便宜得多 }逐行解释:第 2 行的问题不在正则本身,在于matches方法签名里隐藏了Pattern.compile(s.regex)。第 7 行把编译移到静态常量,JVM 只编译一次并缓存;Matcher虽每次 new,但比重新编译正则便宜得多。再进一步,如果同一 Matcher 要匹配多个输入,用matcher.reset(input)复用,连 Matcher 都不用重建。我们曾在网关里把十几处String.matches改成预编译常量,单个校验接口的 CPU 掉了约 15%。
坑二:优先用命名组而不是下标。Java 7 之后支持命名捕获组(?<name>...),比起group(1)、group(2)的下标玩法,可读性和抗变更能力强太多:
// 用命名组解析 "orderId=123&amount=456" 这类简单键值串 private static final Pattern KV = Pattern.compile("(?<k>[a-zA-Z]+)=(?<v>\\d+)"); public void parse(String s) { Matcher m = KV.matcher(s); while (m.find()) { // 1. 循环找所有匹配,而非整串匹配 String key = m.group("k"); // 2. 按名字取,比 group(1) 清晰 String val = m.group("v"); System.out.println(key + " -> " + val); } }逐行解释:第 2 行(?<k>...)把捕获组命名为k,取用时用group("k")而非group(1)。当正则里插入或删除一个组时,下标会全部错位,命名组不受影响。第 4 行find()配合while提取串里所有键值对,而不是matches()那样要求整串匹配。我们代码评审把"group(数字)"列为不推荐写法,强制改用命名组,后续改正则再没出过错位 bug。这条和 ReDoS 不冲突:命名组解决可维护性,占有量词解决性能,两者一起用才是稳的组合。
坑三:.默认不匹配换行。很多人写(?s)想让.匹配换行却忘了开关,导致多行文本匹配失败。如果确实要跨行匹配,用Pattern.DOTALL或在表达式前加(?s);如果只想按行处理,老老实实逐行find。我们日志解析就曾因为.不跨行,漏匹配了带换行的堆栈,排查了一下午。
思考题
把你代码库里所有Pattern.compile和String.matches(注意String.matches内部每次都重新编译,本身就是性能和安全的双重雷)拉出来扫一遍:有没有(a+)+、(.*)*、(a|a)*这类"灾难三件套"?挑一条最复杂的,构造一个超长重复输入的测试用例跑一下,看看你的接口会不会也卡 30 秒。这一试,往往能试出几个你从没注意过的雷。