常用脱敏算法(一):替换、遮蔽、重排

做数据脱敏这些年,我接触最多的就是这三类基础算法。说白了,它们就是脱敏工具箱里的螺丝刀、扳手和钳子——单个就能干活,组合起来能处理绝大多数场景。我个人习惯先把它们分清楚,再谈实战。

核心原则:脱敏后的数据必须保留业务可用性,同时无法还原真实值。这三类算法恰好覆盖了从“完全模糊”到“保留格式”的连续谱。

一、替换算法

替换,就是用预定义的映射表或规则,把原始值换成另一个值。嗯,这里要注意:替换不是随机乱换,而是有依据地换。

1.1 静态替换

我给你举个例子。某客户系统的手机号字段,生产库里的真实号码需要脱敏。我在项目中遇到过一种做法——生成一套“手机号-虚拟号”映射表,脱敏时直接查表替换。这种方式速度快,可逆(如果映射表不公开的话)。

-- 伪代码:静态替换
def static_replace(original, mapping_dict):
    return mapping_dict.get(original, original)  # 找不到就保留原值?不,要报警

避坑:我曾经因为映射表没做完整性校验,导致同一个真实号码映射到两个不同虚拟号,查询关联统计全乱套了。后来我改成 UUID 生成虚拟号,再用 hash 索引保证唯一。

1.2 动态替换

动态替换不依赖预置表,而是根据实时随机值或算法生成。你想想看,如果接入了实时数据流,每秒钟几万条记录,查表会成为瓶径。

// 示例:保留格式的随机替换
String maskPhone(String phone) {
    String prefix = phone.substring(0, 3); // 保留区号
    String suffix = RandomUtil.randomNumbers(8); // 随机生成8位数字
    return prefix + "****" + suffix; // 实际上这是遮蔽,替换可做更彻底的
}
维度静态替换动态替换
确定性固定映射,结果稳定每次结果可能不同
性能需内存存储映射(大表慢)O(1) 计算,适合流式
可逆性映射表可逆向(危险)不可逆(除非用加密性替换)

注意:替换算法容易踩的坑是“格式保留”。如果替换结果不满足目标字段的长度或类型约束,业务系统会报错。我建议你在实施前先收集字段的 DDL 定义。

二、遮蔽算法

遮蔽,就是让人看到一部分,遮住最关键的部分。银行卡号、身份证号,基本上都用这套路。你想想看,客服人员需要看到后四位来核实身份,但不需要知道完整卡号。

2.1 固定位置遮蔽

最常见的是“中间六位变*”。但要注意:遮多少,遮哪里,每个业务场景不一样。我记得有个金融项目,监管要求身份证号只显示前6后4,但是系统里还有个叫“证件影像预览”的功能——它需要显示完整证件号去比对——这时候就不能粗暴遮蔽了。

function maskIdCard(idCard: string): string {
    if (idCard.length < 10) return idCard; // 异常值
    const prefix = idCard.slice(0, 6);
    const suffix = idCard.slice(-4);
    return prefix + '********' + suffix; // 共14个*?长度要对齐
}

避坑指南:我曾经在项目中直接用固定长度“******”遮蔽,结果原字段是15位身份证,变成“前6+******+后4”只有13位,下游系统解析报错。后来我改为根据原长度动态生成等长掩码。

2.2 动态遮蔽(部分保留)

有时候需要动态决定遮蔽比例。比如用户手机号,可以保留前3后2,也可以保留前3后4。我建议用配置表驱动,不要硬编码。

// Python 示例:可配置遮蔽比例
def dynamic_mask(s: str, keep_left: int, keep_right: int) -> str:
    length = len(s)
    if length <= keep_left + keep_right:
        return s  # 太短就不遮了?实际上应该报异常
    mask_len = length - keep_left - keep_right
    return s[:keep_left] + '*' * mask_len + s[-keep_right:]
算法示例(原始:13812345678)适用场景
全遮蔽***********测试库,完全不用看真实值
前后保留138****5678客服核身,需显示部分
动态比例138*****678数据分析,保留更多格式特征

三、重排算法

重排,把原有元素顺序打乱。它不改变字符集合,只改变排列。看起来像乱码,但底层信息量没减少——如果你知道排序规则,可以还原。因此重排单独用不安全,通常需要和其他算法组合。

3.1 字符重排(拼音姓名)

比如把一个名字的拼音字母顺序打乱:“张三”变成“山张”。嗯,有点意思。我试过对中文姓名做 Unicode 码点排序,效果很差——因为同音字多,重排后变成另一个常见词。后来我改用“姓氏+名随机顺序”。

// 示例:对手机号数字重排
String shufflePhone(String phone) {
    char[] digits = phone.toCharArray();
    // Fisher–Yates 洗牌
    Random rnd = new Random(seed); // 可加种子保证可重复
    for (int i = digits.length - 1; i > 0; i--) {
        int j = rnd.nextInt(i + 1);
        char tmp = digits[i];
        digits[i] = digits[j];
        digits[j] = tmp;
    }
    return new String(digits);
}

可复现性:如果要让每次脱敏结果一致(比如用于回归测试),记得用固定种子(seed)。我习惯把种子设为原始记录的主键 hash,这样同一记录无论重跑多少次,重排结果都一样。

3.2 字段级重排(列间置换)

还有一种重排是在记录内部交换字段值。比如同一行里,A 和 B 两列交换。这样做可以混淆关联关系,但保留单列的格式。我参与过一个医疗项目,患者姓名和病案号做捆绑再散列重排,防止跨表关联。

一个经验总结:单独的重排抗攻击性很弱。如果攻击者知道重排规则(比如是否是随机种子),就能暴力还原。所以我的建议是:重排只作为辅助手段,核心机密字段还是用替换+遮蔽更稳。

三种算法对比与选择

下面这张图,我画了这三种算法的核心差异。你一看就明白了。

脱敏算法分类(替换 / 遮蔽 / 重排) 替换算法 映射表 / 随机生成 遮蔽算法 部分保留 / 动态掩码 重排算法 字符乱序 / 列置换 保留格式 可配置展示比例 不可单独使用 组合策略建议 • 身份证号:遮蔽(前6后4)+ 替换(中间位换成合法虚拟数字) • 手机号:遮蔽(前3后4)+ 替换(中间4位按规则换) • 姓名:替换(随机假名)或重排(姓氏不变,名乱序) ↑ 三者可以任意组合,形成多层次防护

看完这张图你可能会问:如果三种算法结合使用时,顺序有讲究吗?我建议先做替换或遮蔽,再做重排。因为重排会破坏原字段的部分结构,如果先重排再遮蔽,遮蔽的规则可能失效(比如原本要保留前6位,但重排后前6位已经不是原值了)。

避坑总结

  • 映射表膨胀: 静态替换时,如果原字段基数大(比如 UID 无边界),映射表会无限增长。我遇到过一张表一个月多出 2000 万行映射记录,后来改用了哈希+分段缓存。
  • 遮蔽的“假安全”: 遮蔽只遮了部分字符,但剩下的字符如果信息熵足够高(比如只有 1000 种可能),攻击者穷举也能猜出整个值。我的建议是:遮蔽后剩余可见字符不能超过 4 位非数字。
  • 重排的可逆性: 如果攻击者知道种子或排序规则,重排可逆性极高。所以重排最好配合一次不可逆的替换(比如 SHA-256 摘要)再打乱。

好了,这些都是我在项目里踩过的坑。记住:没有万能的算法,只有合适的组合。下一种常见算法(随机化、加密等)我们后面再聊。