给 Java 正则设输入预算:用字符访问次数看清回溯开销
正则在正常样本上很快,不代表失败输入也便宜。表达式里的多层重复可能为同一段文本尝试许多分组方式,直到最后一个字符不符合要求,才把这些路径逐个退回。
先不要在正式服务里塞入几兆字符测试超时。下面用JDK 17写一个很小的计数实验,对比 (a+)+b 与 a+b,输入只到一百二十八个a再加一个c。它们都应判定不匹配,我们观察的是为了得出同样结果,Matcher向输入请求了多少次字符。
计数不是通用性能基准,也不是正则引擎所有工作的完整统计;它只为这份可复现样本提供比单次毫秒数更清楚的行为线索。
把成功结果和失败成本分开验证
保存为 RegexBudget.java,执行 java RegexBudget.java。只需要Java 17,不连接网络,不在后台留下失控线程。
import java.util.regex.Pattern;
public class RegexBudget {
static final class Measured implements CharSequence {
final String value; long reads;
Measured(String value) { this.value = value; }
public int length() { return value.length(); }
public char charAt(int index) { reads++; return value.charAt(index); }
public CharSequence subSequence(int start, int end) { return value.subSequence(start, end); }
public String toString() { return value; }
}
static long reads(Pattern pattern, String text) {
Measured input = new Measured(text);
if (pattern.matcher(input).matches()) throw new AssertionError("expected failure");
return input.reads;
}
static boolean boundedMatch(Pattern pattern, String input, int maxChars) {
if (input.length() > maxChars) throw new IllegalArgumentException("input too long");
return pattern.matcher(input).matches();
}
public static void main(String[] args) {
Pattern ambiguous = Pattern.compile("(a+)+b");
Pattern simple = Pattern.compile("a+b");
for (int n : new int[]{16, 32, 64, 128}) {
String text = "a".repeat(n) + "c";
System.out.println("n=" + n + " nested=" + reads(ambiguous, text)
+ " simple=" + reads(simple, text));
}
System.out.println("valid=" + boundedMatch(simple, "aaab", 128));
try { boundedMatch(simple, "a".repeat(129), 128); throw new AssertionError(); }
catch (IllegalArgumentException expected) { System.out.println("limit=REJECTED"); }
System.out.println("greedy=" + Pattern.matches("a+a", "aa"));
System.out.println("possessive=" + Pattern.matches("a++a", "aa"));
}
}
本次固定JDK的输出:
n=16 nested=289 simple=33
n=32 nested=1089 simple=65
n=64 nested=4225 simple=129
n=128 nested=16641 simple=257
valid=true
limit=REJECTED
greedy=true
possessive=false
字符访问量明显不同,但这并不意味着所有Java正则都具有同一种增长级别。JDK实现可能优化某些表达式,换模式、匹配入口或JDK版本,具体计数会变化。能从本例得到的判断是:外层分组重复给内层连续a制造了额外路径,失败尾部让这些路径显露出来,简化后的表达式不再需要这层歧义。
两份模式在这里接受同一种语言:一个或多个a后跟一个b。删掉外层重复没有删掉业务能力,才是一项有依据的修正。表达式很短,不是保留它的理由;无法解释每层重复为何必要时,就值得回到输入协议重新写。
占有量词不是可以全局加上的加速标记
程序也比较了 a+a 和 a++a。前者可以把已经吃掉的一个a退给末尾的a,因此匹配aa;后者不回退,结果为false。改变量词会改变可接受输入,不能为了让测试快一点就批量替换。
对于前面的 a+b,后缀b与a不重叠,可以进一步考虑占有量词;但判断依据仍然是语言和边界,而不是“占有一定更安全”。带可选分支、反向引用和嵌套结构的实际模式,应重新构造成功、边界与失败样本,而不是只跑一个长字符串。
matches()要求整个区域匹配;find()还会寻找后续起点。把入口改成find,可能额外增加尝试范围。代码评审需要同时看模式、输入规模与调用入口,只看Pattern字符串经常漏掉实际工作量。
输入预算在执行前检查
样本里的boundedMatch先检查UTF-16长度,超过上限直接拒绝,再进入匹配。字符单位写清楚,是为了防止把Java length与UTF-8请求字节数混用。网络入口仍应有独立请求体字节限额,否则为了获得这个字符串本身就可能消耗大量内存。
上限必须来自字段业务用途。邮编、简单状态码和用户评论,合理长度完全不同。默认给所有字段一个巨大上限,再宣称已经防护,只是让最坏输入晚一点出现。数据量大又有明确结构时,手写线性扫描或专用解析器可能比不断给复杂正则补护栏更容易验证。
如果模式本身由不可信使用者提供,限制文本长度仍不够,模式长度、功能集合和执行隔离也要考虑。标准Java Pattern没有一个能保证任意模式在指定毫秒内停止的通用超时参数。把匹配放进Future再get(timeout),只能让等待方停止等,不能保证底层匹配已经终止;线程持续占用的问题仍然存在。
需要接受用户模式时,可以选择具有明确资源策略的其他引擎,但这通常意味着不支持某些语法。或者把工作放到可终止的隔离进程,限制CPU与内存。两种方案都需要按产品真正需要的表达能力选择,不应悄悄改变结果语义。
等待超时以外,再做一份可终止的边界实验
如果业务确实允许较复杂且不完全可控的规则,单个Java线程的Future等待超时不能保证停止引擎。这时可以用一个独立工作进程验证隔离边界。下面的辅助程序仍只运行固定的小模式,不接收任意命令或任意正则;超时样本用十秒sleep模拟工作没有完成,绝不把这次等待伪装成正则回溯实测。
这一步测试的是“父进程预算到期,能否让受控子进程退出”,与上一节计数实验回答的问题不同。先把两个证据分开,才不会得到一个看起来严格、实际上只让调用者提前返回的超时实现。
保存为RegexIsolated.java。这次需要先编译,因为子JVM要从class path找到同名工作类:
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
public class RegexIsolated {
static String run(Duration budget, String mode, String input) throws Exception {
if (input.length() > 128) throw new IllegalArgumentException("input too long");
String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
List<String> command = new ArrayList<>(List.of(java, "-cp",
System.getProperty("java.class.path"), RegexIsolated.class.getName(),
"worker", mode, input));
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
try {
if (!process.waitFor(budget.toMillis(), TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
if (!process.waitFor(2, TimeUnit.SECONDS)) throw new IOException("worker did not exit");
throw new java.util.concurrent.TimeoutException("worker budget expired");
}
byte[] bytes = process.getInputStream().readNBytes(1025);
if (bytes.length > 1024) throw new IOException("worker output too large");
if (process.exitValue() != 0) throw new IOException("worker failed: " + process.exitValue());
return new String(bytes, StandardCharsets.UTF_8).strip();
} finally {
if (process.isAlive()) process.destroyForcibly();
process.getInputStream().close(); process.getErrorStream().close();
process.getOutputStream().close();
}
}
public static void main(String[] args) throws Exception {
if (args.length > 0 && args[0].equals("worker")) {
if (args.length != 3 || args[2].length() > 128) throw new IllegalArgumentException();
if (args[1].equals("hold")) Thread.sleep(10_000);
else if (!args[1].equals("match")) throw new IllegalArgumentException();
System.out.println("MATCH=" + Pattern.matches("a+b", args[2]));
return;
}
System.out.println("normal=" + run(Duration.ofSeconds(3), "match", "aaab"));
try { run(Duration.ofMillis(200), "hold", "aaab"); throw new AssertionError(); }
catch (java.util.concurrent.TimeoutException expected) { System.out.println("simulated_stall=TIMEOUT"); }
}
}
在该文件所在目录执行:
javac --release 17 RegexIsolated.java
java -cp . RegexIsolated
核对输出:
normal=MATCH=true
simulated_stall=TIMEOUT
父程序使用当前JDK的绝对可执行路径,不通过Shell拼接命令。参数以列表逐项传入,工作类和模式由程序固定;字段仍在启动前做长度检查。这样可以观察进程机制,而不让这份演示额外引入任意命令执行能力。
预算包含子JVM启动,三秒不是纯匹配运行时间。实际服务若每次字段校验都新建JVM,启动和内存成本通常不划算;是否采用预热工作池、怎样限制池容量与排队、哪个失败需要重建工作进程,要与使用量一起设计。这里给出的是一次受控终止实验,不是建议普通参数校验都改成多进程。
工作进程只打印一行固定长度结果,所以父程序可以先等待退出,再读取有上限的输出。若改成通用工具,子进程可能持续写标准输出,管道装满会阻塞子进程,父进程又在等退出,形成死锁。那时必须并发排空并限额,或者将输出导向受控目标,不能照搬本例顺序。
超时路径调用destroyForcibly以后还等待退出,区分“发出终止请求”与“已经观察到进程结束”。工作进程不创建后代进程,因此没有假装处理整棵进程树;真实隔离若允许本地扩展、文件访问或派生进程,还需要操作系统权限与资源容器管理。
协议上也别把超时当成普通不匹配。MATCH=false表示规则在预算内得到否定结果;TIMEOUT表示没有得到可接受的结果,两者对用户提示、重试和告警的含义不同。若为了方便统一返回false,输入攻击或容量故障就可能被悄悄记成业务校验失败。
因此这两份小程序各保留一条清晰结论:简化规则与执行前限额处理正常字段;进程实验说明当需要可靠终止边界时,应该控制实际工作实体,而不只改变等待对象的状态。选定引擎与语法集合以后,再用真实规则在同一边界下核对正确性和资源上限。
让回归样本围住业务边界
本例保留了一个成功样本aaab和一组失败尾部样本。正式回归还应覆盖空串、最大合法长度、首尾空白、Unicode和换行规则,因为这些正是后来维护者容易“顺手放宽”的地方。
对修正后的规则,先证明需要接受和拒绝的输入没有变化,再观察资源开销。也要记录输入上限的拒绝路径,避免后续重构把限额挪到匹配以后。真正可以维护的规则,应当让读代码的人解释为什么这一层重复存在、输入最多有多大,以及失败时系统会付出什么代价。