目录
缓存常见问题
缓存穿透
问题概述
缓存穿透指请求一个不存在的数据,缓存层和数据库层都没有这个数据,这种请求会穿透缓存直接到数据库进行查询。它通常发生在一些恶意用户可能故意发起的不存在的请求,试图让系统陷入这种情况,以耗尽数据库连接资源或者造成性能问题
解决方案
对于缓存穿透有以下几种解决方案:对请求增加校验机制、缓存空值或特殊值、使用布隆过滤器
对请求增加校验机制
可以对请求的传入参数进行过滤,例如:查询的id是长整型并且是19位,那么如果发送的请求的参数不是长整型不符合位数则直接返回不再查询数据库
缓存空值或特殊值
当查询数据库得到的数据不存在,此时我们仍然去缓存数据,可以缓存一个空值或一个特殊值的数据,避免每次都会查询数据库,避免缓存穿透(但是如果每次发送的请求都是不一样的,会不会缓存非常多键值对而导致redis的资源负担剧增?)
当查询一个数据库不存在的数据时向redis缓存了NullValue对象
第一次请求会打到数据库上,查询数据库,得到一个空值,缓存一个空值
第二次不会再去查询数据库,而是查询缓存,从缓存中直接查询到空值并返回
使用布隆过滤器
什么是布隆过滤器?
布隆过滤器(Bloom Filter)是一种数据结构,用于快速判断一个元素是否属于一个集合中。
使用多个Hash函数将一个元素映射成一个位阵列(Bit array)中的一个点
(将Bit array理解为一个二进制数组,数组元素为0或1)
当一个元素加入集合时,通过N个散列函数将这个元素映射到一个Bit array中的N个点,把它们设置为1
检索某个元素时,通过这个N个散列函数对这个元素进行映射,根据映射找到具体位置的元素,如果这些位置有任何一个0,则该元素一定不存在,如果都是1,则有可能存在误判
这里的意思就是说,使用N个散列函数对需要检索的元素进行映射时,会得到一系列01位阵列,然后通过新得到的01数据与Bit array进行比对,只要有一个01匹配不满足,就可以认为这个元素一定不存在,如果完全满足,考虑到哈希冲突,则可能存在误判
哈希函数基本特性:
同一个数使用同一个哈希函数计算哈希值,其哈希值总是一样的。
对不同的数使用相同的哈希函数进行计算,其哈希值可能一样,这称为哈希冲突
哈希函数通常是不可逆的,即从哈希值不能逆向推导出原始输入。这使得哈希函数适用于加密和安全应用
为什么存在误判?
主要原因是因为哈希冲突。布隆过滤器使用多个哈希函数将输入的元素映射到位数组中的多个位置,当多个不同的元素通过不同的哈希函数映射到相同位数组时就会发生哈希冲突。
由于哈希函数的有限性,不同元素有可能会映射到相同的位置上,这种情况下即使元素不在布隆过滤器中,可能产生误判,即布隆过滤器判断元素在集合中
如何降低误判率?
增加Bit array空间,减少哈希冲突,优化散列函数,使用更多的散列函数
如何使用布隆过滤器?
将要查询的元素通过N个散列函数提前全部映射到Bit array中,比如:查询服务信息,需要将全部服务的id提前映射到bit array中,形成一个位列数组,当去查询元素是否在数据库存在时从布隆过滤器查询即可,如果哈希函数返回0则表示肯定不存在
优点:二进制数组占用空间少,插入和查询效率高
缺点:存在误判率,并且删除困难,因为同一个位置由于哈希冲突可能存在多个元素,删除某个元素可能删除了其他的元素
应用场景:
- 海量数据去重,比如URL去重,搜索引擎爬虫抓取网页,使用布隆过滤器可以快速判定一个URL是否已经被爬取过,避免重复爬取
- 垃圾邮件过滤:使用布隆过滤器可以用于快速判断一个邮件地址是否是垃圾邮件发送者,对于海量的邮件地址,布隆过滤器可以提供高效的判定
- 安全领域:在网络安全中,布隆过滤器可以用于检查一个输入值是否存在黑名单中,用于快速拦截一些潜在的恶意请求
- 避免缓存穿透:通过布隆过滤器判断是否不存在,如果不存在就直接返回,可以实现快速判断不存在的数据,对这类请求做到快速响应
代码实现:
redis的bitmap位图结构实现
redisson实现
google的Guava实现
缓存击穿
问题概述
缓存击穿发生在访问热点数据,大量请求访问同一个热点数据,当热点数据失效后同时去请求数据库,瞬间耗尽数据库资源,导致数据库无法使用
比如,某手机新品发布,当缓存失效时有大量并发到来导致同时去访问数据库
解决方案
有以下几种方案:使用锁,热点数据永不过期,缓存预热,热点数据查询降级处理
使用锁
单体架构下(单进程内)可以使用同步锁控制查询数据库的代码,只允许有一个线程去查询数据库,查询得到的数据存入缓存
synchronized(obj) {
//查询数据库
//存入缓存
}
分布式架构下(多个进程之间)可以使用分布式锁进行控制
RLock lock = redisson.getLock("myLock");
try {
//尝试加锁,最多等待100秒,加锁后自动解锁时间为30秒
boolean isLocked = lock.tryLock(100, 30, java.util.concurrent.TimeUnit.SECONDS);
if (isLocked) {
//查询数据库
//存入缓存
} else {
System.out.println("获取锁失败,可能有其他线程持有锁");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放锁
lock.unlock();
System.out.println("释放锁...");
}
热点数据永不过期
可以由后台程序提前将热点数据加入缓存,缓存过期时间不过期,由后台程序做好缓存同步。
例如当服务上架后将服务信息缓存到redis且永不过期,此时需要使用put注解
缓存预热
分为提前预热、定时预热
提前预热就是提前写入缓存
定时预热就是使用定时程序去更新缓存
热点数据查询降级处理
对热点数据查询定义单独的接口,当缓存中不存在时走降级方法避免查询数据库
缓存雪崩
问题概述
缓存雪崩是缓存中大量key失效后,当高并发到来时导致大量请求同时查询该类信息时,此时就会有大量的同类信息存在相同的过期时间,一旦失效将同时失效,造成雪崩问题。
解决方案
有以下几种解决方案:使用锁进行控制(思路同缓存击穿),对同一类型信息的key设置不同的过期时间,缓存定时预热
对同一类型的key设置不同过期时间
通常对一类信息的key设置的过期时间是相同的,这里可以在原有固定时间的基础上加上一个随机时间使它们的过期时间都不相同
具体实现:在framework工程中定义缓存管理器指定过期时间加上随机数
/**
* 缓存时间1天
*
* @param connectionFactory redis连接工厂
* @return redis缓存管理器
*/
@Bean
public RedisCacheManager cacheManagerOneDay(RedisConnectionFactory connectionFactory) {
//生成随机数
int randomNum = new Random().nextInt(6000);
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
//过期时间为基础时间加随机数
.entryTtl(Duration.ofSeconds(24 * 60 * 60L + randomNum))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(JACKSON_SERIALIZER));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.transactionAware()
.build();
}
缓存定时预热
不用等到请求到来再去查询数据库存入缓存,可以提前将数据存入缓存。使用缓存预热机制通常有专门的后台程序去将数据库的数据同步到缓存。
缓存一致性
问题概述
缓存不一致问题是指当发生数据变更后该数据在数据库和缓存中是不一致的,此时查询缓存得到的并不是与数据库一致的数据。
造成缓存不一致的原因可能是在写数据库和写缓存两步存在异常,也可能是并发所导致
写数据库和写缓存内导致不一致称为双写不一致,比如:先更新数据库成功了,更新缓存时失败了,最终导致不一致。
并发导致缓存不一致:
先写数据库再写缓存
执行流程:
线程1先写入数据库X,当去写入缓存X时网络卡顿
线程2先写入数据库Y
线程2再写入缓存Y
线程1写入缓存旧值X覆盖了新值Y
先写缓存再写数据库
流程:
线程1先写入缓存X,当去写入数据库X时网络卡顿
线程2先写入缓存Y
线程2再写入数据库Y
线程1写入数据库旧值X覆盖了新值Y
解决方案
有以下几种解决方案:使用分布式锁,延迟双删,异步同步
使用分布式锁
流程:
线程1申请分布式锁,拿到锁。此时其他线程无法获取同一把锁
线程1写数据库,写缓存,操作完成释放锁
线程2申请分布式锁成功,写数据库,写缓存
对双写的操作每个线程顺序执行
对操作异常问题仍需要解决:写数据库成功写缓存失败了,数据库需要回滚,此时就需要使用分布式事务组件
使用分布式锁解决双写一致性问题不仅性能低下,复杂度增加
延迟双删
先写数据库再删除缓存,如果删除缓存失败了缓存也就不一致了,那么就改为先删除缓存再写数据库:
流程:
线程1删除缓存
线程2读缓存发现没有数据此时查询数据库拿到旧数据写入缓存
线程1写入数据库
可是也有可能出现问题:线程1删除缓存、写数据库操作之后线程2查询缓存:
线程1向主数据库写,线程2向从数据库查询,流程:
线程1删除缓存
线程1向主数据库写,数据向从数据库同步
线程2查询缓存没有数据,查询从数据库,得到旧数据
线程2将旧数据写入缓存
解决此问题需要使用延迟双删:
线程1先删除缓存,再写入主数据库,延迟一定时间再删除缓存
在延迟双删的过程中,线程1的动作为:先删除缓存,在将数据写入了主数据库中之后,延迟一定的时间,然后再删除缓存
但是这个过程中也有可能出现问题,上图中我们能看到线程2在线程1完成最后的删除缓存之后,在缓存中找不到数据,那么就会去从数据库中查找数据,但是在这一段时间内可能并没有完成从主数据库到从数据库的数据同步过程,这就造成了线程2在查询到了旧数据之后写入缓存,缓存与数据库还是不一致的现象。
那么这个问题就看延迟有多长时间了:
延迟主数据向从数据库同步的时间间隔,如果延迟时间设置不合理也会导致数据不一致。
异步同步
延迟双删的目的也是为了保证组中一致性,即允许缓存短暂不一致,最终保证一致性。
保证最终一致性的方法有很多,比如:通过MQ、Canal、定时任务都可以实现
Canal是一个数据同步工具,读取MySQL的binlog日志拿到更新的数据,再通过MQ发送给异步同步程序,最终由异步程序写到redis。此方案适用于对数据实时性有一定要求的场景。
Canal+MQ
流程:
线程1写数据到数据库
canal读取binlog日志,将数据变化日志写入mq
同步程序监听mq接收到数据变化的消息
同步程序解析消息内容写入redis,写入redis成功正常消费完成,消息从mq删除
定时任务
专门启动一个数据同步任务定时读取数据同步到redis,此方式适用于对数据实时性要求不强更新不频繁的数据
线程1写入数据库(业务数据表,变化日志表)
同步程序读取数据库(变化日志表),根据变化日志内容写入redis,同步完成删除变化日志