2418 字
约 8 分钟
19
S12-A16 · 缓存的空值策略:null 该不该缓存、缓存多久

一次商品详情接口的缓存被"优化"过:查不到商品时直接返回 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. 自测

  1. 三种"没有"里,哪一种绝不能缓存?为什么它会让监控看起来"变好了"?
  2. 为什么直接存 null 不行?哨兵对象必须满足哪两个条件?
  3. 空值 TTL 为什么通常短于正例 TTL?第四项给出的上限是什么?
  4. 空值缓存的哪两个副作用会同时出现?三个保护措施分别针对什么?
  5. 为什么"命中率 95%"需要拆开看?空值命中占比高说明什么?
  6. 数据库恢复后为什么不能只等 TTL?判断"该不该缓存空值"的那个问题是什么?
S12-A16 · 缓存的空值策略:null 该不该缓存、缓存多久
https://www.clxhxhhr.top/posts/6460/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。