日志脱敏:从注解到自动化的完整实现
线上日志里打印了用户手机号、身份证号、银行卡号——这是每个后端工程师的噩梦。GDPR、《个人信息保护法》之下,日志脱敏已经从”最佳实践”变成了”合规红线”。
但现实中,大部分系统的脱敏策略是这样的:
// 方式 1: 手动拼字段(字段多了要命)
logger.info("user: name={}, phone={}, email={}",
user.getName(), maskPhone(user.getPhone()), maskEmail(user.getEmail()));
// 方式 2: 每个 DTO 手写 toString()(容易漏)
@Override
public String toString() {
return "User{name='" + name + "', phone='" + maskPhone(phone) + "'}";
}
// 方式 3: 不管了,查日志时再说(合规风险)
logger.info("user: {}", user);
理想的情况是:在需要脱敏的字段上加个注解,日志打印时自动脱敏,代码零侵入。
本文从零开始,给出四种方案,层层递进,最终实现一个生产可用的全自动脱敏方案。
一、先定义脱敏注解
/**
* 脱敏注解 —— 标记需要脱敏的字段
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Desensitize {
/** 脱敏类型 */
DesensitizeType type();
/** 保留前几位(适用于手机号、身份证等) */
int prefixLen() default 0;
/** 保留后几位 */
int suffixLen() default 0;
}
/**
* 脱敏类型枚举
*/
public enum DesensitizeType {
/** 手机号: 138****1234 */
PHONE(3, 4),
/** 身份证: 320***********1234 */
ID_CARD(3, 4),
/** 银行卡: 6222 **** **** 0123 */
BANK_CARD(4, 4),
/** 邮箱: a***@gmail.com */
EMAIL(1, 0),
/** 姓名: 张** */
NAME(1, 0),
/** 密码: ****** */
PASSWORD(0, 0),
/** 自定义:配合 prefixLen/suffixLen 使用 */
CUSTOM(-1, -1);
final int defaultPrefixLen;
final int defaultSuffixLen;
DesensitizeType(int defaultPrefixLen, int defaultSuffixLen) {
this.defaultPrefixLen = defaultPrefixLen;
this.defaultSuffixLen = defaultSuffixLen;
}
}
/**
* DTO 示例
*/
public class UserDTO {
private Long id;
private String name;
@Desensitize(type = DesensitizeType.PHONE)
private String phone;
@Desensitize(type = DesensitizeType.EMAIL)
private String email;
@Desensitize(type = DesensitizeType.ID_CARD)
private String idCard;
// getter/setter 省略
}
二、方案一:JSON 序列化 + 手动调用(最实用)
2.1 核心思路
利用 Fastjson / Jackson 的序列化扩展点,在序列化时检测 @Desensitize 注解并替换值。然后让 DTO 的 toString() 走 JSON 序列化。
logger.info("msg {}", dto)
│
▼
dto.toString() → JSON.toJSONString(dto) → ValueFilter 检测 @Desensitize → 脱敏后输出
2.2 Fastjson 实现
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.ValueFilter;
/**
* 核心:Fastjson ValueFilter —— 在序列化每个字段时被回调
*/
public class DesensitizeValueFilter implements ValueFilter {
@Override
public Object process(Object object, String name, Object value) {
if (value == null || !(value instanceof String)) {
return value;
}
// 反射获取字段的 @Desensitize 注解
Field field = getField(object.getClass(), name);
if (field == null) return value;
Desensitize anno = field.getAnnotation(Desensitize.class);
if (anno == null) return value;
// 执行脱敏
return doMask((String) value, anno);
}
private String doMask(String value, Desensitize anno) {
DesensitizeType type = anno.type();
int prefixLen = anno.prefixLen() > 0 ? anno.prefixLen() : type.defaultPrefixLen;
int suffixLen = anno.suffixLen() > 0 ? anno.suffixLen() : type.defaultSuffixLen;
switch (type) {
case PHONE:
// 138****1234
return maskMiddle(value, 3, 4);
case ID_CARD:
// 320***********1234
return maskMiddle(value, 3, 4);
case BANK_CARD:
// 6222 **** **** 0123
return maskBankCard(value);
case EMAIL:
return maskEmail(value);
case NAME:
return maskName(value);
case PASSWORD:
return "******";
case CUSTOM:
return maskMiddle(value, prefixLen, suffixLen);
default:
return value;
}
}
// ─── 脱敏方法 ───
private static String maskMiddle(String str, int prefixLen, int suffixLen) {
if (str == null || str.isEmpty()) return str;
if (str.length() <= prefixLen + suffixLen) {
return repeat('*', str.length());
}
return str.substring(0, prefixLen)
+ repeat('*', str.length() - prefixLen - suffixLen)
+ str.substring(str.length() - suffixLen);
}
private static String maskEmail(String email) {
if (email == null || !email.contains("@")) return email;
int at = email.indexOf('@');
String name = email.substring(0, at);
String domain = email.substring(at);
if (name.length() <= 1) return "*" + domain;
return name.charAt(0) + "***" + domain;
}
private static String maskBankCard(String cardNo) {
// 6222 **** **** 0123
if (cardNo == null || cardNo.length() < 8) return cardNo;
return cardNo.substring(0, 4) + " **** **** " + cardNo.substring(cardNo.length() - 4);
}
private static String maskName(String name) {
if (name == null || name.length() <= 1) return name;
return name.charAt(0) + repeat('*', name.length() - 1);
}
private static String repeat(char c, int count) {
StringBuilder sb = new StringBuilder(count);
for (int i = 0; i < count; i++) sb.append(c);
return sb.toString();
}
// ─── 反射缓存(避免每次都反射)───
private static final ConcurrentHashMap<String, Map<String, Field>> FIELD_CACHE =
new ConcurrentHashMap<>();
private Field getField(Class<?> clazz, String fieldName) {
String key = clazz.getName();
Map<String, Field> fieldMap = FIELD_CACHE.computeIfAbsent(key, k -> {
Map<String, Field> map = new HashMap<>();
for (Field f : clazz.getDeclaredFields()) {
f.setAccessible(true);
map.put(f.getName(), f);
}
return map;
});
return fieldMap.get(fieldName);
}
}
2.3 使用方式
// 方式 A: 每个 DTO 继承 BaseDTO
public abstract class BaseDTO {
private static final DesensitizeValueFilter FILTER = new DesensitizeValueFilter();
@Override
public String toString() {
return JSON.toJSONString(this, FILTER);
}
}
public class UserDTO extends BaseDTO {
// ...字段定义
}
// 直接使用 —— toString() 自动脱敏
logger.info("user: {}", userDTO); // → user: {"id":1,"name":"张三","phone":"138****1234","email":"z***@gmail.com"}
// 方式 B: 不继承,用工具方法(更灵活)
public class DesensitizeUtil {
private static final DesensitizeValueFilter FILTER = new DesensitizeValueFilter();
public static String toJson(Object obj) {
return JSON.toJSONString(obj, FILTER);
}
}
// 使用
logger.info("user: {}", DesensitizeUtil.toJson(userDTO));
三、方案二:Jackson 实现(Spring Boot 生态首选)
如果你项目用的是 Spring Boot(默认 Jackson),用 Jackson 的 ContextualSerializer 实现更原生:
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import com.fasterxml.jackson.databind.module.SimpleModule;
/**
* Jackson 版脱敏序列化器
*/
public class DesensitizeSerializer extends JsonSerializer<String>
implements ContextualSerializer {
private DesensitizeType type;
private int prefixLen;
private int suffixLen;
public DesensitizeSerializer() {}
public DesensitizeSerializer(DesensitizeType type, int prefixLen, int suffixLen) {
this.type = type;
this.prefixLen = prefixLen;
this.suffixLen = suffixLen;
}
@Override
public void serialize(String value, JsonGenerator gen,
SerializerProvider provider) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
gen.writeString(doMask(value));
}
/**
* 关键:contextual 方法在第一次序列化某个字段时被调用,
* 从 BeanProperty 中提取注解信息,创建专用的序列化器实例
*/
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov,
BeanProperty property) {
if (property == null) return this;
Desensitize anno = property.getAnnotation(Desensitize.class);
if (anno == null) return this;
return new DesensitizeSerializer(
anno.type(),
anno.prefixLen() > 0 ? anno.prefixLen() : anno.type().defaultPrefixLen,
anno.suffixLen() > 0 ? anno.suffixLen() : anno.type().defaultSuffixLen
);
}
// doMask() 与方案一的实现相同,省略...
}
/**
* 注册 Jackson Module
*/
@Configuration
public class DesensitizeConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
SimpleModule module = new SimpleModule();
// 对所有 String 类型字段,如果标注了 @Desensitize,就用 DesensitizeSerializer
module.addSerializer(String.class, new DesensitizeSerializer());
mapper.registerModule(module);
return mapper;
}
}
// 使用 —— 继承 BaseDTO,Jackson 序列化自动脱敏
public abstract class BaseDTO {
@Override
public String toString() {
try {
// 通过 Spring 容器获取 ObjectMapper,或使用静态持有
return ApplicationContextHolder.getBean(ObjectMapper.class)
.writeValueAsString(this);
} catch (Exception e) {
return super.toString();
}
}
}
四、方案三:Logback 全局 Converter(自动化,无需改 DTO)
前两种方案都依赖 DTO 走 JSON 序列化。如果项目中有大量老代码不继承 BaseDTO,或者想对所有日志做”兜底”脱敏(不依赖注解),Registry 一个 Logback 的 CompositeConverter 是最干净的:
import ch.qos.logback.classic.pattern.ClassicConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
import java.util.regex.Pattern;
/**
* Logback 日志脱敏转换器
*
* 在 logback.xml 中替换 %msg 为 %desensitize
*/
public class DesensitizeConverter extends ClassicConverter {
// ─── 正则规则(按优先级排列,匹配到即脱敏)───
private static final Rule[] RULES = {
// 手机号: 138****1234
new Rule(Pattern.compile("(1[3-9]\\d)\\d{4}(\\d{4})"), "$1****$2"),
// 身份证: 320***********1234
new Rule(Pattern.compile("(\\d{3})\\d{11}(\\d{3}[\\dXx])"), "$1***********$2"),
// 邮箱: a***@xxx.com
new Rule(Pattern.compile("(\\w)[^@]*(@\\w+\\.\\w+)"), "$1***$2"),
// 银行卡: 6222****0123
new Rule(Pattern.compile("(\\d{4})\\d+(\\d{4})"), "$1****$2"),
// 密码/secret 字段: "password":"***"
new Rule(Pattern.compile("(\"(?:password|secret|token)\"\\s*:\\s*\")[^\"]+"), "$1******"),
};
@Override
public String convert(ILoggingEvent event) {
String msg = event.getFormattedMessage();
if (msg == null || msg.isEmpty()) return msg;
for (Rule rule : RULES) {
msg = rule.pattern.matcher(msg).replaceAll(rule.replacement);
}
return msg;
}
private static class Rule {
final Pattern pattern;
final String replacement;
Rule(Pattern p, String r) { this.pattern = p; this.replacement = r; }
}
}
<!-- logback.xml -->
<configuration>
<!-- 注册脱敏转换器 -->
<conversionRule conversionWord="desensitize"
converterClass="com.example.log.DesensitizeConverter"/>
<!-- 使用 %desensitize 替换 %msg -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %desensitize%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
方案三的优缺点
优点: 缺点:
┌─────────────────────────┐ ┌─────────────────────────┐
│ - 对业务代码完全零侵入 │ │ - 依赖正则,可能误伤 │
│ - 能拦截所有日志,不遗漏 │ │ - 无法利用注解的语义信息 │
│ - 配置即可生效 │ │ - 正则大量使用有性能影响 │
└─────────────────────────┘ │ - 复杂对象需要提前序列化 │
└─────────────────────────┘
五、方案四:全自动注解驱动(终极方案)
把方案一和方案三结合起来:对象走注解脱敏 + 纯文本走正则兜底。最关键的工程难点在于:如何让 logger.info("msg {}", dto) 中的 dto 自动走 JSON 注解脱敏?
这就需要切入 Logback 的消息构建过程。
5.1 原理:SLF4J → Logback 的消息处理链路
要理解脱敏方案的设计,先要彻底搞懂一行 logger.info("order {}", dto) 从调用到输出的完整路径。这条链路中有三个关键角色和一个决定性设计。
5.1.1 完整调用链
logger.info("order {}", dto)
│
▼
Logger.info(String format, Object... arguments) ← SLF4J 接口
│
▼
MessageFactory.newMessage(format, args) ← ① 消息工厂
│
▼
new ParameterizedMessage(format, args) ← ② 参数化消息对象
│
│ ┌─── getMessage() 被调用时 ───┐
│ │ │
│ ▼ ▼
│ init() MessageFormatter.arrayFormat(msg, args)
│ │
│ ▼
│ deeplyAppendParameter(sbuf, arg)
│ │
│ ┌───────┴───────┐
│ ▼ ▼
│ 基本类型 对象类型
│ sbuf.append(i) sbuf.append(arg.toString()) ← ③ 关键拦截点
│
▼
ILoggingEvent.getFormattedMessage() ← ④ 日志事件取格式化结果
│
▼
Appender → Layout → Converter → 最终输出
5.1.2 角色一:MessageFactory — 消息工厂
MessageFactory 是 Logback 中的接口,定义了如何从 (format, args) 创建一个 Message 对象。默认实现是 ParameterizedMessageFactory:
public class ParameterizedMessageFactory implements MessageFactory {
public Message newMessage(String format, Object... arguments) {
if (arguments == null || arguments.length == 0) {
return new ParameterizedMessage(format);
}
return new ParameterizedMessage(format, arguments);
}
}
MessageFactory 是整个链路中最早的拦截点——在它这里可以把含 @Desensitize 注解的 DTO 参数替换为脱敏包装器或脱敏后的字符串。
5.1.3 角色二:ParameterizedMessage — 延迟求值的精髓
这是 Logback 设计中最精妙的地方。ParameterizedMessage 不在构造时格式化,而是在 getMessage() 被第一次调用时才格式化:
public class ParameterizedMessage implements Message {
private String messagePattern; // "order {}"
private Object[] argArray; // [dto]
private String formattedMessage; // null —— 初始时不格式化!
public ParameterizedMessage(String pattern, Object[] arguments) {
this.messagePattern = pattern;
this.argArray = arguments;
// 注意:formattedMessage 此时为 null
// 格式化被推迟到真正需要时才执行
}
@Override
public String getMessage() {
if (formattedMessage == null) {
formattedMessage = init(); // ← 第一次调用时才真正格式化
}
return formattedMessage;
}
private String init() {
// 解析参数中的 Throwable(最后一个参数如果可抛出,会被识别为异常)
if (argArray != null && argArray.length > 0) {
int lastIndex = argArray.length - 1;
if (argArray[lastIndex] instanceof Throwable) {
this.throwable = (Throwable) argArray[lastIndex];
}
}
// 调用 MessageFormatter 做真正的格式化
return MessageFormatter.arrayFormat(messagePattern, argArray).getMessage();
}
}
延迟求值的设计意图:
logger.debug("expensive: {}", buildHugeReport()); // 级别:INFO(不输出 DEBUG)
执行流程:
→ new ParameterizedMessage("expensive: {}", [report]) ← formattedMessage = null
→ 级别检查:DEBUG < INFO → 不满足
→ getMessage() 永远不会被调用
→ buildHugeReport() 的返回值虽然传入了,但 toString() 从未执行
→ 零开销
这就是参数化日志比字符串拼接快 ~25 倍的根本原因——字符串拼接方案:
logger.debug("expensive: " + buildHugeReport()); // buildHugeReport() 每次都执行!
5.1.4 角色三:MessageFormatter + deeplyAppendParameter — toString() 被调用的地方
这是整个链路中最核心的部分。当你传入一个 DTO 对象时,究竟在哪个时刻、以什么方式被转成字符串?
arrayFormat 主流程:
// org.slf4j.helpers.MessageFormatter(简化但保留核心逻辑)
public static FormattingTuple arrayFormat(
final String messagePattern,
final Object[] argArray) {
int i = 0; // 在 messagePattern 中的扫描位置
int argIndex = 0; // 当前处理的参数索引
StringBuilder sbuf = new StringBuilder(messagePattern.length() + 50);
while (argIndex < argArray.length) {
int j = messagePattern.indexOf("{}", i);
if (j == -1) {
break; // 没有更多占位符了
}
if (isEscapedDelimiter(messagePattern, j)) {
// 转义: "\{}" → 输出 "{}",不消耗参数
sbuf.append(messagePattern, i, j - 1);
sbuf.append('{');
sbuf.append('}');
i = j + 2;
} else {
// 正常替换:先追加占位符之前的文本
sbuf.append(messagePattern, i, j);
// ★ 核心:把参数值追加到缓冲区
deeplyAppendParameter(sbuf, argArray[argIndex], new HashMap<>());
i = j + 2;
argIndex++;
}
}
// 追加剩余部分
sbuf.append(messagePattern, i, messagePattern.length());
return new FormattingTuple(sbuf.toString());
}
deeplyAppendParameter — 对象的 toString() 调用点:
private static void deeplyAppendParameter(
StringBuilder sbuf, Object o, Map<Object[], Object> seenMap) {
if (o == null) {
sbuf.append("null");
return;
}
// 基本类型快速路径
if (o instanceof String) {
sbuf.append((String) o);
} else if (o instanceof Integer) {
sbuf.append(((Integer) o).intValue());
} else if (o instanceof Long) {
sbuf.append(((Long) o).longValue());
}
// ... 其他基本类型
else if (o.getClass().isArray()) {
deeplyAppendArray(sbuf, o, seenMap); // 数组递归处理
} else {
// ★★★ 对于普通对象,调用 toString() ★★★
safeObjectAppend(sbuf, o);
}
}
private static void safeObjectAppend(StringBuilder sbuf, Object o) {
try {
String s = o.toString(); // ← ← ← 就是这一行!
sbuf.append(s);
} catch (Throwable t) {
sbuf.append("[FAILED toString()]"); // 即使抛异常也不影响日志输出
}
}
5.1.5 数据流实例
logger.info("order {} created, payment {}", orderDto, 100);
messagePattern: "order {} created, payment {}"
argArray: [orderDto, 100]
arrayFormat 执行过程:
argIndex=0:
找到第一个 {} 在位置 5
sbuf.append("order ") → sbuf = "order "
deeplyAppendParameter(sbuf, orderDto) → sbuf.append(orderDto.toString())
→ sbuf = "order Order{id=1,phone=138****1234}"
i=7, argIndex=1
argIndex=1:
找到第二个 {} 在位置 23
sbuf.append(" created, payment ") → sbuf = "order Order{...} created, payment "
deeplyAppendParameter(sbuf, 100) → 走快速路径,直接 sbuf.append("100")
→ sbuf = "order Order{...} created, payment 100"
i=25, argIndex=2 → 循环结束
最终返回: "order Order{id=1,phone=138****1234} created, payment 100"
5.1.6 结论:三个可切入的拦截点
了解了完整链路后,脱敏方案有且仅有三个拦截位置:
位置 A: MessageFactory.newMessage() ← 最早,参数还没被格式化
位置 B: 对象的 toString() ← 最根本,让对象自己的 toString() 返回脱敏内容
位置 C: Converter / PatternLayout ← 最后,对完整字符串做正则替换
| 切入位置 | 优势 | 劣势 |
|---|---|---|
| A (MessageFactory/TurboFilter) | 最前置,可以改参数对象本身 | 需要切入 Logback 内部流程 |
| B (DTO toString()) | 最根本,不改日志框架 | 依赖开发者手动实现,容易遗漏 |
| C (Converter 正则) | 不侵入业务代码,纯配置 | 正则匹配可能误伤,无法利用注解语义 |
5.2 方案 A:自定义 MessageFactory(早期拦截)
最直接的思路:在 MessageFactory.newMessage() 中拦截参数,对含 @Desensitize 注解的对象做 JSON 序列化脱敏。
/**
* 脱敏消息工厂 —— 替换 Logback 默认的 ParameterizedMessageFactory
*/
public class DesensitizeMessageFactory implements ch.qos.logback.classic.spi.MessageFactory {
private static final DesensitizeValueFilter FILTER = new DesensitizeValueFilter();
// 缓存类的脱敏字段信息,避免每次反射
private static final Map<Class<?>, Boolean> DESENSITIZE_CACHE = new ConcurrentHashMap<>();
@Override
public ch.qos.logback.classic.spi.Message newMessage(
String format, Object... arguments) {
if (arguments != null) {
for (int i = 0; i < arguments.length; i++) {
Object arg = arguments[i];
if (arg != null && needsDesensitization(arg)) {
// 替换参数为脱敏后的 JSON 字符串
arguments[i] = JSON.toJSONString(arg, FILTER);
}
}
}
return new ch.qos.logback.classic.spi.ParameterizedMessage(format, arguments);
}
private boolean needsDesensitization(Object obj) {
if (obj instanceof String || obj instanceof Number) return false;
if (obj instanceof Boolean || obj instanceof Character) return false;
return DESENSITIZE_CACHE.computeIfAbsent(obj.getClass(), clazz -> {
for (Field f : clazz.getDeclaredFields()) {
if (f.isAnnotationPresent(Desensitize.class)) return true;
}
return false;
});
}
}
但 MessageFactory 方案有一个工程上的硬伤:Logback 的 Logger 没有公开的 setter 来替换 MessageFactory,需要通过反射操作内部 API,不同版本(1.2 / 1.3 / 1.4)实现细节有差异,升级风险高。
5.3 方案 B:TurboFilter 拦截(零侵入 + 最稳定)⭐ 推荐
Logback 提供了 TurboFilter —— 一个在日志事件创建的极早期被调用的钩子,运行在业务线程中。它比 MessageFactory 更早、更稳定、更易注册:
import ch.qos.logback.classic.turbo.TurboFilter;
import ch.qos.logback.core.spi.FilterReply;
/**
* 脱敏 TurboFilter —— 零侵入、零 DTO 改动的全自动方案
*
* 调用链:
* logger.info("order {}", dto)
* → TurboFilter.decide() ← 业务线程中执行,最早拦截点
* → 检测 dto 是否有 @Desensitize 注解
* → 有: 用 DesensitizeWrapper 包装 dto(延迟求值)
* → 无: 原样放过
* → ParameterizedMessage
* → getMessage() 时调用 wrapper.toString()
* → 返回脱敏后的 JSON
*/
public class DesensitizeTurboFilter extends TurboFilter {
private static final DesensitizeValueFilter FILTER = new DesensitizeValueFilter();
// 缓存:哪些类需要脱敏
private static final Map<Class<?>, Boolean> CACHE = new ConcurrentHashMap<>();
@Override
public FilterReply decide(
Marker marker,
ch.qos.logback.classic.Logger logger,
Level level,
String format,
Object[] params,
Throwable t) {
if (params != null) {
for (int i = 0; i < params.length; i++) {
Object param = params[i];
if (param != null && needsDesensitization(param)) {
// ★ 用包装器替换原始对象
// 包装器的 toString() 返回脱敏后的 JSON
// 保持延迟求值优势:只在真正输出时才序列化
params[i] = new DesensitizeWrapper(param);
}
}
}
return FilterReply.NEUTRAL; // 继续正常流程
}
private boolean needsDesensitization(Object obj) {
if (obj instanceof String || obj instanceof Number) return false;
if (obj instanceof Boolean || obj instanceof Character) return false;
return CACHE.computeIfAbsent(obj.getClass(), clazz -> {
for (Field f : clazz.getDeclaredFields()) {
if (f.isAnnotationPresent(Desensitize.class)) return true;
}
// 也检查父类字段
Class<?> superClass = clazz.getSuperclass();
while (superClass != null && superClass != Object.class) {
for (Field f : superClass.getDeclaredFields()) {
if (f.isAnnotationPresent(Desensitize.class)) return true;
}
superClass = superClass.getSuperclass();
}
return false;
});
}
}
/**
* 脱敏包装器 —— 保持延迟求值
*
* 不会立即执行脱敏(保留参数化日志的延迟求值优势),
* 只在 toString() 被调用时才执行序列化+脱敏。
*/
public class DesensitizeWrapper {
private final Object delegate;
public DesensitizeWrapper(Object delegate) {
this.delegate = delegate;
}
@Override
public String toString() {
try {
return JSON.toJSONString(delegate, DesensitizeValueFilter.INSTANCE);
} catch (Exception e) {
return "[DesensitizeError] " + delegate.getClass().getSimpleName();
}
}
}
注册 — 一行代码:
/**
* 在 Spring Boot 启动时注册 TurboFilter
*
* TurboFilter 的优势:
* 1. Logback 公开 API,所有版本通用,无升级风险
* 2. 在业务线程中执行,早于任何 Appender
* 3. 对 DTO 零侵入,不需要继承 BaseDTO
* 4. 自动识别 @Desensitize 注解,新人不需要知道脱敏逻辑
*/
@Component
public class LogDesensitizeInitializer implements ApplicationListener<ApplicationStartedEvent> {
@Override
public void onApplicationEvent(ApplicationStartedEvent event) {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
context.addTurboFilter(new DesensitizeTurboFilter());
}
}
性能分析:
无 @Desensitize 注解的 DTO(绝大多数场景):
- TurboFilter.decide() 只做一次 ConcurrentHashMap.get()
- 类元数据缓存,第一次反射后不再重复
- 额外开销: ~0.1μs
有 @Desensitize 注解的 DTO:
- 额外开销: new DesensitizeWrapper(obj) + JSON.toJSONString()
- ~5-10μs(Fastjson 序列化,热点数据 JIT 后可降至 2-3μs)
- 对比同步写磁盘的 ~100μs,可以忽略不计
5.4 TurboFilter vs MessageFactory vs BaseDTO
┌───────────────────┬──────────────────┬──────────────────┬───────────────────┐
│ │ TurboFilter │ MessageFactory │ BaseDTO.toString() │
│ │ (5.3 方案 B) │ (5.2 方案 A) │ (方案一) │
├───────────────────┼──────────────────┼──────────────────┼───────────────────┤
│ DTO 零改动 │ ✅ 完全零侵入 │ ✅ │ ❌ 需继承 BaseDTO │
│ Logback 版本兼容 │ ✅ 1.2/1.3/1.4 │ ❌ 内部API有差异 │ ✅ 无关 │
│ 注册复杂度 │ 一行 addTurboFilter│ 需反射操作 │ 每个DTO改继承 │
│ 漏脱敏风险 │ 极低(自动识别) │ 极低 │ 中(新人不知道继承) │
│ 注解支持 │ ✅ │ ✅ │ ✅(但需走JSON序列化)│
│ 延迟求值保持 │ ✅(Wrapper) │ ❌(提前序列化) │ ✅(延迟到toString) │
│ 异步日志兼容 │ ⚠️ 见第九章 │ ✅(已转字符串) │ ⚠️ 见第九章 │
│ 推荐度 │ ⭐⭐⭐⭐⭐ │ ⭐⭐ │ ⭐⭐⭐ │
└───────────────────┴──────────────────┴──────────────────┴───────────────────┘
六、四种方案对比
┌────────────────┬──────────────┬──────────────┬──────────────┬──────────────┐
│ │ 方案一 │ 方案二 │ 方案三 │ 方案四 │
│ │ Fastjson │ Jackson │ Logback │ 全自动 │
│ │ ValueFilter │ Serializer │ Converter │ MessageFactory│
├────────────────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ 代码侵入性 │ 低(继承) │ 低(继承) │ 零 │ 零 │
│ 注解支持 │ ✅ │ ✅ │ ❌ │ ✅ │
│ 误伤风险 │ 极低 │ 极低 │ 中等(正则) │ 极低 │
│ 性能 │ 好 │ 好 │ 好 │ 一般(反射) │
│ 稳定性 │ 极高 │ 极高 │ 极高 │ 中(依赖内部API)│
│ 学习/维护成本 │ 低 │ 低 │ 极低 │ 高 │
│ 兜底能力 │ 仅DTO │ 仅DTO │ 全部日志 │ 全部日志 │
│ │ │ │ │ │
│ 推荐场景 │ 新项目 │ Spring Boot │ 老项目 │ 对合规要求 │
│ │ │ 项目 │ 快速接入 │ 极度严格 │
└────────────────┴──────────────┴──────────────┴──────────────┴──────────────┘
七、推荐落地策略
7.1 90% 场景的最优解:方案一(Fastjson ValueFilter)
// Step 1: 引入 DesensitizeValueFilter(上面的完整代码)
// Step 2: 所有 DTO 继承 BaseDTO
public abstract class BaseDTO {
@Override
public String toString() {
return JSON.toJSONString(this, DesensitizeFilterHolder.INSTANCE);
}
}
// Step 3: 写个 IDEA Live Template:
// 模板: @Desensitize(type = DesensitizeType.$TYPE$)
// 快捷键: dsz
// 效果: 输入 dsz → 选择类型 → 自动生成注解
7.2 接入老项目:方案一 + 方案三双保险
所有新 DTO 继承 BaseDTO(注解脱敏,精准)
+
Logback %desensitize 兜底(正则脱敏,全面)
=
既有精准的注解控制,又有兜底的正则保护
7.3 不要做的事
❌ 不要依赖 toString() 手工拼字段
→ 加了新字段容易忘改 toString(),审计时就是合规漏洞
❌ 不要把脱敏逻辑写在业务代码里
→ maskPhone() 散落各处,时间久了没人知道哪些地方脱敏了、哪些没脱敏
❌ 不要在异步日志场景下依赖对象引用的 toString()
→ RingBuffer 中的 DTO 引用可能在异步线程格式化前被业务线程修改
→ 详见第九章
7.4 终极推荐:TurboFilter + Converter 双保险
TurboFilter(注解脱敏,精准,零侵入)
+
Logback %desensitize Converter(正则脱敏,全局兜底)
=
自动识别注解 + 无注解也能兜底 + 异步安全
为什么 TurboFilter 是最优解:
- 它是 Logback 的公开 API,所有版本通用,不存在升级炸掉的风险
- 注册只需要一行
context.addTurboFilter() - DTO 零改动,开发者不需要知道脱敏逻辑的存在
- 自动缓存类元数据,性能开销可忽略
八、补充:敏感数据检测
写完脱敏逻辑不算完,关键是怎么保证没有遗漏。写一段简单的审计脚本:
/**
* 运行时检查:扫描日志中是否还有未脱敏的敏感信息
* 可以在测试环境开启,每次发版前跑一遍
*/
public class SensitiveDataChecker extends ch.qos.logback.core.AppenderBase<ILoggingEvent> {
private static final Pattern PHONE = Pattern.compile("(?<![*\\d])1[3-9]\\d{9}(?![*\\d])");
private static final Pattern ID_CARD = Pattern.compile("(?<![*\\d])\\d{17}[\\dXx](?![*\\d])");
@Override
protected void append(ILoggingEvent event) {
String msg = event.getFormattedMessage();
Matcher phoneMatcher = PHONE.matcher(msg);
while (phoneMatcher.find()) {
System.err.printf("[脱敏告警] 日志中发现疑似手机号: %s, logger=%s%n",
phoneMatcher.group(), event.getLoggerName());
}
Matcher idMatcher = ID_CARD.matcher(msg);
while (idMatcher.find()) {
System.err.printf("[脱敏告警] 日志中发现疑似身份证: %s, logger=%s%n",
idMatcher.group(), event.getLoggerName());
}
}
}
测试环境挂载这个 Appender,日志里一旦出现漏网之鱼立刻告警。
九、异步日志中的脱敏一致性问题(进阶)
前面讲的方案,在同步日志场景下都能正常工作。但生产环境为了性能,几乎都会启用异步日志(Logback 的 AsyncAppender、Log4j2 的 AsyncLogger)。异步日志引入了一个深层的坑:RingBuffer 传递的是对象引用,而不是字符串快照。
9.1 问题的本质:生产-消费模式下的”对象可见性”
时间线(异步日志 + RingBuffer,如 Log4j2 AsyncLogger / Disruptor):
T0: 业务线程
logger.info("order {}", orderDto); ← DTO 引用被放入 RingBuffer
│
▼
RingBuffer slot[7] = { format: "order {}", args: [orderDto_ref] }
T0+1ms: 业务线程继续执行
orderDto.setStatus("CANCELLED"); ← 修改了 DTO 的字段!
orderDto.setAmount(new BigDecimal("0"));
T0+5ms: 异步刷盘线程
从 RingBuffer 取出 slot[7]
调用 MessageFormatter.arrayFormat()
→ deeplyAppendParameter(sbuf, orderDto_ref)
→ sbuf.append(orderDto_ref.toString()) ← 读到的已是修改后的值
磁盘上的日志:
"order Order{status=CANCELLED, amount=0}"
实际下单时的值:
status=CREATED, amount=100.00
⚠️ 日志记录与真实事件不符!排查线上问题时被错误信息误导!
根因分析:
同步日志:
业务线程格式化 → 业务线程传字符串 → Appender 写磁盘
格式化时刻 = 内容确定时刻,天然一致
异步日志:
业务线程传引用 → RingBuffer 存引用 → 异步线程格式化 → Appender 写磁盘
格式化时刻 ≠ 引用放入时刻,对象可能已被修改
9.2 TurboFilter 包装器为何也有同样的问题
回顾 5.3 的 TurboFilter 方案:
// TurboFilter 中:
params[i] = new DesensitizeWrapper(param); // Wrapper 持有原始 DTO 引用
// DesensitizeWrapper.toString():
JSON.toJSONString(delegate, FILTER); // delegate 就是业务线程中的 DTO
DesensitizeWrapper 持有的是原始 DTO 的引用,而不是快照。异步线程调用 wrapper.toString() 时,DTO 可能已被业务线程修改。脱敏只保证值被遮罩,不保证值是正确的时刻的值。
9.3 解决方案:在业务线程中提前”快照化”
核心思想:在放入 RingBuffer 之前,完成格式化(包括脱敏),让 RingBuffer 中只存不可变的字符串。
方案 A:TurboFilter 中直接序列化为字符串(推荐)
/**
* 异步安全版 TurboFilter —— 在业务线程中直接完成脱敏+序列化
*
* 差异化:
* 同步版: params[i] = new DesensitizeWrapper(param); // 延迟求值
* 异步安全版: params[i] = desensitizeToString(param); // 立即求值
*/
public class AsyncSafeDesensitizeTurboFilter extends TurboFilter {
private static final DesensitizeValueFilter FILTER = new DesensitizeValueFilter();
private static final Map<Class<?>, Boolean> CACHE = new ConcurrentHashMap<>();
@Override
public FilterReply decide(
Marker marker,
ch.qos.logback.classic.Logger logger,
Level level,
String format,
Object[] params,
Throwable t) {
if (params != null) {
for (int i = 0; i < params.length; i++) {
Object param = params[i];
if (param != null && needsDesensitization(param)) {
// ★ 关键:在业务线程中立即序列化
// 生成不可变的字符串快照,断绝 DTO 引用
params[i] = JSON.toJSONString(param, FILTER);
}
}
}
return FilterReply.NEUTRAL;
}
private boolean needsDesensitization(Object obj) {
if (obj instanceof String || obj instanceof Number) return false;
if (obj instanceof Boolean || obj instanceof Character) return false;
return CACHE.computeIfAbsent(obj.getClass(), clazz -> {
for (Field f : clazz.getDeclaredFields()) {
if (f.isAnnotationPresent(Desensitize.class)) return true;
}
return false;
});
}
}
与同步版的差异:
同步版 DesensitizeWrapper:
业务线程: new Wrapper(dto) → RingBuffer
异步线程: wrapper.toString() → 脱敏序列化
⚠️ 序列化时刻在异步线程,DTO 可能已变化
异步安全版(直接转字符串):
业务线程: JSON.toJSONString(dto, filter) → 字符串 → RingBuffer
异步线程: 直接使用字符串
✅ 序列化时刻在业务线程,内容与事件时刻一致
方案 B:DTO 深拷贝(不推荐)
// 放入 RingBuffer 前深拷贝 DTO
Object snapshot = SerializationUtils.clone(dto);
// 问题:性能开销大(~50μs),复杂对象图可能拷贝失败
方案 C:不可变日志快照(理想但落地成本高)
// 日志专用的不可变快照,通过 copy 构造创建
public record OrderLogSnapshot(long id, String mobile, BigDecimal amount) {
public static OrderLogSnapshot from(OrderDTO dto) {
return new OrderLogSnapshot(dto.getId(), dto.getMobile(), dto.getAmount());
}
}
logger.info("order {}", OrderLogSnapshot.from(orderDto));
// 问题:每个 DTO 都要写对应 Snapshot
9.4 性能权衡分析
同步格式化(异步安全):
业务线程:
→ TurboFilter.decide() [反射缓存命中: 0.1μs]
→ JSON.toJSONString(dto, filter) [Fastjson 序列化+脱敏: 5μs]
→ RingBuffer.put("脱敏后的JSON字符串") [存字符串]
→ 返回,继续业务处理
异步线程:
→ RingBuffer.take("字符串") [取字符串,已经是不可变的]
→ 写磁盘 [I/O: ~100μs]
总延迟: 5μs + 100μs = 105μs
格式化阻塞业务线程 5μs,但数据 100% 一致 ✅
延迟格式化(当前 TurboFilter Wrapper):
业务线程:
→ TurboFilter.decide() [反射缓存命中: 0.1μs]
→ new DesensitizeWrapper(dto) [只存引用: 0.05μs]
→ RingBuffer.put(Wrapper_ref) [存引用]
→ 返回,继续业务处理
异步线程:
→ RingBuffer.take(Wrapper_ref) [取引用]
→ wrapper.toString() [Fastjson 序列化+脱敏: 5μs]
→ 写磁盘 [I/O: ~100μs]
总延迟: 0.15μs + 105μs = 105.15μs
业务线程几乎无阻塞,但数据可能不一致 ❌
关键结论:序列化+脱敏的 CPU 开销(~5μs)远小于磁盘 I/O(~100μs)。把格式化移到业务线程中,对整体吞吐量的影响可忽略不计(< 0.5%),但换来了日志内容的绝对正确性。排查线上问题时,日志的可信度远比这 5μs 重要。
9.5 完整的异步安全架构
业务代码
│
logger.info("order {}", dto)
│
▼
┌─────────────────────────────────┐
│ AsyncSafeDesensitizeTurboFilter │ ← 第一层:业务线程中完成脱敏+格式化
│ (立即序列化,生成字符串快照) │ 字符串是不可变的,断绝 DTO 引用
└───────────┬─────────────────────┘
│ 返回已脱敏的字符串
▼
┌─────────────────────────────────┐
│ RingBuffer │ ← 第二层:RingBuffer 中只有字符串
│ slot[n] = "order {\"id\":1, │ 不再是可变对象引用
│ \"phone\":\"138****1234\"}" │
└───────────┬─────────────────────┘
│ 异步线程取出(纯字符串,无对象引用)
▼
┌─────────────────────────────────┐
│ AsyncAppender │ ← 第三层:只负责磁盘 I/O
│ (纯 I/O,不再格式化) │
└───────────┬─────────────────────┘
│
▼
磁盘文件
9.6 决策指南
┌─────────────────────────────────────────────────────────────────┐
│ 你的场景 推荐方案 │
├─────────────────────────────────────────────────────────────────┤
│ Logback AsyncAppender + 日志内容正确性要求高 方案 A (立即序列化) │
│ Log4j2 AsyncLogger (Disruptor) 方案 A (立即序列化) │
│ 纯同步日志 (ConsoleAppender / FileAppender) TurboFilter Wrapper │
│ DTO 本身就是不可变对象(Record / @Value) Wrapper 也可以 │
│ 日志量巨大(> 10万条/秒)+ 可接受偶发不一致 Wrapper(性能最优) │
└─────────────────────────────────────────────────────────────────┘
一句话总结异步安全:让 RingBuffer 里存的是不可变的字符串,而不是可变的 DTO 引用。把”格式化 + 脱敏”这道工序从异步线程移到业务线程,用 5μs 的 CPU 时间换日志内容的绝对可信。
十、总结
日志脱敏不是一个技术难题,而是一个工程纪律问题。核心矛盾在于:敏感信息散落在几百个 DTO 中,开发人员记不住、查不全。
回顾全文,我们从零开始搭建了一套完整的日志脱敏体系:
| 层级 | 方案 | 职责 |
|---|---|---|
| 注解层 | @Desensitize 注解 + DesensitizeType 枚举 |
声明哪些字段需要脱敏 |
| 拦截层 | TurboFilter(推荐)或 MessageFactory |
在 toString() 之前自动识别注解并脱敏 |
| 兜底层 | Logback %desensitize Converter 正则 |
捕获未加注解的敏感字段 |
| 审计层 | SensitiveDataChecker Appender |
测试环境主动扫描漏网之鱼 |
| 异步安全层 | 业务线程中提前序列化 | 保证 RingBuffer 中的字符串不可变 |
最佳实践路线图:
- 起步:在项目中引入
DesensitizeValueFilter(Fastjson),核心 DTO 继承 BaseDTO - 自动化:注册
TurboFilter,让所有logger.info("{}", dto)自动脱敏——DTO 零改动 - 兜底:Logback 配置
DesensitizeConverter,正则捕获遗漏的敏感数据 - 审计:测试环境挂载
SensitiveDataChecker,每次发版前确认为零漏报 - 异步安全:如果使用 AsyncAppender,切换到
AsyncSafeDesensitizeTurboFilter(立即序列化版本) - 降低摩擦:IDEA Live Template
@Desensitize(type = ...),快捷键dsz
做到这六点,日志脱敏就从”每次 code review 的口头提醒”变成了”框架级别的自动保障”——开发者不需要知道脱敏逻辑的存在,@Desensitize 注解是唯一的显式标记,框架在底层自动完成所有事情,包括异步场景下的数据一致性。