返回文章列表

缓存命中率高,不代表缓存策略是对的

命中率只是结果指标,真正决定系统成本的是失效方式、回源压力和错误传播半径

很多团队聊缓存,第一句话就是命中率。

命中率 95%,听起来不错;98%,像是优化做成了;99%,甚至会让人产生一种系统已经被“驯服”了的错觉。

但真到线上出事,大家复盘时看的往往不是那条漂亮的命中率曲线,而是另一组问题:为什么某一批 key 同时过期后数据库被打穿了,为什么价格改完后一小时内还有用户看到旧数据,为什么缓存节点一抖动,最贵的几个查询全都直接砸到了主库上。

我的判断很简单:缓存策略的目标不是把命中率做高,而是把“不命中”这件事的代价控制住。 命中率高,只能说明很多请求读到了缓存;它不能说明你的失效设计是对的,不能说明回源成本可控,更不能说明错误会被限制在小范围内。

如果一个缓存把命中率从 92% 拉到 99%,代价却是更难失效、更难定位脏数据、更容易在抖动时形成回源风暴,那它大概率不是优化,只是把复杂度从数据库挪到了另一个角落。

问题不在“命中了多少”,而在“没命中时打到哪里”

命中率这个指标有一个天然缺陷:它会把冷热分布揉平。

一个接口一天有一百万次请求,其中九十九万次都在读热门商品详情,剩下一万次在读长尾商品。你把热门数据缓存住,命中率立刻很好看。但如果那一万次长尾 miss 恰好都落在最慢、最贵、还会带联表的查询上,数据库压力未必比你想象的小。

也就是说,命中率按“次数”计数,但系统成本往往按“代价”结算。

这就是为什么有些团队明明看着缓存指标很漂亮,数据库 CPU 还是下不来:缓存挡住了便宜请求,真正昂贵的 miss 还在裸奔。

判断缓存策略时,我更愿意先看三件事:

  1. miss 请求里,最贵的那部分占了多少;
  2. miss 是否会集中发生,而不是均匀发生;
  3. miss 之后是否会把压力传染给下游。

这三个问题比“总体命中率是不是又涨了 2 个点”更接近真实成本。

真正决定缓存质量的,通常是失效方式

很多缓存事故,本质上都不是“没缓存”,而是“失效设计太粗”。

最常见的一种做法是只设 TTL。实现简单,指标也容易变好,因为只要 TTL 够长,命中率通常不会太差。但 TTL 方案有两个典型问题。

第一,它把数据何时过期交给时间,而不是交给业务变化。

价格变了、库存变了、权限变了,缓存却还在活着。你当然可以把 TTL 缩短,可 TTL 一短,回源频率又会上来。很多团队就是在“旧数据太久”和“数据库太忙”之间来回摆。

第二,它很容易制造同步失效。

如果一批热点 key 在相近时间写入,又用相同 TTL,它们大概率也会在相近时间一起过期。平时看着命中率没问题,一到过期点就出现瞬时回源洪峰。指标盘上这可能只是五分钟的抖动,对数据库和下游服务来说却是很真实的一拳。

所以缓存设计里最该花心思的,往往不是选 Redis 还是本地缓存,而是失效机制到底跟没跟业务变化对齐。

常见的改法包括:

  • 用事件驱动失效,而不是只靠 TTL;
  • 给 TTL 加随机抖动,避免热点 key 同时过期;
  • 用版本号或时间戳放进 key,显式切换数据代次;
  • 允许短时间返回旧值,但后台异步刷新,而不是让所有请求一起回源。

这里的核心不是“技巧更多”,而是你要决定什么时候接受旧数据,什么时候必须立刻一致,什么时候宁可返回降级结果也不能把主库打爆。 这才是缓存策略,命中率只是它留下来的一个侧影。

把 TTL 拉长,常常只是把问题往后推

我见过不少“缓存优化”是这么做的:线上数据库有压力,于是把 TTL 从 5 分钟改成 30 分钟,再改成 1 小时。结果命中率上去了,数据库曲线也平了一点,团队就宣布优化完成。

这种做法最危险的地方在于,它太容易在短期内看起来有效。

因为大多数指标系统更擅长记录“省了多少查询”,不擅长记录“多活了多少错误数据”。当旧价格、旧配置、旧权限被更多用户读到时,损失经常不会立刻体现在缓存面板上,而会晚一点出现在投诉、补偿、人工核对和事故复盘里。

尤其是下面几类数据,靠粗暴延长 TTL 往往是坏主意:

  • 会影响金额结算的数据;
  • 会影响权限和可见范围的数据;
  • 更新频率不高,但一旦更新就必须尽快收敛的数据。

对这类数据来说,高命中率如果是靠“延迟承认变化”换来的,就不是性能收益,而是在透支正确性。

缓存最难的部分,不是读,而是把错误限制住

很多人把缓存想成“数据库前面多放一层”。这个理解不算错,但太乐观。

真实系统里的缓存不是一层玻璃,而是一个会放大设计缺陷的压力面。key 设计得太粗,失效范围会过大;key 设计得太细,内存占用和维护复杂度会上升;数据拼装放在缓存层,回源时又容易重复计算;本地缓存和分布式缓存叠在一起,还会多出一层一致性问题。

所以我会把缓存方案分成两个问题来审视:

  • 平时快不快;
  • 出事时会不会连带着把系统别的部分一起拖下去。

后一个问题通常更重要。

一个更靠谱的缓存实现,往往会明确处理下面几件事:

v, ok := cache.Get(key)
if ok && !v.SoftExpired() {
    return v.Data
}

return singleflight.Do(key, func() any {
    fresh := db.Load(id)
    cache.Set(key, fresh, ttlWithJitter())
    return fresh
})

这里真正有价值的不是语法,而是两个约束:

  • 同一个 key 回源时,只允许一个请求真的去打数据库;
  • 过期不是“立刻全量失效”,而是给系统一个可控的刷新窗口。

这类设计未必能把命中率做到最漂亮,但它能显著降低回源风暴和瞬时放大。出了抖动,系统更像是“慢一点”,而不是“塌一下”。

一个常见误区:把缓存命中率当成团队 KPI

一旦命中率变成 KPI,团队很容易朝着错误方向努力。

因为最容易提升命中率的手段,往往不是改进策略,而是改进统计结果:

  • 拉长 TTL;
  • 把更多不该缓存的数据也塞进去;
  • 用更粗粒度的 key,把不同场景混在一起;
  • 把接口拆分得对缓存友好,却让业务语义变差。

这些动作都可能让图变好看,但会把别的问题推高:脏数据窗口更长、失效更难做、排障更难、内存更贵、业务边界更模糊。

一个更健康的做法是把缓存和下面这些指标一起看:

  • miss 的 P95/P99 回源耗时;
  • 热点 key 的并发回源数;
  • 缓存失效后的错误率和数据库峰值;
  • 数据更新后收敛到新值的时间;
  • 为了维护缓存额外引入了多少代码路径和运维动作。

只有把这些代价放在一起,命中率才有解释力。单独看它,太容易被骗。

反例:有些场景里,命中率就是应该尽量高

也不能把话说死。

如果数据天然稳定,或者根本就是不可变资源,比如静态文件、版本化配置、CDN 分发内容、变化极少的字典表,那么命中率确实就是很重要的目标。因为这类场景里,“旧一点”不是严重问题,失效也相对简单,回源路径通常清晰可控。

还有一些内部工具型系统,数据量不大,写入不频繁,偶发脏读的业务代价也很低。在这种情况下,简单 TTL 方案完全可能是对的。不是因为它高级,而是因为它足够便宜。

所以这篇文章不是在反对命中率,而是在反对把命中率从上下文里摘出来,单独当成缓存是否成功的证据。

结尾

判断一个缓存方案靠不靠谱,我会先问一句:这个系统最贵的一次 miss 发生时,会怎样?

如果答案是“只是慢一点,系统还能稳住”,那这个缓存大概率有设计。

如果答案是“命中率平时很高,但一旦失效就打穿主库,还顺手把正确性一起拖下水”,那它只是一个平时看起来很努力的隐患。

缓存从来不只是“把数据放近一点”。它真正考验的,是你怎么处理变化、失败和代价。命中率高,最多说明你把前半句做了;后半句,才决定这个策略到底值不值。