Reids 如何处理缓存穿透、缓存击穿、缓存雪崩问题?
Redis 本身是一个高性能的键值存储系统,它提供了一些基础功能,但解决缓存穿透、缓存击穿和缓存雪崩等问题,更多的是依赖于应用层面的策略,并结合 Redis 提供的特性来实现。
下面分别说明这三个问题以及基于 Redis 的常见解决方案:
1. 缓存穿透 (Cache Penetration)
问题描述:
攻击者或用户恶意请求一个在缓存和数据库中都不存在的数据。这样,每次请求都会穿透缓存,直接打到数据库上,导致数据库压力过大,甚至宕机。
解决方案:
-
缓存空对象 (Cache Null Values / Cache Empty Objects):
- 做法: 当数据库查询返回空(即数据不存在)时,仍然将这个“空结果”或一个特殊约定的占位符(如
null_value
)缓存起来,并设置一个较短的过时时间(例如几分钟)。 - 优点: 简单有效,后续对同一不存在Key的请求会直接从缓存获取空对象,避免了对数据库的重复查询。
- Redis 实现:
SET non_existent_key "null_value" EX <short_ttl>
- 缺点:
- 需要消耗额外的缓存空间存储空对象。
- 如果攻击者构造大量不同的不存在的key,依然可能消耗大量缓存。
- 数据一致性问题:如果在缓存空对象的短时间内,数据库中恰好插入了这条数据,那么缓存中的空对象会导致应用在短期内无法获取到新数据。
- 做法: 当数据库查询返回空(即数据不存在)时,仍然将这个“空结果”或一个特殊约定的占位符(如
-
布隆过滤器 (Bloom Filter):
- 做法: 将所有可能存在的数据的 Key 哈希到一个足够大的位数组(bitmap)中。当一个请求到来时,先通过布隆过滤器判断 Key 是否可能存在。
- 如果布隆过滤器判断 Key 不存在,则直接返回,不查询缓存和数据库。
- 如果布隆过滤器判断 Key 可能存在,则继续查询缓存和数据库。(布隆过滤器有误判率,即它可能将不存在的 Key 判断为存在,但不会将存在的 Key 判断为不存在)。
- 优点: 空间效率和查询时间都非常好,能有效拦截大部分对不存在 Key 的请求。
- Redis 实现: Redis 4.0 之后通过插件
RedisBloom
提供了布隆过滤器功能。也可以在应用层面自行实现或使用 Guava 的 BloomFilter,并将位数组存储在 Redis 中。 - 缺点:
- 实现相对复杂。
- 存在一定的误判率(可以通过调整参数控制)。
- 布隆过滤器不支持删除元素(标准布隆过滤器)。如果要支持删除,需要使用 Counting Bloom Filter 等变体,空间占用会增加。
- 初始化和数据同步:需要将全量或热点数据的 Key 加入布隆过滤器。
- 做法: 将所有可能存在的数据的 Key 哈希到一个足够大的位数组(bitmap)中。当一个请求到来时,先通过布隆过滤器判断 Key 是否可能存在。
-
接口层参数校验:
- 做法: 对请求参数进行合法性校验,例如用户ID是否为正整数、长度是否符合规范等。不合法的请求直接拦截。
- 优点: 简单,能拦截一些明显不合法的请求。
- 缺点: 无法防止构造合法的、但不存在的Key的攻击。
2. 缓存击穿 (Cache Breakdown / Hotspot Invalid)
问题描述:
某一个热点 Key(高并发访问、数据不常变动)在缓存中过期失效的瞬间,大量并发请求同时涌向这个 Key。由于缓存未命中,这些请求会同时穿透缓存,直接打到数据库上,导致数据库压力瞬时增大,甚至崩溃。
解决方案:
-
互斥锁 (Mutex Lock / Distributed Lock):
- 做法: 当缓存未命中时,不是所有请求都去查数据库。而是先尝试获取一个互斥锁(例如使用 Redis 的
SETNX
命令或 Redisson 提供的分布式锁)。- 获取到锁的线程/进程负责去数据库查询数据,并将数据写回缓存,然后释放锁。
- 其他未获取到锁的线程/进程则等待一段时间后重试(双重检查锁模式),或者直接返回一个预设的默认值/稍旧的数据。
- 优点: 只允许一个请求去重建缓存,有效防止对数据库的并发冲击。
- Redis 实现:
// 伪代码 - 双重检查锁 + Redis SETNX String value = redis.get(key); if (value == null) { // 缓存不存在if (redis.setnx(lockKey, "1", lock_ttl)) { // 尝试获取锁try {value = db.query(key); // 从数据库查询if (value != null) {redis.set(key, value, cache_ttl); // 写回缓存} else {redis.set(key, "null_value", short_ttl); // 防止穿透}} finally {redis.del(lockKey); // 释放锁}} else { // 未获取到锁Thread.sleep(50); // 等待一小会return redis.get(key); // 重试获取缓存} } return value;
- 缺点:
- 实现相对复杂,需要处理锁的获取、释放、超时等问题。
- 如果获取锁的线程在重建缓存时失败,其他线程可能长时间等待。
- 做法: 当缓存未命中时,不是所有请求都去查数据库。而是先尝试获取一个互斥锁(例如使用 Redis 的
-
热点数据永不过期 (Logical Expiration / Never Expire):
- 做法: 对于极热点的数据,不设置物理上的 TTL (Time To Live),或者设置一个非常长的 TTL。而是在缓存值中额外存储一个逻辑过期时间字段。
- 当应用访问缓存时,如果发现数据已逻辑过期,则启动一个后台异步线程去更新缓存中的数据,当前请求仍然返回旧的(但可接受的)缓存数据。
- 优点: 避免了热点 Key 过期瞬间的并发问题,保证了高可用性。
- 缺点:
- 实现复杂,需要额外的逻辑来判断和更新。
- 数据一致性会有一定延迟。
- 需要一个机制(如定时任务或消息队列)来定期或触发式地更新逻辑过期的数据。
- 做法: 对于极热点的数据,不设置物理上的 TTL (Time To Live),或者设置一个非常长的 TTL。而是在缓存值中额外存储一个逻辑过期时间字段。
-
提前续期 (Proactive Renewal / Early Expiration):
- 做法: 对于热点数据,在缓存过期之前,如果检测到有访问,就主动延长其过期时间。或者设置一个后台任务,在数据即将过期前主动刷新缓存。
- 优点: 保证热点数据总是在缓存中。
- 缺点: 需要额外的机制来监控和续期。
3. 缓存雪崩 (Cache Avalanche)
问题描述:
缓存雪崩有两种情况:
- 大量 Key 同时过期: 大量缓存 Key 在同一时间点集中过期失效(例如,应用重启后所有缓存都失效了,或者一批 Key 设置了相同的过期时间)。此时,大量的请求都会穿透缓存,直接打到数据库上,导致数据库压力剧增。
- Redis 实例宕机: Redis 缓存服务整体不可用(例如 Redis 节点故障、网络分区),导致所有请求都直接访问数据库。
解决方案:
针对大量 Key 同时过期:
-
过期时间打散 (Randomized Expiration Times):
- 做法: 在设置缓存的过期时间 TTL 时,在基础过期时间上增加一个随机值(例如,基础 TTL 是 30 分钟,随机范围是 1-5 分钟,那么最终 TTL 就是 30 到 35 分钟之间的某个值)。
- 优点: 使得 Key 的过期时间点分散开,避免了集中失效。简单有效。
- Redis 实现:
SET key value EX (base_ttl + random_offset)
-
使用互斥锁或逻辑过期:
- 对于热点数据,可以采用缓存击穿中提到的互斥锁或逻辑过期方案,这也能在一定程度上缓解雪崩时单个 Key 的压力。
针对 Redis 实例宕机:
-
Redis 高可用集群 (Redis Sentinel / Redis Cluster):
- 做法: 搭建 Redis Sentinel(哨兵模式)实现主从复制和故障自动切换,或者使用 Redis Cluster 实现分布式和数据分片,并自带故障转移能力。
- 优点: 保证 Redis 服务的整体可用性,即使部分节点宕机,服务也能继续。这是最根本的解决方案。
- 缺点: 运维复杂度增加。
-
多级缓存 (Multi-Level Caching):
- 做法: 例如,应用本地缓存 (如 Guava Cache, Caffeine) + 分布式缓存 (Redis)。当 Redis 宕机时,本地缓存仍然可以承担一部分请求。
- 优点: 增加了一层防护。
- 缺点: 增加了系统复杂度,需要考虑数据一致性问题。
-
服务降级与熔断 (Service Degradation and Circuit Breaking):
- 做法:
- 降级: 当检测到数据库压力过大或 Redis 不可用时,应用可以临时关闭某些非核心功能,或者返回预设的默认值、静态页面,以保证核心功能的可用性。
- 熔断: 当依赖的服务(如数据库)持续出错达到一定阈值时,暂时切断对该服务的调用,一段时间后再尝试恢复。可以使用 Hystrix、Sentinel 等熔断组件。
- 优点: 在极端情况下保护整个系统不被拖垮。
- 缺点: 牺牲了部分用户体验或功能。
- 做法:
-
请求限流 (Rate Limiting):
- 做法: 在应用入口层(如网关、Nginx)或应用内部,对单位时间内的请求数量进行限制。当请求超过阈值时,直接拒绝服务或引导用户排队。
- 优点: 防止过量请求直接冲击后端。
- 缺点: 可能会影响正常用户的使用。
-
数据预热 (Data Pre-warming):
- 做法: 在系统启动或低峰期,提前将热点数据加载到缓存中。
- 优点: 避免了系统刚启动时大量缓存未命中。
总的来说,Redis 提供了基础的缓存能力和一些原子操作(如 SETNX
),而解决这些缓存问题通常需要结合应用层的设计模式和策略。选择哪种方案或方案组合,取决于具体的业务场景、系统架构和对一致性、可用性的要求。