缓存命中率高,不代表缓存策略是对的
命中率只是结果指标,真正决定系统成本的是失效方式、回源压力和错误传播半径
很多团队聊缓存,第一句话就是命中率。
命中率 95%,听起来不错;98%,像是优化做成了;99%,甚至会让人产生一种系统已经被“驯服”了的错觉。
但真到线上出事,大家复盘时看的往往不是那条漂亮的命中率曲线,而是另一组问题:为什么某一批 key 同时过期后数据库被打穿了,为什么价格改完后一小时内还有用户看到旧数据,为什么缓存节点一抖动,最贵的几个查询全都直接砸到了主库上。
我的判断很简单:缓存策略的目标不是把命中率做高,而是把“不命中”这件事的代价控制住。 命中率高,只能说明很多请求读到了缓存;它不能说明你的失效设计是对的,不能说明回源成本可控,更不能说明错误会被限制在小范围内。
如果一个缓存把命中率从 92% 拉到 99%,代价却是更难失效、更难定位脏数据、更容易在抖动时形成回源风暴,那它大概率不是优化,只是把复杂度从数据库挪到了另一个角落。
问题不在“命中了多少”,而在“没命中时打到哪里”
命中率这个指标有一个天然缺陷:它会把冷热分布揉平。
一个接口一天有一百万次请求,其中九十九万次都在读热门商品详情,剩下一万次在读长尾商品。你把热门数据缓存住,命中率立刻很好看。但如果那一万次长尾 miss 恰好都落在最慢、最贵、还会带联表的查询上,数据库压力未必比你想象的小。
也就是说,命中率按“次数”计数,但系统成本往往按“代价”结算。
这就是为什么有些团队明明看着缓存指标很漂亮,数据库 CPU 还是下不来:缓存挡住了便宜请求,真正昂贵的 miss 还在裸奔。
判断缓存策略时,我更愿意先看三件事:
- miss 请求里,最贵的那部分占了多少;
- miss 是否会集中发生,而不是均匀发生;
- 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 发生时,会怎样?
如果答案是“只是慢一点,系统还能稳住”,那这个缓存大概率有设计。
如果答案是“命中率平时很高,但一旦失效就打穿主库,还顺手把正确性一起拖下水”,那它只是一个平时看起来很努力的隐患。
缓存从来不只是“把数据放近一点”。它真正考验的,是你怎么处理变化、失败和代价。命中率高,最多说明你把前半句做了;后半句,才决定这个策略到底值不值。