1 缓存穿透问题探究
1.1 定义与影响
缓存穿透是指对于数据库中不存在的数据,缓存层也无法提供答案的情况,导致请求直接到达数据库层。这通常在对某些不存在的记录进行反复查询时发生,如果攻击者利用这个点进行大量伪造id的请求,将对数据库造成很大的压力。 缓存穿透的影响包括:
- 应用性能下降,数据库压力增大。
- 如果攻击者恶意利用,可能会导致数据库服务不稳定甚至宕机。
- 由于大量请求绕过缓存,缓存的命中率会降低,从而使缓存的作用大打折扣。
1.2 具体案例分析
比如在一个电商系统中,用户查询商品详情时,如果查询的是一个不存在的商品ID,正常情况下系统会返回商品不存在。但是如果有用户或者攻击者反复查询大量不存在的商品ID,每次查询都能绕过缓存直接查数据库,数据库就会因为这些无效查询操作而负载过高。
1.3 缓存穿透的常见解决策略
1.3.1 验证码机制
通过在用户频繁请求时加入验证码校验,可以有效防止脚本或者攻击者的恶意请求。
1.3.2 布隆过滤器
布隆过滤器可以有效过滤掉大部分无效id的查询。通过将所有可能的数据存储在一个足够大的位数组中,查询时首先通过布隆过滤器检查,不存在的数据将被过滤。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
public class CachePenetrationSolution {
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 1000000);
public static boolean mightContain(Integer id) {
return bloomFilter.mightContain(id);
}
}
1.3.3 缓存空对象
当查询不到数据时,不是选择直接返回,而是在缓存中设置一个空对象,绑定在查询关键字上,下次再有查询请求时,就能查询到这个空对象,从而防止对数据库的查询。
public class NullObjectCache {
private final Map<String, Object> cache = new HashMap<>();
public Object getData(String key) {
if (!cache.containsKey(key)) {
// 数据库查询结果为空
Object data = queryFromDatabase(key);
if (data == null) {
data = new Object(); // 空对象
cache.put(key, data);
}
}
return cache.get(key);
}
private Object queryFromDatabase(String key) {
// 数据库查询逻辑
return null;
}
}
2 深入解读缓存击穿
2.1 概念解释
缓存击穿与缓存穿透类似,但它是指在缓存中有但是已经过期的键,当这个键对应的数据库查询压力很大时,如果缓存层失效,那么所有的请求都会落到数据库层,对数据库造成巨大压力,这一现象被称为缓存击穿。
2.2 实战案例剖析
一个典型的例子是,一个电商平台中的热门商品,其详情信息在缓存中的存活时间已到期,恰好在大促活动中,数以万计的用户几乎在同一时间查询这个商品信息,这时候缓存已失效,所有请求直接打到了后端的数据库上,可能会导致数据库瞬间压力过大,响应变慢,甚至服务暂时不可用。
2.3 应对缓存击穿的策略
2.3.1 互斥锁(Mutex)方案
使用互斥锁防止多个请求同时击穿缓存。当缓存失效时,不是所有查询都去请求数据库,而是用锁保证只有一个查询去数据库,其他查询等待。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class CacheBreakdownPrevention {
private static Lock lock = new ReentrantLock();
private volatile String cacheData;
public String getCacheData(String key) {
String result = cacheData; // 首先从缓存获取数据
if (result == null) {
if (lock.tryLock()) {
try {
if (cacheData == null) { // Double check
result = queryFromDatabase(key); // 从数据库查询
cacheData = result;
}
} finally {
lock.unlock(); // 释放锁
}
}
}
return result;
}
private String queryFromDatabase(String key) {
// 数据库查询逻辑
return "Some_database_value";
}
}
2.3.2 缓存数据预加载
为了解决缓存数据预加载的问题,我们可以实现一个缓存预热的系统,该系统会在缓存过期前重新加载数据。我们可以使用定时任务(例如使用ScheduledExecutorService)来实现这一点。
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class CachePreload {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// 初始化时启动预加载
public CachePreload() {
startPreloadTask("hot_item_cache_key");
}
private void startPreloadTask(String key) {
scheduler.scheduleWithFixedDelay(() -> {
try {
// 实际的缓存加载逻辑
Object data = queryFromDatabase(key);
updateCache(key, data);
} catch (Exception e) {
e.printStackTrace();
}
}, 0, 1, TimeUnit.HOURS); // 每小时执行一次,实际间隔根据业务需求进行调整
}
private Object queryFromDatabase(String key) {
// 数据库查询逻辑
return "数据库中的热点数据";
}
private void updateCache(String key, Object data) {
// 更新缓存
// cache.put(key, data);
}
}
在上述代码中,CachePreload 类初始化时会调用 startPreloadTask 方法来设立一个周期性任务,该任务会定期更新缓存中的内容,以保证缓存中的数据保持最新。避免热点数据过期后的缓存击穿问题。
2.3.3 失效瞬间的请求队列
将对同一key的数据库请求排队,在缓存失效的瞬间,不是所有请求都去打数据库,而是一个个排队请求。
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
public class RequestQueueSolution {
private ConcurrentHashMap<String, ConcurrentLinkedQueue<Object>> requestQueueMap = new ConcurrentHashMap<>();
public Object getData(String key) {
if (!requestQueueMap.containsKey(key)) {
synchronized (this) {
if (!requestQueueMap.containsKey(key)) {
// 请求数据库
Object data = queryFromDatabase(key);
// 添加到队列供后续请求使用
ConcurrentLinkedQueue<Object> queue = new ConcurrentLinkedQueue<>();
queue.add(data);
requestQueueMap.put(key, queue);
return data;
}
}
}
return requestQueueMap.get(key).poll();
}
private Object queryFromDatabase(String key) {
// 模拟数据库查询
return new Object();
}
}
3 缓存雪崩的诊断与应对
3.1 缓存雪崩的成因及危害
缓存雪崩指的是在我们的缓存系统中,大量的缓存键集中在同一时刻失效,导致短时间内大量的请求直接打到数据库上,造成数据库压力骤增,严重时甚至可能导致服务瘫痪。 它的主要成因有:
- 缓存服务器重启导致的缓存失效。
- 缓存键设置了相同的过期时间,导致集中失效。
缓存雪崩的危害显著,能迅速增加后端数据库的负载,影响程序正常运行,严重时会导致服务不可用。
3.2 真实场景分析
假设一个新闻应用,它使用缓存来储存热门新闻内容。如果这些热门新闻的缓存在夜间12点同时失效,而此时恰逢用户高峰,会有大量的查询请求击中数据库。由于数据库的处理能力相比于缓存要低得多,突然的增加可能会使得数据库应答时间大大增加,造成服务延迟甚至服务中断。
3.3 避免缓存雪崩的技巧
3.3.1 键值过期策略优化
为了避免集中失效,可以将缓存键的过期时间进行随机化,使得缓存键不是集中在同一个时间点失效。
import java.util.Random;
public class CacheExpireStrategy {
private static final Random rand = new Random();
public static int getRandomExpireTime(int baseTime, int bound) {
// baseTime基础过期时间,bound过期时间的浮动上限
return baseTime + rand.nextInt(bound);
}
public void setCacheWithRandomExpire(String key, Object value) {
int expireTime = getRandomExpireTime(3600, 1800); // 1小时基础,加上0到30分钟的随机浮动
// cache.set(key, value, expireTime);
}
}
3.3.2 高可用缓存设置
实现高可用缓存通常涉及到本地缓存和分布式缓存相结合的策略。以下是如何结合使用Guava本地缓存和Redis分布式缓存的代码示例:
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import redis.clients.jedis.Jedis;
public class HighAvailabilityCache {
// 本地缓存设置
private final Cache<String, Object> localCache = CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
// Redis客户端
private Jedis redisClient = new Jedis("localhost");
public Object getValue(String key) {
// 首先从本地缓存获取
Object value = localCache.getIfPresent(key);
if (value == null) {
// 本地缓存未命中,查找Redis缓存
value = redisClient.get(key);
if (value == null) {
// Redis中也没有,则查询数据库
value = queryFromDb(key);
// 更新Redis和本地缓存
redisClient.set(key, String.valueOf(value));
localCache.put(key, value);
} else {
// Redis中有,则只更新本地缓存
localCache.put(key, value);
}
}
return value;
}
private Object queryFromDb(String key) {
// 数据库查询逻辑
// ...
return "数据库查询结果";
}
}
在上述代码示例中,HighAvailabilityCache 类通过一个本地的Guava缓存和Redis客户端实现了一个两级缓存的示例。当本地缓存击中失败时,会尝试在Redis缓存中获取数据;如果Redis中也没有数据,则回退到数据库查询,并将结果放入Redis和本地缓存中。 这样的高可用缓存设置可以有效地防止缓存雪崩,即使其中一层缓存失效,其他层可以提供保护,降低直接访问数据库的概率。
3.3.3 分布式锁的运用
与缓存击穿时使用的互斥锁类似,分布式锁可以确保在缓存失效的初期,不会有大量的数据库访问请求被同时处理。
import java.util.concurrent.TimeUnit;
public class DistributedLockSolution {
// 分布式锁的实现这里只是伪代码,需要依赖具体的分布式锁解决方案
private DistributedLock lock;
public Object handleRequestWithDistributedLock(String key) {
try {
if (lock.tryLock(10, TimeUnit.SECONDS)) {
try {
// 检查缓存是否有数据,如果没有则查询数据库并更新缓存
// ...
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 获取数据逻辑
// ...
return null;
}
}