令牌映射与存储:数据库、缓存与分布式映射
令牌映射,说白了就是要把令牌和被保护数据之间的对应关系存下来。嗯,这事看着简单,但坑不少。我最早做令牌化系统时,觉得随便塞个关系型数据库就行,结果上线当天就踩了雷——并发一上来,数据库连接池直接打满,应用卡死。
所以这一章,咱们好好聊聊令牌映射该怎么存。本地用啥、缓存用啥、分布式又用啥,每个场景都有讲究。
3.1 映射存储的核心维度
先理清我们需要存什么。一个令牌映射条目,至少包含四个核心字段:
- 令牌() — 对外暴露的替代值,通常是随机字符串或 FPE 加密结果
- 原始值(Plain Value) — 被保护的真实数据,可能是身份证号、卡号、手机号
- 上下文标签(Context Tag) — 区分不同业务域,比如支付场景和注册场景用的是同一套映射表吗?
- 元数据(Metadata) — 创建时间、过期时间、算法版本、轮转次数等
关键设计原则:令牌本身应当是索引主键,而非原始值。原始值可以重复(同一身份证可能生成多个不同令牌用于不同服务),但令牌必须全局唯一。
我在一个金融项目中遇到过这样的场景:客户要求同一手机号在不同渠道生成不同令牌,但查询时又要能反向定位到原始号码。这种时候,上下文标签就派上用场了。
3.2 数据库存储方案
3.2.1 关系型数据库
最朴素的方式,一张表搞定:
CREATE TABLE _mapping (
_hash VARCHAR(64) PRIMARY KEY,
_value VARCHAR(128) NOT NULL,
plain_value VARBINARY(512) NOT NULL,
context_tag VARCHAR(32) NOT NULL DEFAULT 'default',
algorithm_version INT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
INDEX idx_plain_value (plain_value, context_tag)
);
我个人的习惯:主键不要存令牌明文,存它的 SHA-256 哈希。理由很简单——万一数据泄露,攻击者拿不到原始令牌值,也就没法遍历。令牌明文存在 _value 字段里,业务端查询时先哈希再查主键,速度也快。
不过,关系型数据库有个硬伤:高并发下磁盘 I/O 会成为瓶颈。令牌化系统往往是截获在交易链路上的,延迟要求极高。我曾经线上压测时发现,单表 TPS 超过 3000 就开始报警了,那还是 SSD + 16 核机器。
3.2.2 键值存储
如果关系型数据库扛不住,可以换 NoSQL。Redis、RocksDB、FoundationDB 都是常见选择。我推荐用 Redis 的 Hash 结构来存:
# 存储一条映射
HSET :map:sha256_{hash} "tok_abc123" plain "13900001111" ctx "payment"
# 查询
HGETALL :map:sha256_{hash}
Redis 的 TTL 机制天然适合令牌过期场景。而且内存操作延迟不到 1ms,比走磁盘快两个数量级。
小心:Redis 做持久化存储要谨慎。我曾经遇到过 RDB 快照期间进程 OOM 被 kill 的情况,导致几十万条映射数据丢失。建议只把 Redis 当缓存层,底层仍然持久化到数据库。
3.3 缓存层设计
不管是数据库还是键值存储,都不能直接扛所有读流量。缓存是必须的。我习惯用多级缓存架构:
| 层级 | 载体 | 容量 | 延迟目标 |
|---|---|---|---|
| L1 本进程缓存 | Caffeine / Guava Cache | 几万条 | < 0.1ms |
| L2 分布式缓存 | Redis Cluster | 数百万条 | < 1ms |
| L3 持久层 | MySQL / FoundationDB | 无上限 | < 10ms |
缓存策略需要特别注意 逐出算法。我踩过一个坑:用 LRU 淘汰热数据,结果收费系统的令牌周期性地被挤出去,每次都要回源查数据库,延迟从 0.5ms 飙到 8ms。换成 W-TinyLFU 后才稳定下来。
避坑指南:我曾经因为缓存命中率从 99.8% 掉到 97% 没当回事,结果流量高峰时数据库连接池被打爆,业务中断了 6 分钟。所以一定要给缓存层设置熔断和降级开关,宁可返回失败也别让缓存击穿拖垮所有服务。
3.4 分布式映射
当系统扩展到多数据中心或多机房,问题就复杂了。你需要考虑:
- 一致性 — 同一令牌在不同节点上查到的原始值必须相同
- 可用性 — 某个节点挂了,不能影响映射写入与查询
- 分区容错 — 网络抖动时,系统还能正常工作
CAP 定理在这里很真实。我个人倾向于 最终一致性 + 本地优先写入 的做法:
- 每个节点收到映射写入请求时,先写本地数据库,再异步同步到全局
- 查询时优先查本地缓存和数据库,如果找不到,通过一致性哈希路由到目标节点
- 定期核对:每天跑一次全量对账任务,修正不一致数据
下面这张图展示了分布式令牌映射的典型架构:
3.5 一致性哈希与分片
分布式映射最关键的设计是 数据分片策略。我强烈推荐一致性哈希,它解决了一个很实际的问题——节点增减时,不需要大规模数据迁移。
具体做法:
- 将令牌的哈希值映射到一个 2^64 的环形空间
- 每个物理节点在该环上占据若干虚拟节点(vnode)
- 查询时,顺时针找到第一个虚拟节点,就是目标节点
实战经验:我在一个日处理 5000 万令牌的项目中,用一致性哈希把分布式的 Redis 集群从 6 节点扩展到 12 节点,整个过程只迁移了约 15% 的数据,服务完全没停机。如果用简单取模分片,99% 的数据都要重新分布。
3.6 映射存储的容灾与备份
数据安全无小事。令牌映射一旦丢失,所有被保护的数据都无法还原。
| 灾难级别 | 应对措施 |
|---|---|
| 单节点宕机 | 一致性哈希自动迁移至邻近节点;本地数据库主从切换 |
| 跨机房故障 | 多机房部署,每个机房都有完整映射副本;流量切换到备用机房 |
| 数据全量丢失 | 每日全量备份 + 每 5 分钟增量备份到对象存储(S3/MinIO) |
| 逻辑误删 | 软删除:增加 deleted_at 字段,保留 30 天等待恢复 |
血的教训:我曾经遇到过误操作执行了 DELETE FROM _mapping 且没带 WHERE……你猜结果如何?好在当天有全量备份,但恢复花了 3 小时,期间所有令牌化查询都走备份回放,业务感知到了 3 小时响应变慢。从那以后,我再也不允许直接 DELETE 了,必须打软删除标记。
映射存储没有银弹。数据库、缓存、分布式架构各有适用场景。我的建议是:先搞清楚你的并发量、延迟容忍度和数据一致性要求,再选方案。如果每天几万笔,MySQL 完全够用;如果是百万级实时交易,那就老老实实上分布式缓存 + 一致性哈希。