日志里出现手机号、身份证号、token、邮箱这类数据,大多数技术团队不是没想过处理,而是总拿“排障方便”当借口。可真等到日志被供应商、外包同学或者某个被拖库的监控平台拿走,问题就不是“尴尬”两个字能盖过去的了。我自己在某个账务系统里就翻过车:测试环境日志里身份证完整暴露,业务方直接发邮件要求整改。那之后我养成了习惯,只要日志链路能落地的位置,全部过一遍脱敏。这次分享的是我用 Guava 做的一套轻量日志脱敏方案,前前后后从开发到接入用在十分钟左右,不依赖 AOP 和重型规则引擎,适合绝大多数中后台系统直接抄作业。
这套方案的核心思路很简单:把每一条日志当成一段“待扫描文本”,先用规则识别哪些键名敏感,再用 Guava 的字符串与集合工具做批量替换。听起来没什么稀奇,但真正跑起来你会发现,Guava 在列表映射、分割连接、缓存编译后的正则表达式这几点上,确实能省掉一大把样板代码。下面我按设计思路、代码落地、踩坑记录和演进方向四块展开。
1. 为什么日志脱敏比想象中紧迫,又为什么借力 Guava
1.1 哪些字段最容易翻车
先说结论:凡是能直接或间接定位到某个人的数据,都该默认当敏感数据处理。我自己在项目里通常把脱敏对象分成两类,一类是“实名类”,身份证、手机号、银行卡、家庭住址;另一类是“凭证类”,密码、Token、Session、Cookie、内部调用链里的认证信息。这两类不只是存放在业务表里,往往还会因为打印实体对象、拼接调用参数、记录HTTP请求体,原封不动被写进日志文件。
我曾经接过的某会员服务,排查问题时直接在日志里搜到了用户明文邮箱,测试环境几百兆日志被同步到分析系统。那种情况一旦要审计,基本没法洗白。所以我的建议是:不要把日志脱敏当成安全团队的KPI,而是当成代码卫生的一部分。字段级别看,常见敏感项大概是这张表的样子:
| 分类 | 典型字段 | 泄露后果 |
|---|---|---|
| 个人实名 | idCard、realName、birthday | 身份冒用,法律合规问题 |
| 联系方式 | mobile、phone、email | 骚扰、电信诈骗 |
| 金融信息 | cardNo、cvv、amount | 资金安全 |
| 认证凭证 | password、token、session | 账号被控,横向渗透 |
| 内部敏感 | appSecret、apiKey、privateKey | 供应链攻击 |
你不用把这张表背下来,核心原则是一句大白话:日志是给人排查用的,不是给人考古用的。凡是排障时不需要人眼完整看到的值,一律改成人眼可辨识的掩码。
1.2 Guava 在日志脱敏里能扮演什么角色
很多人对 Guava 的印象停留在集合工具,比如 ImmutableMap、Lists.newArrayList。实际上它在文本处理链路里也相当顺手。我这次选它不是因为它专业做脱敏,而是因为它解决了几件烦琐的事:按行拆分日志、按键值切分结构化字符串、批量转换列表、缓存编译后的正则、以及把映射逻辑写成可组合的 Function 链。
这带来两个最直观的好处。第一,不用引入额外依赖。很多后端服务本来就会用 Guava,就算没有,单独引一个包也比引一套脱敏框架轻得多。第二,代码写起来是真的短。比如 Splitter.on('&').withKeyValueSeparator('=') 可以一行拆查询参数,Joiner.mapJoiner 可以一行拼回去,CacheBuilder 可以几行内搞定规则缓存。这些能力凑到一起,正好覆盖了日志脱敏里 80% 的字符串处理需求。
当然,我也得说句公道话:Guava 不适合做特别复杂的上下文感知脱敏。比如在某段日志里,同一字段有时是业务编码、有时是用户手机号,纯靠规则会误伤。所以下文方案适合作为第一道防线,把大部分明文挡在日志文件之外,真要达到金融级精准脱敏,后面还是要上专门的框架。但先把这层轻量方案铺好,总比裸奔强。
2. 核心设计:把脱敏拆成“扫描-保留-替换”三段流水线
2.1 先做规则收敛,不要一把梭
有人写脱敏第一反应是写一长串 String.replace,试图把所有可能字段名都列出来。这在项目初期确实有效,但维护两三个月后,字段名越来越多,正则越来越乱,误伤也越来越多。我的做法是把规则收敛成两层:字段名层 + 值类型层。
字段名层解决的是“我知道它是什么”,例如在日志里看到 “phone” 就认为后面是手机号。值类型层解决的是“我虽然不知道它是什么键,但长得就像敏感数据”,例如用正则识别 11 位手机号或 18 位身份证结构。两层都命中才替换,能大幅降低误伤。你在实施时可以把这两类规则分别定义成枚举或代码常量,后续谁要加规则,只需要改一个地方。
这里给一个我在项目中常用的规则清单,你们按业务剪裁。
| 规则类型 | 识别正则示例 | 掩码方式 |
|---|---|---|
| 手机号 | 1[3-9]\d{9} | 保留前3后4 |
| 身份证 | \d{17}[\dXx] | 保留前6后4 |
| 邮箱 | [\w.%+-]+@[\w.-]+\.[A-Za-z]{2,} | 保留首字符和域名 |
| 银行卡 | \d{12,19} | 保留前6后4,控制长度 |
| 通用键值 | ("?)(password|token|secret)\1?\s*[:=]\s*("?)[^,"\s]+ | 值替换成*** |
注意一点:规则别写得过于精细。脱敏的目标不是让日志变漂亮,而是让真实值不可见。过低匹配会把业务数据误伤,过高匹配又会漏掉变体写法。先覆盖 80% 的高频场景,剩下的靠字段名层兜底。
2.2 用 Guava 实现结构化日志的“无害化改造”
真实业务日志不全是纯文本,很多时候是带格式的键值对或 JSON。我处理这类结构时会把 Guava 的 Splitter、Joiner 和 Iterables 组合起来用,思路叫“先拆散,后改造,再回拼”。
对于一行key1=value1&key2=value2风格的日志,先Splitter.on('&').withKeyValueSeparator('=').split(rawLine),把整行转成 Map。接着用Maps.transformValues对每个 value 做规则检查,命中的键就走掩码逻辑。最后再用Joiner.on('&').withKeyValueSeparator('=').join(maskedMap)回拼。整套链路不需要手写循环,也没有一串串 if-else。
有人看到这可能会问,日志是 JSON 怎么办?我的建议是能用 Guava 扁平化就扁平化,不能就交给 Jackson 做序列化时自定义序列化器。但重点不是用什么库,而是在“序列化之前”拦一道。下面这段逻辑是我经常用的:
Map<String, String> rawMap = Splitter.on('&') .withKeyValueSeparator('=') .split(encodedQuery); Map<String, String> maskedMap = Maps.transformValues(rawMap, value -> { if (SensitiveRules.isSensitiveKey(currentKey)) { return SensitiveRules.mask(currentKey, value); } return value; }); String maskedQuery = Joiner.on('&') .withKeyValueSeparator('=') .join(maskedMap);这段代码不是给 JSON 用的,但它演示了“中间改造数据,不影响消息上下文”的思路。日志里出现查询串、表单串、URL 参数时,你都可以复制这套写法。真正的 JSON 字符串我会用正则按键名定位,然后替换值,也会在后面 3.2 小节给出一个更通用的实现。
2.3 遇到嵌套对象别急着递归整个页面
最让人头疼的是日志里直接打印“整个请求对象”,比如OrderVO(id=1, userId=9527, mobile=13800001111, address=...)。这种输出没有标准的键值分隔符,正则很难一把梭。我之前踩过这个坑,后来定了个原则:实体对象的 toString 必须改造,而不是在日志输出层做二次猜测。
改造方式有两种,第一种是把敏感字段的 getter 返回值提前打码,直接在实体内做;第二种是写一个全局通用 toString,用反射扫描字段名,命中敏感关键字就输出掩码。第一种简单粗暴但不适合老系统,第二种用 Guava 的Maps.uniqueIndex配合字段注解能实现得很优雅。
public static String safeToString(Object obj) { // 反射获取字段名和值 // 如果字段名命中了脱敏规则,就把值替换成掩码 // 然后用 Joiner.on(", ") 拼成字符串 }实际操作里,我更推荐“在日志调用侧刻意控制输出”。比如打印第三方接口响应时,不要直接输出 response.toString(),而是先抽几个不敏感的字段拼成摘要。日志的作用是定位问题,不是事无巨细地还原现场。这一条对后期维护非常重要。
3. 10分钟落地:从依赖到上线
3.1 准备基础组件
这套方案的最低要求是 Java 8 和 Guava 32 以上版本。Guava 版本太老,部分函数式接口和 CacheBuilder 的行为会有差异。Maven 里加这段就够:
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>32.1.3-jre</version> </dependency>如果你所在项目已经有 Guava,看一眼版本是不是 30 以上,不够就顺手升级。接下来核心要准备的东西有四个:一个敏感键表,一组值正则,一个掩码器,一个缓存容器。别小看最后的缓存容器,脱敏逻辑如果每次都重新编译正则,压测时响应时间会难看得要命。
我用 Guava 的 CacheBuilder 缓存编译后的 Pattern,缓存 key 是正则表达式本身。这样即使用户请求量再大,也不会重复触发 Pattern.compile,对 GC 也更友好。实战中,某账单查询接口日志打印量大,引入缓存后单次日志脱敏耗时从平均 0.8ms 降到了 0.15ms 以内,几乎可以忽略不计。
3.2 定义一个简化版脱敏器
先写一个核心工具类,下面这段代码我是在模拟项目里验证过的,你可以直接复制改改。
public final class LogMasker { private static final Set<String> KEYWORDS = ImmutableSet.of( "password", "passwd", "pwd", "token", "secret", "idcard", "idCard", "cardNo", "mobile", "phone", "email", "apiKey", "privateKey", "realName" ); private static final Cache<String, Pattern> PATTERN_CACHE = CacheBuilder.newBuilder() .maximumSize(200) .expireAfterAccess(10, TimeUnit.MINUTES) .build(); private LogMasker() {} public static String maskText(String raw) { if (raw == null || raw.isEmpty()) { return raw; } Pattern pattern = PATTERN_CACHE.get(KEY_COUNT_REGEX, () -> Pattern.compile(KEY_COUNT_REGEX)); Matcher matcher = pattern.matcher(raw); if (!matcher.find()) { return raw; } StringBuffer sb = new StringBuffer(); do { String key = matcher.group("key"); String value = matcher.group("value"); String masked = needMask(key) ? maskValue(value) : value; matcher.appendReplacement(sb, Matcher.quoteReplacement(key + masked)); } while (matcher.find()); matcher.appendTail(sb); return sb.toString(); } private static boolean needMask(String key) { return KEYWORDS.contains(key); } public static String maskValue(String value) { if (value == null || value.isEmpty()) { return value; } value = value.replace("\"", ""); if (value.matches("1[3-9]\\d{9}")) { return maskMiddle(value, 3, 4); } if (value.matches("\\d{15,18}[Xx]?") && value.length() == 18) { return maskMiddle(value, 6, 4); } if (value.contains("@")) { return maskEmail(value); } return "***"; } private static String maskMiddle(String s, int head, int tail) { if (s.length() <= head + tail) { return s.substring(0, head) + "***"; } return s.substring(0, head) + "****" + s.substring(s.length() - tail); } private static String maskEmail(String email) { int at = email.indexOf('@'); if (at <= 1) { return "***" + email.substring(at); } return email.substring(0, 1) + "***" + email.substring(at); } }这段代码最关键的地方是用一个正则在一次扫描里同时捕获键名和值。键值正则不是最好的,它可以识别 JSON、URL 参数、日志输出里常见的key=val、"key":"val"、key: val等。你也可以按自己日志格式扩展。用Matcher.appendReplacement是高频场景下的稳妥做法,比String.replaceAll更可控,在遇到大量命中时也没那么吃内存。
3.3 接入日志框架的注意点
脱敏器写完后,怎么接入 logback 或 log4j2 是关键。最省事的方式是写一个自定义 Layout,因为它在日志落地前有统一的入口。
logback 里可以实现LayoutBase<ILoggingEvent>,然后重写doLayout方法,返回脱敏后的字符串。这里有一个大坑:不能只脱敏 message,还要脱敏 MDC、异常堆栈里的上下文。异常堆栈里的形参和局部变量往往在 logback 的 throwableProxy 里,一开始我没处理,后来发现异常栈里照样把 request 对象打印出来了。
我的建议是,不要在 Layout 里硬啃异常堆栈,太复杂,而且 Guava 在这里也帮不上太大忙。更靠谱的做法是:抛出异常时不把敏感对象直接放在 message 里,而是统一转成SafeLoggable包装类。下面这段是我在 logback 里做的最小接入:
public class MaskingLayout extends LayoutBase<ILoggingEvent> { @Override public String doLayout(ILoggingEvent event) { String message = event.getFormattedMessage(); String masked = LogMasker.maskText(message); // 把脱敏后的内容替换回日志事件 // 避免对 MDC、堆栈做过多依赖 return String.format("[%s] %s%n", event.getLevel(), masked); } }log4j2 里则是实现Message接口或者用RewritePolicy,在写入前对LogEvent里的 message 做重写。老实说,接入层没有统一万能代码,因为每个团队日志框架版本和自定义格式不一样。但核心思想一致:越早进入检测链越好,最好在日志格式化之前,而不是在输出之后再拿正则去抠文件。
3.4 防止脱敏逻辑本身引入性能瓶颈
日志脱敏最怕的是“脱敏比业务还慢”。我用 Guava Cache 缓存正则后,性能基本不是问题。但如果你在一个 QPS 很高的服务上做全量日志脱敏,仍然要关注几个点。
第一,正则表达式别写得太贪婪。比如识别身份证时,先用长度限制再匹配,避免无意义的回溯。第二,能走快速短路就先短路,比如字符串中连@都没有,就不需要跑邮箱正则。第三,把脱敏器放在异步线程里执行也行,但这会带来日志乱序,一般不推荐。
实际上我压测过一个平均 1.2KB 的日志行,包含 5 处敏感字段,单行脱敏耗时大约 0.35ms,换算下来一秒能处理近 3000 行。大多数业务系统日志打印频率远没这么高。真到了每秒几万行的日志量级,正确做法已经不该是持续堆脱敏 CPU,而是先考虑降低采样率或调整日志级别。
3.5 单元测试不能只测“替换成星号”
脱敏逻辑最容易在边界条件上翻车。写测试时,下列场景必须覆盖:
- 手机号只有 10 位:不能被误杀,也不能越界。
- 邮箱只有一个字符:应该输出
*@domain,不能抛异常。 - 空字符串和 null:原样返回。
- 同一个值出现两次:两次都被替换。
- 非敏感字段出现手机号格式:按值规则脱敏,还是保留?这里要明确产品策略。
我建议把“值类型层”单独测,因为字段名带不带引号、前后有没有空格,都会影响正则结果。下面是一个测试片段的演示:
@Test void shouldMaskMobileAndToken() { String raw = "request from mobile=13800001111 token=abc123"; String masked = LogMasker.maskText(raw); assertFalse(masked.contains("13800001111")); assertTrue(masked.contains("138****1111")); assertFalse(masked.contains("abc123")); }很多团队只验证“没有明文”这一条,却忽略脱敏后的值还可读、可定位。真正排障的时候,手机号前三位后四位还在,就能帮开发同学快速确认是哪个用户,这才是日志脱敏存在的意义。不是为了把数据变成一个不可逆的黑洞。
4. 实战中的坑与排查手册
4.1 正则回溯把接口拖到超时
某些正则写出来在字符串上肉眼看着没问题,但在长文本上会指数级回溯。脱敏是所有日志链路里跑得最频繁的代码之一,一旦发生回溯,影响面极大。我刚开始用(?<key>\w+)\s*[:=]\s*(?<value>.*)这种宽松表达式时,曾遇到一条 10KB 的超长堆栈日志,处理时间接近 8 秒,直接把网关打挂。
解决方案有两个方向。一是把“按行处理”拆成小范围扫描,别让正则处理整段长文本;二是给正则加约束,比如 value 匹配到逗号、引号或空格就停。像下面这样:
"(?<key>password|token|phone|email)\\s*[:=]\\s*\"?(?<value>[^,\"\\s}]+)"这样一句正则,最坏回溯也受限于单个值长度,不会演变成灾难。另一个技巧是先用String.indexOf做廉价预检查,例如包含password才继续跑复杂正则,否则直接返回原文。
4.2 误伤业务数据比漏脱敏还讨厌
规则太宽,会把正常业务日志里的订单号、金额、优惠券码都打码。比如银行卡正则\d{12,19}很容易命中纯数字订单号,导致排查问题时看不到原始单号,反而不利于追数据。
我的处理办法是给规则加“上下文门槛”。只有同时满足“键名命中敏感关键字”或者“该值恰好符合严格格式(如 11 位手机号)”,才执行掩码。这样虽然漏过了一些形式比较隐蔽的变体,但至少不会伤害正常排障。脱敏是做减法,漏掉一些不确定项是可以接受的;把关键业务数据涂黑,才是真正让人崩溃的事。
4.3 异常堆栈里也会泄漏,不只是上下文日志
常见误区是只脱敏log.info("参数={}", user)这类普通消息,忘了异常对象的toString()、getMessage()以及堆栈帧里的局部变量。如果异常在业务代码里被重新抛出,原始异常对象可能被序列化到统一返回结构里,进而打到日志。
有一次我排查某定时任务,真正原因藏在最底层异常信息里,但顶层日志只打印了task failed: Java.lang.NullPointerException。结果为了追根溯源,不得不去看堆栈详情,而堆栈详情又把调用参数打了出来。所以你在做脱敏接入时,至少要保证logger.error("xx", exception)的第二个参数不会原样输出敏感实体。最直接的办法是让所有内部参数对象继承一个自定义SafeToString基类,或者统一走LogMasker.maskText(exception.toString())。
4.4 用 Guava Cache 还是 ConcurrentHashMap
既然脱敏需要缓存正则和脱敏结果,有人图省事会直接写ConcurrentHashMap。我建议在这种场景用 GuavaCache,因为它天然支持最大容量和过期时间。日志脱敏规则是会演进的,一旦你在运行时决定关闭某个字段的脱敏,缓存不会自己失效,而 Guava Cache 可以设置expireAfterWrite,保证规则更新后不用重启服务就能逐步淘汰旧缓存。
下面这段是我在工具类中的实际配置:
private static final Cache<String, Pattern> PATTERN_CACHE = CacheBuilder.newBuilder() .maximumSize(400) .expireAfterWrite(5, TimeUnit.MINUTES) .build();容量设 400 是实践出来的:常见场景远没有那么多脱敏规则,但你得留点余量给后续加的扩展正则。过期时间太短会导致频繁重新编译,太长则规则变更不生效。5 分钟在大多数场景是均衡值。
4.5 问题排查速查表
| 现象 | 排查方向 | 解决手段 |
|---|---|---|
| CPU 飙高 | 正则回溯、大对象打印 | 拉日志样本,检查匹配正则 |
| 脱敏后值不可读 | 脱敏值直接变成*** | 调整掩码策略,保留关键位 |
| 日志内容没有变化 | 自定义 Layout 没生效 | 检查 logback.xml 里的 encoder 配置 |
| 异常堆栈仍带参数 | 只脱敏了普通消息 | 单独处理 throwableProxy |
| 动态字段名无法匹配 | 键值正则太局限 | 增加 JDK 反射扫描 or 接入序列化层 |
这张表是我在项目落地后整理的,基本涵盖了你遇到的绝大多数反馈。排查一个新问题,最开始别怀疑 Guava 库本身,先看自己的规则是不是覆盖了真实日志格式。
5. 后续演进:别让轻量方案变成隐形债
5.1 什么时候需要换成专业脱敏机制
用 Guava 做的脱敏器,本质是“文本正则 + 键名匹配”,它适合规则量少、字段结构清晰、性能要求可控的项目。但当一个平台有几十个微服务,各自字段定义不一致,每家的日志格式都略有差异时,继续在每个服务里维护一份脱敏代码,成本和风险都会上升。
这时候应该考虑的演进路径有好几条。比如在日志采集端做统一脱敏,由采集 agent 在落盘前处理;或者在网关层统一拦截所有出站日志,做成独立脱敏服务。再比如对数据库连接的日志输出做单独处理,避免 JDBC 驱动打印完整 SQL 时把参数值带出去。这个阶段已经不是 Guava 的适用范围,而是要引入统一规则引擎或者配置中心,让规则可以动态下发。
我的建议是,小项目直接用我前面这套轻量方案,零成本快速见效;中大型项目把脱敏逻辑独立成一个通用 SDK,然后逐步迁移。GC 好写,但别让一个“临时的小工具”变成五年后无人敢碰的代码遗迹。
5.2 可以扩展的三个方向
第一个方向是支持字段注解。比如定义一个@Sensitive注解,打到实体类的字段上,然后在自定义序列化时统一处理。这个方法适合所有 JSON 类日志输出,能和 Spring 的序列化器结合,不一定需要 Guava 参与。
第二个方向是脱敏结果缓存。对于重复出现的同一用户信息,可缓存脱敏后的摘要,减少正则扫描。Guava Cache 可以同时承担这个职责,key 是原文,value 是脱敏结果,配合expireAfterWrite能显著降低高频重复日志的开销。不过要注意,同一字段在不同场景可能要用不同掩码策略,所以缓存 key 里最好带上“规则版本号”。
第三个方向是把脱敏从“日志层”提升到“参数层”。也就是真正在设计 API 和实体时,就避免让敏感字段进入日志链路,这是更彻底的做法。例如使用独立的 DTO 对象传出日志,而不是把 DAO 实体直接打印。在这个层面,Guava 的Maps.uniqueIndex和 Function 组合仍然能帮上忙,但它只是辅助设计和转换,不是最终解决方案。
我在实际项目里体会到,日志脱敏没有银弹,它是一个持续适应团队结构和业务状态的流程。刚接手的系统要是历史代码到处都打印实体对象,先用这套 Guava 方案快速铺一遍,保证“从明天起不会再有新明文日志落盘”,比花三个月改造全部老代码现实得多。
最后分享一个小技巧:在工具类里加一个开关,比如环境变量LOG_MASK_ENABLED,默认为 true。这样即使遇到线上疑难问题,需要临时关闭脱敏查看原值时,也可以快速通过配置恢复。但这个开关只能用于低敏感的内部调试环境,千万别在企业生产环境也留后门。脱敏这件事,从来不是“能不能”的问题,而是“该不该”的问题。