一次商品详情接口的缓存被"优化"过:查不到商品时直接返回 404,不写缓存。上线两周后,一个下架商品的 ID 被爬虫反复请求,每秒两千次穿透到数据库——因为"查不到"这件事没有被记住。
先要分清两种"穿透":一种是被同一个人反复问同一件不存在的事,另一种是很多人各问各的。前者靠空值缓存解决,后者靠布隆过滤器或参数校验解决——用错手段会让两种问题都变严重。
空值缓存的争论通常停在"要不要缓存 null"。真正该问的是**"查不到"这件事有多大概率被反复问**:如果同一个不存在的键会被重复查询,缓存它就是必要的;如果每次都问不同的键,缓存 null 只是把穿透换成了内存增长。
1. 三种"没有"要区分
| 情形 | 语义 |
|---|---|
| 数据不存在 | 确定性结论,可缓存 |
| 查询失败 | 临时状态,不该缓存 |
| 数据被过滤 | 权限或状态导致,可缓存但短 |
这类错误之所以危险,是因为它在监控上看起来像改善:错误率下降、命中率上升、数据库压力下降。三个指标同时"变好",没有人会去看第四个——用户投诉量。
第二项是最常见的错误:数据库超时被当成"查不到",于是把一次故障固化成了缓存里的"不存在"——后续所有请求都拿到 404,即使数据库早已恢复。
这个错误在监控上的表现是:错误率骤降、缓存命中率骤升,看起来是"变好了"。
// 反例:把异常也当成"没有",缓存了故障
try {
var p = repo.findById(id); // 超时会抛异常
cache.put(id, p); // p 为 null 时也写入
} catch (Exception e) {
cache.put(id, null); // 事故:把故障固化为"不存在"
}
// 正例:只有确定性结论才写入缓存
var p = repo.findById(id); // 异常向上抛,不写缓存
cache.put(id, p == null ? NULL_MARKER : p, TTL_MISS);
2. 空值标记与序列化
| 做法 | 问题 |
|---|---|
存 null |
多数缓存客户端禁止或不区分 |
| 存哨兵对象 | 需要与真实值区分 |
| 存特殊字符串 | 可能与真实值冲突 |
| 用独立的"缺失键" | 多一次 key 计算 |
第二项是主流做法:用一个不可变的哨兵对象表示"确定不存在",读取时把它翻译成"没找到"。关键是哨兵不能与任何业务值相等,且序列化后仍能被识别。
还有一层要处理:跨语言的序列化一致性。Java 写哨兵、Go 读缓存时不能识别同一个哨兵,于是"确定不存在"在一侧变成"命中了一个奇怪对象"。约定要么落成统一的结构,要么在网关层做翻译。
// 统一的结构化空值:跨语言也能识别
{"__absent__": true, "reason": "not_found", "at": "2026-11-10T13:52:00Z"}
// 各语言只需判 __absent__ 字段,不必理解对方的类/结构体
第三项的风险是冲突:如果业务允许存字符串 "null",用它表示缺失就会混淆。
// 哨兵对象:类型上不可混淆
public final class NullValue implements Serializable {
public static final NullValue INSTANCE = new NullValue();
private NullValue() {}
}
// 读取:显式翻译三态
Object raw = cache.get(key);
if (raw == null) return MISS; // 缓存里没有
if (raw == NullValue.INSTANCE) return ABSENT; // 确定不存在
return HIT(raw);
3. 空值 TTL 该多长
| 依据 | TTL 倾向 |
|---|---|
| 数据可能被创建 | 短(分钟级) |
| 数据可能被恢复 | 短(分钟级) |
| 确定永久不存在(非法 ID) | 长(小时级) |
| 高频重复查询 | 至少覆盖一个查询风暴周期 |
第一项与第二项说明"不存在"是个有寿命的结论:商品可能被重新上架、用户可能被解封、权限可能被授予。所以空值 TTL 通常应短于正例 TTL。
第四项给出了上限:TTL 至少要能让缓存挡住一次爬虫风暴,否则它在风暴期间会一次次放行。
// 分级 TTL:正例长、空值短、非法值最长
cache:
product:
ttl: 30m # 正例
ttl_absent: 2m # 不存在(可能被创建/恢复)
ttl_invalid: 6h # 格式非法(不可能存在)
max_size: 200k # 空值也要计入容量
4. 容量:空值缓存的两个副作用
| 副作用 | 机制 |
|---|---|
| 内存被垃圾键占满 | 每次查询不同 ID |
| 正例被挤出去 | LRU/LFU 下空值抢占 |
第一项在爬虫场景里非常真实:攻击者或爬虫用一个随机 ID 序列扫描,每一个都会被缓存成空值,于是缓存里塞满了永远不会命中的键。
第一项还有"放大效应":空值缓存让扫描成本变得更低——扫描者发现每次请求都很快(都命中缓存),会加大并发,于是缓存先爆。
缓解手段是空值的容量上限与淘汰优先级:让空值进入一个可被快速清空的分区,或按 key 形态限制(如只缓存"格式合法"的键)。
// 空值缓存的三个保护措施
1. 只在 key 形态合法时缓存(排除乱码 ID)
2. 空值单独计数,超过容量阈值时优先淘汰空值
3. 按来源限流:同一来源的"查不到"命中率极低时,不写缓存
// 三条都不做 → 一次扫描就能把缓存变成内存泄漏
5. 空值缓存对指标的影响
| 指标 | 变化 |
|---|---|
| 缓存命中率 | 上升(含空值命中) |
| 数据库 QPS | 下降 |
| 接口错误率 | 可能上升(缓存的 404) |
| 平均延迟 | 下降 |
第一项要拆分看:"命中率 95%" 里有多少是空值命中。如果空值命中占三成,说明真实数据命中只有 65%,而每次空值命中都在给用户返回"不存在"。
所以空值缓存必须单独看两个数:空值命中次数与空值命中后人工判定的正确率。后者只能抽样,但它决定了这套策略是在保护用户还是在欺骗用户。
第三项是最危险的:缓存了错误的"不存在",会让错误率长期停留在某个非零水平,而它不会告警,因为它是"稳定的"。
6. 排错:五种"空值缓存引发的问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 新上架商品搜不到 | 空值 TTL 太长 | 缩短或写入时主动清除 |
| 缓存命中率很高但用户报错 | 缓存了故障结果 | 只在成功路径写入 |
| 内存缓慢增长 | 垃圾键被缓存 | 加 key 形态限制 |
| 恢复后仍返回不存在 | 故障期间写入的空值 | 主动失效 + 监控 |
| 不同环境表现不一致 | 空值序列化方式不同 | 统一哨兵与序列化 |
第四项值得单独说:数据库故障期间产生的空值缓存,需要在恢复后主动清理。仅靠 TTL 等待,会让"恢复"这件事延后一个 TTL 周期——对高频接口就是几分钟的持续错误。
7. 边界:什么情况不该缓存空值
| 场景 | 为什么 | 做法 |
|---|---|---|
| 查询键高度分散(几乎不重复) | 缓存无收益 | 不限流即可 |
| 数据会被极频繁创建 | TTL 无法覆盖 | 依赖写时清除 |
| 权限敏感(存在与否本身是信息) | 可能泄漏 | 按用户维度隔离或不缓存 |
| 后端已有布隆过滤器 | 已有防护 | 不重复实现 |
还有一条与"写入路径"有关的边界:新建数据时要主动清除对应空值。只靠 TTL 的话,"创建后仍显示不存在"会持续一个 TTL 周期,而这个窗口在业务上常被当成 bug 投诉。
判断口径一句话:同一个"不存在"的键,会不会在短时间内被问第二次。会,就缓存且 TTL 要覆盖风暴周期;不会,缓存它只是让内存变脏。
8. 自测
- 三种"没有"里,哪一种绝不能缓存?为什么它会让监控看起来"变好了"?
- 为什么直接存
null不行?哨兵对象必须满足哪两个条件? - 空值 TTL 为什么通常短于正例 TTL?第四项给出的上限是什么?
- 空值缓存的哪两个副作用会同时出现?三个保护措施分别针对什么?
- 为什么"命中率 95%"需要拆开看?空值命中占比高说明什么?
- 数据库恢复后为什么不能只等 TTL?判断"该不该缓存空值"的那个问题是什么?