一:缓存穿透
缓存穿透:请求一个不存在的数据,缓存层和数据库层都没有这个数据,这种请求会穿透缓存直接到数据库进行查询。它通常发生在一些恶意用户可能故意发起不存在的请求,试图让系统陷入这种情况,以耗尽数据库连接资源或者造成性能问题。
查询一个缓存中不存在的数据将会执行方法查询数据库,数据库也不存在此数据,查询完数据库也没有缓存数据,缓存没有起到作用。
解决方案
1.对请求增加校验机制
比如:查询的Id是长整型并且是19位,如果发来的不是长整型或不符合位数则直接返回不再查询数据库。
2.缓存空值或者特殊值
当查询数据库得到的数据不存在,此时我们仍然去缓存数据,缓存一个空值或一个特殊值的数据,避免每次都会查询数据库,避免缓存穿透。
流程如下:
3.使用布隆过滤器
布隆过滤器(Bloom Filter)是一种数据结构,用于快速判断一个元素是否属于一个集合中。
它使用多个Hash函数将一个元素映射成一个位阵列(Bit array)中的一个点,将Bit array理解为一个二进制数组,数组元素是0或1。
当一个元素加入集合时,通过N个散列函数将这个元素映射到一个Bit array中的N个点,把它们设置为1。
检索某个元素时再通过这N个散列函数对这个元素进行映射,根据映射找到具体位置的元素,如果这些位置有任何一个0,则该元素一定不存在,如果都是1很可能存在误判。
布隆过滤器原理图
哈希函数的基本特性
同一个数使用同一个哈希函数计算哈希值,其哈希值总是一样的。
对不同的数用相同的哈希函数计算哈希值,其哈希值可能一样,这称为哈希冲突。
哈希函数通常是单向的不可逆的,即从哈希值不能逆向推导出原始输入。这使得哈希函数适用于加密和安全应用。
布隆过滤器为什么会存在误判
主要原因是哈希冲突。布隆过滤器使用多个哈希函数将输入的元素映射到位数组中的多个位置,当多个不同的元素通过不同的哈希函数映射到相同的位数组位置时就发生了哈希冲突。
由于哈希函数的有限性,不同的元素可能会映射到相同的位置上,这种情况下即使元素不在布隆过滤器中可能产生误判,即布隆过滤器判断元素在集合中。
如何降低误判率
增加Bit array空间,减少哈希冲突,优化散列函数,使用更多的散列函数。
如何使用布隆过滤器
将要查询的元素通过N个散列函数提前全部映射到Bit array中,比如:查询服务信息,需要将全部服务的id提前映射到Bit array中,当去查询元素是否在数据库存在时从布隆过滤器查询即可,如果哈希函数返回0则表示肯定不存在。
布隆过滤器的优点是:二进制数组占用空间少,插入和查询效率高效。
缺点是存在误判率,并且删除困难,因为同一个位置由于哈希冲突可能存在多个元素,删除某个元素可能删除了其它元素。
布隆过滤器的应用场景
1、海量数据去重,比如URL去重,搜索引擎爬虫抓取网页,使用布隆过滤器可以快速判定一个URL是否已经被爬取过,避免重复爬取。
2、垃圾邮件过滤:使用布隆过滤器可以用于快速判断一个邮件地址是否是垃圾邮件发送者,对于海量的邮件地址,布隆过滤器可以提供高效的判定。
3、安全领域:在网络安全中,布隆过滤器可以用于检查一个输入值是否在黑名单中,用于快速拦截一些潜在的恶意请求。
4、避免缓存穿透:通过布隆过滤器判断是否不存在,如果不存在则直接返回。
如何使用布隆过滤器
使用redit的bitmap位图结构实现。
使用redisson实现。
使用google的Guava库实现。
第一步:引入依赖
<!-- https://mvnrepository.com/artifact/com.google.guava/guava --> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>28.2-jre</version> </dependency>
第二步,编写测试代码
public class BloomFilterExample { public static void main(String[] args) { // 创建一个布隆过滤器,预期元素数量为1000,误判率为0.01 BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 1000, 0.01); // 添加元素到布隆过滤器 bloomFilter.put("example1"); bloomFilter.put("example2"); bloomFilter.put("example3"); // 测试元素是否在布隆过滤器中 System.out.println(bloomFilter.mightContain("example1")); // true System.out.println(bloomFilter.mightContain("example4")); // false } }
在上述代码中,创建了一个预期包含1000个元素、误判率为0.01的布隆过滤器。然后,向布隆过滤器中添加了三个元素("example1"、"example2" 和 "example3"),并测试了几个元素是否在布隆过滤器中。
请注意,误判率是你可以调整的一个参数。较低的误判率通常需要更多的空间和计算资源。
二:缓存击穿
缓存击穿发生在访问热点数据,大量请求访问同一个热点数据,当热点数据失效后同时去请求数据库,瞬间耗尽数据库资源,导致数据库无法使用。
比如某手机新品发布,当缓存失效时有大量并发到来导致同时去访问数据库。
解决方案
1.加锁
单体架构下(单进程内)可以使用同步锁控制查询数据库的代码,只允许有一个线程去查询数据库,查询得到数据库存入缓存。
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("释放锁..."); }
2.合理设置热点数据过期时间
可以由后台程序提前将热点数据加入缓存,缓存过期时间不过期,由后台程序做好缓存同步。
例如:当服务上架后将服务信息缓存到redis且永不过期,此时需要使用put注解。
3.缓存预热
分为提前预热、定时预热。
提前预热就是提前写入缓存。
定时预热是使用定时程序去更新缓存。
4.限流和熔断
对热点数据查询定义单独的接口,当缓存中不存在时走降级方法避免查询数据库。
三:缓存雪崩
缓存雪崩是缓存中大量key失效后当高并发到来时导致大量请求到数据库,瞬间耗尽数据库资源,导致数据库无法使用。
比如对某信息设置缓存过期时间为30分钟,在大量请求同时查询该类信息时,此时就会有大量的同类信息存在相同的过期时间,一旦失效将同时失效,造成雪崩问题。
解决方案
1.合理设置同一时期的key的过期时间
通常对一类信息的key设置的过期时间是相同的,这里可以在原有固定时间的基础上加上一个随机时间使它们的过期时间都不相同。
2.缓存定时预热
不用等到请求到来再去查询数据库存入缓存,可以提前将数据存入缓存。使用缓存预热机制通常有专门的后台程序去将数据库的数据同步到缓存。
3.加锁
此时加锁的策略如同缓存击穿一般
4.使用降级或者限流策略
四:缓存不一致
缓存不一致问题是指当发生数据变更后该数据在数据库和缓存中是不一致的,此时查询缓存得到的并不是与据库一致的数据。
缓存不一致有什么后果
比如:查看商品信息的价格与真实价格不一致,影响用户体验,如果直接使用缓存中的价格去计算订单金额更会导致计算结果错误。
造成缓存不一致的原因可能是在写数据库和写缓存两步存在异常,也可能是并发所导致。
写数据库和写缓存导致不一致称为双写不一致,比如:先更新数据库成功了,更新缓存时失败了,最终导致不一致。
并发缓存不一致案例
线程1先写入数据库X,当去写入缓存X时网络卡顿
线程2先写入数据库Y
线程2再写入缓存Y
线程1 写入缓存旧值X覆盖了新值Y
即使先写入缓存再写数据在并发环境也可能存在问题
线程1先写入缓存X,当去写入数据库X时网络卡顿
线程2先写入缓存Y
线程2再写入数据库Y
线程1 写入数据库旧值X覆盖了新值Y
解决方案
1.使用分布式锁
线程1申请分布式锁,拿到锁。此时其它线程无法获取同一把锁。
线程1写数据库,写缓存,操作完成释放锁。
线程2申请分布锁成功,写数据库,写缓存。
对双写的操作每个线程顺序执行。
对操作异常问题仍需要解决:写数据库成功写缓存失败了,数据库需要回滚,此时就需要使用分布式事务组件。
使用分布式锁解决双写一致性不仅性能低下,复杂度增加。
2. 延迟双删
既然双写操作存在不一致,我们把写缓存改为删除缓存呢?
先写数据库再删除缓存,如果删除缓存失败了缓存也就不一致了,那我们改为:先删除缓存再写数据库
执行流程:
线程1删除缓存
线程2读缓存发现没有数据此时查询数据库拿到旧数据写入缓存
线程1写入数据库
即使线程1删除缓存、写数据库操作后线程2再去查询缓存也可能存在问题,
执行流程:
线程1向主数据库写,线程2向从数据库查询,流程如下:
线程1删除缓存
线程1向主数据库写,数据向从数据库同步
线程2查询缓存没有数据,查询从数据库,得到旧数据
线程2将旧数据写入缓存
线程1先删除缓存,再写入主数据库,延迟一定时间再删除缓存。
延迟主数据向从数据库同步的时间间隔,如果延迟时间设置不合理也会导致数据不一致。
3.异步同步
延迟双删的目的也是为了保证最终一致性,即允许缓存短暂不一致,最终保证一致性。
保证最终一致性的方案有很多,比如:通过MQ、Canal、定时任务都可以实现。
Canal是一个数据同步工具,读取MySQL的binlog日志拿到更新的数据,再通过MQ发送给异步同步程序,最终由异步同步程序写到redis。此方案适用于对数据实时性有一定要求的场景。
执行流程:
线程1写数据库
canal读取binlog日志,将数据变化日志写入mq
同步程序监听mq接收到数据变化的消息
同步程序解析消息内容写入redis,写入redis成功正常消费完成,消息从mq删除。
4.定时任务
专门启动一个数据同步任务定时读取数据同步到redis,此方式适用于对数据实时性要求不强更新不频繁的数据。
执行流程:
线程1写入数据库(业务数据表,变化日志表)
同步程序读取数据库(变化日志表),根据变化日志内容写入redis,同步完成删除变化日志。
总结:
在实际的开发项目中,可以根据具体的业务场景选择合适的缓存解决方案,以便满足高并发的需求和缓存安全的问题。