3、常见Tokenization方案:格式保留令牌(FPE)、随机令牌、哈希令牌
说到具体的tokenization方案,我见过不少团队一开始就选错了方向。其实没有银弹,三种主流方案各有各的适用场景。这一节我把它们掰开了讲,顺便说说我在项目中踩过的坑。
3.1 三种方案的核心逻辑
先给个全景图,方便你心里有个谱。
核心观点:选择哪种方案,取决于你对"格式保留"、"性能"、"安全性"三者的优先级排序。
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(匿名化需求),再用随机令牌处理订单号(频繁查询需求)。别纠结于"选哪一个",而是"哪个场景用哪个"。
嗯,到这里三种方案的核心就讲完了。你想想看,其实最难的往往不是技术实现,而是前期分析清楚你的业务到底需要什么。下一环节我们聊具体怎么落地的架构设计,再往下会讲更细的代码实现。