49. 什么是缓存击穿?如何在Java中处理缓存击穿问题?
大约 6 分钟
什么是缓存击穿?
缓存击穿 是指在缓存中某个热点数据(通常是被频繁访问的数据)在失效的瞬间,大量并发请求同时到达,这些请求直接穿透到后端数据库,导致数据库负载骤增,甚至可能导致数据库崩溃。与缓存穿透不同,缓存击穿是指一个存在的数据,因为缓存失效,导致瞬间大量请求打到数据库。
举例说明:
假设一个电商网站上有一款非常热门的商品,用户频繁查询这个商品的信息。这个商品的数据被缓存起来,但由于缓存有一个过期时间,如果在某个高峰期,这个缓存突然过期了,而恰好在这一瞬间有大量用户访问这个商品,导致这些请求直接打到数据库,可能会造成数据库的压力陡增,甚至宕机。
如何在Java中处理缓存击穿问题?
为了防止缓存击穿问题,可以采用以下几种常见的解决方案:
1. 互斥锁(Mutex)
当缓存失效时,使用互斥锁(如分布式锁)来保证只有一个线程能够查询数据库并更新缓存,其他线程等待缓存更新完成后再获取数据。
实现步骤:
- 缓存失效后,第一个请求获取一个锁(如 Redis 分布式锁)。
- 只有持有锁的线程可以查询数据库并更新缓存。
- 其他请求等待锁释放后,再从缓存获取数据。
Java代码示例:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
public class CacheWithMutexExample {
private Jedis jedis;
public CacheWithMutexExample(Jedis jedis) {
this.jedis = jedis;
}
public String getData(String key) {
String value = jedis.get(key);
if (value != null) {
return value; // 缓存命中,直接返回
}
// 获取分布式锁
String lockKey = "lock:" + key;
String lockValue = "lockValue";
SetParams params = new SetParams().nx().ex(5); // 设置5秒过期时间
String result = jedis.set(lockKey, lockValue, params);
if ("OK".equals(result)) {
try {
// 缓存失效,第一个请求获得锁,查询数据库
value = queryFromDatabase(key);
if (value != null) {
jedis.setex(key, 60, value); // 更新缓存,设置60秒过期时间
}
} finally {
jedis.del(lockKey); // 释放锁
}
} else {
// 其他线程等待,直到缓存更新完成
while ((value = jedis.get(key)) == null) {
try {
Thread.sleep(100); // 等待缓存更新
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
return value;
}
private String queryFromDatabase(String key) {
// 模拟数据库查询
return "data-from-db";
}
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);
CacheWithMutexExample cacheExample = new CacheWithMutexExample(jedis);
String value = cacheExample.getData("key1");
System.out.println("Value: " + value);
}
}
优点:
- 确保在缓存失效时,只有一个线程能够访问数据库,避免数据库过载。
缺点:
- 锁的引入可能会带来额外的复杂性和性能开销。
2. 提前刷新缓存(Pre-Cache/Cache Warming)
通过提前刷新缓存的方式,避免缓存失效的瞬间发生大量并发请求。例如,可以设置定时任务,在缓存即将过期时提前更新缓存,这样可以保证缓存始终存在,有效避免缓存失效后瞬间击穿的情况。
实现步骤:
- 设置定时任务,定期刷新热点数据的缓存。
- 在缓存即将过期前,主动更新缓存,确保缓存不会失效。
Java代码示例:
import redis.clients.jedis.Jedis;
import java.util.Timer;
import java.util.TimerTask;
public class PreCacheExample {
private Jedis jedis;
public PreCacheExample(Jedis jedis) {
this.jedis = jedis;
}
public void scheduleCacheRefresh(String key, int intervalInSeconds) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
String value = queryFromDatabase(key);
if (value != null) {
jedis.setex(key, intervalInSeconds, value); // 更新缓存
}
}
}, 0, intervalInSeconds * 1000); // 每intervalInSeconds秒刷新一次缓存
}
private String queryFromDatabase(String key) {
// 模拟数据库查询
return "data-from-db";
}
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);
PreCacheExample cacheExample = new PreCacheExample(jedis);
cacheExample.scheduleCacheRefresh("key1", 60); // 每60秒刷新一次缓存
}
}
优点:
- 避免了缓存失效后可能导致的缓存击穿问题。
缺点:
- 适用于访问量高、更新频率低的热点数据,对于不确定的热点数据可能不适用。
3. 设置热点数据不过期
对于特别重要的热点数据,可以选择不设置过期时间,或者在过期之前进行手动更新,确保热点数据始终在缓存中存在。这种方法适用于那些确实不希望缓存失效的数据。
实现步骤:
- 对于热点数据,不设置过期时间,或者通过定期任务刷新缓存。
- 确保数据在应用程序中不会被淘汰出缓存。
Java代码示例:
import redis.clients.jedis.Jedis;
public class CacheWithNoExpirationExample {
private Jedis jedis;
public CacheWithNoExpirationExample(Jedis jedis) {
this.jedis = jedis;
}
public String getData(String key) {
String value = jedis.get(key);
if (value != null) {
return value;
}
// 缓存失效,重新查询数据库并更新缓存
value = queryFromDatabase(key);
if (value != null) {
jedis.set(key, value); // 不设置过期时间
}
return value;
}
private String queryFromDatabase(String key) {
// 模拟数据库查询
return "data-from-db";
}
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);
CacheWithNoExpirationExample cacheExample = new CacheWithNoExpirationExample(jedis);
String value = cacheExample.getData("key1");
System.out.println("Value: " + value);
}
}
优点:
- 彻底避免缓存击穿问题。
缺点:
- 如果缓存的数据量大且不设置过期时间,可能导致缓存占用大量内存。
4. 使用双层缓存(Double Cache)
通过设置一个短期缓存和一个长期缓存来应对缓存击穿问题。短期缓存用于快速过期并防止击穿,长期缓存用于存储数据并减少数据库压力。
实现步骤:
- 设置两个缓存,一个短期缓存和一个长期缓存。
- 数据查询时,首先查询短期缓存,然后查询长期缓存,最后查询数据库。
- 短期缓存失效时从长期缓存中获取数据,避免直接访问数据库。
Java代码示例:
import redis.clients.jedis.Jedis;
public class DoubleCacheExample {
private Jedis jedis;
public DoubleCacheExample(Jedis jedis) {
this.jedis = jedis;
}
public String getData(String key) {
String shortTermCacheKey = "short:" + key;
String longTermCacheKey = "long:" + key;
String value = jedis.get(shortTermCacheKey);
if (value != null) {
return value; // 短期缓存命中
}
value = jedis.get(longTermCacheKey);
if (value != null) {
jedis.setex(shortTermCacheKey, 60, value); // 更新短期缓存
return value; // 长期缓存命中
}
// 缓存失效,重新查询数据库并更新缓存
value = queryFromDatabase(key);
if (value != null) {
jedis.setex(shortTermCacheKey, 60, value); // 设置短期缓存
jedis.set(longTermCacheKey, value); // 设置长期缓存
}
return value;
}
private String queryFromDatabase(String key) {
// 模拟数据库查询
return "data-from-db";
}
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);
DoubleCacheExample cacheExample = new DoubleCacheExample(jedis);
String value = cacheExample.getData("key1");
System.out.println("Value: " + value);
}
}
优点:
- 减少缓存击穿的概率,并且长期缓存可以减少对数据库的压力。
缺点:
- 需要维护两个缓存,可能增加系统复杂度。
总结
缓存击穿是缓存系统中常见的问题之一,尤其在热点数据突然失效时。通过以下几种策略,可以有效防止缓存击穿问题:
- 互斥锁:通过分布式锁,保证只有一个线程可以查询数据库并更新缓存,防止缓存失效时大量请求打到数据库。
- 提前刷新缓存:通过定时任务在缓存过期前主动刷新缓存,避免缓存失效。
- 设置热点数据不过期:对特别重要的热点数据不设置过期时间,确保缓存一直存在。
- 双层缓存:使用短期缓存和长期缓存的组合来减少缓存失效带来的冲击。
根据具体业务场景,选择合适的策略,可以有效提高系统的稳定性和响应速度。