公司的用户列表接口,每次返回 200 条用户数据,手机号、身份证号都要脱敏。原本接口响应时间在 50ms 左右,加上脱敏后变成了 200ms。排查发现瓶颈在 Jackson 序列化时做的脱敏——每条数据的每个字段都要反射一次拿注解、判断要不要脱敏、再反射一次执行脱敏逻辑。200 条数据、每条 3 个敏感字段 = 做了 1200 次反射操作。
脱敏是必要的,但反射是有代价的。问题不在脱敏本身,而在于"每次序列化都重新反射一遍"。今天聊聊怎么用字节码增强和静态缓存,让脱敏的性能损耗降到几乎为零。
为什么基于反射的脱敏慢
最常见的脱敏方案是用 Jackson 的 JsonSerializer:
public class SensitiveSerializer extends JsonSerializer<String> {
@Override
public void serialize(String value, JsonGenerator gen,
SerializerProvider provider) throws IOException {
gen.writeString(desensitize(value)); // 简单脱敏逻辑
}
}
每次序列化一个敏感字段,Jackson 要:
- 通过反射找到该字段的
SensitiveSerializer - 实例化 Serializer(如果没有缓存)
- 调用
serialize方法(反射 invoke)
200 条数据、3 个敏感字段 = 600 次反射调用。反射的成本虽然不高(单次几微秒),但攒起来就是几十毫秒。
方案:静态缓存 + 提前解析
把反射操作从"序列化时做"提前到"启动时做"。启动时遍历所有需要脱敏的类,解析每个类的敏感字段,缓存起来。序列化时直接从缓存里拿,零反射。
@Component
public class SensitiveFieldCache {
// 启动时解析:类 → 需要脱敏的字段列表
private static final Map<Class<?>, List<SensitiveField>> CACHE =
new ConcurrentHashMap<>();
@PostConstruct
public void init() {
// 扫描指定包下所有类
Set<Class<?>> classes = scanPackage("com.example.entity");
for (Class<?> clazz : classes) {
List<SensitiveField> fields = new ArrayList<>();
for (Field field : clazz.getDeclaredFields()) {
Sensitive annotation = field.getAnnotation(Sensitive.class);
if (annotation != null) {
field.setAccessible(true);
fields.add(new SensitiveField(field, annotation.type()));
}
}
if (!fields.isEmpty()) CACHE.put(clazz, fields);
}
log.info("脱敏缓存初始化完成: {} 个类", CACHE.size());
}
// 序列化时直接从缓存拿,零反射
public List<SensitiveField> getFields(Class<?> clazz) {
return CACHE.getOrDefault(clazz, List.of());
}
}
序列化时:
// 序列化时不用反射,直接从 cache 拿字段列表
List<SensitiveField> fields = cache.getFields(order.getClass());
for (SensitiveField sf : fields) {
Object raw = sf.field.get(order); // 还是反射 —— 但字段的 Field 对象已缓存
sf.field.set(order, mask(raw, sf.type));
}
注意这里 sf.field 已经是被缓存过的 Field 对象,field.get() 仍然是反射,但省掉了"找注解、找 Field"的反射操作。大头的开销已经省掉了。
如果真的想彻底消除反射
反射无法完全避免,但可以用字节码增强绕过它。原理是启动时用 ByteBuddy 或 ASM 为每个需要脱敏的类生成一个子类或增强后的类,重写 getter 方法,在 getter 里直接写死脱敏逻辑。
// ByteBuddy 增强:为 User 生成一个 User$Masked,getPhone() 自动脱敏
public class MaskedEnhancer {
@SuppressWarnings("unchecked")
public <T> T enhance(T obj) {
Class<?> clazz = obj.getClass();
DynamicType.Builder<?> builder = new ByteBuddy()
.subclass(clazz);
for (SensitiveField sf : cache.getFields(clazz)) {
// 拦截 getter,返回脱敏后的值
builder = builder.method(ElementMatchers.named(sf.getter))
.intercept(FixedValue.value(mask(obj, sf)));
}
Class<?> enhanced = builder.make()
.load(clazz.getClassLoader())
.getLoaded();
return (T) enhanced.getDeclaredConstructor().newInstance();
}
}
字节码增强后,getter 方法的调用就是普通的 Java 方法调用,零反射。适合对性能要求极高的场景。
组合打法
对于大多数场景,静态缓存就够用了:
启动时扫描 → 缓存敏感字段 → 序列化时从缓存取 → 避开反射找注解的开销
极致性能用字节码增强:
启动时 ByteBuddy 生成脱敏子类 → getter 直接返回脱敏值 → 零反射
内存里多一个子类,序列化性能跟不脱敏差不多。
总结
脱敏慢不是业务逻辑的问题,是反射的问题。把反射从"每次序列化"移到"系统启动时",剩余的反射操作有限,性能损耗就可控。
- 静态缓存 —— 启动时扫描注解 → 缓存 Field + 脱敏类型,序列化时直接取
- 字节码增强 —— ByteBuddy 生成脱敏子类,getter 直接返回脱敏值,零反射
- 两者选其一 —— 一般场景静态缓存足够,极致性能选字节码增强
配完之后,200 条用户数据的接口从 200ms 回到 50ms。
