{T}

如何避免缓存穿透、缓存击穿、缓存雪崩?

如何避免缓存穿透、缓存击穿、缓存雪崩?

设计缓存系统不得不考虑的问题是缓存穿透、缓存击穿与失效时的雪崩效应,同时,关于这几种问题场景的认识及解决方案,也是面试中的高频考点。今天的内容,可以说是缓存应用的三板斧,下面我们一起来分析一下缓存应用中的这几个热门问题。

为什么使用内存数据库?

最早期的 Web 架构中,业务请求直接打到数据库。一旦业务量迅速增高,就会出现各种请求延迟、超时响应甚至请求被拒绝的性能问题,而这类性能问题又集中在数据库层面

产生这种情况的原因在于:数据库的数据存放在硬盘上,硬盘的 I/O 读写瓶颈会直接影响并发量。既然磁盘 I/O 是瓶颈,我们是否可以采用速度更快的内存来存储常用但数据量不算大的数据呢?答案是肯定的。

为了解决上面的问题,目前通用的做法是引入基于内存的数据库,把数据先放到内存里。这样便能较大程度缓解传统数据库带来的磁盘 I/O 读写瓶颈,而我们最常使用的基于内存的数据库就是 RedisMemCached

Redis 和 Memcached 对比

1. 存储方式

  • MemCached 目前只支持单一的数据结构 Key-Value 形式
  • Redis 支持多种数据结构:字符串、列表、集合、散列表、有序集合等。

2. 持久化

持久化就是把数据从内存永久存储到磁盘里,可以防止断电等异常情况下数据丢失。目前 Redis 支持持久化,MemCached 不支持。遇到灾难,MemCached 无法恢复数据,Redis 可以恢复数据,保证了数据的安全性。

从以上特点可以看出 Redis 在数据多样性安全性上远高于 MemCached。以从业经历讲,MemCached 使用频率越来越低,绝大多数的业务场景使用 Redis 居多。

Redis 带来的性能影响

Redis 的读写操作在毫秒级别,而数据库磁盘 I/O 在秒级别。用一个 Spring Boot + Redis 的 demo 对比:第一次从 MySQL 直接读取 10w 条数据耗时 39.43s,第二次数据已进入 Redis,请求仅耗时 2.62s。在正常互联网业务中,Redis 读写均在毫秒级别,与数据库性能差距明显。


缓存穿透

缓存穿透,顾名思义,是指业务请求穿过了缓存层,落到持久化存储上。在大多数场景下,我们应用缓存是为了承载前端业务请求,缓存被击穿以后,如果请求量比较大,则会导致数据库出现风险。

以双十一为例,整体网站的访问量会是平时的千倍甚至万倍。巨大的流量暴涨,单靠数据库是不能承载的,如果缓存不能很好地工作,可能会影响数据库的稳定性,继而直接影响整体服务。

哪些场景会发生缓存穿透?

  • 不合理的缓存失效策略:设置了大量缓存在同一时间点失效,将导致大量缓存数据在同一时刻发生缓存穿透,业务请求直接打到持久化存储层。
  • 外部用户的恶意攻击:外部恶意用户利用不存在的 Key,构造大批量不存在的数据请求服务。由于缓存中并不存在这些数据,海量请求全部穿过缓存落在数据库中,将导致数据库崩溃。

如何避免缓存穿透?

1. 设置合理的缓存失效策略:避免缓存数据在同一时间失效(详见下文「缓存雪崩」的随机打散方案)。

2. 缓存空数据:针对数据库不存在的数据,在查询为空时,添加一个对应 null 的值到缓存中。这样下次请求可以通过缓存的结果判断数据库中是否存在,避免反复请求数据库。不过需要考虑空数据的 Key 在新增后的处理。该缓存值的有效时间可以设置得短一点(如 30s),在业务代码中判断如果是 null 值就取消查询数据库,或者间隔 30s 后重试,能大幅减轻数据库的查询压力。

3. 布隆过滤器:在缓存前添加一层过滤,布隆过滤器映射到缓存,在缓存中不存在的数据会在布隆过滤器这一层被拦截,从而保护缓存和数据库的安全。不过布隆过滤器本身存在误判情况,实现也较复杂,工程上常用于海量 Key 的防穿透场景。


缓存击穿

缓存击穿是一个非常形象的表达:前端请求大量访问某个热点 Key,而这个热点 Key 在某个时刻恰好失效,导致请求全部落到数据库上。

这里涉及二八定律(80/20 定律):往往 20% 的缓存数据承担了 80% 甚至更高的请求,剩下 80% 的缓存数据仅承担了 20% 的访问流量。因此缓存击穿虽然可能只是一小部分数据失效,但这部分数据如果恰好是热点数据,还是会对系统造成非常大的危险。

缓存击穿可以认为是缓存穿透的一种特殊场景,两者都降低了整体缓存命中率,在表现上比较类似,解决方案上也可以互相借鉴。

如何避免缓存击穿?

  • 热点数据永不过期:对于一些经常被访问的热点数据,根据业务特性主动检查使其 Redis 数据永不过期。这里的"永不过期"并非让数据一直不更新,而是根据数据字段中的失效时间系统时间的对比主动检查更新数据。
  • 后台定时刷新:根据缓存失效时间节点批量刷新缓存数据,适合 Key 失效时间相对固定的场景。
  • 互斥锁 / setNX(见下文「缓存并发」):同一时间只允许一个请求重建缓存,其余请求等待,可同时缓解缓存击穿。

缓存并发

缓存并发是指:在缓存失效的同时,出现多个客户端并发请求获取同一个 key。因为 key 已过期,所有请求在缓存中查询不命中,就会全部到数据库查询,再重复将查询到的数据更新到缓存。这不仅增加数据库压力,还会因反复更新缓存而占用缓存资源。

解决缓存并发(Redis setNX)的流程:

  1. 客户端发起请求,先从缓存中读取数据;
  2. 如果读取到数据,则直接返回给客户端,流程结束;
  3. 如果没有读取到数据,则在 Redis 中使用 setNX 方法设置一个状态位,表示锁定状态;
  4. 如果锁定成功,则从数据库读取数据、更新缓存,最后返回数据给客户端;
  5. 如果锁定失败,表示状态位已被其他请求锁定,此请求等待一段时间后重新发起查询;
  6. 再次查询后发现缓存中已有数据,直接返回。

这样能保证同一时间只有一个请求查询数据库并更新缓存,其他请求只能等待,从而解决缓存并发问题。


缓存雪崩

缓存雪崩的表现有两种:

  • 大量缓存数据在同一时刻失效,请求全部转发到数据库,导致数据库压力过大、服务宕机;
  • 缓存服务不稳定,比如负责缓存的 Redis 集群宕机。

在业务开发中,缓存雪崩非常危险,可能直接导致大规模服务不可用。一方面是整体数据存储链路,另一方面是服务调用链路,最终导致微服务整体对外服务出现问题。在电商场景中,如果商品服务不可用,可能最终导致依赖的订单服务、购物车服务、用户浏览等级联出现故障。

缓存雪崩与缓存击穿的区别在于:缓存击穿是单个数据失效,缓存雪崩是多个数据同一时间失效

如何避免缓存雪崩?

  1. 缓存失效时间随机打散:在原有失效时间基础上增加一个随机值(如 1 到 10 分钟),让每个缓存的过期时间都不重复,降低缓存集体失效的概率。
  2. 不设置过期时间:让程序的定时任务自动定时更新或清除缓存,避免因缓存失效造成的雪崩,也能在一定程度上避免缓存并发问题。
  3. 集群化保证高可用:以 Redis 为例,可部署 RedisCluster、Proxy 等不同的缓存集群来实现高可用,防止缓存集群本身宕机引发雪崩。
  4. 明确容量峰值 + 限流降级:通过合理的限流和降级,防止大量请求直接拖垮缓存;对缓存服务进行压测,明确缓存最大水位,超阈值时通过服务限流、请求降级、消息队列等方式调整。

缓存稳定性

跳出这几个名词,从更高的维度思考缓存应用的稳定性。首先明确应用缓存的目的:大部分缓存都是内存数据库,支持非常高的 QPS,可防止海量业务请求击垮数据库。

考虑缓存稳定性要从两个方面展开:

  • 缓存数据层面:有一个缓存命中率概念,指落到缓存上的请求占整体请求总量的占比。缓存命中率在电商大促等场景是非常关键的指标,一般要求达到 90% 以上,大促等场景会要求 99% 以上。
  • 缓存服务层面:缓存集群本身也是一个服务,有集群部署、服务可用率、服务最大容量等。要对缓存服务进行压测,明确缓存最大水位,超过阈值时通过限流、降级、消息队列等不同方式调整。

进阶:热点数据动态缓存与缓存操作解耦

动态缓存热点数据策略

由于存储受限,系统不会将全部数据放入缓存,而只缓存一部分热点数据。如何设计一个动态缓存热点数据的策略?

以电商平台"只缓存用户经常访问的 Top 1000 商品"为例,总体思路是通过判断数据最新访问时间做排名,过滤掉不常访问的数据,只保留经常访问的数据:

  1. 通过缓存系统做一个排序队列(存放 1000 个商品),系统根据访问时间更新队列信息,越是最近访问的商品排名越靠前;
  2. 系统定期过滤掉队列中排名最后的 200 个商品,再从数据库随机读取出 200 个商品加入队列;
  3. 请求每次到达时,先从队列获取商品 ID,如果命中,再根据 ID 从另一个缓存数据结构读取实际商品信息并返回;
  4. 在 Redis 中可以用 zaddzrange 完成排序队列和获取商品的操作。

缓存操作与业务代码解耦

将缓存操作与业务代码耦合,虽然项目初期实现简单,但随项目迭代代码可维护性会越来越差,也不符合"高内聚,低耦合"的架构原则。解耦的通用方案是 MySQL Binlog + Canal + MQ

用户在后台上添加一条配置信息,配置存储到 MySQL,数据库更新 Binlog 日志数据;通过 Canal 组件读取最新 Binlog 日志,解析日志数据并通过约定格式发送到 MQ 消息队列;应用系统再将 MQ 中的数据更新到 Redis,从而完成缓存操作与业务代码的解耦。


总结

本文分享了分布式缓存应用和面试的经典问题:缓存穿透、缓存击穿、缓存雪崩,以及对应的解决方案:

问题现象解决方案
缓存穿透请求穿过缓存落到数据库(不存在的数据反复查库)缓存空数据、布隆过滤器、合理失效策略
缓存击穿热点 Key 失效瞬间大量请求打到数据库热点永不过期、后台定时刷新、setNX 互斥
缓存并发失效瞬间多请求并发重建缓存Redis setNX 分布式锁
缓存雪崩大量缓存同时失效或集群宕机随机打散失效时间、不设过期、集群高可用、限流降级

跨课程参考

  • 缓存问题的性能测试视角见《说透性能测试》第 17 讲(Redis 选型与性能对比)。
  • 缓存问题的面试应答视角见《架构设计面试精讲》第 14 讲(缓存策略面试题)。

缓存的使用虽然带来非常多好处,但也要充分考虑使用上的坑:缓存和数据库的一致性、缓存容量限制,以及每次存放到缓存的数据大小等。在实际业务开发中,永远都是具体情况具体分析,对不同的业务适用不同的解决方案。