Java后端做数据脱敏,很多团队的做法还停留在“前端遮一下”或者“日志里手动替换”的阶段。这种做法在安全审计和合规场景下基本等于没做。真正需要解决的是后端返回数据、日志输出、数据库导出、接口对接这几个环节的明文泄露问题。脱敏的核心不是把数据弄乱,而是让不同角色在不同场景下拿到不同粒度的数据,同时保证脱敏规则可配置、可追溯、不影响业务主流程。

先明确脱敏的边界:不是所有数据都要脱敏

后端脱敏最容易犯的错误是一刀切。比如用户手机号,在用户自己的个人中心页面应该显示完整号码,但在客服工单列表里只显示前3后4,在运营导出报表里可能只需要归属地。所以脱敏的第一件事是定义数据分级和场景策略。通常建议把字段分为L1公开、L2内部、L3敏感、L4高敏四级。L3和L4字段必须走脱敏组件,L2字段可以按角色动态决定。手机号、身份证、银行卡、邮箱、地址、IP、设备号这些都属于L3起步。脱敏策略要挂在字段级别,而不是接口级别,因为同一个字段在不同接口里的敏感程度可能完全不同。

基于Jackson的自定义脱敏注解是目前最实用的方案

Java后端返回JSON给前端时,序列化环节是脱敏最自然的切入点。直接改DTO的getter方法虽然简单,但会导致业务代码里拿到的也是脱敏后的值,后续如果要做数据比对或二次处理就会出错。推荐的做法是自定义Jackson注解,在序列化时根据当前登录用户的角色和场景动态决定是否脱敏。下面是一个可以直接落地的实现思路:

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveJsonSerializer.class)
public @interface Sensitive {
    SensitiveType type();
    String scene() default "default";
}

public class SensitiveJsonSerializer extends JsonSerializer implements ContextualSerializer {
    @Override
    public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) {
        Sensitive annotation = property.getAnnotation(Sensitive.class);
        if (annotation != null && needMask(annotation.scene())) {
            return new SensitiveMaskSerializer(annotation.type());
        }
        return new SensitiveNoMaskSerializer();
    }
}

这里的关键是ContextualSerializer接口。它可以在序列化时拿到字段上的注解信息,然后根据当前请求上下文中的用户角色判断是否需要脱敏。如果不需要脱敏,就返回一个原样输出的序列化器;如果需要,就返回一个执行具体脱敏逻辑的序列化器。这样业务代码里DTO的字段值始终保持原始数据,只有JSON输出时才做处理。

脱敏类型要覆盖常见格式,不能只做星号替换

很多团队以为脱敏就是中间加星号,实际上不同数据类型的脱敏规则差异很大。手机号通常是保留前3后4,中间4位星号;身份证保留前3后4,中间11位星号,但要注意15位老身份证的兼容;银行卡一般只显示后4位,前面全部星号;邮箱要保留首字母和@后面的域名;地址通常只保留省市区,详细地址全部隐藏;IP地址最后一段替换为星号。这些规则应该做成枚举和策略类,而不是在代码里散落一堆substring。建议定义一个SensitiveType枚举,每个枚举值对应一个脱敏函数:

public enum SensitiveType {
    MOBILE(value -> value.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1$2")),
    ID_CARD(value -> value.replaceAll("(\\d{3})\\d{11}(\\d{3})", "$1*$2")),
    BANK_CARD(value -> "   " + value.substring(value.length() - 4)),
    EMAIL(value -> value.replaceAll("(^\\w)[^@]*(@.*$)", "$1*$2")),
    ADDRESS(value -> value.replaceAll("(.{2}).*", "$1"));
    
    private final Function masker;
}

这样做的好处是脱敏规则集中管理,新增类型只需要加一个枚举值。而且正则表达式的方式比手动substring更健壮,能处理长度不固定的数据。需要注意的是,脱敏函数必须处理null和空字符串,否则序列化时会抛异常导致整个接口500。

日志脱敏是另一个重灾区,而且比接口脱敏更容易被忽略

后端日志里打印请求参数、SQL参数、异常堆栈时,经常会把手机号、身份证、密码明文打出来。尤其是使用MyBatis或JPA时,SQL参数日志默认是明文。解决这个问题要从三个层面入手。第一,日志框架层面使用logback或log4j2的conversionRule,对日志消息中的敏感格式做正则替换。第二,在日志工具类里提供脱敏方法,要求开发在打印对象时先调用。第三,也是最彻底的,在DTO的toString方法里使用脱敏后的值,这样即使直接打印对象也不会泄露。但第三种方案会影响到调试效率,需要配合环境判断,生产环境脱敏,开发环境保留明文。

logback的conversionRule配置示例如下:

public class SensitiveLogConverter extends MessageConverter {
    @Override
    public String convert(ILoggingEvent event) {
        String message = super.convert(event);
        return maskSensitiveData(message);
    }
}

// logback.xml
<conversionRule conversionWord="msg" converterClass="com.example.SensitiveLogConverter"/>

这个方案的好处是对业务代码零侵入,所有日志输出都会经过脱敏处理。但正则匹配的性能开销需要注意,建议只对手机号、身份证、银行卡这几种高敏格式做匹配,不要试图用一个大而全的正则覆盖所有情况。另外,异常堆栈里的敏感信息往往来自第三方库的异常消息,这种场景下日志转换器也能覆盖到,因为堆栈信息最终也是通过message输出的。

数据库查询结果脱敏需要区分OLTP和OLAP场景

如果是业务接口查询,脱敏应该在应用层做,而不是在SQL里做。SQL里做脱敏会导致索引失效、查询性能下降,而且脱敏后的数据无法用于业务逻辑判断。但如果是数据导出、报表生成、离线分析这类场景,应用层脱敏会消耗大量内存和CPU,这时候应该考虑在数据仓库或导出工具层面做脱敏。一个折中的方案是,在DAO层提供两套查询方法:一套返回原始数据供业务逻辑使用,一套返回脱敏数据供展示和导出使用。但这样会增加代码量,更好的做法是在Service层和Controller层之间加一个脱敏切面,根据接口注解自动选择脱敏策略。

动态数据脱敏(DDM)是更高级的方案,但落地成本较高

如果系统中有大量不同的角色和场景,每个角色对同一字段的可见粒度都不同,那么基于注解的静态脱敏就会变得难以维护。这时候可以考虑动态数据脱敏,即在数据库访问层拦截SQL,根据当前用户角色动态改写查询语句。比如运营角色查询用户表时,手机号字段自动变成CONCAT(SUBSTRING(mobile,1,3),'',SUBSTRING(mobile,8,4))。这种方案的好处是脱敏逻辑完全在数据库层完成,应用代码不需要任何改动。但缺点是SQL改写复杂,对复杂查询和存储过程的支持不完善,而且不同数据库的SQL方言差异大。目前国内主流方案还是应用层脱敏,DDM更多用在数据中台和数据共享平台这类场景。

脱敏策略的配置化和审计不能缺失

脱敏规则不应该硬编码在代码里,至少要支持通过配置中心动态调整。比如某个字段的脱敏类型从“部分隐藏”改成“全部隐藏”,应该能通过修改配置实时生效,而不是重新发版。同时,脱敏操作本身需要记录审计日志,包括谁在什么时间访问了什么数据、返回的是明文还是脱敏数据、脱敏策略是什么。这在等保测评和金融行业合规中是硬性要求。审计日志本身也要脱敏,避免审计日志变成新的泄露源。

性能优化方面,脱敏操作要尽量轻量

正则替换在高并发场景下会有明显的CPU开销。如果接口返回的数据量很大,比如列表接口一次返回几百条记录,每条记录有多个字段需要脱敏,那么序列化时的脱敏操作可能成为性能瓶颈。优化手段包括:对脱敏函数做缓存,避免每次序列化都创建新的Pattern对象;对不需要脱敏的场景直接跳过,不要走脱敏逻辑;对于批量导出场景,考虑在SQL查询时就完成脱敏,减少应用层的处理压力。实测下来,一个简单的手机号正则替换在单线程下每秒可以执行百万次以上,但如果接口QPS很高且单次返回数据量大,这个开销就不能忽视。

最后要强调一点:脱敏不等于加密

脱敏是不可逆的,加密是可逆的。有些场景下数据需要还原,比如用户修改手机号时需要先验证原手机号,这时候就不能用脱敏,而应该用加密存储加解密查询。脱敏解决的是展示和传输环节的泄露问题,加密解决的是存储环节的泄露问题。两者要配合使用,不能互相替代。很多安全事件的发生,就是因为把脱敏当成了加密,以为数据脱敏了就安全了,结果数据库被拖库后明文照样泄露。正确的做法是:存储用加密,展示用脱敏,传输用HTTPS,日志用脱敏,四层防护缺一不可。