缓存出现的常见问题
一、缓存穿透
定义:查询一个一定不存在的数据,由于缓存是不命中时被动写,并且处于容错考虑,如果从 DB 查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,失去了缓存的意义。
解决方案:
- 缓存空对象:当从 DB 查询数据为空,仍然将这个空结果进行缓存,具体的值需要使用特殊的标识(如"NULL"或空字符串),能和真正缓存的数据区分开。另外,需要设置较短的过期时间,一般建议不要超过 5 分钟,防止恶意攻击用大量不同空 key 占满缓存空间。优点是实现简单,缺点是如果攻击 key 数量巨大,空对象仍会占用缓存资源。
- 布隆过滤器(BloomFilter):在缓存服务的基础上,构建 BloomFilter 数据结构,在 BloomFilter 中存储对应的 KEY 是否存在,如果存在,说明该 KEY 对应的值不为空(存在误判率),如果不存在则直接返回,请求不会打到 DB。优点是内存占用极小,缺点是存在误判可能,且删除 key 时布隆过滤器无法直接删除。
二、缓存雪崩
定义:缓存由于某些原因无法提供服务(例如缓存挂掉了),或者大量缓存 key 在同一时间集中过期,导致所有请求全部达到 DB 中,导致 DB 负荷大增,最终挂掉的情况。
解决方案(从多个方面共同着手):
- 缓存高可用:通过搭建缓存的高可用,避免缓存挂掉导致无法提供服务的情况。假设使用 Redis 作为缓存,则可以使用 Redis Sentinel(哨兵模式)实现主从自动切换,或使用 Redis Cluster(集群模式)实现分片 + 副本,单节点故障不影响整体服务,从而降低出现缓存雪崩的情况。
- 过期时间打散:对于集中过期的 key,在设置过期时间时增加随机偏移量(如基础过期时间 + 0~300 秒随机值),避免大量 key 在同一时刻同时过期。
- 本地缓存兜底:使用本地缓存时,即使分布式缓存挂了,也可以将 DB 查询到的结果缓存到本地,避免后续请求全部到达 DB 中。如果使用 JVM,则可以使用 Ehcache、Guava Cache、Caffeine 等实现本地缓存功能,形成两级缓存架构(本地缓存 + 分布式缓存)。
三、缓存击穿
定义:某个极度"热点"数据在某个时间点过期时,恰好在这个时间点对这个 KEY 有大量的并发请求过来,这些请求发现缓存过期一般都会从 DB 加载数据并回设到缓存,但是这个时候大并发的请求可能会瞬间把 DB 压垮。对于一些设置了过期时间的 KEY,如果这些 KEY 可能会在某些时间点被超高并发地访问,是一种非常"热点"的数据,这个时候需要考虑这个问题。
区别:
- 和缓存"雪崩"的区别在于,击穿针对某一 KEY 缓存,雪崩则是很多 KEY
- 和缓存"穿透"的区别在于,击穿的 KEY 是真实存在对应的值的,穿透的 KEY 不存在对应值
解决方案:
- 互斥锁:请求发现缓存不存在后,去查询 DB 前,使用分布式锁(如 Redis setnx),保证有且只有一个线程去查询 DB,并更新到缓存。其他线程等待锁释放后重新读取缓存。优点是保证 DB 只被查询一次,一致性较好;缺点是会阻塞其他线程,增加响应延迟。
- 逻辑过期(手动过期):缓存上从不设置物理过期时间,功能上将过期时间存在 KEY 对应的 VALUE 里。流程如下:
- ① 获取缓存,通过 VALUE 中存储的逻辑过期时间,判断是否过期。如果未过期,则直接返回;如果已过期,继续往下执行。
- ② 通过一个后台的异步线程进行缓存的构建,也就是"手动"过期。通过后台的异步线程,使用分布式锁保证有且只有一个线程去查询 DB 并更新缓存。
- ③ 同时,虽然 VALUE 已经逻辑过期,但当前请求仍然直接返回旧数据。通过这样的方式,保证服务的可用性,虽然损失了一定的时效性(强一致性),但做到了高可用、不阻塞。
总结对比
| 问题 | 核心原因 | 解决方案 |
|---|---|---|
| 穿透 | 查询不存在的数据 | 缓存空对象(短过期)、布隆过滤器 |
| 雪崩 | 缓存服务不可用或大量 key 同时过期 | 缓存高可用(Sentinel/Cluster)、过期时间加随机值、本地缓存兜底 |
| 击穿 | 热点 key 过期瞬间高并发 | 互斥锁(分布式锁)、逻辑过期(手动过期+异步刷新) |

浙公网安备 33010602011771号