背景与目标
日志里会带上手机号、身份证号这类值:
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(测法见性能一节)。
原理
输入按字符逐个过一遍状态机。遇到键就把键查一次注册表,命中就替换值,没命中就把字符原样搬走;整段输入只扫一遍,不回退、不重扫。
图中蓝色是与键相关的状态,橙色与值相关,紫色只做复位,灰色是入口。七个状态各自负责一段边界:起始态负责找键(引号键或无引号键候选),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)
三、新增内置类型。 只需要改两处,第三处会自动跟上:
MaskType里加一个枚举值Maskers里加对应的静态方法,并在mask(MaskType, String)的 switch 中补一个 caseBasicMaskStrategies遍历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 中提出。