
数据脱敏这事我在实际项目里踩过不少坑。很多人以为给前端返回数据时把手机号打几个星号就完事了但真正落到 Spring Boot 项目里会发现要处理的问题远比想象中多日志里明文泄露、接口返回脱敏不彻底、嵌套对象里的敏感字段漏网、脱敏规则写死在业务代码里难以维护……这篇文章我把自己的完整实现思路和踩坑记录整理出来希望对你有点帮助。1. 数据脱敏的场景与方案选型1.1 哪些场景必须做数据脱敏数据脱敏不是锦上添花而是很多系统的硬性合规要求。我总结下来日常项目里最常见的敏感数据场景有这么几类个人隐私信息手机号、身份证号、银行卡号、邮箱、家庭住址等这些都是最常见需要打码的字段。业务敏感信息订单号、流水号、部分业务编码虽然不属于个人隐私但暴露给前端或日志系统也有风险。内部系统交互数据微服务之间调用的响应体、消息队列中的消息体如果包含敏感字段同样需要处理。日志输出这个经常被忽略。接口返回做了脱敏但日志里打出来的参数或响应值还是明文等于白干。触发脱敏的时机也分几种接口返回值序列化时脱敏、日志输出时脱敏、数据库查询结果脱敏、文件导出脱敏。不同时机对应不同技术方案先想清楚自己系统需要覆盖哪几个点再选择方案。1.2 三种主流实现方案对比Spring Boot 项目里实现数据脱敏主流方案大概有三条路我分别说下优缺点和适用场景。方案一自定义注解 Jackson 序列化器在实体类字段上标注自定义注解通过重写 Jackson 的序列化器在对象转 JSON 输出的过程中自动完成脱敏。这是目前最推荐的做法侵入性最低、最灵活可以精确到字段级别控制。字段加注解 - Jackson 序列化时读取注解 - 调用对应的脱敏逻辑 - 输出脱敏后的 JSON方案二MyBatis 拦截器 / 数据库层脱敏通过拦截 SQL 或对查询结果做统一处理。这样做的好处是数据从持久层出来就已经是脱敏的接口层和日志层都不用重复处理。但缺点是影响面太大一个拦截器会作用在所有查询上很容易误伤不需要脱敏的字段或接口而且如果多个系统共用一个数据库反而导致别处拿不到明文数据。方案三业务代码手动调用脱敏工具类在 VO 层或 Controller 层手动调用SensitiveUtil.maskPhone(mobile)之类的方法。这种方式最直接但代码侵入性最强每个需要脱敏的接口都要写一行调用逻辑字段一旦多起来代码会非常啰嗦而且容易漏掉某个接口。综合对比下来我推荐方案一。它把脱敏逻辑收敛到序列化层业务代码无感知新增脱敏字段只需要加一个注解维护成本最低。这也是我后面要展开细说的方案。1.3 为什么最终选择了 Jackson 序列化器方案选择这个方案核心原因是它解决了我在以往项目里最头疼的几个问题。第一和 Spring Boot 深度集成。Spring MVC 默认使用 Jackson 做消息转换器我们不需要引入额外的 JSON 库也不需要对现有 Controller 代码做任何改动只要注册好自定义序列化器所有ResponseBody接口立刻生效。第二字段级精确控制。通过注解加在字段上哪些字段脱敏、用什么规则脱敏一目了然相比全局拦截器那种无差别处理可控性强得多。第三和业务逻辑解耦。脱敏是表现层的需求不应该污染 Service 层的业务逻辑。用注解方案Service 层返回的对象仍然是明文只有输出到前端时才被脱敏这符合分层架构的设计原则。第四方便嵌套对象和集合处理。只要让自定义序列化器实现ContextualSerializer就能在序列化任意层级字段时正确读取到注解上的配置嵌套 DTO、List 里的对象都能覆盖到。当然这个方案也有一些局限比如脱敏是单向的脱敏后数据无法还原比如它只能处理 JSON 输出场景日志脱敏还需要另外配合。这些我放到后面的常见问题部分再展开。2. 核心细节解析注解与脱敏规则设计2.1 定义脱敏类型枚举脱敏规则要统一管理第一步是定义一组枚举类型。这一步看似简单但枚举的粒度设计会直接影响后续的可维护性。我见过有人把脱敏类型做得特别细比如MOBILE_PHONE和MOBILE_PHONE_FULL实际使用下来反而让人困惑。我推荐的粒度是一个枚举对应一种有明确含义的脱敏规则。public enum SensitiveType { /** * 用户名保留第一位和最后一位 */ USERNAME, /** * 手机号保留前 3 位和后 4 位 */ MOBILE_PHONE, /** * 身份证号保留前 4 位和后 4 位 */ ID_CARD, /** * 银行卡号保留前 6 位和后 4 位 */ BANK_CARD, /** * 邮箱保留前缀前 3 位和后缀域名 */ EMAIL, /** * 地址仅保留省市区 */ ADDRESS, /** * 自定义规则配合注解上的其他属性使用 */ CUSTOM }这里我额外定义了一个CUSTOM类型用于那些不好归类的字段。比如某个字段需要保留前 2 位和最后 3 位这种规则如果都塞进枚举里枚举会被撑得很大用一个支持自定义正则的 CUSTOM 类型会更灵活。当然如果项目里没有这种诉求去掉它也行不必过度设计。2.2 自定义注解 Sensitive有了枚举之后我们定义一个字段级别的注解让实体类通过加注解的方式声明需要脱敏的字段。Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) JacksonAnnotationsInside JsonSerialize(using SensitiveInfoSerializer.class) public interface Sensitive { /** * 脱敏类型 */ SensitiveType value() default SensitiveType.CUSTOM; /** * 自定义脱敏正则仅 CUSTOM 类型生效 */ String pattern() default ; /** * 自定义脱敏替换字符仅 CUSTOM 类型生效 */ String replacement() default ****; }这里有两个关键点要说明。第一JacksonAnnotationsInside是让注解组合生效的关键。没有这个注解Jackson 不会把Sensitive当作一个“复合注解”来解析JsonSerialize的配置就无法通过Sensitive触发。我刚开始写这个功能时漏了这一步结果注解一直不生效排查了很久才发现是这个细节。第二JsonSerialize(using SensitiveInfoSerializer.class)直接把序列化器和注解绑定。这样在序列化阶段Jackson 看到带Sensitive的字段就会走SensitiveInfoSerializer这个序列化器。2.3 脱敏工具类实现要点脱敏的核心逻辑是字符串处理我单独抽了一个工具类。每个脱敏方法都写得尽量清晰并且在正则匹配上做了性能优化避免每次调用都重新编译Pattern。public class SensitiveInfoUtils { private static final Pattern MOBILE_PHONE_PATTERN Pattern.compile((\\d{3})\\d{4}(\\d{4})); private static final Pattern ID_CARD_PATTERN Pattern.compile((\\d{4})\\d{10}(\\w{4})); private static final Pattern BANK_CARD_PATTERN Pattern.compile((\\d{6})\\d(\\d{4})); private static final Pattern EMAIL_PATTERN Pattern.compile((\\w{3})\\w(\\w\\.[a-z])); private SensitiveInfoUtils() { } public static String desensitize(String value, SensitiveType type) { if (StringUtils.isBlank(value)) { return value; } switch (type) { case USERNAME: return maskUsername(value); case MOBILE_PHONE: return MOBILE_PHONE_PATTERN.matcher(value).replaceAll($1****$2); case ID_CARD: return ID_CARD_PATTERN.matcher(value).replaceAll($1********$2); case BANK_CARD: return BANK_CARD_PATTERN.matcher(value).replaceAll($1****$2); case EMAIL: return EMAIL_PATTERN.matcher(value).replaceAll($1***$2); case ADDRESS: return maskAddress(value); default: return value; } } }之所以把Pattern定义为静态常量是因为正则表达式的compile是一个相对耗时的操作如果每个字段每次序列化都重新编译一次在高频调用场景下会产生不必要的开销。用静态常量预编译代价几乎可以忽略。maskUsername和maskAddress这两个方法我单独说一下因为正则写起来会比较绕private static String maskUsername(String username) { int length username.length(); if (length 1) { return *; } if (length 2) { return username.charAt(0) *; } return username.substring(0, 1) *.repeat(length - 2) username.substring(length - 1); } private static String maskAddress(String address) { if (address.length() 6) { return *.repeat(address.length()); } return address.substring(0, 3) *.repeat(address.length() - 6) address.substring(address.length() - 3); }用户名和地址的脱敏没有统一的定长规则不同系统要求可能不一样放在这里作为一个可改的兜底实现。比如有的系统要求用户名保留第一个字和最后一个字中间用星号填充maskUsername这种动态长度的写法就能直接满足。2.4 设计细节为什么脱敏逻辑放在序列化层而不是业务层这个问题我在 1.3 里大致提过这里再往深了说一下。脱敏的本质是数据在展示或传输前做一次不可逆的转换。从职责划分的角度看Service 层应该关心业务规则DAO 层应该关心数据存取而“如何把数据安全地输出给外部”这件事本质上是接口层的职责。把脱敏放在 Jackson 序列化层相当于在系统边界做了一道统一的数据出口检查。业务逻辑模块完全不需要知道自己返回的数据会被怎么处理专注自己的职责即可。反过来如果把脱敏写进业务代码一旦脱敏规则调整比如从“保留前 3 位”改成“保留前 4 位”就要把所有调用点全找出来改一遍这是非常典型的维护噩梦。另外一点序列化层脱敏天然对所有通过ResponseBody输出的对象生效。包括统一响应体ResultT里嵌套的对象、List 集合里的元素只要字段加了注解就一定会被处理不需要在每一层 Controller 里重复编写脱敏调用。3. 实操过程与核心环节实现3.1 环境准备与项目结构我用的是 Spring Boot 2.7 版本Java 8不需要额外引入其他 JSON 库。整个实现涉及的类不多项目结构大概是这样的com.example.demo ├── DemoApplication.java ├── common │ ├── annotation │ │ └── Sensitive.java │ ├── enums │ │ └── SensitiveType.java │ ├── serializer │ │ └── SensitiveInfoSerializer.java │ └── utils │ └── SensitiveInfoUtils.java ├── controller │ └── UserController.java ├── entity │ └── UserVO.java └── vo └── Result.java需要说明的是这只是一个最小可运行的结构。实际项目里注解和序列化器通常放在公共模块或 starter 中这样多个服务可以复用同一套脱敏能力避免代码重复拷贝。如果你所在的公司有自研的公共组件库建议直接把脱敏功能作为一个 starter 发布服务方只需要引入依赖即可。3.2 核心序列化器的完整实现这是整个方案的心脏。SensitiveInfoSerializer继承了JsonSerializerString并实现了ContextualSerializer接口。后者是关键它允许序列化器在创建时读取到字段上的注解信息从而动态决定当前字段使用哪种脱敏规则。public class SensitiveInfoSerializer extends JsonSerializerString implements ContextualSerializer { private final SensitiveType type; public SensitiveInfoSerializer() { this.type SensitiveType.CUSTOM; } public SensitiveInfoSerializer(SensitiveType type) { this.type type; } Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeString(SensitiveInfoUtils.desensitize(value, type)); } Override public JsonSerializer? createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { if (property ! null) { Sensitive sensitive property.getAnnotation(Sensitive.class); if (sensitive ! null) { return new SensitiveInfoSerializer(sensitive.value()); } } return this; } }createContextual的调用时机是Jackson 在第一次序列化某个类时会为类中的每个字段寻找合适的序列化器。如果序列化器实现了ContextualSerializerJackson 就会把该字段的BeanProperty传进来让我们有机会读取字段上的注解信息并创建新的、带脱敏类型配置的序列化器实例。这里有个细节容易踩坑serialize方法里拿到的value是未脱敏的原始字符串我们在这里执行脱敏逻辑然后把脱敏后的结果写回 JSON。所以 Service 层拿到的、数据库里存的仍然是明文只有最终输出的 JSON 是脱敏后的这一点在排查问题时很有用。3.3 注册序列化器到 Jackson序列化器写好了接下来的问题是怎么让它生效。有两种方式我推荐第一种。方式一启用注解驱动 注解绑定推荐我们在Sensitive注解上已经加了JsonSerialize(using SensitiveInfoSerializer.class)所以只要保证 Spring 容器里的ObjectMapper开启了注解扫描即可。默认情况下 Spring Boot 的ObjectMapper是开启的但为了保险起见可以在配置类里显式设置Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer sensitiveDataCustomizer() { return builder - builder .serializerByType(String.class, new SensitiveInfoSerializer()) .featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); } }等一下如果你直接把serializerByType(String.class, new SensitiveInfoSerializer())配成对String类型生效那所有的字符串都会走这个序列化器等于把整个项目所有字符串字段全部进入SensitiveInfoSerializer.serialize然后因为默认 type 是CUSTOM最终原样返回逻辑上没问题但白白多了一层调用开销而且容易干扰其他序列化器。所以更稳妥的做法是用注解驱动不全局注册 String 类型处理器。JsonSerialize(using SensitiveInfoSerializer.class)已经足够让 Jackson 在序列化带注解的字段时自动找到对应序列化器了不需要额外往ObjectMapper里注册。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - builder .featuresToDisable(SerializationFeature.FAIL_ON_EMPTY_BEANS) .featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); } }如果你的项目里没有这种全局配置类也完全不影响注解生效。Spring Boot 默认会扫描JsonSerialize注解。3.4 实体类中使用注解工具类和序列化器都就位后使用起来非常简单。在实体类或 VO 类的敏感字段上加上Sensitive注解并指定脱敏类型即可。public class UserVO { private Long id; Sensitive(SensitiveType.USERNAME) private String username; Sensitive(SensitiveType.MOBILE_PHONE) private String mobile; Sensitive(SensitiveType.ID_CARD) private String idCard; Sensitive(SensitiveType.EMAIL) private String email; Sensitive(SensitiveType.ADDRESS) private String address; private Integer age; // getter / setter 省略 }Controller 层不需要做任何特殊处理RestController RequestMapping(/user) public class UserController { GetMapping(/{id}) public ResultUserVO getUser(PathVariable Long id) { UserVO user userService.getById(id); return Result.success(user); } }请求/user/1接口时返回的 JSON 大致长这样{ code: 200, message: success, data: { id: 1, username: 张*三, mobile: 138****1234, idCard: 1101**********1234, email: abc***example.com, address: 北京市******海淀区, age: 25 } }注意id和age没有加注解所以不受影响。脱敏逻辑对普通字段完全透明一个注解搞定一个敏感字段代码非常干净。3.5 嵌套对象和集合的处理机制实体类里嵌套了其他对象或者返回的是ListUserVO这种场景下脱敏还能生效吗答案是能。因为我们的序列化器是实现ContextualSerializer的Jackson 在处理嵌套对象和集合元素时会递归地为每个对象的每个字段创建序列化器。所以只要嵌套对象内部的字段也加了Sensitive就能正常脱敏。举个例子订单 DTO 里嵌套了用户地址信息public class OrderVO { private String orderNo; private UserAddressVO address; // getter / setter 省略 } public class UserAddressVO { private String province; private String city; Sensitive(SensitiveType.ADDRESS) private String detailAddress; // getter / setter 省略 }序列化OrderVO时Jackson 会递归处理address字段最终detailAddress也会被脱敏。这种能力对于订单列表、用户详情这类多层嵌套的接口非常实用不需要额外编码。4. 常见问题与排查技巧实录4.1 脱敏不生效的排错清单我在自己项目和帮同事排查时遇到最多的问题就是“注解加了但返回结果还是明文”。原因排查可以从这几个方向依次检查1. 确认字段有 getter/setterJackson 依赖 getter 取值的 2. 确认实体的 getter 方法上没加 JsonIgnore 之类的注解 3. 确认项目里没有自定义的 ObjectMapper 覆盖了注解扫描配置 4. 确认注解的 Retention 是 RUNTIME如果是 CLASS 就无效 5. 确认 JacksonAnnotationsInside 没有被误删 6. 确认序列化的是该实体而不是被转成了 Map 或其他结构第 3 条值得多说一句。有些项目为了统一处理日期格式或 Long 转 String会自定义ObjectMapper甚至在配置里手动注册了各种模块。这种情况下如果新注册的SimpleModule覆盖了 String 类型的序列化器就可能导致Sensitive失效。解决办法是把自己定义的序列化器也注册进去或者改用Jackson2ObjectMapperBuilderCustomizer这种方式追加配置而不是直接 new 一个ObjectMapper覆盖 Spring 的默认配置。还有第 6 条如果 Controller 返回的是MapString, ObjectJackson 对 Map 的值做序列化时不会读取字段上的注解脱敏当然不生效。数据量不大的时候依然能正常脱敏但如果实体被转换成了Map或JSONObject就会发现注解失效。遇到这种情况优先规范接口返回类型尽量使用 DTO/VO 而不是Map裸返回。4.2 字段为 null 或空串的边界情况SensitiveInfoUtils.desensitize里我开头就判断了StringUtils.isBlank(value)返回空或 空字符串时原样返回不做脱敏。这个判断是必须的否则传入正则匹配器会直接抛异常。字段为null时Jackson 默认不会调用serialize方法而是走nullValueSerializer。这意味着Sensitive注解对 null 值的字段是“无感”的不会报错也不会把 null 变成空字符串或星号。如果你的业务希望 null 也显示为某种占位符需要在序列化器里额外处理null序列化逻辑或者在实体字段上直接初始化默认值。4.3 大对象批量序列化的性能优化脱敏本质上是在序列化过程中做了额外的字符串处理和正则匹配性能损耗主要在正则表达式上。如果一次接口返回几千条用户记录每条记录里有 3~4 个敏感字段正则匹配的压力还是不小的。优化手段主要有三个我在 2.3 里提到过预编译 Pattern这是最基础也最有效的一步。第二是减少不必要的脱敏调用比如Sensitive加在字段上后如果该字段本身已经是脱敏态的数据比如某些老系统从库里读出来就是脱敏的可以再加一个注解属性alreadyMasked true跳过二次处理。第三是缓存序列化器实例createContextual理论上只会在字段首次序列化时调用一次后续会复用所以本身性能开销不大但如果你的实体类特别多留意不要在createContextual里做太重的初始化操作。从我实测的数据来看用预编译 Pattern 的方式单次脱敏一个字段的开销在微秒级别一个接口脱敏上千条记录总耗时也就增加几十毫秒对绝大多数业务系统来说完全可接受。如果真到了百万级列表导出的场景那就不是优化正则的问题了得考虑在导出文件时用流式处理或者用批量线程池来做。4.4 脱敏接口与日志脱敏的配合接口返回脱敏和日志脱敏是两回事。注解方案只解决 JSON 输出时的脱敏如果项目在 Controller 层打印了请求参数日志或者在全局异常处理器里打印了响应体日志里仍然可能记录明文敏感数据。解决日志脱敏通常是用 logback 的MessageConverter或PatternLayout加正则过滤比如在logback.xml里配置对手机号、身份证号做全局替换。也有团队用logstash-logback-encoder结合自定义字段脱敏。这块和接口脱敏是互补关系没有哪个方案能两处同时覆盖我是强制要求项目里所有日志打印规范统一禁止在日志中输出业务对象的toString()全量内容尤其控制层和持久层查询参数日志。4.5 脱敏的不可逆性预警这是很多初次接触脱敏的人容易忽略的点。脱敏本质上是不可逆的脱敏后的数据无法还原成明文。所以一旦数据输出后又被前端保存、又被别人转发那这个状态就是终态了。我们在接口文档里都会明确标注“脱敏字段不支持回显明文”。另外如果存在“展示脱敏、点击查看详情时需要明文”这样的业务场景不能依赖脱敏后的数据去做二次请求。正确做法是列表接口脱敏详情接口根据权限判断是否需要明文。权限通过后返回明文否则也走脱敏。这里要特别提醒不要把脱敏后的值作为参数回传到后端来查数据库因为脱敏值本身已经丢失了精度查询会失败或得到错误结果。4.6 脱敏规则的可配置化扩展思路注解方案有个潜在短板脱敏规则是硬编码在枚举和工具类里的一旦规则变化就要改代码重新发版。如果你的项目对规则灵活性要求高可以考虑把脱敏规则外部化做成配置中心动态下发。我自己的做法是引入了一个脱敏规则表结构大概是脱敏类型、正则表达式模式、替换格式、是否启用。核心序列化器不直接写死规则而是调用一个SensitiveRuleProvider接口从内存缓存中读取规则。配置变更时刷新缓存不需要重启服务。public interface SensitiveRuleProvider { SensitiveRule getRule(SensitiveType type); }不过这套方案需要额外的配置管理成本如果规则基本稳定我建议还是用最简单直接的硬编码方案避免过度设计。我之前就见过一个团队把脱敏规则搞成数据库配置结果规则被人误改后线上脱敏直接失效反而引入了新的风险。规则稳定时代码里的常量才是最可靠的。5. 实际部署中的补充建议5.1 统一脱敏组件的沉淀如果你所在的项目组有多个微服务每个服务都复制一份脱敏代码是很大的维护负担。我建议把脱敏功能封装成一个公共 starter包含注解、枚举、序列化器、工具类。各服务只需在pom.xml中引入依赖实体类里加注解即可。封装 starter 时有几个注意点注解包名和序列化器包名要稳定因为业务代码中引用了这些类spring.factories或自动配置类要正确声明让引入方无需额外配置就能生效版本号管理要规范避免升级时产生兼容性问题。5.2 与统一响应体的适配经验很多项目有统一的响应体ResultT数据放在data字段里。只要data中的实体字段加了Sensitive注解脱敏依旧生效因为 Jackson 序列化的是整个ResultT对象会递归遍历到内部的data。这里没有特殊的适配工作唯一要注意的是不要把data字段声明成Object类型后塞入一个已经被转成 JSONString 的字符串那样序列化出来的是字符串内容而非结构化对象注解自然无法生效。5.3 测试用例怎么写才能覆盖全面很多人写完脱敏功能后忘了写测试。脱敏逻辑虽然简单但边界条件不少建议至少覆盖这几类用例正常长度字段11 位手机号、18 位身份证号空字符串和 null长度极短的字段比如用户名只有 1 个字包含特殊字符或格式不规范的手机号比如加了空格或横线嵌套对象和 List 集合中的脱敏脱敏规则变更后的回归验证用MockMvc调用接口验证 JSON 输出是最直接的方式。如果是工具类的单元测试直接断言脱敏后的字符串即可。这里多说一句脱敏本来是为了保护数据安全测试时也别忘了用假数据别把真实用户信息写死在测试用例里我之前见过有同事直接把生产环境的手机号写进单元测试断言这种习惯很危险。我个人做这类功能最大的体会是脱敏看着是个小功能但它涉及接口协议、数据安全、性能、可维护性多个维度真正做好还是需要花点心思的。如果项目里还没有一套统一的脱敏机制从自定义注解加序列化器这套方案开始改造是成本最低、见效最快的路径。你完全可以拿这篇文章里的代码直接改改落到自己的项目里去试试。