ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

缓存穿透、击穿与雪崩:从原理到 Redis 工程实践的完整指南

缓存穿透、击穿与雪崩:从原理到 Redis 工程实践的完整指南

缓存这个话题,几乎每个做后端的人都会被问到几次。面试也好,线上故障也罢,翻来覆去就是那几个经典问题:缓存穿透、击穿、雪崩,再加上缓存和数据库的一致性。很多人能背出概念,但真到了线上环境,一遇到缓存失效导致数据库被打爆的场景,还是会手足无措。这篇内容我想抛开教科书式的定义,从实际踩坑和解决问题的角度,把这几类经典问题掰开揉碎聊一遍。这些问题看似基础,但每个背后都藏着不少设计上的取舍,适合刚接触缓存、或者已经在用 Redis 但被线上问题困扰过的开发者参考。

1. 缓存穿透:请求打到了不存在的数据上

1.1 穿透的本质与故障表现

缓存穿透说的是:请求查询的数据在缓存和数据库里都不存在,每次请求都绕过了缓存,直接打到数据库上。正常流程下,用户请求先查缓存,缓存没命中再查数据库,数据库有数据则回写缓存,下次请求直接命中。但如果被查询的是一个不存在的 ID,数据库查不到,缓存也不会写,于是每次请求都会重复经历“缓存未命中→数据库查询→返回空”这个完整链路。

我见过最典型的一次事故是这样的:某个对外接口接收用户传入的订单号,某天有一批脚本刷量请求,传入的全是随机拼接、根本不存在的订单号。正常情况下这个接口的缓存命中率在 95% 以上,而这批请求全部穿透,数据库瞬间扛不住,接口响应时间从 30ms 飙到了 3 秒以上,紧接着数据库连接池被打满,整个服务开始连环超时。

穿透的本质是请求绕过了缓存这层屏障,让数据库承受了本该由缓存挡住的压力。更麻烦的是,这种请求往往不是正常用户行为,而是恶意遍历、脚本攻击或者上游系统传入的脏数据。所以解决穿透,不只是加一层缓存那么简单,得从入口流量、缓存策略和数据校验三个层面同时想办法。

很多团队第一反应是“把不存在的 key 也缓存起来”,这确实有效,但要注意细节。对于查询结果为空的 key,可以把它缓存成一个特殊值,比如空字符串或者一个约定好的占位符,并且设置一个相对较短的过期时间,比如 5 到 10 分钟。这样做的好处是,短时间内相同的无效请求会命中这个空值缓存,不再穿透到数据库。坏处也很明显:如果有大量不同的无效 key 进来,缓存里会堆积海量的空值占位,白白浪费内存。

所以空值缓存通常会加一个限制条件:只有当某个 key 在短时间内被重复查询多次,才去缓存空值。比如用一个简单的计数结构记录每个 key 的查询次数,超过阈值再写入空值缓存。这个方案比无脑缓存所有空 key 要聪明一些,内存占用可控,也能防住大多数穿透场景。

1.2 布隆过滤器与前置拦截

另一个经典解法是布隆过滤器。布隆过滤器的核心思想是:用一个很小的位数组加上多个哈希函数,判断一个元素是否在集合中。它的特点是“判断不存在一定准确,判断存在可能有误判”。所以布隆过滤器特别适合用来挡穿透:把所有可能存在的订单号、用户 ID 或商品 ID 预先放到布隆过滤器里,请求进来先查布隆过滤器,如果它说这个 key 不存在,那就直接返回,根本不用查缓存和数据库。

我实际用过布隆过滤器之后,感受比较深的一点是:初始化数据是个麻烦事。如果是订单号这种有规律的 ID,可以全量刷进过滤器;但如果数据量大,比如上亿条记录,构建布隆过滤器要花不少时间和内存。还有一种场景是增量数据,新产生的 ID 必须同步写入布隆过滤器,否则实际存在的 key 会被过滤器误判为不存在,导致正常请求被拦截。这个同步逻辑往往比过滤器本身更复杂。

布隆过滤器的使用位置也有讲究。可以放在应用层,由业务代码先查过滤器再查缓存;也可以放在缓存前面的一层代理上,比如某些网关或者缓存代理组件支持过滤器配置。放在应用层的好处是灵活,可以根据不同业务定制不同的过滤器策略。放在代理层的好处是对业务透明,不用改业务代码。我在实践里更倾向于应用层,因为能拿到更细的业务语义,比如不同接口可以用不同的过滤器集合。

需要注意,布隆过滤器的误判率跟位数组大小和哈希函数数量直接相关。位数组越小误判率越高,但省内存;位数组越大误判率越低,但占用空间也随之增加。新手很容易忽视初始化参数的调整,直接用了默认值,结果误判率偏高。实际上,误判带来的影响不是“放过了不存在的请求”,而是“拦截了部分真实请求”,这种问题在线上是很难排查的。所以参数一定要根据预估数据量提前算好。

提示:布隆过滤器不是万能的,它只能解决“key 不存在”的穿透问题。如果请求背后的业务逻辑本来就是查询一个合法但不存在的资源,比如未上架的商品,那过滤器里就不会有对应 ID,这些请求依然会走数据库。这种情况要靠空值缓存来兜底。

说到穿透,还有一个容易忽略的点:入口层的参数校验。很多穿透攻击其实是通过不断变换 GET 参数实现的,如果在网关或者 Controller 层就能校验参数格式、范围、长度,大部分无效请求根本走不到缓存层。比如订单号有固定长度和格式,那就先校验格式,格式不对直接返回参数错误。这个成本几乎为零,但能挡掉大量低质量攻击。

排除上面的思路,还有一个更立体的方案:限流 + 降级。如果穿透请求量实在太大,超出了一定阈值,干脆对非核心接口做降级处理,返回默认值或者提示“系统繁忙”,优先保护数据库。这虽然不是根治方案,但在大促或者被刷的高峰期,是保命的手段。我现在的习惯是:穿透治理从来不只靠一种手段,空值缓存、布隆过滤器、参数校验、限流策略组合起来用,才能把数据库的压力彻底降下来。

2. 缓存击穿:热点 key 过期的瞬间压力

2.1 击穿与穿透的区别

很多人把缓存击穿和缓存穿透混为一谈,其实它们完全是两类问题。穿透针对的是不存在的 key,而击穿针对的是一个非常热点、同时在缓存中过期的 key。这个热点 key 一旦过期,瞬间会有大量请求同时发现缓存没有数据,然后一起涌向数据库去查同一条数据。

击穿的关键词是“热点 key”和“瞬时并发”。比如某个电商平台突然有一个爆款商品被推上首页,这个商品的详情被缓存起来了,缓存过期时间设的是 30 分钟。过了 30 分钟缓存刚好失效,而此刻正有几十万用户在看这个商品,这几十万个请求在同一秒里全部穿透缓存,直接打到数据库。数据库根本扛不住这种同 key 高并发查询,哪怕只是查一条简单的数据,也会因为瞬间涌入的连接数把数据库打垮。

我做过一次线上复盘,场景跟上面说的几乎一模一样。当时为了缓解数据库压力,我们不得不在缓存失效后的那几秒内,对商品详情接口做了人工限流,同时让老数据继续返回。这次事故之后我们彻底认识到:热点 key 的过期时间设计,不能跟普通 key 一样拍脑袋决定。

从本质上讲,击穿是“并发放大”的问题。一个 key 过期,本来只有一次数据库查询需求,但因为有大量并发请求同时到达,变成了一次性 N 次数据库查询。这种问题不能光靠加缓存解决,因为缓存解决的是“读多写少”的重复查询问题,而击穿是“同一时刻集体绕过缓存”的问题。要解决它,思路就是:要么让同一时刻只有一个请求去查数据库,要么让这些请求不直接打到数据库。

2.2 互斥锁与逻辑过期方案

拦截并发请求最直接的方式是互斥锁。当缓存没有命中时,不是立刻去查数据库,而是先尝试获取一个分布式锁(比如 Redis 的 SETNX 命令)。拿到锁的请求去查数据库并重建缓存,其他请求则在锁外等待,缓存重建完成后统一从缓存读取。

这个方案的关键细节在于锁的获取方式。如果用 SETNX 实现,要注意设置过期时间,防止持有锁的线程崩溃导致死锁。我用的是SET key value NX EX 10这种原子命令,一次性设置锁和过期时间。锁的粒度要尽可能小,最好只用缓存 key 本身作为锁的标识,这样不同数据之间的查询不会互相阻塞。

互斥锁方案的缺点是:在缓存失效的短暂窗口内,会有大量请求阻塞等待锁释放,导致接口响应时间变长。从用户体验来看,假如热点 key 重建缓存需要 200ms,那排在后面的请求可能要多等 200ms 甚至更久。对于一些对延迟极为敏感的接口,这种等待是不可接受的。

逻辑过期方案则是另一种思路。它不设置真正的 TTL,而是在缓存 value 里额外存一个过期时间戳。请求读取缓存时,发现逻辑过期时间到了,不会立刻删除缓存,而是先获取互斥锁,在锁内去数据库查新数据并更新缓存;其他请求在锁未释放时,依然可以读取旧的缓存数据,只是这次读取可能返回的不是最新数据。

从一致性角度看,逻辑过期方案牺牲了短暂的数据新鲜度,换来了可用性。对于商品详情、公告内容这类对时效性要求没那么苛刻的数据,这个权衡是非常有价值的。我实际用下来,逻辑过期方案比互斥锁更平滑,因为不会出现请求堆在锁上的现象,但代价是实现复杂度高一些,要为缓存 value 设计带有过期字段的结构,还要处理旧数据和新数据结构兼容的问题。

注意:逻辑过期方案中的“旧数据”不等于脏数据。它只是延迟了数据更新的生效时间,最终新数据还是会写入缓存。业务场景必须能容忍这种短暂不一致,如果数据一秒都不能差,这个方案就不适用。

还有一种辅助手段是热点 key 的永不过期策略。对于一些极端热点的数据,干脆不设置过期时间,而是通过后台异步任务定时刷新缓存。刷新任务每隔一段时间从数据库拉取最新数据写进缓存,如果数据库更新失败了,缓存里还有旧数据,不会出现缓存完全失效的情况。这相当于把“读时重建”变成了“主动刷新”,规避了瞬时高并发打库的问题。

永不过期方案适合那些数据量不大但读取频率超高的场景,比如配置信息、热门榜单、推荐位数据。它的缺点是需要额外的定时任务或者消息驱动机制来维护缓存更新,而且对于数据实时性要求高的场景,异步刷新的延迟可能达不到预期。所以我一般会把“永不过期 + 异步刷新”跟“逻辑过期 + 互斥锁”搭配使用:核心热点数据用前者,普通热点数据用后者。

3. 缓存雪崩:大规模缓存同时失效

3.1 雪崩的典型成因

缓存雪崩跟击穿最大的区别在于范围。击穿是单个热点 key 失效,雪崩是大批 key 同时失效,或者整个缓存层不可用。大面积失效的直接后果就是:几乎所有请求都绕过缓存打到数据库,数据库瞬间被压垮,整个系统进入雪崩状态。

最常见的雪崩诱因有两个。第一个是大量 key 设置了相同的过期时间。比如系统里有一批热点数据,都是每天凌晨 0 点统一过期,一到 0 点,成千上万的 key 同时失效,数据库压力瞬间达到峰值。第二个是整个缓存节点宕机,比如 Redis 主节点挂了,如果没做好高可用,所有请求在缓存恢复前都会直接打到数据库。

我遇到过一种典型的雪崩场景:运营活动上线时,一次性往缓存里写了几千个商品 key,过期时间全部设置为 30 分钟。活动上线半小时后,这批 key 同时到期,瞬间数据库查询量翻了十倍,CPU 直接飙到 99%。复盘时发现,其实只要在生成 key 时给过期时间加个随机值,就能避免这种集体失效。

从系统设计上看,雪崩的破坏力不只是数据库压力大,还会引发连锁反应。数据库慢查询变多,导致应用服务线程池被打满,应用层接口全部变慢,前端不断重试,进一步放大流量。这种正反馈一旦形成,光靠恢复缓存是救不回来的,必须要有熔断和降级机制兜底。

3.2 过期分散化与多级缓存降级

防止雪崩最实用的一招,就是让 key 的过期时间分散开。具体做法是在基础过期时间上加上一个随机数。比如原本设置 30 分钟过期,现在设置为 30 分钟加上 1 到 5 分钟的随机偏移量。这样每个 key 的实际过期时间都有差异,不会集中在同一秒大面积过期。

代码实现非常简单:

int baseTtl = 1800; // 30分钟 int randomOffset = random.nextInt(300); // 随机偏移 0~5 分钟 int actualTtl = baseTtl + randomOffset; redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(actualTtl));

这个随机偏移量的上限怎么定?我一般按基础 TTL 的 5% 到 10% 来设置。如果基础 TTL 是 30 分钟,随机偏移 3 分钟左右就够用了。设置太大可能会导致部分 key 提前过期,影响缓存命中率;设置太小则分散效果不明显。

第二招是多级缓存。在多级缓存的架构下,应用本地缓存(比如 Caffeine、Guava)作为第一级,Redis 作为第二级,数据库作为第三级。查询时先查本地缓存,没命中再查 Redis,再没命中才查数据库。这样一来,即使 Redis 里的大批 key 同时失效,本地缓存依然能扛住很大一部分请求,数据库压力并不会瞬间暴涨。

多级缓存的难点在于数据一致性维护。Redis 里的数据更新了,本地缓存可能还是旧的,需要借助消息通知或者版本号机制让各节点感知变化。我之前在某个项目里用的是发布订阅模式:数据变更时先更新数据库和 Redis,再发送一条消息到本地节点的监听器,让本地缓存主动失效。这种方式实现不算复杂,但前提是业务能接受短暂的数据不一致。多级缓存适合读多写少、数据量适中、节点数可控的业务。如果节点特别多,每个节点的本地缓存都存储同一份数据,内存浪费会比较严重。

第三招是依赖降级和熔断。即使做了过期分散,缓存层也可能因为别的故障整体不可用,这时系统必须有预案。我的习惯是给每个下游调用设置超时时间和失败降级逻辑:查缓存超时了,直接跳过缓存查数据库;数据库也超时了,返回一个兜底数据或者直接提示稍后重试,而不是让请求无限阻塞。

降级逻辑在代码里要写得尽量轻,不要因为降级本身引入新的性能问题。比如有些团队在熔断时用线程池隔离,但线程池的排队机制反而会加剧资源耗尽。我后来改用信号量隔离或者直接用轻量级限流组件,效果比线程池好不少。

提示:雪崩治理的最终目标是让系统在极端情况下仍能保持核心可用,而不是追求缓存永不失效。所以每次做缓存设计时,我都会提前问自己:如果缓存 10 秒内不可用,系统会怎样?如果数据库撑不住 5 倍流量,哪些接口可以先降级?把这些问题的答案写进应急预案,比出事后再想方案可靠得多。

4. 缓存与数据库的一致性:最棘手的数据正确性问题

4.1 更新顺序的权衡

缓存与数据库一致性的核心矛盾在于:缓存和数据库是两个独立的存储系统,无法用单事务保证原子性,任何顺序都会存在不一致的窗口。经典的做法是 Cache Aside 模式:读的时候先读缓存,读不到再读数据库,然后回写缓存;写的时候先更新数据库,再删除缓存。

为什么更新数据库后是删除缓存,而不是更新缓存?这个选择背后有实际原因。更新缓存需要知道数据库里的最新值,还要考虑并发写覆盖的问题;删除缓存则简单得多,下次读请求自然会把最新数据重新加载到缓存。删除缓存的方式避免了并发写时旧数据覆盖新数据的风险,代价是删除后的一小段时间里缓存是空的,会有一次额外的数据库查询。

但“先更新数据库,再删除缓存”也有问题:如果删缓存失败了,缓存里就一直是旧数据,除非等 TTL 到期,否则用户会持续读到过期数据。这是我在线上遇到过最多的情况。删除失败的原因可能是 Redis 超时、网络抖动,或者删除时 Redis 正好在故障切换。单纯执行一次删除操作而不考虑失败后的补偿,一致性就无从保证。

还有一种并发场景很隐蔽:两个线程同时操作,线程 A 先更新数据库为新值,线程 B 后更新数据库为更旧的值,然后线程 A 删除缓存,线程 B 也删除缓存,最终缓存是空的,看起来没问题。但更复杂的时序里,如果线程 A 先删了缓存,线程 B 在数据库更新前又读了一次旧值并写回缓存,那么线程 A 更新后的删除操作可能发生在缓存回写之后,导致缓存留下旧值。这种场景在前几年被认为是个 bug,后来逐渐被延迟双删方案覆盖。

4.2 延迟双删与可靠补偿

延迟双删的思想很简单:更新数据库之后,删除一次缓存;然后等待几百毫秒,再删除一次缓存。第二次删除是为了清除在第一次删除之后、数据库更新完成之前,可能被并发读请求写回缓存的旧数据。

延迟时间怎么定?需要大于一次典型的读请求耗时。如果业务读请求平均耗时在 50ms,延迟时间可以设置成 200ms 到 500ms。如果读请求耗时波动大,或者涉及慢查询,延迟时间要相应拉长。这个参数没有标准答案,需要根据业务实际观察。延迟双删的实现可以放在业务代码里,用一个延迟线程池去执行第二次删除,也可以用消息队列异步发送删除任务。

不过延迟双删并不能做到强一致,它只是缩小了不一致的时间窗口。如果对一致性要求极高,光靠延迟双删是不够的。更可靠的做法是引入可靠事件机制:删除缓存失败后,把删除任务投递到消息队列,由消费端重试删除。重试逻辑里要设置最大重试次数和退避策略,避免无限重试打爆 Redis。

我的推荐是:对一致性要求高的数据,用“先更新数据库→删除缓存→删除失败则发消息重试”这套组合拳。配合对 Redis 操作的监控,能发现删除失败的概率高不高。实际使用中,这套方案的不一致窗口非常小,对绝大多数业务完全够用。

另外一种思路是监听数据库的 binlog 来驱动缓存更新。组件会监听数据库变更日志,解析出变更的数据内容,然后由消费者更新对应缓存。这个方案的好处是业务代码完全不需要手动维护缓存逻辑,数据库更新即触发缓存更新,不需要考虑业务代码里忘删缓存的问题。坏处是引入了额外的中间件,架构复杂度上升,而且 binlog 解析到缓存更新之间有延迟,依然不是强一致。

从实际选型的角度考虑,我认为大部分业务场景不需要追求缓存强一致。缓存本来就是为了性能牺牲一点一致性而引入的中间层。只要不一致窗口控制在可接受范围,比如几百毫秒,用户基本感受不到。真正要注意的是不能出现长时间、大面积的不一致,比如缓存误删失败导致数据老半天不更新。可靠的删除补偿机制才是一致性的底线。

注意:凡是涉及缓存更新,我都不推荐在业务代码里直接“更新缓存值”。宁可多花一次缓存重建的数据库查询成本,也不要冒着并发覆盖的风险去更新缓存。这个原则看似保守,实际能规避大量复杂问题。

5. 缓存淘汰与过期策略:内存管理的实践细节

5.1 LRU、LFU 与 Redis 的近似实现

缓存容量总是有限的,不可能把所有数据都放在内存里。当缓存空间不足时,必须有一套规则决定淘汰哪些数据。最常见的淘汰算法是 LRU(Least Recently Used,最近最少使用)和 LFU(Least Frequently Used,最不经常使用)。

LRU 的核心思路是:如果一个数据最近被访问过,那么未来被访问的概率也更高。所以淘汰时优先干掉最近没被访问过的数据。实现上常用双向链表配合哈希表:每次访问一个 key,就把它移动到链表头部;淘汰时从链表尾部删除。

LFU 的核心思路则是:如果数据在过去的访问频率很低,未来大概率也不会被访问。淘汰时优先干掉访问频率最低的数据。LFU 适合处理那种“周期性热点”的场景——数据可能不是最近访问,但是长期被频繁访问,LRU 很可能误杀高频数据。

Redis 实际实现的是近似 LRU,并非严格 LRU。因为严格 LRU 需要记录每个 key 的访问顺序,内存开销太大。Redis 的做法是:在内存达到上限时,随机抽取若干个 key,按最后访问时间排序,淘汰其中最旧的一个。抽取样本数可以配置,默认是 5 个,样本数越多越接近严格 LRU,但 CPU 消耗也越高。

Redis 4.0 之后加入了 LFU 淘汰策略。LFU 的实现也用近似方式,用一个概率衰减机制来模拟访问频率的衰减,避免热数据永远不被淘汰。从实践看,LFU 适合读多写少且存在长期热点数据的业务,LRU 适合访问模式相对均匀的业务。很多团队直接默认 LRU,遇到数据访问频率差距很大的场景时命中率其实并不理想。

5.2 淘汰策略的选择与参数配置

Redis 的淘汰策略通过maxmemory和maxmemory-policy两个参数控制。maxmemory设定了 Redis 能用的最大内存,maxmemory-policy设定了内存达到上限后的淘汰行为。

常见策略包括:

  • noeviction:不淘汰,写命令直接报错
  • allkeys-lru:在所有 key 中按 LRU 淘汰
  • volatile-lru:只在设置了过期时间的 key 中按 LRU 淘汰
  • allkeys-lfu:在所有 key 中按 LFU 淘汰
  • volatile-lfu:只在设置了过期时间的 key 中按 LFU 淘汰
  • allkeys-random:随机淘汰任意 key
  • volatile-random:在设置了过期时间的 key 中随机淘汰

这里有一个新手比较容易踩的坑:如果使用volatile-lru,缓存里必须有一定比例的 key 设置了过期时间,否则内存达到上限时找不到可淘汰的 key,Redis 就只能对写入报错。我之前就把一个业务实例的内存策略配成了volatile-ttl,结果业务代码里大量 key 都没设置 TTL,运行一段时间后 Redis 直接拒绝写入,排查了很久才意识到是策略跟业务不匹配。

从实践角度,我给大部分缓存场景推荐allkeys-lru。理由很简单:缓存层的数据本身就没有强一致要求,任何 key 被淘汰都可以由下次查询重建。设置过期时间的主要目的是防止数据长期不更新,而不是配合“只能淘汰过期 key”的策略。

maxmemory的设定也有讲究。不能直接设成物理内存大小,要给操作系统和 Redis 自身的其他开销留余量。比如机器内存 16G,Redis 实例的maxmemory可以设在 12G 左右。设置太小浪费机器内存,设置太大容易触发系统 swap,性能会急剧下降。Redis 分配内存是高于实际数据量一点的,所以预留 20% 到 30% 比较稳妥。

另外还要关注过期的 lazzy 清理机制。Redis 默认是惰性删除 + 周期删除结合:访问 key 时如果发现过期就删除,同时后台周期性地抽样清理过期 key。如果大量 key 设置了同一个过期时间,周期清理可能来不及处理,导致过期 key 还占着内存,等访问时才发现过期。这也是我建议过期时间加随机分散的原因之一,不止是防雪崩,还能缓解过期 key 清理的压力。

提示:内存监控不能只看used_memory,还要关注maxmemory之下是否频繁触发驱逐。如果 Redis 日志里经常出现evicted_keys增长,说明内存已经不健康了,要么扩容,要么优化 key 的大小。我之前见过一个团队把一个大对象序列化后直接存 Redis,一个 key 就占了几 MB,导致maxmemory很快打满,排查时看到缓存命中率其实很高,但内存全部被那几百个大 key 吃掉了。

这里也顺带提一个容易被忽略的点:key 本身要设计得短而规范。比如user:123456:detail:v3这种 key 虽然可读性好,但比u:123456:d:3多占不少内存。当 key 数量达到千万级别时,key 命名的长短对内存的影响就很明显了。我曾经把一个业务模块的 key 从长命名改成短命名,Redis 内存占用降了 15% 左右,效果相当可观。

6. 缓存预热与监控:别等上线后才拍脑袋

6.1 缓存预热策略与启动加载

缓存预热是指系统上线前或大促开始前,把预计会被高频访问的数据提前写入缓存。预热做得好,系统启动后就能直接命中缓存,避免冷启动阶段大量请求打到数据库。

预热的做法并不复杂,重点是判断哪些数据需要预热。我的做法是结合业务特征和数据统计两步走。首先,列出一批核心接口涉及的数据范围,比如商品详情、用户信息、配置参数。其次,根据历史访问日志统计出 Top N 热点数据,把这些数据优先预热。

预热任务一般放在应用启动后的异步线程里执行,或者单独跑一个离线任务。如果是多节点部署,要注意重复预热问题。每个节点启动都去拉全量热点数据写缓存,会在短时间内产生大量重复的数据库查询和批量写入。我一般会加一个分布式锁,保证同一时间只有一个节点在预热,其他节点等预热完成后再提供服务。

如果是大促场景,预热通常在活动开始前 1 小时到半小时执行。预热的 key 要覆盖两类:一类是已知的固定热点,比如主推商品;另一类是可能的潜在热点,比如加购量突然飙升的商品。潜在热点的获取可以依赖实时热点分析系统,把每个商品的访问量做滑动窗口统计,达到阈值自动加入预热队列。这个方案实现起来成本略高,但对大促这类极端场景几乎是必需的。

预热之后还要考虑缓存数据的过期时间设计。如果过期的时点恰好是大促高峰期,那预热的效果就大打折扣了。所以预热数据的过期时间要避开高峰,或设置得足够长,大促期间尽量不失效。

6.2 命中率监控与调优闭环

缓存用了不等于用好了,必须通过监控数据不断调优。最重要的指标是缓存命中率。命中率高说明缓存发挥了作用;命中率低说明缓存的数据跟实际访问的匹配度差,需要调整缓存策略。

Redis 本身提供了info命令查看命中率,比如keyspace_hits和keyspace_misses。我习惯在监控系统里把这两个指标拉出来,算出一个滑动窗口内的命中率,按业务模块分别统计。如果发现某个模块命中率低于 80%,我就会去分析几个原因:key 设计是否合理、过期时间是否太短、是否存在大量无效查询、预热是否覆盖了真正的热点。

围绕命中率调优,常见手段包括:延长过期时间、调整淘汰策略、增加二级缓存、优化数据访问模式。调优过程不能一次性改太多参数,要把改动和线上效果对应起来,用数据说话。我通常的做法是每次只改一个变量,观察一天到两天的命中率变化,再决定下一步。

还有一个指标容易被忽略:缓存 key 的数量与内存使用率。如果 key 数量长期增长、内存使用率接近maxmemory,说明缓存中存在大量不被访问的“僵尸 key”。此时可以引入 key 的定期体检任务,统计每个 key 的最后访问时间,把长时间没被访问的 key 主动清理掉。Redis 扫描全部 key 用scan命令,千万不要用keys *,会阻塞 Redis。

注意:不管命中率多高,缓存都是性能优化手段,不是数据持久层。要定期演练缓存宕机的应急预案,确认降级逻辑真的能兜住。有些团队线上 Redis 一直很健康,结果内存不足引发一次节点迁移,缓存短暂不可用,数据库直接被流量打挂,就是因为降级预案只写在文档里,从没验证过。

关于缓存,很多经典问题从概念上看并不复杂,但真正处理好,需要结合业务场景做大量取舍。穿透、击穿、雪崩、一致性、淘汰策略、预热监控,每一个都是一套组合拳,没有任何单一方案能解决所有问题。我在实际项目里,最基本的准则就一条:每加一层缓存,都要想清楚它万一失效了会怎样。想清楚这个,再回头设计缓存方案,很多坑都能提前避开。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进