☰
Redis 缓存三大经典问题:从原理到代码,一次讲清楚
2026/10/1 20:05:44 网站建设 项目流程

去年双十一前一周,我们有个商品详情页接口 P99 从 40ms 涨到了 3.2s。

监控上看,QPS 没涨,Redis 也没挂,CPU 也不高。但数据库连接池被打满了,慢查询日志里全是同一条 SQL——查一个已经不存在的 SKU。

排查到最后,原因很朴素:**一个下架很久的商品,被某个爬虫反复抓,缓存里永远 miss,请求全部落到 DB。**而那个 SKU 在 DB 里根本不存在,所以每次都是全表扫描级别的代价。

那天我学到一件事:**缓存的三种“失效”,名字听起来像兄弟,成因和治法完全不同。**混着讲的人很多,但线上出问题的时候,你只能选一种方案,选错就是二次事故。

这篇文章把这三个问题拆开,每个都配能跑的代码和明确的适用边界。你可以把它当排查清单,也可以当面试前的复习提纲——但我更希望你永远只用上第一部分。

一、先把概念钉死:它们不是同一件事

这是最容易糊的地方。很多人把三个词当同义词用,其实它们的答案完全不同。

触发条件打的是什么典型特征
缓存穿透请求的数据根本不存在(缓存没有,DB 也没有)每次都穿透到 DB流量可能不大,但每次都是无效查询;命中率长期偏低
缓存击穿单个热点 key 过期的瞬间,大量并发涌入一瞬间打爆 DB 的某一行/某一张表尖峰型,持续时间短(秒级),日志里是同一个 key
缓存雪崩大量 key 同时过期,或Redis 实例整体不可用DB 整体被压垮大面积失败,伴随超时、熔断、级联故障

一句话记法:

  • 穿透 =查不到(数据不存在)
  • 击穿 =来不及(一个 key 刚过期)
  • 雪崩 =一起过期 / 一起挂了

分清这个,后面的方案才对得上号。拿防雪崩的方案去治穿透,纯属浪费。

二、缓存穿透:不存在的数据,才是最大的漏洞

成因:请求的 key 在缓存和数据库都不存在。于是每次都是「miss → 查 DB → 回写失败(因为没数据可写)→ miss」的死循环。如果是恶意构造的不存在 ID,等于用最便宜的请求换你最贵的资源。

四个解法,按性价比排序:

1. 接口层先拦一层(最便宜,也最容易被忘)

if(skuId==null||skuId<=0){returnResult.fail("参数非法");}// 更狠一点:ID 雪花算法自带趋势性,非单调的随机长串直接拒掉

很多穿透流量连 DB 都不用见,在 Controller 层就该死掉。参数校验、登录态校验、频率限制,这三样做好能挡掉一大半。

2. 缓存空值(最简单,但有坑)

publicSkugetSku(LongskuId){Stringkey="sku:"+skuId;Stringjson=redisTemplate.opsForValue().get(key);// 约定一个特殊值表示"确实不存在"if("NULL".equals(json)){returnnull;}if(json!=null){returnJSON.parseObject(json,Sku.class);}Skusku=skuMapper.selectById(skuId);if(sku==null){// 关键:必须给 TTL,且要短redisTemplate.opsForValue().set(key,"NULL",60,TimeUnit.SECONDS);returnnull;}redisTemplate.opsForValue().set(key,JSON.toJSONString(sku),30,TimeUnit.MINUTES);returnsku;}

**坑在哪?**如果攻击者枚举海量不存在的 key(比如sku:1到sku:999999),你的 Redis 会被一堆"NULL"占满——这叫缓存污染,相当于自己给自己做了个 DoS。

所以空值缓存必须配两样东西:短 TTL(几十秒到几分钟)+下面第 3 条。

3. 布隆过滤器(治本,但要接受它的性格)

思路:在 Redis 前面再放一个“成员判定”结构。key 进来先问布隆过滤器,说不在,就直接返回,连 Redis 都不用问。

// Redisson 的布隆过滤器RBloomFilter<Long>bloomFilter=redissonClient.getBloomFilter("sku:bloom");// 服务启动时初始化:预期元素 100w,误判率 1%bloomFilter.tryInit(1000000L,0.01);// 全量 SKU 灌进去(实际项目建议用脚本或异步任务,别在主流程里堵着)for(Longid:allSkuIds){bloomFilter.add(id);}

调用侧:

if(!bloomFilter.contains(skuId)){// 一定不存在(误判率为 0 的"不存在"判断)returnnull;}

用布隆过滤器必须记住三件事:

  • 它只有假阳性,没有假阴性。它说“不在”,那就一定不在;它说“在”,有可能不在(误判率由fpp控制)。所以它只能用来拦截,不能用来确认存在。
  • 元素删不掉。标准布隆过滤器不支持删除(计数布隆可以但有代价)。SKU 上下架、数据变更,意味着你要重建整个过滤器。工程上通常的做法是:准备两个过滤器交替切换(双缓冲),重建期间读旧的,建完切新的。
  • **初始化要离线做。**别在请求路径里 add,也别指望它实时准确。它保护的是“大概率存在的那部分空间”,不是精确集合。

4. 限流兜底

上面三道都漏了,还有最后一道:单 IP / 单用户 / 单 key 的 QPS 限制。Guava RateLimiter、Sentinel、Redis 滑动窗口都行。限流不是优化,是保险丝。

三、缓存击穿:热点 key 过期的那一秒

成因:某个极高并发的 key(比如首页爆款、秒杀商品、热点新闻)在某一时刻过期,几千个线程同时 miss,同时去打 DB。

注意和穿透的区别:**数据是存在的,只是缓存刚好没了。**所以空值缓存和布隆过滤器对它完全没用——布隆会说“在”,然后你照样要去查。

三个解法:

方案 A:互斥锁(强一致,有性能代价)

只让一个线程去查 DB,其他线程等着。

publicSkugetHotSku(LongskuId){Stringkey="sku:"+skuId;StringlockKey="lock:sku:"+skuId;Stringjson=redisTemplate.opsForValue().get(key);if(json!=null){returnJSON.parseObject(json,Sku.class);}// 只有一个线程拿到锁去重建Booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",10,TimeUnit.SECONDS);if(locked!=null&&locked){try{// 双重检查:等锁的线程醒来可能已经有值了json=redisTemplate.opsForValue().get(key);if(json!=null){returnJSON.parseObject(json,Sku.class);}Skusku=skuMapper.selectById(skuId);redisTemplate.opsForValue().set(key,JSON.toJSONString(sku),30,TimeUnit.MINUTES);returnsku;}finally{// 只删自己的锁,别误删别人的redisTemplate.delete(lockKey);}}else{// 没抢到锁:自旋重试 or 直接返回旧值(如果有)Thread.sleep(50);returngetHotSku(skuId);// 生产环境建议改成有限次自旋}}

要点:

  • setIfAbsent必须带过期时间,否则 DB 查询一旦异常,锁永不释放 =永久击穿。
  • 锁的粒度要按 key 分,一把大锁会把所有热点串行化。
  • 一定要双重检查,否则排队的那些线程醒来又会各自查一遍 DB。

方案 B:逻辑过期(高可用优先,牺牲一点一致性)

思路:Redis 里的数据永不过期,但在 value 里自己塞一个过期时间戳。读取时如果发现“逻辑过期”了,不阻塞当前请求,而是起一个异步线程去重建,本次先返回旧值。

@DataclassCacheObject<T>{privateTdata;privateLocalDateTimeexpireTime;// 逻辑过期时间}publicSkugetWithLogicalExpire(LongskuId){Stringkey="sku:"+skuId;CacheObject<Sku>co=getCacheObject(key);if(co==null){// 第一次:同步构建returnbuildAndSet(key,skuId);}if(LocalDateTime.now().isBefore(co.getExpireTime())){returnco.getData();// 没过期,直接返回}// 已过期:异步重建,本次返回旧值REBUILD_POOL.submit(()->buildAndSet(key,skuId));returnco.getData();}

优点:永远不会出现“所有线程同时等重建”的情况,可用性拉满。
代价:用户可能看到一小段时间的旧数据;线程池要隔离,否则重建任务堆积会把自己拖垮。

方案 C:热点数据永不过期 + 主动预热

对于真正的超级热点(比如大促主会场),最稳的办法是不设过期时间,靠发布系统或定时任务主动刷新。配合 CDN / 本地缓存(Caffeine)做二级缓存,DB 基本感知不到流量。

选型建议:能容忍短暂脏数据 → 方案 B/C;不能容忍(涉及金额、库存、权限)→ 方案 A。

四、缓存雪崩:大面积失效,或者缓存整体没了

两种形态,治法不同:

形态一:大量 key 同一时刻过期

最常见的原因是“批量设置相同 TTL”。比如凌晨 0 点跑批,把所有商品缓存刷新成 30 分钟,那么 0:30 那一刻就会集体失效。

解法:在基础 TTL 上加随机抖动。

// 基础 30 分钟 + 0~5 分钟随机抖动longttl=30*60+ThreadLocalRandom.current().nextInt(300);redisTemplate.opsForValue().set(key,value,ttl,TimeUnit.SECONDS);

别小看这个+ random。它把“同时失效”变成了“均匀分布”,成本是一行代码,收益是整晚的安稳。这是全文性价比最高的一行。

形态二:Redis 节点宕机 / 集群大面积不可用

这时候没有任何技巧能救你,只能靠架构:

  • 高可用:哨兵模式或 Cluster,主节点挂了能自动切。注意:主从异步复制下,主节点宕机那几秒写入的数据会丢,业务上要能接受。
  • 多级缓存:本地缓存(Caffeine)扛第一波。Redis 挂了,本地缓存还能顶住一部分读,给 DB 喘息空间。
  • 限流 + 降级:Sentinel / Hystrix 设定好阈值,超了就快速失败或返回默认值/静态兜底页。宁可让用户看到“暂时不可用”,也不要让 DB 跟着一起死——DB 一旦被打挂,恢复时间比 Redis 长一个数量级。
  • 持久化兜底:重要配置类数据可以落一份到本地文件或 DB,启动时预加载。

一个容易忽略的点:缓存重建风暴。

Redis 重启恢复后,一开始是空的,所有请求瞬间 miss,等于人工制造了一次雪崩。所以重启后第一件事不是放开流量,而是预热:挑核心 key 分批加载,或者用灰度方式逐步放量。

五、顺带说一句:缓存和数据库的一致性

聊缓存不提一致性是不完整的。这里只给结论,不展开(展开又是另一篇文章的长度):

  • 主流做法:先更新数据库,再删除缓存(Cache Aside Pattern)。为什么是删除而不是更新?因为更新可能有并发覆盖问题,而且有些缓存值是计算出来的。
  • **删除失败了怎么办?**重试队列 / binlog 订阅(Canal)做最终一致性补偿。
  • “延迟双删”有用,但不是银弹:它能缓解“更新 DB 后、删缓存前,有读请求把旧值写回缓存”的窗口问题,但第二次删除的时间间隔很难给对,且引入了新的复杂度。
  • 强一致场景不要赌缓存:余额、库存扣减、权限,直接走 DB + 分布式锁,或者用 Redis 做辅助校验但以 DB 为准。
六、一份可抄的排查清单

线上出现缓存相关慢查询,按这个顺序过:

  1. 看是不是同一个 key。日志里全是同一个 key → 击穿;全是不同的、且不存在的 key → 穿透;大面积、多 key 同时 → 雪崩。
  2. 看 Redis 命中率和连接数。命中率骤降 + 连接数正常 → 过期类问题;命中率骤降 + 连接数暴涨/超时 → Redis 本身有问题。
  3. 看过期时间分布。redis-cli --scan抽样看 TTL,如果一批 key 的剩余 TTL 几乎一样,就是批量设置没加抖动。
  4. 看 DB 慢查询。如果慢查询的条件是“查一个不存在的值”,基本可以确定是穿透。
  5. 看有没有新增活动/爬虫/定时任务。很多雪崩是运营活动 + 定时任务叠加出来的。
  6. 临时止血三板斧:限流 → 降级(返回兜底值/静态页)→ 手动预热核心 key。顺序别反,先保 DB 活着,再谈恢复。
写在最后

回到开头那个事故。

我们最后的处理是分三步:先在网关层对 SKU 查询加了频率限制(五分钟内止血),第二天上了空值缓存 + 短 TTL(一周内稳定),第三周上了布隆过滤器并改造了重建流程(长期方案)。

事后复盘时有人问:“为什么不一步到位直接上布隆?”

我的回答是:**线上问题的解法,优先级永远是「先活下来」>「再不出事」>「最后优雅」。**布隆过滤器要解决初始化、重建、误判、内存占用一堆问题,它在事故当晚是给不出来的。

这也算是我写这篇文章的一点私心:网上讲这三个概念的文章很多,大多把它们并列成三道背诵题。但在真实系统里,它们从来不是选择题,而是分层防御的不同位置——

  • 接口层拦住非法的(防穿透)
  • 布隆过滤器挡住不存在的(防穿透)
  • 互斥锁或逻辑过期管住过期的(防击穿)
  • 随机抖动打散集体的(防雪崩)
  • 限流降级托住所有防线都漏掉的(兜底)

每一层都不完美,但叠在一起,就足够稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询