3、常见Tokenization方案:格式保留令牌(FPE)、随机令牌、哈希令牌

说到具体的tokenization方案,我见过不少团队一开始就选错了方向。其实没有银弹,三种主流方案各有各的适用场景。这一节我把它们掰开了讲,顺便说说我在项目中踩过的坑。

3.1 三种方案的核心逻辑

先给个全景图,方便你心里有个谱。

核心观点:选择哪种方案,取决于你对"格式保留"、"性能"、"安全性"三者的优先级排序。
三种Tokenization方案核心架构 Tokenization 方案 格式保留令牌 (FPE) 随机令牌 哈希令牌(加盐) 可逆 · 格式保留 FF1/FF3算法 可逆 · 格式不保留 UUID/雪花ID 不可逆 · 定长输出 SHA-256 + 盐值 核心选择因子:格式保留需求 · 是否可逆 · 性能敏感度 · 合规要求

3.2 格式保留令牌(FPE)

FPE 说白了就是把敏感数据变成另一个看起来很像的数据。电话号码还是11位,信用卡号还是16位,但内容已经不是原来的了。这个方案在金融和电商领域特别受欢迎。

我印象最深的一个项目:银行核心系统的数据库需要做脱敏测试,但下游系统有几百个字段格式校验。如果用随机令牌,改代码量能把人逼疯。最后选了FPE,字段直接替换,一行业务代码都没动。

FPE的核心算法

目前主流的是 NIST 标准化的 FF1 和 FF3 算法。它们基于 AES 加密,把原文映射到相同长度的密文空间。

// Java示例:使用Bouncy Castle实现FF1
import org.bouncycastle.crypto.fpe.FPEEngine;
import org.bouncycastle.crypto.fpe.FPEParameters;
import org.bouncycastle.crypto.params.KeyParameter;

public class FPETokenizer {
    public static String tokenize(String plaintext, byte[] key, byte[] tweak) {
        // FF1算法,保留原格式
        FPEEngine engine = new FPEEngine(new AESEngine());
        engine.init(true, new FPEParameters(new KeyParameter(key), 12, tweak));
        
        byte[] input = plaintext.getBytes(StandardCharsets.US_ASCII);
        byte[] output = new byte[input.length];
        engine.processBlock(input, 0, output, 0);
        
        return new String(output, StandardCharsets.US_ASCII);
    }
}
个人建议:tweak参数别用固定值。我在生产环境吃过亏——所有调用都用同一tweak,结果相同明文生成相同令牌,跟没加密一样。建议每条记录拼接业务ID和时间戳作为tweak。

FPE的优缺点

维度说明
优势格式完全保留,系统改动最小;可逆(反向解除令牌化)
劣势加密速度比随机令牌慢;密钥管理复杂度上升
适用场景遗留系统改造、数据库字段格式约束强的场景

3.3 随机令牌

随机令牌最直白:把原文丢进一个映射表,生成一串随机字符当令牌。说白了就是个查字典的过程。

我见过一些团队认为"随机令牌这么简单,肯定安全"。其实未必。映射表本身才是最大的安全风险——如果这表被拖库,所有数据等于裸奔。

实现方式

// Python示例:随机令牌生成器
import uuid
import hashlib

class RandomTokenVault:
    def __init__(self):
        self._store = {}  # 映射表:原文 -> 令牌
        
    def tokenize(self, plaintext):
        if plaintext in self._store:
            return self._store[plaintext]
        
        # 生成随机令牌
         = str(uuid.uuid4()).replace('-', '')[:16]
        self._store[plaintext] = 
        return 
    
    def detokenize(self, ):
        # 反向查找
        for pt, tk in self._store.items():
            if tk == :
                return pt
        return None
警告:千万别把映射表放在应用进程内!我曾经接手一个项目,映射表用本地内存的ConcurrentHashMap,应用重启后所有令牌失效,下游系统全部报错。映射表必须持久化,推荐用Redis Cluster或分布式的强一致存储。

核心设计考量

  • 令牌空间:至少128位随机数,避免生日攻击
  • 冲突处理:两个原文可能映射到同一令牌?概率极低但必须有重试机制
  • 性能瓶颈:映射表的读写IO会成为系统短板

3.4 哈希令牌

哈希方案有点意思。它和FPE、随机令牌最大的区别是:不可逆。这意味着你不能从哈希值恢复原文。什么场景需要这个?密码存储、数据撞库检测、匿名化统计。

但是有个大坑:单纯用哈希等于没加密。彩虹表可以直接反查。你想想看,信用卡号总共就16位数字,暴力枚举能有多难?

正确做法:加盐哈希

// Python示例:带盐值的哈希令牌
import hashlib
import secrets

class SaltedHashToken:
    def __init__(self, global_salt=None):
        # 全局盐值,后台安全存储
        self._global_salt = global_salt or secrets._hex(32)
    
    def tokenize(self, plaintext):
        # 每个原文还可以再加一层随机盐
        local_salt = secrets._hex(8)
        combined = f"{plaintext}:{self._global_salt}:{local_salt}"
        hash_val = hashlib.sha256(combined.encode()).hexdigest()[:32]
        return f"{local_salt}:{hash_val}"
    
    def verify(self, plaintext, hashed_):
        local_salt, hash_val = hashed_.split(':')
        combined = f"{plaintext}:{self._global_salt}:{local_salt}"
        return hashlib.sha256(combined.encode()).hexdigest()[:32] == hash_val
关键区别:哈希令牌不可逆,所以没法做detokenize。你只能通过"验证"来判断原文是否匹配。这一点在设计API接口时要特别注意。

3.5 方案对比与选型决策

我习惯用一张表来对比,清晰明了。

特性FPE随机令牌哈希令牌
是否可逆✅ 可逆✅ 可逆❌ 不可逆
格式保留✅ 完整保留❌ 不保留❌ 不保留
性能(10万条/秒)约 30ms约 15ms(映射表无瓶颈)约 5ms
密钥/存储依赖加密密钥映射表(分布式存储)盐值(安全存储)
安全性风险密钥泄露全盘皆输映射表泄露全盘皆输彩虹表(需加盐防御)

选型建议

  • 老系统改造、字段格式校验严格:选FPE。你还得保留向后兼容能力
  • 新建系统、数据量巨大、需要频繁查询:选随机令牌。映射表用Redis Cluster扛
  • 仅做匿名化、不需反向查询、数据可丢弃:选哈希令牌。记得加盐,否则等于裸奔
我的经验:实际项目里经常混用。比如我会用FPE处理信用卡号(格式保留需求),用哈希令牌处理用户ID(匿名化需求),再用随机令牌处理订单号(频繁查询需求)。别纠结于"选哪一个",而是"哪个场景用哪个"。

嗯,到这里三种方案的核心就讲完了。你想想看,其实最难的往往不是技术实现,而是前期分析清楚你的业务到底需要什么。下一环节我们聊具体怎么落地的架构设计,再往下会讲更细的代码实现。