2259 字
约 7 分钟
15
S12-A17 · 缓存的键设计:什么该进 key,什么不该

一次"用户信息缓存"的 key 写成 user:{id},看起来没问题。直到某天有人问"为什么管理员看到的用户详情里多了手机号完整号码"——因为同一个缓存被两种权限的用户共用,管理员写入的是完整数据,普通用户读到的是同一份。

缓存键的设计不是"拼一个字符串",而是决定"哪些条件会让结果不同"。这两个错误方向有个不对称:"少了维度"会造成数据错误(严重),"多了维度"只是命中率下降(可观测)。所以评审时应当先找"少了什么",而不是先优化"多了什么"。
凡是影响结果的维度都必须进 key,凡是不影响的都不该进——前者导致串数据,后者导致命中率被稀释。


1. 键必须包含的三类维度

维度 例子
实体标识 用户 ID、订单号
影响结果的上下文 权限、区域、语言、币种
数据版本 结构版本、来源版本

第二项最容易被漏:**"同一份数据在不同权限下返回的东西不同"**第二项还有一个"隐性上下文":调用方的租户/组织。多租户系统里"用户 1"在每个租户里都存在,而 key 只写了 user:1,于是 A 租户的数据被 B 租户读到——这是最严重的一类串数据。
,而这一点在代码里看不出来——它体现在 service 层的分支里。

第三项在"数据来源迁移"时救命:换了数据源或改了字段含义时,旧缓存必须失效,否则读到的结构不对。

// 反例:只含实体标识
String key = "user:" + userId;
// 管理员与普通用户共用 → 串权限

// 正例:含影响结果的上下文
String key = String.format("user:%d:role:%s:ver:%d", userId, role, STRUCTURE_VER);
// 结构版本让"改了字段含义"时自然失效

2. 键的四个质量要求

要求 原因
确定性 同样的输入必须得到同样的 key
唯一性 不同的结果必须对应不同 key
可读性 排障时要能看懂
有界性 长度可控、无用户原文

第一项的排查手段是"把同一个请求跑两次,打印两次的 key"。如果两次不同,就一定有问题;这比读代码快得多。
第一项最常见的问题来源:key 里放了 HashMap 的迭代顺序、时间戳毫秒、对象 toString(),于是"同一个查询"生成不同的 key,命中率跌到零。

第四项是安全要求:不要把用户输入的原文直接拼进 key(可能超长、可能含分隔符冲突),应当先哈希。

// 反例:key 里含可变顺序与毫秒
String key = "q:" + params.toString() + ":" + System.currentTimeMillis();
// 结果:永不命中

// 正例:排序 + 哈希 + 固定长度
String key = "q:" + sha256(sorted(params)) + ":v3";

3. 分隔符与转义

做法 风险
冒号拼接 值里含冒号时歧义
直接拼 超长、特殊字符
哈希整体 可读性下降
结构化编码(JSON + 哈希) 唯一推荐

第一项的问题很具体:a:1:b 与 a:1:b:c 这类歧义第一项的实际触发条件是"值来自用户输入":用户名里含冒号、搜索词里含冒号、标签里含冒号。而在测试数据里几乎不会出现,于是它长期潜伏。
,后者可能是"a 的值是 1:b"。当值来自用户输入时,这类歧义会变成"不同请求命中同一个 key"。

第二项的风险是"key 过长":Redis 的 key 也会占内存,一个 4KB 的 key 比 4KB 的 value 更浪费(因为 key 不参与压缩)。

// 三种 key 形态的对比
易冲突:user:1:role:admin:2:cn       ← 值里若含冒号则歧义
较安全:user#1#role#admin#ver#2#cn   ← 换一个不常出现的分隔符
最稳:  u:{sha256(结构化JSON)[:16]}   ← 无歧义、定长、但不可读
// 折中:短可读前缀 + 哈希后缀(u:1:a3f9c2)

4. 不该进 key 的三类东西

不该进 后果
与结果无关的请求元数据 命中率被稀释
高频变化的值(时间戳、随机数) 永不命中
用户隐私原文 安全与长度问题

第一项在网关层很常见:把 trace_id、request_id 拼进 key,于是每个请求都是新 key。这类"看起来加了信息"的做法,实际把缓存废掉了。

第三项(隐私原文)还有个"合规"角度:缓存键会出现在慢查询日志、监控采样、甚至堆快照里。把手机号写进 key,等于把它写进了三处你没想到的地方。
第二项最典型的就是"带毫秒的时间",它常出现在"按时间分片缓存"的实现里。

// 同一份数据,两种 key 的命中率差异
A: user:1:role:admin                     → 命中率 95%
B: user:1:role:admin:req:8f3c...:ts:...  → 命中率 0%
// 后者的"信息更全",但它缓存的是"每次请求"而不是"数据"

5. 版本与失效

手段 效果
key 里带版本号 改结构时自然失效
前缀命名空间 可批量清理
哈希值代替原文 长度可控
TTL 兜底

第一项是最"省事"的失效手段:把 v3 改成 v4,旧 key 全部作废第二项(命名空间)还有一个运维用途:"清空某个业务的缓存"是故障时的常用止血手段,而前缀设计决定了这个动作能否一次完成。
,不需要写批量删除逻辑。代价是旧 key 会占内存直到过期。

第二项在"按业务清理"时有用:统一前缀 order: 可以一次清空某个业务的所有缓存。

// 推荐的 key 结构(四段)
key_schema: "{namespace}:{entity}:{id}:{ctx_hash}:{ver}"
example:   "shop:user:7781:a3f9c2:v4"
// namespace → 可按业务清理
// ctx_hash  → 权限/区域/语言等上下文的哈希
// ver       → 结构版本,改结构时 +1

6. 排错:五种"缓存串数据"

现象 成因 处理方向
不同用户看到同一条 key 少了用户维度 加实体标识
权限不同但内容相同 key 少了权限维度 加上下文
改了结构读到旧格式 无版本号 加 ver
命中率极低 key 含随机/时间 去掉无关维度
偶发串数据 分隔符歧义 改用哈希

第四项(命中率极低)应当有监控:命中率低于某个阈值就告警。否则"缓存加了但没用"这件事可以持续几个月不被发现,而它看起来"有缓存"。
第五项最难查:只在"某个值特别"时冲突(比如用户名里正好有冒号)。而它表现为"偶发",让人以为是并发问题。

7. 边界:什么情况不必精细设计 key

场景 为什么 做法
单实体、无权限差异 维度单一 简单拼
内部工具(数据不敏感) 无串数据风险 简单拼
已有统一缓存框架 框架管 key 跟随框架
缓存时间极短(秒级) 失效快 可粗

判断口径一句话:同一个 key 会不会被"两种不同的请求"用到。最后一条与"迁移"有关的边界:key 结构变更时,旧 key 不会自动消失。所以要么靠版本号自然淘汰,要么写一次清理脚本——而"改了 key 格式但没清理"会造成内存里两套 key 并存,浪费一半容量。
会,就必须把"它们的不同之处"放进 key;不会(只有一种请求形态),简单拼就够。


8. 自测

  1. 键必须包含的三类维度是什么?"影响结果的上下文"为什么最容易漏?
  2. 键的四个质量要求里,哪一项会因为 HashMap 顺序或毫秒时间戳而失败?
  3. 冒号分隔符会产生什么歧义?三种 key 形态各自的取舍是什么?
  4. 为什么把 trace_id 拼进 key 等于废掉缓存?
  5. "key 里带版本号"为什么是最省事的失效手段?代价是什么?
  6. 判断"是否需要精细设计 key"的那个问题是什么?
S12-A17 · 缓存的键设计:什么该进 key,什么不该
https://www.clxhxhhr.top/posts/6512/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。