前言
在我们日常开发中,我们存储数据的方式一般都在数据库中,一般业务系统不会存在高并发的情况,也不怎么可能会发生概率性BUG问题,可一旦发涉及了高并发的需求,例如现在年底抢火车票的情景,单一使用数据库来保存数据肯定是不行的,首先我们的DB数据库是面向磁盘的,服务端与数据库交互都会有磁盘读/写操作而且该方式效率以及性能比较慢。
而且我们换个方式想想,假如一瞬间我们大量的请求操作我们的数据库,往往数据库是承受不了,就会造成数据库服务的不可用最终导致整个系统瘫痪掉。
为了解决上面的问题,我们可以采用缓存中间件来解决这个问题,将部分数据放入到缓存中,因为缓存是将数据存储在内存中的,从内存中读取数据可谓是相当快的。
本文采用的中间件是redis,但是引用了redis会出现缓存穿透、缓存击穿、缓存雪崩等问题,接下来我们就来详细聊聊这几种问题的解决方案
引用
在聊缓存穿透、缓存击穿、缓存雪崩等这些问题之前,我们先看一下微服务架构大概结构图,因为下面再聊这些问题的时候,都是以此图为基础进行侃侃而谈哈。
缓存穿透
什么是缓存穿透?
缓存穿透是指缓存和数据库中都没有数据,导致所有请求都落到了数据库上,造成数据库短时间内承受大量请求而崩掉。
场景
我们结合上图进行分析,现在大型互联网公司都是在使用微服务架构模式对吧,我们往往为了解决并发的问题,通常会设置三层缓存架构模式,如图中第一层缓存架构在Nginx端,例如某些图片等数据缓存的Nginx端,我们通过商品id在nginx缓存中找到了,然后直接返回出去,如果Nginx不存在,则会将请求转发到web层,然后web层去查询redis缓存是否存在数据,如果不存在,又将请求传递到某个服务去查询数据库,如果数据库查询到值,那就将该商品的数据缓存到redis中,如果不存在,则不进行缓存,那么现在问题来了,如果黑客知道了我们架构模式,就会利用不存在的值去攻击我们的服务,我们数据库扛不住压力,最终导致数据库可能发生宕机,可想而知这个缓存架构设计还是有缺陷的。
解决方案
1.缓存空对象
从缓存中没有获取到数据,在数据库中也没有获取的到,这时候可以将key-value写为key-null存储到redis中,缓存的有效期可以设短点(例如10秒钟,如果设置太长可能会导致正常商品无法正常使用),这样就可以防止黑客反复使用同一个id进行暴力攻击
可以看一下伪代码示例:
代码语言:javascript复制public String getCommodity(String commodityId) {
//从缓存中获取数据 String cacheValue = stringRedisTemplate.opsForValue().get(commodityId);
//如果缓存为空 if (StringUtils.isBlank(cacheValue)) {
//从db获取数据 String dbValue = commodityMapper.get(commodityId);
//将db查询出的结果缓存到redis中 stringRedisTemplate.opsForValue().set(commodityId, dbValue);
//如果db查询的值为null,则为缓存设置一个过期时间(350秒) if (StringUtils.isBlank(dbValue)) { stringRedisTemplate.expire(commodityId, 350, TimeUnit.SECONDS); } return dbValue; } else { return cacheValue; }
}
2.使用布隆过滤器(推荐)
布隆过滤器(Bloom Filter,简称BF)由Burton Howard Bloom在1970年提出,是一种空间效率高的概率型数据结构。
布隆过滤器专门用来检测集合中是否存在特定的元素。
布隆过滤器的设计原理
布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。
向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会很大,如果这个位数组比较拥挤,这个概率就会降低。
布隆过滤器的适用场景
- 爬虫系统url去重
- 垃圾邮件过滤
- 黑名单或者白名单
缓存击穿
什么是缓存击穿
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),此刻由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
场景
例如:最近人们较为关心的新冠病毒新闻和吃瓜群众关注的美国总统选举新闻,像这些新闻的热度是非常高的,每天有非常多的用户会查看这类新闻,那么对应到我们缓存中,该两则新闻就是热点key的存在,时常会有大量的并发的去读取该key的数据,那么问题来了,如果该key因为某些原因过期了,结果大量的并发请求在缓存中无法查询新闻数据,随之这大量的请求去查询数据库,我们可想而知,这么一瞬间我们的数据库压力瞬间增大。
解决方案
1.设置热点数据永远不过期
2.通过分布式锁(互斥锁)来解决此问题
既然上面有说到,同时有很多请求查询这则新闻,我们完全是可以用分布式锁来控制只有一个请求查询DB,然后接着放入到缓存中,那么接下来的请求又都是查询redis的数据了。
示例代码
代码语言:javascript复制public String getCommodity(String commodityId) {
//从缓存中获取数据 String cacheValue = stringRedisTemplate.opsForValue().get(commodityId);
//如果缓存为空 if (StringUtils.isBlank(cacheValue)) { RLock lock = redissonClient.getLock(commodityId); lock.lock();
//从db获取数据 String dbValue = commodityMapper.get(commodityId);
//将db查询出的结果缓存到redis中 stringRedisTemplate.opsForValue().set(commodityId, dbValue);
//如果db查询的值为null,则为缓存设置一个过期时间(随机时间在300-600秒之间) int expireTime = new Random().nextInt(300) 300; if (StringUtils.isBlank(dbValue)) { stringRedisTemplate.expire(commodityId, expireTime, TimeUnit.SECONDS); } lock.unlock(); return dbValue; } else { return cacheValue; }}
缓存雪崩
什么是缓存雪崩
缓存雪崩是指缓存服务发生宕机或缓存数据同一时间大面积的失效,所以后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉,和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
场景
例如:我们现在马上要过年了,在电商平台上会发布一些年货商品,然后每天都会有大量的用户去购买商品,而此时有大批量的商品数据在缓存中已经过期了或不存在了,那么此时可能会导致后端服务瘫痪掉,为什么呢?因为会有大量的用户请求不同的商品数据且是在同一刻访问我们的数据库,我们的数据库一旦承受不了这样的并发压力,直接发生宕机,最后导致整个服务不可用,解决还会产生额外的(服务雪崩效应)
上方的案例还不够形象的话,那在看一个案例,如果某电商公司的项目架构,它的缓存服务是一个单机版本,之前我测过单机版的redis并发量大概是7w的qps左右(本机测试,非官方证实),一旦超出这个范围则可能会导致redis的宕机,接着发生缓存的雪崩效应。
解决方案
1.保证redis的高可用性(例如使用redis哨兵架构或redis集群架构模式)
2.使用限流降级组件为服务进行降级(例如使用sentinel或者Hystrix组件)
3.提前演练(例如提前演练redis发生宕机等场景,然后做出适当的解决方案出来)
4.缓存数据的过期时间设置随机,防止同一时间大量数据过期
还是按照上面的场景说,我们完全可以给同一时刻下架的商品,在redis里面的缓存过期时间设置的过期时间各不一样,这样就不会导致大量的key同一时刻全部失效的问题。
可以看一下伪代码示例:
代码语言:javascript复制public String getCommodity(String commodityId) {
//从缓存中获取数据 String cacheValue = stringRedisTemplate.opsForValue().get(commodityId);
//如果缓存为空 if (StringUtils.isBlank(cacheValue)) {
//从db获取数据 String dbValue = commodityMapper.get(commodityId);
//将db查询出的结果缓存到redis中 stringRedisTemplate.opsForValue().set(commodityId, dbValue);
//如果db查询的值为null,则为缓存设置一个过期时间(随机时间在300-600秒之间) int expireTime = new Random().nextInt(300) 300; if (StringUtils.isBlank(dbValue)) { stringRedisTemplate.expire(commodityId, expireTime, TimeUnit.SECONDS); } return dbValue; } else { return cacheValue; }
}
缓存双写不一致问题
什么是缓存与数据库双写不一致问题
在并发情况下只要使用了缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一 定会有数据一致性的问题
场景
1.双写不一致情况
如上图情况,线程1将库存数量改为10并写入到数据库中,因为业务耗时原因,导致没有及时写入到缓存中,在延时的这个时间点,线程2又进来修改库存值为5,然后将缓存更新5了,但是恰好线程1业务执行完,又将缓存的库存数量更新为10,那么这就导致数据库与缓存的数据不一致的情况了。
2.并发读写不一致情况
如上图情况,线程1没有问题,我们看到线程2和线程3,线程2查询缓存肯定是为空的,然后再查询数据库,这个时候,业务发生了耗时操作,导致redis延时更新,那么再这个时间点,线程3又进来进行DB操作了,将库存改为5且删掉了缓存,不久线程2业务逻辑执行完成,修改缓存库存为10,那么这个时候就出现数据不一致的情况了。
解决方案
1.对于并发几率很小的业务场景,很少会发生会发生双鞋不一致的情况,可以采取给redis的key设置一个过期时间,每隔一段时间主动更新key的数据即可
2.如果不能非常不能容忍数据不一致的情况,我们考虑使用读写锁并发写或者写写的时候按顺序进行排好队,读读几乎相当于无锁(效率也还行的,redisson就有读写锁)
3.使用cannal中间件,监控mysql的binlog日志及时去修改缓存数据,不好的一点则是增加了系统的复杂度
我是黎明大大,我知道我没有惊世的才华,也没有超于凡人的能力,但毕竟我还有一个不屈服,敢于选择向命运冲锋的灵魂,和一个就是伤痕累累也要义无反顾走下去的心。
●Redis哨兵架构搭建以及详解
●Redis主从架构的搭建
●深入理解Redis的持久化机制
●Redis集群搭建及原理解剖
●我们所了解的Redis分布式锁真的就万无一失吗?
●手把手教你如何在CentOS7环境下安装部署Redis