Skip to content

开场白

做后端三年,最大的感悟就一条:缓存是万能的,直到它出问题

今天不聊理论,只讲我在生产环境真实踩过的坑。每个坑都导致过线上故障,每个教训都是半夜被报警电话叫醒换来的。

坑 1:缓存穿透,但不是你以为的那种

很多人一听到缓存穿透就想到了布隆过滤器。但我要说的这个不太一样。

场景:用户中心服务,QPS 8000,缓存命中率 95%。

问题:某天突然有几百个请求直接打到数据库,CPU 飙升到 90%。

原因:我们有个批量查询接口,一次查 50 个用户。代码逻辑是:

先查缓存,拿到所有命中的用户
再查数据库,补齐缺失的用户
最后把缺失的也写入缓存

看起来没问题对吧?但问题就出在"最后一步"。

当有恶意请求故意查询不存在的用户 ID 时,每次都会查数据库,然后写入缓存。但这些"不存在"的数据本身就没有意义,缓存它们只会浪费内存。

解决方案

我们做了两层处理:

第一,对"不存在"也做缓存,但过期时间很短(5 分钟)。这样即使有恶意请求,也不会持续打库。

第二,加了一层本地缓存(Caffeine),过期时间 30 秒。这样即使 Redis 挂了,本地缓存还能扛一会儿。

教训:缓存穿透的防护不是上一个布隆过滤器就完事了,要根据业务场景做多层防护。

坑 2:缓存雪崩,我们真的遇到了

场景:促销活动,预计 QPS 5 万。

问题:活动开始后 10 分钟,缓存服务整体不可用,数据库直接被打挂。

原因:我们所有缓存 key 的过期时间都是整点对齐的。比如活动 10 点开始,所有缓存都设置成 10 点过期。结果就是 10 点整,所有 key 同时失效,所有请求瞬间打到数据库。

这个坑其实很经典,但我们还是踩了。因为开发的时候觉得"整点过期好计算",没人想到会同时失效。

解决方案

在原有过期时间基础上,随机加一个 0-300 秒的偏移量。这样即使同一批写入的缓存,也会在不同时间过期,不会同时失效。

基础过期时间:3600 秒
随机偏移:0-300 秒
实际过期时间:3600 + random(0, 300)

教训:不要相信"这种事不会发生在我们身上"。越是简单的坑,越容易踩。

坑 3:缓存击穿的另一种解法

场景:热点商品详情页,单个商品 QPS 3000+。

问题:某个爆款商品缓存失效时,瞬间几千个请求打到数据库,查询耗时从 5ms 涨到 500ms。

常规解法:互斥锁。第一个请求拿到锁去查数据库,其他请求等待。

但我们的场景有个问题:等待的请求太多,锁的持有时间稍长就会导致大量请求超时。

我们的解法

用了"逻辑过期"的方案:

缓存数据里不存真实的过期时间,而是存一个"逻辑过期时间"。查询的时候,如果过了逻辑过期时间,不直接查数据库,而是:

  1. 返回旧数据(保证响应速度)
  2. 异步启动一个线程去更新缓存
  3. 新数据准备好后,下一次请求就能拿到

这样即使缓存"过期"了,也不会瞬间打库,而是平滑过渡。

代价:用户可能会看到几秒的旧数据。但对我们这种商品详情页来说,几秒的数据延迟是可以接受的。

教训:没有银弹,只有取舍。一致性 vs 可用性,要看业务能接受什么。

坑 4:缓存一致性,我放弃了

说出来可能不信,做缓存三年,我最后的选择是:放弃强一致

场景:订单系统,用户下单后要更新库存缓存。

问题:无论用哪种方案,都无法保证 100% 的一致性。

我们试过的方案:

方案 1:先更新数据库,再删除缓存

问题:删除缓存可能失败,或者删除后有新查询进来,把旧数据又写回缓存。

方案 2:先删除缓存,再更新数据库

问题:删除后、更新前,有查询进来,会把旧数据写入缓存。

方案 3:延时双删

问题:延时多久?500ms?1s?无法保证覆盖所有场景。

方案 4:监听 binlog 异步删除

问题:有延迟,延迟期间数据不一致。

最终方案

我们选择了"先更新数据库,再删除缓存",然后加了一层兜底:

给所有缓存设置一个较短的过期时间(比如 30 分钟)。这样即使删除失败,数据也会在 30 分钟内自动恢复一致。

同时,对库存这种敏感数据,查询的时候加了一层"数据库校验":

查询缓存,拿到库存数量
如果库存 < 10,直接查数据库(不信任缓存)
如果库存 >= 10,用缓存数据

这样至少保证了"不会超卖"。

教训:缓存一致性是个伪命题。与其追求 100% 一致,不如思考业务能接受什么样的不一致。

坑 5:大 key 问题,我们差点挂了

场景:用户信息缓存,单个 key 存储用户所有信息。

问题:某天某个大 V 用户的信息查询导致 Redis 单节点卡顿,影响其他所有用户。

原因:这个用户的信息特别大(5MB+),包含:

  • 基本信息
  • 会员信息
  • 积分信息
  • 优惠券列表(几百张)
  • 收货地址(几十个)

每次查询这个大 key,Redis 都要花几十毫秒处理,期间其他请求全部阻塞。

解决方案

做了拆分:

  • 用户基本信息:user:basic:
  • 用户会员信息:user:vip:
  • 用户积分信息:user:points:
  • 用户优惠券列表:user:coupons:{id}(分页存储)
  • 用户收货地址:user:addresses:

查询的时候,根据场景按需加载。比如商品详情页只需要基本信息,就不查其他数据。

教训:单个 key 不要超过 10KB。超过这个大小,就要考虑拆分。

坑 6:缓存预热,不是简单的提前加载

场景:每天早高峰前做缓存预热。

问题:预热脚本跑了 2 小时,早高峰都过了还没跑完。

原因:我们 naive 地认为"预热就是把所有数据查一遍写入缓存"。但数据量太大(千万级),全量预热根本不现实。

解决方案

做了三层预热策略:

第一层:热点数据预热

只预热 Top 1000 的热点数据。这些数据覆盖了 80% 的流量,预热成本低,效果好。

第二层:分层预热

对不同数据类型设置不同优先级:

  • P0 数据(商品基本信息):最高优先级,最早预热
  • P1 数据(价格、库存):次优先级,稍后预热
  • P2 数据(评价、详情):最低优先级,有空再预热

第三层:按需预热

对长尾数据不做预热,等用户访问时自动加载。但加了一层"预加载"机制:

用户访问 A 商品时,顺便把相关的 B、C 商品也预加载到缓存(因为用户很可能会看)。

教训:预热不是越多越好,要算 ROI。80% 的流量用 20% 的数据就能覆盖。

坑 7:多级缓存,一致性成了噩梦

场景:本地缓存(Caffeine)+ Redis 两级缓存。

问题:数据更新时,本地缓存和 Redis 缓存不一致。

原因:我们有多台服务器,每台都有本地缓存。更新数据时,只能删除 Redis 缓存,但无法通知其他服务器删除本地缓存。

解决方案

用了 Redis 的 Pub/Sub 机制:

更新数据时:
1. 更新数据库
2. 删除 Redis 缓存
3. 发送一个"失效消息"到 Redis Channel

所有服务器订阅这个 Channel:
收到消息后,删除本地缓存中对应的 key

但这样又引入了新问题:Pub/Sub 不可靠,消息可能丢失。

所以最后又加了一层兜底:本地缓存设置较短的过期时间(1 分钟)。即使消息丢失,1 分钟内也能自动恢复。

教训:多级缓存能提升性能,但会极大增加复杂度。要想清楚是否真的需要。

坑 8:缓存监控,我们后知后觉

场景:缓存系统运行半年,一直没出问题。

问题:某天缓存命中率从 95% 降到 60%,但没人发现,直到用户投诉。

原因:我们居然没有缓存监控。

补救措施

加了一套完整的监控体系:

基础指标

  • 缓存命中率(按 key 维度)
  • 缓存请求量/淘汰量
  • 缓存响应时间(P50/P95/P99)
  • 内存使用率

业务指标

  • 各业务线缓存使用情况
  • 热点 key 排行
  • 大 key 排行
  • 过期 key 数量

告警规则

  • 命中率下降超过 10% → P1 告警
  • 单个 key 请求量超过 1000 QPS → P2 告警
  • 内存使用率超过 80% → P2 告警
  • 缓存服务不可用 → P0 告警

教训:监控不是出了事再加,应该一开始就有。没有监控的缓存系统就像没有仪表盘的飞机。

坑 9:缓存降级,关键时刻能救命

场景:Redis 集群故障,所有缓存不可用。

问题:数据库瞬间被打挂,整个系统不可用。

解决方案

做了多层降级策略:

第一层:本地缓存兜底

Redis 不可用时,自动切换到本地缓存。虽然数据可能不是最新,但至少能扛住流量。

第二层:限流

本地缓存也扛不住时,启动限流。只保留核心接口的服务,非核心接口直接返回降级数据。

第三层:静态化

对商品详情页这种可以静态化的页面,直接返回预先生成的静态 HTML。

关键:这些降级策略要能自动触发,不能等人来操作。故障往往发生在半夜,等人响应早就晚了。

教训:不要相信"不会故障",要假设"随时会故障"。降级策略是最后的保命符。

坑 10:缓存不是银弹,别什么都往里塞

这是最大的一个坑。

场景:刚学会用缓存,觉得什么东西都可以缓存。

问题:缓存了大量不该缓存的数据,内存紧张,频繁淘汰,反而影响性能。

教训

缓存只适合以下场景:

  • 读多写少
  • 数据变化不频繁
  • 查询成本高
  • 可以接受短暂不一致

以下场景不适合缓存:

  • 实时性要求极高(如余额)
  • 写多读少
  • 数据量巨大且访问分散
  • 查询本身很快(如简单配置)

核心原则:缓存是解决特定问题的工具,不是万能药。

最后说两句

做缓存三年,最大的感悟不是学会了多少技术方案,而是明白了几个道理:

第一,没有银弹。每个方案都有代价,关键是取舍。

第二,监控先行。没有监控,出了问题就是瞎子。

第三,接受不完美。强一致做不到,就接受最终一致。

第四,简单优先。能用简单方案解决,就不要上复杂方案。

缓存这事儿,说难也难,说简单也简单。难在细节,简单在原理。

希望我的这些坑,能帮你少踩几个。


参考资源

  • 《Redis 设计与实现》
  • 美团技术团队博客:缓存那些事
  • 阿里中间件团队:高并发缓存设计实践