☰
Redis 入门(五):过期淘汰策略与缓存穿透、击穿、雪崩
2026/10/8 2:18:28 网站建设 项目流程
个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <

Redis 入门(五):过期淘汰策略与缓存穿透、击穿、雪崩

文章目录

  • Redis 入门(五):过期淘汰策略与缓存穿透、击穿、雪崩
    • 一、过期策略:key 到底是怎么没的
      • 1.1 设置与查询过期时间
      • 1.2 惰性删除 + 定期删除
    • 二、内存淘汰:内存满了丢谁
      • 2.1 maxmemory 与 8 种淘汰策略
      • 2.2 怎么选,以及打满后的现象
    • 三、缓存穿透:查一个根本不存在的数据
    • 四、缓存击穿:一个热点 key 的失效瞬间
    • 五、缓存雪崩:一片 key 同时倒下
    • 六、顺带一提:缓存与数据库的一致性
    • 七、一张表记住三个问题的区别
    • 小结

上一篇我们把数据落到了磁盘上,重启之后数据还在,这解决的是"存得住"的问题。可缓存真正难的地方,从来不是存,而是怎么用好:数据什么时候该消失、内存满了该丢谁、查询打空了会发生什么。这一篇我们把过期机制、内存淘汰和缓存三大经典难题一次性收掉,也算给整个系列收个尾。

一、过期策略:key 到底是怎么没的

1.1 设置与查询过期时间

给 key 挂上生存时间,是缓存最基本的操作:

SET user:1001"tom"EX60# 写入时直接带 60 秒过期EXPIRE user:10013600# 秒级过期PEXPIRE user:100130000# 毫秒级过期TTL user:1001# 返回剩余秒数:-1 表示永不过期,-2 表示 key 不存在PTTL user:1001# 毫秒精度的剩余时间PERSIST user:1001# 摘掉过期时间,变回永久 key

几条容易踩的坑:EXPIRE用在已有过期时间的 key 上是重置而非叠加;SET一个已存在的 key 却不带EX,会把原来的过期时间抹掉;PERSIST返回 0 说明这个 key 本来就没有 TTL。

1.2 惰性删除 + 定期删除

Redis 不给每个 key 配定时器——百万级 key 就是百万级定时器,CPU 扛不住。它靠两条路配合:

惰性删除(lazy expiration):过期了也不主动清,等你访问时判断一下,过期就顺手删掉并返回空。零额外开销,但没人访问的过期 key 会一直占着内存。

定期删除(active expiration):默认每秒跑 10 次(hz配置),每次从设了过期时间的 key 里随机抽一批检查并删除;若这批里过期 key 占比超过 25%,就再来一轮。注意它是抽样而非全量遍历,所以过期 key 不保证被及时清理,只保证内存不会被它们吃光。

两条路的结果是:过期 key 在逻辑上一定查不到,但它占的内存可能过一会儿才真正还给系统。

二、内存淘汰:内存满了丢谁

2.1 maxmemory 与 8 种淘汰策略

过期策略管的是"有 TTL 的 key",可要是写入的 key 大多不带过期时间呢?内存该满还是满。这时就轮到淘汰策略出场:

# redis.conf maxmemory 2gb maxmemory-policy allkeys-lru # LRU/LFU 都是抽样近似实现,样本数越大越准、越耗 CPU,默认 5 maxmemory-samples 5

8 种策略按"淘汰范围"和"淘汰算法"两个维度组合,记住这个分类就不会乱:

策略候选范围淘汰依据
noeviction不淘汰写入直接报错
allkeys-lru所有 key最久未被访问
allkeys-lfu所有 key访问频率最低
allkeys-random所有 key随机
volatile-lru仅带 TTL 的 key最久未被访问
volatile-lfu仅带 TTL 的 key访问频率最低
volatile-random仅带 TTL 的 key随机
volatile-ttl仅带 TTL 的 key剩余存活时间最短

LRU 与 LFU 的区别一句话就能说清:LRU 看"上一次什么时候被访问",LFU 看"被访问了多少次"。半夜被批量扫过一次的冷数据,在 LRU 里可能刚好把真热点挤出去;LFU 给每个 key 记访问计数(默认按分钟衰减,lfu-decay-time可调),更留得住长期热点。代价是它对刚写入的新 key 不友好,所以有初始计数和概率性增长来缓解。

2.2 怎么选,以及打满后的现象

选择逻辑其实很朴素:

  • 缓存场景里 key 基本都带 TTL,用allkeys-lru最稳,也是绝大多数业务的第一选择;
  • 有明显热点且热点稳定(比如首页商品、配置类数据),allkeys-lfu命中率通常更高;
  • 缓存和持久数据混在同一个实例里,必须用volatile-*,否则可能把不该丢的数据淘汰掉;
  • 数据绝对不能丢、宁可写失败也不能被淘汰,才用noeviction。

如果内存打满且策略是noeviction,写入会直接失败,报错长这样:

127.0.0.1:6379>SET foo bar(error)OOMcommandnot allowed when used memory>'maxmemory'.

注意读请求不受影响,只有写命令被拒绝。线上出现这个报错的排查顺序建议是:

  1. INFO memory看used_memory_human和maxmemory_human,确认是不是真的到顶;
  2. CONFIG GET maxmemory-policy确认策略是不是被误设成了noeviction;
  3. redis-cli --bigkeys找出异常大 key,多数是某个 List/Hash 被堆到了几十 MB;
  4. 检查是否大量 key 没设过期时间,本该自动释放的数据变成了常驻;
  5. 都排除之后,才是扩容——加内存或上集群分片。

三、缓存穿透:查一个根本不存在的数据

现象:请求带着一个数据库里压根不存在的 ID(比如id=-1),缓存里没有,数据库里也没有,于是每次请求都稳稳落到 DB 上。

原因:缓存生效的前提是"查到了才写缓存"。查不到就没东西可写,下次同样的请求继续穿过去,缓存形同虚设。

后果:关键不是流量大小,而是命中率恒为 0。偶发几次无所谓,一旦被脚本刷起来,DB 会被这一条 SQL 打满。它和击穿最大的区别是:击穿的数据 DB 里有,穿透的数据 DB 里没有。

解决方案(按优先级):

  1. 最外层参数校验——成本最低、见效最快。商品 ID 必须是正整数、用户 ID 必须符合编码规则、页码不能为负,不合法直接返回。很多攻击在这一层就没了。
  2. 缓存空值 + 短过期——查完 DB 为空时,往缓存写一个特殊标记,并设置较短 TTL(比如 60 秒),下次同样的请求直接被缓存挡住:
publicUsergetUser(Longid){Stringkey="user:"+id;Stringcached=redis.get(key);if(cached!=null){// 约定 "__NULL__" 表示这个 ID 确实不存在return"__NULL__".equals(cached)?null:JSON.parseObject(cached,User.class);}Useruser=userMapper.selectById(id);if(user==null){// 空值也缓存,但过期时间要短,避免长期占用内存和数据不一致redis.set(key,"__NULL__",60,TimeUnit.SECONDS);returnnull;}redis.set(key,JSON.toJSONString(user),30,TimeUnit.MINUTES);returnuser;}

代价是两点:被大量不同 ID 穿透时,缓存里会塞满空值,占内存;TTL 内如果 DB 真的插入了这条数据,会短暂读到"不存在"。

  1. 布隆过滤器——把所有可能存在的 key提前放进过滤器,请求进来先问一句,过滤器说不存在就直接返回,连 Redis 都不用查。

布隆过滤器的结构很简单:一个很长的 bit 数组,加上 k 个哈希函数。写入时,元素经过 k 个哈希算出 k 个下标,把这些位都置 1;查询时同样算出 k 个下标,只要有一位是 0,就说明该元素一定不在集合里;如果全是 1,只能说可能存在。

为什么"存在"不敢打包票?因为不同元素哈希后可能落到同一批位上,别的元素替你把这几位点亮了,这就是误判(假阳性)。而"假阴性"永不会出现——真正写入过的元素,它对应的位不可能被别人清掉。

误判率是可以控制的:bit 数组越长、哈希函数个数越合适,冲突概率越低,但内存和 CPU 开销也随之上升。RedisBloom 默认误判率 0.01、默认容量 100;容量设小了,误判率会随着元素增多而上升,扩容则是再挂一层子过滤器(默认 2 倍),查询时逐层检查。

实践中最关键的取舍是:布隆过滤器只支持添加、不支持删除。所以别把它用在"记录不存在的 ID"上(这些 ID 后续可能被真实创建),更稳的做法是用它兜住"数据库里已有的合法 ID 全集合",不在集合里的直接拦掉。

# 初始化:期望误判率 0.001,容量 100 万BF.RESERVE user:bloom0.0011000000BF.ADD user:bloom1001BF.MEXISTS user:bloom10011002# 1 表示可能存在,0 表示一定不存在

四、缓存击穿:一个热点 key 的失效瞬间

现象:某个高频访问的热点 key(秒杀商品、首页 banner)恰好到期,失效的这一瞬间,成千上万个并发请求同时发现缓存空了,一起涌向数据库。

原因:缓存"过期即删除",删除之后到新数据写回之间存在一个窗口。窗口可能只有几十毫秒,但热点 QPS 足够高,挤进来的请求量很可观。

后果:DB 瞬时压力暴涨几十倍,可能直接被打挂。特征是单点、瞬时——只有一个 key,持续很短,但破坏力集中。

解决方案(按优先级):

  1. 互斥锁 / 单飞(singleflight)——只放一个请求去查 DB 并回填缓存,其余请求短暂等待后重试。锁要按 key 维度加,不能全站共用一把锁,否则不同 key 之间会互相阻塞:
publicStringgetHotData(Stringkey){Stringvalue=redis.get(key);if(value!=null)returnvalue;StringlockKey="lock:"+key;// SET NX PX:抢到锁的线程才有资格回源booleanlocked=redis.set(lockKey,"1","NX","PX",3000);if(locked){try{value=loadFromDb(key);// 加一点随机,避免同一批 key 再次同时过期redis.set(key,value,300+ThreadLocalRandom.current().nextInt(60),TimeUnit.SECONDS);returnvalue;}finally{redis.delete(lockKey);}}// 没抢到锁:短暂等待后重试读缓存,不要把压力转到 DBsleep(50);returnredis.get(key);}

这里锁的过期时间必须大于回源耗时,否则第一个线程还没写完缓存锁就没了,锁形同虚设。

  1. 逻辑过期,物理不设 TTL——缓存里存的 value 自己带一个expireAt字段,Redis 层面永不过期。读的时候发现逻辑上过期了,就返回旧数据,同时异步起一个线程去刷新。好处是任何请求都不会被阻塞,代价是有一小段时间读的是旧值,适合能接受最终一致的场景。要注意必须有兜底:异步刷新失败要有重试和告警,别让脏数据一直留在里面。

  2. 热点数据预热——在流量高峰到来之前(活动开始前、大促前几分钟)主动把热点数据加载进缓存,并把 TTL 设得长一些,从源头上减少"刚好在高峰期失效"的概率。

五、缓存雪崩:一片 key 同时倒下

现象:不是某一个 key,而是一大批 key 在同一时刻集体失效,请求成片涌向 DB;或者更极端——Redis 实例宕机,全部流量一次性压到 DB 上。

原因:两类。一是设计问题:批量写入时用了统一过期时间,比如凌晨预热时全设 30 分钟,30 分钟后它们集体消失。二是可用性问题:单点挂掉、网络抖动、主从切换期间不可用。

后果:可以理解为"缓存击穿 × N 倍"。DB 面对的是全站流量的原始压力,通常撑不过几秒,随后服务不可用,甚至拖垮整条链路。

解决方案(按优先级):

  1. 过期时间加随机值——成本最低、收益最大的一招,目的就是让失效时刻散开:
// 基础 30 分钟,再叠加 0~5 分钟的随机抖动intttl=1800+ThreadLocalRandom.current().nextInt(300);redis.set(key,value,ttl,TimeUnit.SECONDS);
  1. 多级缓存——本地缓存(Caffeine)挡在前面,Redis 作为二级。Redis 整体不可用时,本地缓存还能扛住热点读请求,给后端争取恢复时间。适合数据变更不频繁、能容忍秒级不一致的场景。

  2. 限流与降级——即使缓存全挂,也要保证 DB 不被瞬间打死。入口按 QPS 限流,超出部分快速失败;核心接口降级返回兜底数据(默认值、静态页、上一版快照),而不是把请求全放到 DB。

  3. 集群高可用——主从 + 哨兵或 Cluster 分片,配合合理的故障转移,降低"整台 Redis 不可用"的概率。这是架构层面的兜底,重投入,所以排在几乎零成本的前三项之后。

另外别忘了:maxmemory打满触发大量淘汰,效果上也是一种"大批 key 同时消失",所以淘汰策略要和过期策略一起设计。

六、顺带一提:缓存与数据库的一致性

缓存和 DB 双写,必然遇到一致性问题。做到"绝对实时一致"基本不现实,工程上追求的是最终一致 + 不一致窗口尽量短。

先更新 DB,再删除缓存(Cache Aside):推荐的默认做法。为什么不先删缓存再更新 DB?因为中间会有并发读把旧值重新写回缓存,脏数据留得更久。为什么删缓存而不是更新缓存?删除更简单,也能避免并发写入导致的乱序覆盖。

但它仍有漏洞:读请求在缓存失效后查到旧值,还没来得及写回,写请求已经完成"更新 DB + 删缓存",随后读请求把旧值写了进去。这个窗口比"先删缓存"小得多,但确实存在。

延迟双删:更新 DB 之后删一次缓存,隔一小段时间(比如 500ms,大于一次读请求的耗时)再删一次,把上面那个窗口里可能被写回的旧值再清一遍。

redis.delete(key);// 先删一次userMapper.update(user);// 更新数据库Thread.sleep(500);// 等待可能存在的并发读回填完成redis.delete(key);// 再删一次,清掉窗口期脏数据

最终一致的兜底:给缓存设置合理的过期时间,让脏数据最多存活一个 TTL;更严格的做法是订阅 DB 的 binlog(Canal 之类)异步刷新缓存,把一致性收敛交给消息驱动,从而摆脱"双写顺序"的困扰。

七、一张表记住三个问题的区别

维度缓存穿透缓存击穿缓存雪崩
数据在 DB 中不存在存在存在
影响范围单个/多个不存在的 key单个热点 key大批 key 或整个实例
时间特征持续性,命中率恒 0瞬时,几百毫秒内瞬时到持续,取决于故障
根本原因查询结果为空无法缓存热点 key 到期瞬间并发过期时间集中 / Redis 宕机
首选方案参数校验 + 缓存空值 + 布隆过滤器互斥锁 / 逻辑过期 + 预热过期时间加随机 + 多级缓存 + 限流降级
兜底手段布隆过滤器前置拦截逻辑过期异步刷新集群高可用 + 服务降级

小结

到这里,系列五篇走完了:从 Redis 是什么、五大类型怎么用,到 Spring Boot 整合、数据持久化,最后是过期淘汰和缓存三大难题。回头会发现一个规律——Redis 用起来简单,用好全靠对"边界情况"的预判:key 会过期、内存会满、缓存会失效、DB 会挂。功力不在于会敲几条命令,而在于提前想到这些情况并给出兜底方案。

如果还想继续往下走,建议按这个顺序深入:

  1. 主从复制、哨兵与 Cluster 分片——理解数据怎么在多个节点之间分布和故障转移,这是高可用的基础;
  2. 分布式锁的正确实现——SET NX PX加值校验、Lua 脚本保证原子释放、Redlock 的争议,以及看门狗续期;
  3. 性能调优与线上排查——慢查询日志、大 key 与热 key 治理、Pipeline 与批量操作、内存碎片率;
  4. 实战场景——延时队列、排行榜、限流器、分布式 ID,把数据结构用在对的地方。

缓存是后端性能的第一道防线,也是最容易被低估的一环。把这五篇的内容吃透,再去面对线上问题,心里会有底得多。

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

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

立即咨询