令牌映射与存储:数据库、缓存与分布式映射

令牌映射,说白了就是要把令牌和被保护数据之间的对应关系存下来。嗯,这事看着简单,但坑不少。我最早做令牌化系统时,觉得随便塞个关系型数据库就行,结果上线当天就踩了雷——并发一上来,数据库连接池直接打满,应用卡死。

所以这一章,咱们好好聊聊令牌映射该怎么存。本地用啥、缓存用啥、分布式又用啥,每个场景都有讲究。

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 定理在这里很真实。我个人倾向于 最终一致性 + 本地优先写入 的做法:

  1. 每个节点收到映射写入请求时,先写本地数据库,再异步同步到全局
  2. 查询时优先查本地缓存和数据库,如果找不到,通过一致性哈希路由到目标节点
  3. 定期核对:每天跑一次全量对账任务,修正不一致数据

下面这张图展示了分布式令牌映射的典型架构:

分布式令牌映射架构 分布式令牌映射架构 应用客户端 负载均衡器 节点 1 本地缓存 + 数据库 主写分区 A 节点 2 本地缓存 + 数据库 主写分区 B 节点 3 本地缓存 + 数据库 主写分区 C 异步同步 异步同步 全局持久存储(FoundationDB / 分布式MySQL) 设计要点 1. 一致性哈希:令牌的哈希值决定由哪个节点负责主写入 2. 异步复制:主节点写入后立即返回,后台同步到其他节点和全局存储 3. 本地优先:查询时先查本地缓存 + 本地库,找不到再走分布式路由

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 完全够用;如果是百万级实时交易,那就老老实实上分布式缓存 + 一致性哈希。