ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

Redis 入门(五):过期淘汰策略与缓存穿透、击穿、雪崩 个人主页 我不会起名字322 欢迎各位大佬莅临其他栏目 技术栈学习笔记 其他栏目 力扣Hot100题目解析 其他栏目 Go项目学习笔记 Redis 入门五过期淘汰策略与缓存穿透、击穿、雪崩文章目录Redis 入门五过期淘汰策略与缓存穿透、击穿、雪崩一、过期策略key 到底是怎么没的1.1 设置与查询过期时间1.2 惰性删除 定期删除二、内存淘汰内存满了丢谁2.1 maxmemory 与 8 种淘汰策略2.2 怎么选以及打满后的现象三、缓存穿透查一个根本不存在的数据四、缓存击穿一个热点 key 的失效瞬间五、缓存雪崩一片 key 同时倒下六、顺带一提缓存与数据库的一致性七、一张表记住三个问题的区别小结上一篇我们把数据落到了磁盘上重启之后数据还在这解决的是存得住的问题。可缓存真正难的地方从来不是存而是怎么用好数据什么时候该消失、内存满了该丢谁、查询打空了会发生什么。这一篇我们把过期机制、内存淘汰和缓存三大经典难题一次性收掉也算给整个系列收个尾。一、过期策略key 到底是怎么没的1.1 设置与查询过期时间给 key 挂上生存时间是缓存最基本的操作SET user:1001tomEX60# 写入时直接带 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 58 种策略按淘汰范围和淘汰算法两个维度组合记住这个分类就不会乱策略候选范围淘汰依据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:6379SET foo bar(error)OOMcommandnot allowed when used memorymaxmemory.注意读请求不受影响只有写命令被拒绝。线上出现这个报错的排查顺序建议是INFO memory看used_memory_human和maxmemory_human确认是不是真的到顶CONFIG GET maxmemory-policy确认策略是不是被误设成了noevictionredis-cli --bigkeys找出异常大 key多数是某个 List/Hash 被堆到了几十 MB检查是否大量 key 没设过期时间本该自动释放的数据变成了常驻都排除之后才是扩容——加内存或上集群分片。三、缓存穿透查一个根本不存在的数据现象请求带着一个数据库里压根不存在的 ID比如id-1缓存里没有数据库里也没有于是每次请求都稳稳落到 DB 上。原因缓存生效的前提是查到了才写缓存。查不到就没东西可写下次同样的请求继续穿过去缓存形同虚设。后果关键不是流量大小而是命中率恒为 0。偶发几次无所谓一旦被脚本刷起来DB 会被这一条 SQL 打满。它和击穿最大的区别是击穿的数据 DB 里有穿透的数据 DB 里没有。解决方案按优先级最外层参数校验——成本最低、见效最快。商品 ID 必须是正整数、用户 ID 必须符合编码规则、页码不能为负不合法直接返回。很多攻击在这一层就没了。缓存空值 短过期——查完 DB 为空时往缓存写一个特殊标记并设置较短 TTL比如 60 秒下次同样的请求直接被缓存挡住publicUsergetUser(Longid){Stringkeyuser:id;Stringcachedredis.get(key);if(cached!null){// 约定 __NULL__ 表示这个 ID 确实不存在return__NULL__.equals(cached)?null:JSON.parseObject(cached,User.class);}UseruseruserMapper.selectById(id);if(usernull){// 空值也缓存但过期时间要短避免长期占用内存和数据不一致redis.set(key,__NULL__,60,TimeUnit.SECONDS);returnnull;}redis.set(key,JSON.toJSONString(user),30,TimeUnit.MINUTES);returnuser;}代价是两点被大量不同 ID 穿透时缓存里会塞满空值占内存TTL 内如果 DB 真的插入了这条数据会短暂读到不存在。布隆过滤器——把所有可能存在的 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持续很短但破坏力集中。解决方案按优先级互斥锁 / 单飞singleflight——只放一个请求去查 DB 并回填缓存其余请求短暂等待后重试。锁要按 key 维度加不能全站共用一把锁否则不同 key 之间会互相阻塞publicStringgetHotData(Stringkey){Stringvalueredis.get(key);if(value!null)returnvalue;StringlockKeylock:key;// SET NX PX抢到锁的线程才有资格回源booleanlockedredis.set(lockKey,1,NX,PX,3000);if(locked){try{valueloadFromDb(key);// 加一点随机避免同一批 key 再次同时过期redis.set(key,value,300ThreadLocalRandom.current().nextInt(60),TimeUnit.SECONDS);returnvalue;}finally{redis.delete(lockKey);}}// 没抢到锁短暂等待后重试读缓存不要把压力转到 DBsleep(50);returnredis.get(key);}这里锁的过期时间必须大于回源耗时否则第一个线程还没写完缓存锁就没了锁形同虚设。逻辑过期物理不设 TTL——缓存里存的 value 自己带一个expireAt字段Redis 层面永不过期。读的时候发现逻辑上过期了就返回旧数据同时异步起一个线程去刷新。好处是任何请求都不会被阻塞代价是有一小段时间读的是旧值适合能接受最终一致的场景。要注意必须有兜底异步刷新失败要有重试和告警别让脏数据一直留在里面。热点数据预热——在流量高峰到来之前活动开始前、大促前几分钟主动把热点数据加载进缓存并把 TTL 设得长一些从源头上减少刚好在高峰期失效的概率。五、缓存雪崩一片 key 同时倒下现象不是某一个 key而是一大批 key 在同一时刻集体失效请求成片涌向 DB或者更极端——Redis 实例宕机全部流量一次性压到 DB 上。原因两类。一是设计问题批量写入时用了统一过期时间比如凌晨预热时全设 30 分钟30 分钟后它们集体消失。二是可用性问题单点挂掉、网络抖动、主从切换期间不可用。后果可以理解为缓存击穿 × N 倍。DB 面对的是全站流量的原始压力通常撑不过几秒随后服务不可用甚至拖垮整条链路。解决方案按优先级过期时间加随机值——成本最低、收益最大的一招目的就是让失效时刻散开// 基础 30 分钟再叠加 0~5 分钟的随机抖动intttl1800ThreadLocalRandom.current().nextInt(300);redis.set(key,value,ttl,TimeUnit.SECONDS);多级缓存——本地缓存Caffeine挡在前面Redis 作为二级。Redis 整体不可用时本地缓存还能扛住热点读请求给后端争取恢复时间。适合数据变更不频繁、能容忍秒级不一致的场景。限流与降级——即使缓存全挂也要保证 DB 不被瞬间打死。入口按 QPS 限流超出部分快速失败核心接口降级返回兜底数据默认值、静态页、上一版快照而不是把请求全放到 DB。集群高可用——主从 哨兵或 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 的 binlogCanal 之类异步刷新缓存把一致性收敛交给消息驱动从而摆脱双写顺序的困扰。七、一张表记住三个问题的区别维度缓存穿透缓存击穿缓存雪崩数据在 DB 中不存在存在存在影响范围单个/多个不存在的 key单个热点 key大批 key 或整个实例时间特征持续性命中率恒 0瞬时几百毫秒内瞬时到持续取决于故障根本原因查询结果为空无法缓存热点 key 到期瞬间并发过期时间集中 / Redis 宕机首选方案参数校验 缓存空值 布隆过滤器互斥锁 / 逻辑过期 预热过期时间加随机 多级缓存 限流降级兜底手段布隆过滤器前置拦截逻辑过期异步刷新集群高可用 服务降级小结到这里系列五篇走完了从 Redis 是什么、五大类型怎么用到 Spring Boot 整合、数据持久化最后是过期淘汰和缓存三大难题。回头会发现一个规律——Redis 用起来简单用好全靠对边界情况的预判key 会过期、内存会满、缓存会失效、DB 会挂。功力不在于会敲几条命令而在于提前想到这些情况并给出兜底方案。如果还想继续往下走建议按这个顺序深入主从复制、哨兵与 Cluster 分片——理解数据怎么在多个节点之间分布和故障转移这是高可用的基础分布式锁的正确实现——SET NX PX加值校验、Lua 脚本保证原子释放、Redlock 的争议以及看门狗续期性能调优与线上排查——慢查询日志、大 key 与热 key 治理、Pipeline 与批量操作、内存碎片率实战场景——延时队列、排行榜、限流器、分布式 ID把数据结构用在对的地方。缓存是后端性能的第一道防线也是最容易被低估的一环。把这五篇的内容吃透再去面对线上问题心里会有底得多。
返回列表