别等系统崩溃才想起缓存
凌晨两点,监控告警疯狂闪烁,数据库连接池耗尽,所有接口响应时间飙到十秒以上。你翻看代码,发现一个高频查询接口居然每次都在直查数据库。这不是段子,这是我真实经历过的生产事故。缓存的缺席,往往就是系统崩塌的第一块多米诺骨牌。
缓存不是锦上添花,而是后端开发者的生存刚需。这篇文章不讲教科书式的概念堆砌,只讲我从无数次线上故障中提炼出的缓存策略与实战血泪。
缓存策略的本质:用空间换时间,但别换出新的灾难
缓存的核心逻辑很简单:把热数据存进更快的存储介质(通常是内存),减少对慢速存储(数据库、远程服务)的访问。但策略选错,轻则缓存命中率低下,重则引发雪崩。
过期策略:TTL 不是设得越长越好
最常见的坑是随意给缓存设置一个很长的 TTL(比如 24 小时)。这会导致“缓存与源数据不一致”的幽灵问题。用户明明改了头像,三天后还在显示旧图。正确的做法是按照业务容忍度来设定 TTL。比如用户基本信息可以缓存 5 分钟,但库存信息最好不超过 10 秒。
如果一定要长缓存,请搭配“主动失效”机制:更新数据库时,同时删除或更新缓存。但要注意,删除缓存前先校验数据版本号,避免并发写入造成脏数据。
淘汰策略:LRU 不是银弹
当缓存空间满时,需要淘汰旧数据。大多数缓存组件默认用 LRU(最近最少使用)。但 LRU 在面对“突发热点访问”时非常脆弱——一个非热门的 key 被频繁访问几分钟,就会把真正的热点挤出去。
实战中,我会根据数据访问模式混合使用策略:对于有明显冷热分层的数据(如新闻头条),使用 LFU(最不经常使用);对于批量扫描型数据(如定时任务生成的报表),使用 FIFO(先进先出)反而更合适。Redis 的allkeys-lru和volatile-lru要区分好,不要对所有 key 一刀切。
缓存穿透、击穿、雪崩:三个兄弟一个比一个狠
这三个名词每个后端面试都会问,但很少有人讲清它们的实战解法。
穿透:查一个不存在的东西
客户端疯狂请求一个不存在的数据(比如恶意攻击用不存在的 ID),缓存查不到就穿透到数据库,数据库也查不到,流量直接干掉你的 DB。
布隆过滤器是分布式场景下的标准答案。在启动时把所有有效 ID 预加载进布隆过滤器(或者用 Redis 的 bitmap 实现),请求进来先判断 ID 是否存在,不存在直接返回失败。注意布隆过滤器有误判率,但误判只会让请求打到数据库,不会漏掉数据。
另一种简单粗暴的办法:对空结果也缓存。比如查询用户返回 null,就把这个 key 缓存 30 秒。缺点是会缓存大量无效 key,所以必须配合给每个 key 加一个固定的 TTL。
击穿:热点 key 过期瞬间
一个超高并发访问的 key 突然过期,无数请求同时穿透到数据库,瞬间压垮 MySQL。
解决方案:互斥锁。当缓存 miss 时,不是所有线程都去查 DB,而是只允许一个线程去查 DB 并重建缓存,其他线程等待。实现可以用 Redis 的 SETNX 命令,但要注意死锁问题(加超时时间,或者用 Redisson 的分布式锁)。
另一个大招:热点 key 永不过期(逻辑过期)。缓存里不设 TTL,而是存一个逻辑过期时间字段。后台异步刷新缓存,前台命中时判断逻辑时间是否过期,过期则返回旧数据的同时触发异步更新。这样永远不会出现缓存空窗期。
雪崩:大量缓存同时过期
比击穿更可怕——大批 key 在同一时刻过期,导致大批流量涌向 DB。比如你设置所有缓存 TTL 都是 1 小时,整点一到,服务器集体“裸奔”。
解决:过期时间加随机偏移。在基础 TTL 上加一个 [-30s, 30s] 的随机数,避免集体过期。同时,服务降级也是必备:如果数据库压力过大,直接返回降级数据(比如从静态文件或本地缓存读取)。
双写一致性:缓存与数据库的世纪难题
读场景很好解决:先读缓存,miss 后读 DB 再写缓存。但写场景就复杂了:更新数据库后,缓存是更新还是删除?如果并发高,很可能出现“先后写”导致缓存里还是老数据。
推荐方案:先更新数据库,后删除缓存
网上很多文章争论先删缓存还是先更新数据库。我推荐先更新数据库,后删除缓存,因为数据库是最终权威。为什么?如果先删缓存,再更新数据库,在删除缓存和更新数据库之间如果有另一个线程读到缓存 miss 并查询 DB(还没更新),就会把旧数据写回缓存,导致永久不一致。
而先更新数据库后,再删除缓存:如果删除失败,下次读缓存会 miss,重新从 DB 读回新数据。唯一的代价是删除失败的那一小段时间会有不一致,但可以靠延迟双删(先删缓存,更新 DB,等几百毫秒再删一次)或其他补偿机制兜底。
极高一致性场景:读时校验版本号
对于金融、库存等要求强一致的数据,可以引入版本号。缓存中存一个 version,DB 每更新一次 version+1,每次读缓存时比较版本,不一致则淘汰缓存并重新拉取。虽然复杂,但能保证最终一致。
实战中的缓存踩坑与优化
热点 Key 的发现与处理
热点 key 是缓存系统最大的敌人。一个 key 的 QPS 可能达到几十万,导致 Redis 实例 CPU 飙升。如何发现?事前根据业务经验预估(比如秒杀商品 ID、热门新闻 ID),事后在 Redis 监控中观察 hotkeys(Redis 4.0 以上有hotkeys命令)。
处理手段:
本地缓存+分布式缓存:在应用层使用 Guava Cache 或 Caffeine 做一级缓存,减轻 Redis 压力。
key 打散:比如把热点 key 拆成多个副本,如 key:0、key:1……请求进来时随机选择一个副本,命中概率不变,但每个副本的负载降低。
缓存空间膨胀与内存碎片
缓存数据越来越多,内存不够怎么办?不要指望 TTL 自动回收所有数据。我见过一个项目缓存了全量用户信息,每个 key 10KB,1000万用户就是 100GB,远超 Redis 内存。解决方案:只缓存高频访问的字段,比如用户昵称、头像 URL,而不是整个用户对象。用 Protobuf 或 MessagePack 压缩序列化大小。
另一点:Redis 内存碎片严重时,及时执行MEMORY PURGE或重启(生产谨慎)。更优雅的方式是设置activedefrag yes自动整理。
缓存穿透的变种:批量查询
如果你用mget或批量命令查询多个 key,其中某些 key 不存在,依然可能引起穿透。处理方案:在批量查询时先过滤掉空 key 的集合。或者将批量查询拆成小批量,逐个判断是否在布隆过滤器中。
分布式缓存的高可用与监控
集群模式选型
Redis 主流模式有:主从、Sentinel、Cluster。对于缓存场景,Cluster 是最推荐的,因为它天然支持分片和数据自动迁移。但要注意:Cluster 不支持多 key 操作(如跨 slot 的 mset),业务设计时要避免。
如果不想引入 Redis 的复杂性,可以使用一致性哈希算法在客户端做分片(如 Jedis ShardedJedis)。但客户端分片缺乏自动故障转移,需要配合哨兵或自己做巡检。
缓存降级与熔断
当缓存服务(Redis)宕机时,不能让系统直接挂掉。设计原则:缓存不可用时,所有请求直接降级为读 DB,但需要加上限流保护。比如用 Hystrix 或 Sentinel 对 DB 访问做熔断,避免雪崩。
另外,缓存最好多级部署:本机内存(Caffeine)+ 分布式缓存(Redis)+ 数据库。当 Redis 挂了,自动降级到本地缓存(虽然更新慢但至少可用)。
监控指标必须盯
命中率:低于 90% 就要排查是否 TTL 太短或热点 key 分散。
缓存键数量:避免无限制增长,设置最大键数报警。
内存使用率:超过 80% 就要考虑扩容或淘汰。
慢查询:Redis 的慢查询日志能发现大 key 或复杂操作。
最后的心法:缓存是系统底线,而非万能膏药
每次有人问我“系统响应慢,加缓存能解决吗?”,我都会反问:缓存只能加速读取,不能优化慢查询和烂代码。如果数据库查询本身是select from 全表,加缓存只是延迟死亡;如果业务逻辑有嵌套 N+1 次调用,缓存也无法拯救架构。
最好的缓存策略,是让大多数请求在缓存层就被优雅地终结。你有责任让缓存命中率稳定在 95% 以上,让数据库只做它该做的事——持久化与事务。
别等到凌晨两点被报警电话惊醒,才想起重构缓存策略。现在就去检查你的缓存代码,看看 TTL 是不是合理,有没有防穿透机制,热点 key 是否被保护。你的系统能在缓存失效时优雅降级吗?如果不能,这就是下一个事故预告单。