缓存雪崩是指在缓存系统中,由于大量缓存数据在同一时间失效,导致大量原本可以直接从缓存中获取数据的请求直接转向数据库或后端服务,从而给数据库或后端服务带来巨大的压力,甚至可能导致系统崩溃的一种现象。
以下是缓存雪崩的一些特点和影响:
一、产生原因
- 过期时间设置不合理:
- 通常是由于将大量缓存的过期时间设置为相同,在某个时刻,这些缓存同时过期,导致大量请求无法从缓存中获取数据,全部涌向数据库。例如,许多业务在每天凌晨统一更新缓存,并且将过期时间设置为 24 小时,那么在第二天凌晨更新缓存时,前一天的缓存会同时失效。
- 大量缓存同时过期会使系统的缓存命中率急剧下降,原本可以通过缓存快速响应的请求都需要后端系统重新计算或从数据库读取数据,导致数据库的负载瞬间增加。
- 缓存服务故障:
- 缓存服务可能因为硬件故障、网络问题或软件问题而不可用,此时也会导致所有请求都直接穿透到后端,产生类似于缓存雪崩的效果。例如,Redis 集群的主节点突然故障,从节点还未完成切换,或者 Redis 服务器所在的网络出现分区故障,导致缓存服务无法正常提供服务。
二、影响
- 系统性能下降:
- 数据库或后端服务需要处理超出其处理能力的大量请求,导致响应时间变长,服务质量下降。用户可能会感受到页面加载缓慢、服务响应延迟,甚至出现请求超时的情况。
- 数据库的性能指标,如 CPU 使用率、内存使用率、I/O 负载等会急剧上升,可能会出现数据库连接池耗尽,无法处理新的连接请求。
- 系统崩溃风险:
- 极端情况下,如果后端服务无法承受巨大的压力,可能会导致服务崩溃,进而影响整个系统的可用性。例如,对于电商平台,可能会导致用户无法查看商品、无法下单;对于社交平台,可能会导致用户无法刷新消息、无法发送消息等
以下是解决缓存雪崩的一些常见做法及相应的代码示例:
一、随机 TTL
通过为缓存设置随机的过期时间,避免大量缓存同时失效。
java
import java.util.Random;
import java.util.concurrent.TimeUnit;
import redis.clients.jedis.Jedis;public class CacheExample {private static final Jedis jedis = new Jedis("localhost", 6379);private static final Random random = new Random();public static void main(String[] args) {String key = "example_key";String value = "example_value";// 基础的过期时间范围,单位为秒int baseTTL = 3600; // 随机过期时间范围,单位为秒int randomTTLRange = 300; int finalTTL = baseTTL + random.nextInt(randomTTLRange);jedis.setex(key, finalTTL, value);}
}
代码解释:
- 上述 Java 代码使用 Jedis 客户端连接 Redis。
- 首先定义了一个基础的过期时间
baseTTL
为 3600 秒(1 小时)。 - 然后定义了一个随机的过期时间范围
randomTTLRange
为 300 秒(5 分钟)。 - 最终的过期时间
finalTTL
是基础过期时间加上一个 0 到randomTTLRange
之间的随机数,通过random.nextInt(randomTTLRange)
生成。 - 使用
jedis.setex(key, finalTTL, value)
方法将键值对存储到 Redis 中并设置最终的过期时间。
二、多级缓存
使用多级缓存,例如本地缓存(如 Guava Cache)和远程缓存(如 Redis),先查询本地缓存,如果本地缓存未命中,再查询远程缓存,更新本地缓存。
java
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import redis.clients.jedis.Jedis;
import java.util.concurrent.TimeUnit;public class MultiLevelCacheExample {private static final Jedis jedis = new Jedis("localhost", 6379);// 本地缓存,Guava Cacheprivate static final Cache<String, String> localCache = CacheBuilder.newBuilder().maximumSize(1000).expireAfterWrite(10, TimeUnit.MINUTES).build();public static String getValue(String key) {// 先从本地缓存中查找String value = localCache.getIfPresent(key);if (value == null) {// 本地缓存未命中,从 Redis 中查找value = jedis.get(key);if (value!= null) {// 将 Redis 中的值更新到本地缓存localCache.put(key, value);}}return value;}public static void main(String[] args) {String key = "example_key";String value = getValue(key);if (value == null) {// 缓存未命中,从数据库或其他数据源获取数据value = "example_value";// 将数据存储到 Redisjedis.setex(key, 3600, value);// 将数据存储到本地缓存localCache.put(key, value);}}
}
代码解释:
- 首先使用 Guava Cache 创建了一个本地缓存
localCache
,最大容量为 1000 条,写入 10 分钟后过期。 - 在
getValue
方法中,先尝试从本地缓存中获取数据。 - 如果本地缓存未命中,则从 Redis 中查找。
- 如果 Redis 中找到数据,将数据更新到本地缓存中。
- 在
main
方法中,如果缓存都未命中,从数据库或其他数据源获取数据,将数据存储到 Redis 和本地缓存。
三、加锁机制
在缓存失效时,使用锁机制,只允许一个线程去更新缓存,其他线程等待,避免大量请求同时穿透到数据库。
java
import redis.clients.jedis.Jedis;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;public class CacheLockExample {private static final Jedis jedis = new Jedis("localhost", 6379);private static final Lock lock = new ReentrantLock();public static String getValue(String key) {String value = jedis.get(key);if (value == null) {lock.lock();try {// 再次检查缓存是否已被更新value = jedis.get(key);if (value == null) {// 从数据库或其他数据源获取数据value = "example_value"; // 设置缓存过期时间jedis.setex(key, 3600, value); }} finally {lock.unlock();}}return value;}public static void main(String[] args) {String key = "example_key";String value = getValue(key);}
}
代码解释:
- 定义了一个
ReentrantLock
锁。 - 当缓存未命中时,使用
lock.lock()
加锁。 - 再次检查缓存是否已被其他线程更新,若未更新,从数据库或其他数据源获取数据并更新缓存。
- 最后使用
lock.unlock()
释放锁。
四、缓存预热
在系统启动时,将热点数据提前加载到缓存中。
java
import redis.clients.jedis.Jedis;public class CachePreheatExample {private static final Jedis jedis = new Jedis("localhost", 6379);public static void preheatCache() {// 假设这些是热点数据的键String[] keys = {"key1", "key2", "key3"};for (String key : keys) {// 从数据库或其他数据源获取数据String value = "value_" + key; // 将数据存储到 Redisjedis.setex(key, 3600, value); }}public static void main(String[] args) {preheatCache();}
}
代码解释:
preheatCache
方法中,定义了一些热点数据的键。- 对于每个键,从数据库或其他数据源获取数据并存储到 Redis 中,设置过期时间为 3600 秒。
五、限流降级
当缓存失效时,对请求进行限流,避免大量请求同时穿透到数据库,同时可以考虑降级策略,返回一些默认数据。
java
import redis.clients.jedis.Jedis;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.Semaphore;public class CacheThrottlingExample {private static final Jedis jedis = new Jedis("localhost", 6379);private static final Semaphore semaphore = new Semaphore(10); // 允许 10 个并发请求public static String getValue(String key) {String value = jedis.get(key);if (value == null) {try {if (semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)) {try {// 从数据库或其他数据源获取数据value = "example_value"; // 设置缓存过期时间jedis.setex(key, 3600, value); } finally {semaphore.release();}} else {// 限流时,返回默认数据或错误信息value = "default_value"; }} catch (InterruptedException e) {// 处理异常e.printStackTrace();}}return value;}public static void main(String[] args) {String key = "example_key";String value = getValue(key);}
}
代码解释:
- 使用
Semaphore
进行限流,允许 10 个并发请求。 - 当缓存未命中时,尝试获取信号量,如果获取成功,从数据库或其他数据源获取数据并更新缓存,然后释放信号量。
- 如果无法获取信号量,返回默认数据或错误信息。
通过上述几种方法,可以有效地缓解缓存雪崩问题,具体选择哪种方法或组合多种方法使用,需要根据实际的系统架构和业务需求来决定。不同的场景下,不同的方案会有不同的效果,需要在实践中进行测试和调整。