日志脱敏:不改业务代码,单遍扫描完成替换

背景与目标

日志里会带上手机号、身份证号这类值:

text 复制代码
2025-09-07 10:00:00 order callback user=张三 phone=13800138000 idCard=110101199003072345 result=OK

处理它之前,先限定两个约束:不改业务代码 ,以及在日志热路径上的开销要足够低。

常见做法在这两点上都有折扣:

  • 注解 + 反射:注解要打在类上,但这行日志里的值来自网关请求的 String、另一个服务返回的 Map、用户填写的昵称,多数情况下没有对应对象;即使有对象,反射取字段、深拷贝、递归对象图的开销也发生在每次输出中
  • 工具类包装 :MaskUtils.mask(...) 这种形式要求逐个改造调用点,日志散落在业务各处时改造面很大,而且以后新增的日志容易漏掉
  • 正则替换:每条日志上跑一遍正则,Pattern 匹配与回溯的成本随日志量累积,规则一复杂就更明显

对应到实现上是两件事:脱敏放在日志输出层 执行,业务代码不动;正文按字符流单次扫描,不反射、不正则、不遍历对象图,成本与输入长度线性相关。

热路径上大概是什么量级:1MB 日志文本、4 个字段需要脱敏约 190 MB/s,单条 113 字节日志逐条调用约 0.63 µs(测法见性能一节)。

原理

输入按字符逐个过一遍状态机。遇到键就把键查一次注册表,命中就替换值,没命中就把字符原样搬走;整段输入只扫一遍,不回退、不重扫。

flowchart TB S([开始扫描]) --> A[START] A -->|遇到引号键| B[IN_KEY] A -->|标识符后紧跟等号| F[IN_UNQUOTED_VALUE] B -->|键闭合| C[AFTER_KEY] C -->|值是字符串| D[IN_STRING_VALUE] C -->|值是裸值| E[IN_VALUE] C -->|值是对象或数组| A D -->|遇到未被转义的引号| G[AFTER_VALUE] E -->|遇到逗号或右花括号| A F -->|空白/分号/右花括号或输入结束| A G -->|复位| A classDef entry fill:#ECEFF1,stroke:#607D8B,stroke-width:1px,color:#263238 classDef key fill:#E3F2FD,stroke:#1E88E5,stroke-width:1px,color:#0D47A1 classDef val fill:#FFF8E1,stroke:#FB8C00,stroke-width:1px,color:#E65100 classDef reset fill:#F3E5F5,stroke:#8E24AA,stroke-width:1px,color:#4A148C class S entry class A,B,C key class D,E,F val class G reset

图中蓝色是与键相关的状态,橙色与值相关,紫色只做复位,灰色是入口。七个状态各自负责一段边界:起始态负责找键(引号键或无引号键候选),IN_KEY / AFTER_KEY 负责把键读完并等到分隔符,然后按值的第一形态分流到字符串值、裸值或无引号值,AFTER_VALUE 只做复位。

几种"看起来会出错"的输入之所以安全,是因为状态里记着上下文:值在不在字符串内由所处状态表达,字符串内出现 =,} 这类字符不会被当成结构;转义标记单独记在上下文里,所以值里的 \" 和 \\ 不会造成截断,也不会让后面的内容错位。

核心逻辑很短(MaskEngine.java,309 行含注释),扩展点见后文。

支持哪些文本

  • JSON :带引号的键,支持嵌套对象、数组、空白与 = 分隔符
  • KV :name=李四&mobile=13800138000&email=test@example.com
  • 日志文本 :无引号键值对,标识符后紧跟 = 即为键,值以空白、&、,、;、}、] 或输入结尾结束
  • 值与键里的转义 :\" 和 \\ 会被正确识别,不会截断内容,也不会把值里的内容误当成新的键
  • 未注册的字段、时间戳、traceId 之类:原样透传,不做处理

以下输出均为本地实际运行结果:

text 复制代码
IN : {"name":"李四","idCard":"110101199003072345","mobile":"13800138000","email":"test@example.com"}
OUT: {"name":"李*","idCard":"110******345","mobile":"138******8000","email":"******.com"}

IN : 2025-09-07 10:00:00 user=张三 phone=13800138000 email=test@example.com status=success
OUT: 2025-09-07 10:00:00 user=张* phone=*******8000 email=******.com status=success

IN : name=李四&mobile=13800138000&email=test@example.com
OUT: name=李*&mobile=138******8000&email=******.com

IN : {"user":{"name":"王五","mobile":"13912345678"},"trace_id":"abc","ts":"2025-09-07T10:00:00"}
OUT: {"user":{"name":"王*","mobile":"139******5678"},"trace_id":"abc","ts":"2025-09-07T10:00:00"}

IN : {"note":"he said \"mobile=13800138000\"","mobile":"13800138000"}
OUT: {"note":"he said \"mobile=13800138000\"","mobile":"138******8000"}

IN : name = 李四, token=abcdef, unregistered=13800138000
OUT: name = 李四, token=***, unregistered=13800138000

IN : [{"name":"赵六","mobile":"13700001111"}]
OUT: [{"name":"赵*","mobile":"137******1111"}]

IN : {"password":"mySecret123","address":"北京市海淀区中关村大街1号"}
OUT: {"password":"***","address":"北京******1号"}

注册的规则:

java 复制代码
MaskEngine engine = new MaskEngine();
engine.register("name", MaskType.CHINESE_NAME);
engine.register("user", MaskType.CHINESE_NAME);
engine.register("idCard", MaskType.ID_CARD);
engine.register("mobile", MaskType.MOBILE_PHONE);
engine.register("phone", MaskType.FIXED_PHONE);
engine.register("email", MaskType.EMAIL);
engine.register("address", MaskType.ADDRESS);
engine.register("password", MaskType.ALL);
engine.register("token", value -> "***");

嵌套对象与数组正常处理;note 里的转义引号没有导致截断,其中的 mobile=... 也没被当成键;trace_id、ts 未注册,内容保持原样;name = 李四 因空格未被识别;unregistered 无规则,透传。

内置的十二种类型,实现在 Maskers.java:

类型 规则 示例
CHINESE_NAME 只保留第一个汉字 张三 → 张*
ID_CARD 前 3 后 3,中间 ****** 110101199003072345 → 110******345
MOBILE_PHONE 前 3 后 4 13800138000 → 138******8000
FIXED_PHONE 只保留后 4 位 01012345678 → *******5678
ADDRESS 前 2 后 2 北京市海淀区中关村大街1号 → 北京******1号
EMAIL 最后一个点之前全部替换 test@example.com → ******.com
BANK_CARD 前 2 后 4 6225880123456789 → 62******6789
BANK_NAME 只保留前 4 位 中国工商银行 → 中国工商**
CNAPS_CODE 前 4 后 4 123456789012 → 1234******9012
BANK_CARD_DATE 前 1 后 1 0126 → 0**6
ALL 固定三个星号 mySecret123 → ***
NULL 清空 mySecret123 → 空

两处行为需要留意。值的长度不足规则要求时(例如手机号只有 5 位),不做部分显示,直接返回 ******,避免短值接近明文。值的字面量为 "null" 时保持原样返回,这是上游序列化产生的异常数据,保留它可以减少排查成本。

扩展

实际项目里规则很难靠内置类型覆盖完,所以留了四个扩展点。

一、自定义规则。 规则就是一个函数式接口,接一个字段值、返回脱敏后的值:

java 复制代码
@FunctionalInterface
public interface MaskStrategy {
    String mask(String value);
}

比如把手机号改成"中间四位固定打码",直接给 lambda,不必动枚举:

java 复制代码
engine.register("mobile", value -> value.length() > 7
        ? value.substring(0, 3) + "****" + value.substring(value.length() - 4)
        : "******");

二、配置驱动。 规则串格式是 field:TYPE,逗号和分号都能分隔,类型名大小写不敏感,方便从配置中心或启动参数下发:

java 复制代码
engine.registerAll("mobile:MOBILE_PHONE,name:CHINESE_NAME,password:ALL");

格式或类型名写错会直接抛异常,而不是静默忽略,避免配置错了但日志看起来正常:

java 复制代码
engine.registerAll("mobile:NOT_A_TYPE");
// java.lang.IllegalArgumentException: 未知脱敏类型 'NOT_A_TYPE'(字段 mobile)

三、新增内置类型。 只需要改两处,第三处会自动跟上:

  1. MaskType 里加一个枚举值
  2. Maskers 里加对应的静态方法,并在 mask(MaskType, String) 的 switch 中补一个 case
  3. BasicMaskStrategies 遍历 MaskType.values() 建表,无需修改

四、日志适配器的扩展点。 Logback 侧可以子类化 MaskingConverter,在构造器里 setEngine(engine) 注入带自定义规则的引擎,此时转换词上的规则选项会被忽略;适合规则来自配置中心、需要在启动时组装的场景。Log4j2 侧通过 rules 属性配置规则,MaskingRewritePolicy#getEngine() 暴露内部引擎,便于程序化复用与测试。

无侵入的落点:Logback 与 Log4j2

要让业务代码一行不改,脱敏必须发生在日志输出阶段。Logback 侧实现为自定义转换词,改动只在配置里:

xml 复制代码
<conversionRule conversionWord="maskedMsg"
    converterClass="io.github.lancelot.datamasking.logback.MaskingConverter"/>

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
  <encoder>
    <pattern>%d %-5level %logger{36} - %maskedMsg{mobile:MOBILE_PHONE,name:CHINESE_NAME}%n</pattern>
  </encoder>
</appender>

(MaskingConverter.java)业务的 log.info(...) 不需要任何改动。

它只处理 event.getFormattedMessage(),即占位符替换完成后的消息正文。MDC、线程名、logger 名不在处理范围内:这些位置通常不承载业务敏感值,全量扫描会让每条日志都承担额外成本。脱敏失败不会中断日志链路------异常时回退输出原始消息。

Log4j2 侧实现为插件,挂在 Rewrite appender 上,可以只作用于需要脱敏的链路(MaskingRewritePolicy.java):

xml 复制代码
<Rewrite name="masked">
  <MaskingRewritePolicy rules="mobile:MOBILE_PHONE,name:CHINESE_NAME,password:ALL"/>
  <Console name="Console" target="SYSTEM_OUT"/>
</Rewrite>

它不修改原事件,而是重建一个,异常同样回退原事件。两个日志框架的依赖在 pom 中均为 provided,避免这个库约束使用方的日志版本。

引擎本身不持有扫描状态,可以被并发复用(appender 本身就是并发调用的),规则在启动期注册完、运行期只读即可。

性能

单线程、1MB 输入、best-of-N、JDK 8。不是 JMH 基准,数值只代表量级,换机器或 JDK 会有波动:

场景 吞吐
1MB 日志文本,4 个字段需脱敏 约 190 MB/s
1MB JSON 数组,4 个字段需脱敏 约 240 MB/s
1MB 日志,纯扫描且无命中 约 240 MB/s
单条 113 字节日志逐条调用 约 0.63 µs
同任务正则 replaceAll(简化规则,口径不同) 约 110 MB/s

开销集中在逐字符判断,没有正则回溯,也没有反射。最后一行只是参照,口径与前面不同,只用于确认量级。放进真实日志链路时,除脱敏本身还要算上 appender 原有的格式化成本。

已知限制

  • name = x:键与 = 之间存在空格,不识别
  • 'name':'x':单引号键不支持
  • mobile:13800138000:冒号加无引号,不脱敏
  • 键必须以字母或下划线开头,后续字符为字母、数字或下划线
  • 无引号值的结束符为空白、&、,、;、}、] 或输入结尾
  • 不处理对象脱敏、递归对象图脱敏和业务框架封装

在文本格式规范的前提下不会触发;格式不规范时引擎选择漏判,而不是在文本中猜测键名。

使用方式

xml 复制代码
<dependency>
    <groupId>io.github.lancelothuxi</groupId>
    <artifactId>data-masking</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>
java 复制代码
MaskEngine engine = new MaskEngine();
engine.registerAll("name:CHINESE_NAME,mobile:MOBILE_PHONE,idCard:ID_CARD,password:ALL");

engine.process("{\"name\":\"李四\",\"mobile\":\"13800138000\"}");
// {"name":"李*","mobile":"138******8000"}

适用于日志与调试输出、JSON/KV/HTTP body 的文本脱敏、网关与消息通道中的敏感字段过滤。不适合作为业务对象的脱敏框架,定位不同。

仓库地址:github.com/lancelothux...(Apache-2.0)。后续计划补充更多内置类型、细化日志框架适配(例如按 appender 配置规则)、接入配置中心。缺失的字段类型可以在 issue 中提出。

相关推荐
lightning_bug1 小时前
docker部署黑马笔记
后端·架构
威哥爱编程1 小时前
【AI全栈12-10】Spring Boot 用多模态做商品图审核 / 发票识别
java·spring boot·后端
bruce541101 小时前
给 UniRAG RAG 配一个前端,为什么最后选了 Lobe UI
后端
imDwAaY1 小时前
用户态与内核态到底怎么切换?从系统调用到上下文切换
后端·操作系统
imDwAaY1 小时前
进程、线程、协程有什么区别?从资源分配到调度,一篇理清
后端·操作系统
威哥爱编程1 小时前
【AI全栈12-09】Spring Boot 用流式输出做打字机对话:点下去不再干等
java·spring boot·后端
威哥爱编程1 小时前
【AI全栈12-08】Spring Boot 用 MCP 统一接入内部系统:让 AI 接一次,全公司复用
java·spring boot·后端
deviant-ART1 小时前
Java 并发编程:深入理解 synchronized 的使用与原理
java·开发语言·后端
tachibana21 小时前
Go语言里slice是怎么扩容的?
后端·golang·go·切片