一、前言
在 Spring Cloud 微服务集群项目中,客户端的请求首先会经过 Nginx,Nginx 会将请求反向代理到 Gateway 网关层,接着才会将请求发送到具体的服务 service。
在 service 中如果要查询数据,则会到缓存中查询,如果缓存未命中,再到数据库中查询数据。
作为缓存的 Redis 扛住了系统中大量的请求,极大的减小了数据库的压力。
但是当流量很大、高并发时,倘若 Redis 没能扛住,便会导致缓存穿透、缓存击穿、缓存雪崩
二、缓存击穿
当一个被大量并发访问且缓存重建过程比较复杂的键(key)突然失效时,大量的请求会在瞬间直接打到数据库,给数据库带来巨大的压力。
比如,某个电商网站上有一款非常热销的商品,很多人都在不断刷新页面查看商品详情。这个商品详情被缓存了起来,访问速度很快。但是,如果缓存突然失效了,所有用户的请求都会直接发送到数据库服务器,短时间内数据库会承受巨大的访问压力,可能导致数据库响应变慢或甚至宕机。
解决方案:解决缓存击穿问题的关键在于防止在缓存失效时大量请求直接打到数据库。
通过使用乐观锁、分布式锁等策略,可以有效地减轻数据库的压力,提高系统的稳定性和响应速度。
乐观锁
使用乐观锁机制来防止多个客户端同时重建缓存。
-
当缓存中找不到数据时,尝试更新缓存。
-
如果更新失败(例如因为数据版本冲突),则重试。
示例代码(伪代码):
while (true) {
data = redis.get(key);
if (data != null) {
break;
}
try {
data = db.getData(key);
redis.set(key, data);
break;
} catch (OptimisticLockingFailureException e) {
// 更新失败,重试
}
}
互斥锁(Mutex Lock)
使用分布式锁(如 Redis 的 SETNX 或 Redlock 算法)来确保同一时间内只有一个请求可以重建缓存。
-
当缓存中找不到数据时,尝试获取分布式锁。
-
如果获取锁成功,再查询数据库并将数据写回缓存。
-
如果获取锁失败,等待一段时间后重试。
示例代码(伪代码):
if (redis.setnx(lockKey, lockValue)) {
// 获取锁成功
try {
// 查询数据库
data = db.getData(key);
// 将数据写回缓存
redis.set(key, data);
} finally {
// 释放锁
redis.del(lockKey);
}
} else {
// 获取锁失败,等待并重试
Thread.sleep(100);
// 递归调用自己
getDataFromCacheOrDb(key);
}
优点:
没有额外的内存消耗
保证一致性
实现简单
缺点:
线程需要等待,性能受影响
可能有死锁风险
这里还存在一个问题:分布式锁 key 的时间应该设置多长?
如果设置得太短,可能会导致线程还没有执行完业务逻辑锁就失效了;
如果设置得太长,则可能导致锁占用时间过长,影响其他请求的处理效率。
为了解决这个问题,可以使用 Redisson 这样的分布式协调工具,它提供了看门狗机制(Watchdog Mechanism)来动态延长锁的超时时间。
Redisson 的看门狗机制可以自动续期锁的过期时间,确保锁持有者在执行业务逻辑期间锁不会过期。
当一个线程获得了锁之后,Redisson 会在后台启动一个定时任务(看门狗),每隔一段时间自动延长锁的过期时间。
这样,只要锁持有者还在执行业务逻辑,锁就不会过期。
下面是一个使用 Redisson 实现分布式锁的例子,包括如何利用看门狗机制:
-
引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.4</version>
</dependency>
-
配置 Redisson 客户端
创建一个 Redisson 客户端实例,连接到 Redis 服务器
public class DistributedLockExample {
private static final String LOCK_NAME = "myDistributedLock";
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock(LOCK_NAME);
try {
// 尝试获取锁,如果没有获取到,则等待
lock.lock();
// 执行业务逻辑
System.out.println("Executing business logic...");
Thread.sleep(5000); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Interrupted while waiting for lock.");
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
System.out.println("Lock released.");
}
redisson.shutdown();
}
}
}
-
看门狗机制的细节
Redisson 的看门狗机制是自动启用的。
调用 lock.lock()
方法获取锁时,Redisson 会自动启动一个后台任务(看门狗),每隔一段时间(默认为锁的超时时间的一半)自动续期锁的过期时间。
这样,只要持有锁的线程还在执行业务逻辑,锁就不会过期。
如果希望自定义看门狗续期的时间间隔,可以通过 lock.tryLock(long waitTime, long leaseTime, TimeUnit unit)
方法来指定:
try {
lock.tryLock(10, 30, TimeUnit.SECONDS); // 尝试获取锁,等待 10 秒,锁的过期时间为 30 秒
// 执行业务逻辑
System.out.println("Executing business logic...");
Thread.sleep(5000); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Interrupted while waiting for lock.");
}
优势:
自动续期:看门狗机制自动续期锁的过期时间,确保锁持有者在执行业务逻辑期间锁不会过期。
简化代码:使用 Redisson 可以简化分布式锁的实现代码,避免手动管理锁的续期逻辑。
容错性:如果持有锁的线程因异常而提前结束,Redisson 会自动释放锁,避免死锁。
通过使用 Redisson 的看门狗机制,可以有效地解决分布式锁的超时问题,确保锁在执行业务逻辑期间不会过期,同时也提高了系统的健壮性和可用性。
为什么不建议使用布隆过滤器解决缓存击穿问题?
-
误报问题:布隆过滤器的误报特性意味着它可能会错误地认为一个不在缓存中的数据项存在于缓存中。如果这个数据项实际上并不存在,那么误报会导致不必要的缓存查找,反而增加了系统负担。
-
缓存更新问题:布隆过滤器不能用于存储具体的值,只能用于标记存在性。因此,它不能替代缓存来存储实际的数据。
-
缓存重建问题:缓存击穿问题的核心在于缓存重建时如何避免大量并发请求直接打到数据库。布隆过滤器无法解决这个问题,因为它并不涉及缓存重建的逻辑。
三、缓存穿透
缓存穿透是指当你要访问的数据既不在缓存中,也不在数据库中时,导致请求先去访问缓存,发现缓存中没有数据,于是再去访问数据库,结果发现数据库中也没有这个数据。这样一来,应用无法从数据库中读取数据并写入缓存,导致后续请求还是会直接打到数据库,缓存就失去了作用。
想象一下,你正在使用一个购物网站,用户输入了一个根本不存在的商品编号(比如一个随机生成的编号)。第一次请求时,缓存中没有这个商品的信息,于是请求会去数据库查找。但是数据库中也没有这个商品的信息。这时,应用无法把数据缓存起来,因为根本就没有数据可以缓存。接下来,如果其他用户也输入同样的编号,他们的请求还是会直接打到数据库,因为缓存中还是没有这条数据。
存在一种情况,恶意用户伪造了大量不存在的 id 发起请求,这将会直接导致 DB 宕机
解决方案:可以通过缓存空值、布隆过滤器、前端请求校验
缓存空值
当查询结果为空时,也将这个空结果缓存起来,并设置一个较短的过期时间。
优点:
简单易行,可以立即减少数据库的压力。
适用于偶尔出现的空查询。
缺点:
如果大量请求查询的都是不存在的数据,缓存中会积累大量的空值,占用缓存空间。
如果空值的过期时间设置不合理,可能会导致频繁的缓存失效。
// 查询缓存
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 查询数据库
value = databaseService.getValue(key);
if (value == null) {
// 缓存空值
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
} else {
// 正常缓存数据
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
}
} else {
// 从缓存中获取数据
}
布隆过滤器
使用布隆过滤器来判断某个数据项是否可能存在。如果布隆过滤器表明数据项不存在,则直接返回;否则再进行正常的缓存和数据库查询。
优点:
几乎不会占用太多内存,可以高效地过滤掉不存在的数据项。
减少了数据库的压力。
缺点:
存在一定的误报率(false positive),即有可能把不存在的数据误认为存在。
需要维护布隆过滤器,有一定的复杂度。
布隆过滤器可能会产生误判(即把不在集合中的元素误认为在集合中),但它不会漏掉任何一个实际在集合中的元素。
布隆过滤器存在一定的误报率,因此设计时需要考虑到这一点,并合理选择布隆过滤器的容量和哈希函数数量,以降低误报的概率。
如果数据频繁增删改,是不太适合用布隆过滤器的。
因为一个数据变更之后,布隆过滤器无法删除 key,因此,只能重新创建一个布隆过滤器,再加载一遍所有的数据,创建出新的 bitmap。
可以使用带删除功能的布谷鸟布隆过滤器,来满足动态变化的需求。
// 初始化布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), expectedInsertions, fpp);
// 检查数据项是否存在
if (!bloomFilter.mightContain(key)) {
// 数据项不存在,直接返回
return null;
}
// 查询缓存
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 查询数据库
value = databaseService.getValue(key);
if (value == null) {
// 缓存空值
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
} else {
// 正常缓存数据
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
}
} else {
// 从缓存中获取数据
}
前端请求校验
在前端对请求进行检查确实可以帮助减轻缓存穿透问题,但这通常不是唯一的解决方案,而是与其他后端策略相结合的一种补充措施。
-
在前端对用户输入进行验证,确保请求的数据项是合法的。
-
在前端维护一个黑名单或白名单,确保只有合法的数据项才能发起请求。
-
在前端通过UI提示用户输入无效的数据项,并阻止提交请求。
-
分析用户的行为,检测异常的请求模式。
前端的检查和验证可以作为一种预防措施来减少缓存穿透的风险,但它并不是万能的。为了全面解决缓存穿透问题,还需要结合后端的技术手段。前端和后端的结合使用可以更有效地应对缓存穿透问题,提高系统的整体性能和稳定性。
四、缓存雪崩
缓存雪崩是指很多个缓存数据项同时过期,导致大量请求在同一时间涌向数据库。
不像缓存击穿那样,只是一个单独的数据项过期,但即使是一个数据项过期,也会给数据库带来很大的压力。因此,可以把缓存雪崩看作是由很多个缓存击穿组合而成的现象。
在实际应用中,缓存雪崩是一个更常见的问题。
它不像缓存击穿那样极端,一个数据项过期就能引发成千上万的并发请求,直接把数据库打垮。
相反,缓存雪崩可能是因为每一个数据项只带来几十到几百个并发请求,但当大量数据项同时过期时,这些并发请求叠加在一起,累积到成千上万个,从而把数据库压垮。
想象一下,你在一个大型电商网站上购物,有很多商品的信息都被缓存起来了。如果这些商品的信息都在同一时间过期了,那么所有的用户在查询这些商品信息时,都会直接向数据库发起请求。虽然每个商品的请求量不算太大,但加在一起就变得非常庞大,足以让数据库不堪重负。
相比之下,缓存击穿更像是一个单一的热点商品突然过期了,所有的用户都在同一时间查询这个商品的信息,短时间内数据库会受到极大的压力。
解决方案:
设置不同的过期时间
为了避免大量缓存条目同时过期,可以为每个缓存条目设置一个随机的过期时间,使得缓存条目不会在同一时间失效。
示例代码:
import java.util.concurrent.TimeUnit;
public void cacheData(String key, Object value, int baseExpirationTime) {
// 为每个缓存条目设置一个随机的过期时间
int expirationTime = baseExpirationTime + new Random().nextInt(60); // 例如,基础过期时间 ± 30 秒
redisTemplate.opsForValue().set(key, value, expirationTime, TimeUnit.SECONDS);
}
降级策略
当缓存失效时,如果数据库压力过大,可以采用降级策略,以减轻数据库压力。
-
返回固定值或默认值
public String fetchDataWithFallback(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 数据库压力过大时返回默认值
return "Default Value";
}
return value;
}
-
返回错误信息
public ResponseEntity<String> fetchDataWithFallback(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 数据库压力过大时返回错误信息
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body("Too many requests, please try again later.");
}
return ResponseEntity.ok(value);
}
-
直接拒绝请求
public ResponseEntity<String> fetchDataWithDirectRejection(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 数据库压力过大时直接拒绝请求
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Request rejected due to high load.");
}
return ResponseEntity.ok(value);
}
-
重定向到降级页面
public ResponseEntity<String> fetchDataWithRedirect(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 数据库压力过大时重定向到降级页面
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).header("Location", "/high-load").build();
}
return ResponseEntity.ok(value);
}
-
延迟响应
public String fetchDataWithDelay(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
try {
// 延迟响应
Thread.sleep(5000); // 延迟 5 秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Interrupted while sleeping.");
}
// 数据库压力过大时返回默认值
return "Default Value";
}
return value;
}
在实际应用中,通常会结合多种降级策略来处理高负载情况。例如,可以先尝试返回默认值或错误信息,如果仍然无法缓解压力,则进一步采取直接拒绝请求或延迟响应的措施。
public ResponseEntity<String> fetchDataWithCombinedFallback(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 数据库压力过大时尝试返回默认值
return ResponseEntity.ok("Default Value");
}
// 检测系统负载,如果负载过高,则进一步降级
if (isSystemUnderHighLoad()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Request rejected due to high load.");
}
return ResponseEntity.ok(value);
}
private boolean isSystemUnderHighLoad() {
// 检测系统负载的方法
return false; // 根据实际情况实现
}
不过,要考虑一种情况,如果你的业务对时点性要求高,必须每天的指定时间,去更新我们的数据,比如游戏排行每日零点更新。
在某一个固定的时间,由于业务要求,必须使得数据刷新,并且不允许出现旧数据,让缓存全部失效。
像这样的业务应该怎么办?
既然 redis 无法分散过期时间,那就在业务层下功夫。
时间一到,redis 数据全部失效,大量并发前来查询,在 service 服务层查询时,设置一个短暂的随机延迟时间。
这样,少量的请求先查询,就会读数据库,然后存入 redis;其他请求,由于随机时间稍稍慢了点,就可以去 redis 读出数据。
在业务层设置一个短暂的随机延迟时间,可以有效平滑请求。
import java.util.concurrent.ThreadLocalRandom;
public String fetchDataWithRandomDelay(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 设置一个短暂的随机延迟时间
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(1000)); // 随机延迟 0 到 1000 毫秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Interrupted while sleeping.");
}
value = databaseService.getValue(key);
if (value != null) {
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
}
}
return value;
}
参考文章:
不用背八股文!一文搞懂redis缓存击穿、穿透、雪崩!
【面试】redis缓存穿透、缓存击穿、缓存雪崩区别和解决方案
一 叶 知 秋,奥 妙 玄 心