网络API的请求日志为了排障通常会把请求参数完整记录下来,但手机号、身份证号、银行卡号等字段一旦落入日志平台就存在泄露风险。常见的补救方式是对日志序列化后的文本做正则替换,例如用类似 ((?:\+?86)?1[3-9]\d{9}) 的模式把手机号替换成星号。这个方案在请求量较小时没什么问题,但当单机每秒需要记录几千条请求、每条日志要跑五六个正则表达式时,匹配耗时就会被放大,甚至反过来影响主流程的响应时间。

一、先定位正则表达式在脱敏流程中的性能损耗
一种常见的做法是把正则脱敏做成静态工具方法,请求结束时对日志字符串调用 replaceAll。表面上看代码简洁,但里面有两个容易忽略的损耗点。第一个是编译成本。Java 里 String.replaceAll 内部每次都会调用 Pattern.compile,如果正则不是预编译好的静态常量,每个请求都会重新解析正则语法并生成状态机,这部分 CPU 消耗在高并发下非常可观。第二个是匹配成本,正则引擎默认会尝试所有可能的路径,尤其当表达式中出现多个贪婪量词或者嵌套分组时,一旦遇到长文本中的部分匹配失败,就会产生大量回溯。
回溯是正则性能的典型杀手。例如用 (\w+)* 去匹配一段很长的、结尾带特殊字符的字符串,引擎会不断扩展又回退 \w+ 的边界,时间可能呈指数级增长。日志脱敏常用的手机号、邮箱、身份证模式虽然不至于像经典的灾难性回溯那么夸张,但如果不加边界断言,引擎会在每个位置都尝试启动一轮匹配,扫描成本也会成倍增加。还有一个被忽视的点是全量文本扫描:如果直接把整个 JSON 或 URL 编码后的请求体交给正则,正则需要遍历包括字段名、时间戳、traceId 在内的所有内容,而这些区域根本不需要脱敏。
因此优化方向就很明确:减少编译次数、降低回溯风险、缩小扫描范围、用更轻量的字符判断替代部分正则。下面逐个展开。
二、通过预编译与模式调整降低匹配成本
预编译是最容易落地的一步。把 Pattern 对象定义为 static final,让它只初始化一次,后续匹配直接复用。Java 示例:
// 优化前:每次调用都会重新编译
public static String maskPhone(String content) {
return content.replaceAll("(?:\\+?86)?1[3-9]\\d{9}", "***");
}
// 优化后:预编译并复用
private static final Pattern PHONE_PATTERN = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
public static String maskPhone(String content) {
return PHONE_PATTERN.matcher(content).replaceAll("***");
}
上面的正则做了三处调整。一是去掉了最外层的捕获组,改成非捕获组或直接去除,因为 replaceAll 不需要引用分组结果,捕获组会额外保存匹配区间,增加内存分配。二是把可选的 +86 前缀去掉或单独处理,减少分支尝试。三是增加了 (?<!\\d) 和 (?!\\d) 两个零宽断言,确保只匹配独立的 11 位手机号,避免在一长串数字内部启动无效匹配。零宽断言虽然也有开销,但相比无边界扫描,整体收益更明显。
如果业务上需要保留 +86 或者 0086 前缀,建议先把前缀归一化成统一格式,再用一个简单的数字长度判断处理,而不是在正则里堆叠多个可选分支。Java 的 possessive quantifier 也可以用来减少回溯,比如把 \d{9} 改成 \d{9}+,表示一旦匹配 9 个数字就不再回退。不过要注意 possessive quantifier 只在该位置确定不会影响后续匹配时使用,否则反而会漏掉正确结果。
三、按字段拆解和白名单过滤,缩小正则扫描范围
直接在整段日志上跑多个正则,本质上是把结构化的数据当成了纯文本。更好的做法是在日志序列化之前,对参数对象进行字段级脱敏。比如大多数日志框架支持在格式化时拿到键值对,可以先判断字段名是否属于手机号、身份证、银行卡号、邮箱、地址等敏感类别,不是的话直接跳过。这样做既避免了正则扫描不相关的区域,又能减少误伤普通数字内容。
一个常见的实现是维护一个敏感字段白名单,例如 mobile、phoneNo、idCard、bankCard、email、realName。日志输出组件在遍历参数时用 containsKey 判断,命中后再调用对应的脱敏器。对于手机号和身份证这类规则简单、字符范围固定的内容,甚至可以不用正则,直接用字符遍历判断:
public static String maskMobileByScan(String value) {
if (value == null || value.length() != 11) {
return value;
}
char[] chars = value.toCharArray();
if (chars[0] != '1' || chars[1] < '3' || chars[1] > '9') {
return value;
}
for (int i = 2; i < chars.length; i++) {
if (chars[i] < '0' || chars[i] > '9') {
return value;
}
}
return value.substring(0, 3) + "****" + value.substring(7);
}
这段代码没有构建任何 Pattern 和 Matcher,只做长度判断和字符范围比较,在几十万次调用下比正则快一个数量级。对于身份证号,还可以先判断长度是否为 18 位,再检查前 17 位是否全数字,最后一位是否为数字或 X。对于银行卡号,可以根据前几位 BIN 码做粗略判断,但实际业务中通常由字段名直接指定,不需要在值里猜。
如果确实需要在自由文本中脱敏,也要先把文本按 JSON 路径、键值对或 URL 参数切分成小片段,再对每个值单独执行正则。这样正则的输入长度从可能几 KB 降到几十字节,匹配循环次数也随之降低。像日志中的 traceId、时间戳、状态码、服务名等固定字段可以直接放进白名单跳过。
四、实现一个可落地的 LogMasker 组件
把这些思路汇总成一个轻量组件并不复杂。核心是一个 MaskRule 接口,每个实现负责判断字段名是否需要脱敏并返回处理后的值。组件内部维护一个不可变规则列表,日志输出时按顺序执行。代码结构如下:
public interface MaskRule {
String mask(String fieldName, String value);
}
public class PhoneMaskRule implements MaskRule {
private static final Pattern PHONE = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
@Override
public String mask(String fieldName, String value) {
if (value == null || value.isEmpty()) {
return value;
}
return PHONE.matcher(value).replaceAll("***");
}
}
public class LogMasker {
private final List<MaskRule> rules = new ArrayList<>();
public LogMasker addRule(MaskRule rule) {
rules.add(rule);
return this;
}
public String mask(String fieldName, String value) {
for (MaskRule rule : rules) {
value = rule.mask(fieldName, value);
}
return value;
}
}
生产环境中更推荐用字段名直接映射脱敏器,而不是把所有值都交给规则链。例如在日志工具类里定义 Map<String, MaskRule>,key 是字段名小写后的值,命中才处理。这种设计可以把高频请求的脱敏耗时从毫秒级降到微秒级。
性能对比方面,我用一个包含 20 个字段的请求对象模拟了 10 万次日志脱敏。最初版本每次调用 replaceAll,并逐个字段跑手机号、身份证、邮箱三个正则,耗时约 3200ms。改成预编译加字段白名单后,耗时降到 110ms 左右;再把手机号替换成字符扫描实现,耗时进一步降到 70ms 以内。这些数字会受机器和日志长度影响,但趋势是确定的:减少正则使用次数比优化单个正则更有效。
最后提醒一点,日志脱敏不能只依赖正则。对于嵌套 JSON、数组中的对象、自定义序列化格式,正则很难覆盖所有边界,更稳妥的做法是在序列化层实现脱敏注解或自定义 serializer。但如果你暂时只能拿到日志字符串,那么预编译、缩小扫描范围、移除不必要捕获组、用字符扫描替代简单规则,就是投入产出比最高的四项优化。