MingJunDuan的博客
热爱可抵一切,探索未知之境
全站访问量

日志脱敏:从注解到自动化的完整实现

线上日志里打印了用户手机号、身份证号、银行卡号——这是每个后端工程师的噩梦。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 中的字符串不可变

最佳实践路线图

  1. 起步:在项目中引入 DesensitizeValueFilter(Fastjson),核心 DTO 继承 BaseDTO
  2. 自动化:注册 TurboFilter,让所有 logger.info("{}", dto) 自动脱敏——DTO 零改动
  3. 兜底:Logback 配置 DesensitizeConverter,正则捕获遗漏的敏感数据
  4. 审计:测试环境挂载 SensitiveDataChecker,每次发版前确认为零漏报
  5. 异步安全:如果使用 AsyncAppender,切换到 AsyncSafeDesensitizeTurboFilter(立即序列化版本)
  6. 降低摩擦:IDEA Live Template @Desensitize(type = ...),快捷键 dsz

做到这六点,日志脱敏就从”每次 code review 的口头提醒”变成了”框架级别的自动保障”——开发者不需要知道脱敏逻辑的存在,@Desensitize 注解是唯一的显式标记,框架在底层自动完成所有事情,包括异步场景下的数据一致性。

本文阅读量