Redis作为当下最火热的非关系型数据库之一,很多项目的数据缓存都已经离不开它;
然而当我们在使用Redis时会遇到一些意外情况影响数据同步的一致性,从而影响到项目的数据查询的正确性;下面是使用Redis时的常见问题以及解决方案:
Redis脑裂,顾名思义,就是同时出现了两个“大脑”主导,在Redis上就是短时间内同时出现了两个Master;两个Master的后果就是会造成旧Master数据丢失;以下是Redis正常工作状态的示意图:
当Redis的主机端由于网络问题导致主机端的网络分区与sentinel哨兵和slave从主机端网络分区不同时,sentinel哨兵由于无法感知Master的存在,便会开始向所有从主机端获取状态挑选新主机端;
但此时需要注意的是原主机端依旧是正常的(没有挂机),这就说明客户端仍然可以向Master写入数据;此时整个Redis访问结构中存在两个主机端;这种现象就是Redis脑裂;
在Redis脑裂中,如果客户端依然基于原来的Master写入数据,那么当网络问题恢复后,Master再次和其他端口同属一片网络分区;尽管它是主机端,但整个访问结构中已经存在Master2这个新主机端,所以sentinel哨兵会将Master降为从主机端Slave2,然后再从Master2同步数据;
这样就会导致,客户端一直是基于Master写入数据的,Master2中只有网络出问题之前从Master同步的数据;网络出问题期间的数据在Master,但是Master在网络问题解决后又被降为从主机端Slave2然后同步Master2的数据,所以网络出问题期间的数据就会丢失;
在海量数据读写操作的场景下,几毫秒的脑裂时间都可能导致超大量的数据丢失,所以需要避免这种情况;
下面是Redis脑裂的解决方案:
在redis.conf中配置两个参数:
min-replicas-to-write 1
min-replicas-max-lag 5
第一个参数的含义:主机端最少要和1个slave从主机端连接才会执行写入操作
第二个参数的含义:主机端向slave从主机端同步复制数据的延迟不能超过5秒,否则不执行写入操作
添加参数后,访问结构如下:
配置参数后,根据上面的例子分析,当Master主机端和其他访问端处于不同的网络分区时,由于Master与Slave断联,无法将Master主机端内的数据复制同步到Slave从主机端,所以Master主机端会拒绝写入操作;这样一来,网络出问题期间的写入数据操作会全部在Master2新主机端上执行,从而不会造成数据丢失
好不容易写完了一个项目,结果进行高并发测试时就发现数据库宕机了?
仔细想想原因,一定是短时间内数据库涌入大量的数据,那我的Redis是没用的吗?
那是因为没有进行缓存预热!在项目刚刚开始进行运转时,Redis中是没有任何缓存的!
因为还没有执行过数据交互!如果在项目的上线初期就有大量的数据涌入,又没有缓存预热的情况下,这些数据都会毫无征兆地冲进数据库,最后数据库承受不住就导致了宕机。。。
所以缓存预热真的很有必要!
综上所述,缓存预热指的就是在项目上线前,一定要先向Redis添加一定的数据缓存,专业点就是预加载一些数据;
如果数据量非常大,我们不可能把所有数据都添加到Redis中,那么我们需要统计访问频率偏高的热点数据,实时将这些热点数据写入Redis中;
在分布式系统中,我们可以通过多个服务并行读取热点数据,实时将热点数据写入到Redis中进行缓存预热
在正常的查询数据场景中,我们都是先去查本地缓存,然后查Redis分布式缓存,还查不到才会去查询数据库;这样有效大幅减少了数据库的访问量,从而大大减少了宕机的可能性;
然而,在查询数据库时你能确保每次都能查到你想要的数据吗?显然不能;
例如查询 id 为 -1 的数据,显然数据库中是不存在这条数据的,数据库中没有Redis缓存和本地缓存更不可能有;
所以发送一次查询数据库中不存在数据的请求,Redis中不存在这个数据,请求直通数据库,就会浪费数据库的一次查询资源;
在正常的生产环境中,少量的资源浪费不可避免;
但是有些不法分子利用这种现象,在明知查不到该数据的情况下依然不断发送大量请求查询该数据;
这就会导致数据库因为受到恶意攻击而瘫痪!!!
因为Redis也是没有这条数据的,所以每次的恶意请求都能穿过Redis访问数据库;
这种通过发送大量无用请求穿透Redis恶意攻击消耗数据库资源的行为称作Redis的缓存穿透!
当数据库查不到指定的数据时会返回一个空值null给服务端Service,这时可以在Redis中存入该不存在数据的空值结果缓存,并且缓存时间不超过五分钟;
但显然,只要查询条件变得多样化,这种机制一样会瘫痪,比如 id 为 -2 、-3.....
所以这并不是一种很好的解决办法;
接下来登场的是一种全新的数据结构:布隆过滤器!
简单介绍一下布隆过滤器,就是一种以BitMap为底层实现的概率型数据结构,在数据的插入和查询方面效率嘎嘎高;
BitMap是一种用二进制位来储存元素状态的一种数据结构,单个二进制位只有两个值0和1,所以它可以存储元素是否存在的状态;
其次由于是位存储,一个字节有八个二进制位,意味着一个字节可以存储8个不同元素的存在状态;所以BitMap结构可以用更少的空间存储更多的元素存在状态;
讲了这么多,举个例子就知道了:
假设要处理一亿个用户的签到状态,每个用户的签到状态占用1位,则一亿用户的状态需要1亿/8字节=12500000字节=12.5MB的内存;
在项目中,通俗点讲,就是可以在布隆过滤器中查询数据,它会给你返回 true 存在和 false 不存在;
它说数据存在证明数据不一定存在,它说不存在该数据一定不存在!
这里的误判跟BitMap的映射机制和哈希函数有关,感兴趣的可以自行了解(对我来说太难了);
回到缓存穿透的问题上,基于这个特质,我们就可以使用它在解决缓存穿透问题上大展身手啦!
我们可以先将所有的数据加入布隆过滤器,然后在服务端向数据库发送查询请求时,先在布隆过滤器中判断该数据是否存在,如果存在再继续向Redis和数据库发送查询请求,如果不存在直接返回,不向数据库发送无用请求;
这时候肯定有人发问:如果布隆过滤器返回 true ,然后查询后不存在,不是照样造成浪费了吗?
布隆过滤器发生误判的概率其实很小,在过滤无用请求这种场景面前它的误判造成的少量资源浪费可以忽略不计,这就是我们在缓存穿透这种场景下用它的原因;
讲了这么多,那我们怎么通过代码使用布隆过滤器呢?
由于我主学的语言是Java,所以在这里我演示的是通过Java实现布隆过滤器:
第一步:我们要在项目的依赖中添加布隆过滤器相关依赖
<!-- 引入Hutool包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
第二步:Java实现布隆过滤器(测试版)
// 初始化 注意 构造方法的参数大小10 决定了布隆过滤器BitMap的大小
BitMapBloomFilter filter = new BitMapBloomFilter(10);
//向布隆过滤器中添加数据
filter.add("123");
filter.add("abc");
filter.add("ddd");
//判断数据是否存在于布隆过滤器中
boolean abc = filter.contains("cba");
System.out.println(abc);
众所周知,在一个项目中总会存在一些查询频率极高的热点数据;Redis恰恰就是处理这种查询频率高的数据的缓存机制,它可以极大地减少数据库的查询次数,还能加快查询速度
然而,在Redis中的数据缓存总有过期时间;如果一个热点数据在项目运行中过期了,会造成什么后果呢?
此时服务端的查询请求在Redis查不到对应的缓存后,会到数据库查询;
其实这还挺正常,问题就出在过期的是热点数据,在高并发的项目环境中,这个数据过期的一瞬间,可能会有成千上万的请求同时请求缓存;没有对应的缓存,一大批量请求全部访问数据库
数据库会承受巨大的压力,极大可能会被压垮,这种由于单个热点数据过期引发的数据库崩盘属于缓存击穿。
对热点数据设置永不过期;
在缓存即将过期前更新缓存,避免失效
这是最最最重要的解决方案!!!
这个方案的总体是:在多个线程同时请求Redis中过期的热点数据时,只让其中一个线程拿到唯一标识(锁)并进入数据库中查询;
查询到数据后将该数据加入Redis缓存,最后放开锁,后面请求该热点数据的线程都已经能从缓存中获取数据;
这样对数据库的访问只有一个线程,不会造成数据库压力
那么我们怎么代码实现互斥锁呢?
首先我们要知道,在Redis创建键的命令中,有一个setnx的命令;
这个命令的作用是创建一个之前没有创建过键名的键;
如果之前有相同键名的键被创建,这个命令执行失败;
这种唯一标识的特质恰好很适用于锁的机制,因为锁机制就是要求有一个唯一标识才能执行;
所以我们可以让请求线程持有一个唯一标识才能访问数据库并由这个线程加缓存,这样就避免了大量相同的请求访问数据库;
下面使用Java代码实现互斥锁:
1、首先创建 trylock 方法:在Redis中创建一个唯一键,并以它作为线程访问数据库的唯一标识
在这里我们还需要设置这个唯一键的过期时间,防止死锁
返回值为布尔值,Redis中如果没有就创建唯一键并返回true,如果有这个唯一键直接返回false
private final String LOCK_SUFFIX = "lock:";
//初始化trylock方法,传入键名,键值,定时时间,时间单位四个参数
public boolean trylock(String key , String value , Integer timeout , TimeUnit timeUnit){
//调用setIfAbsent方法创建唯一键,和在setnx命令作用相同
return redisTemplate.opsForValue().setIfAbsent(LOCK_SUFFIX+key,value,timeout,timeUnit);
}
2、创建获取用户方法(示例)
@Autowired
private UserMapper userMapper;
@Autowired
private RedisTemplate<String , Object> redisTemplate;
//参数要带上查询的条件,这里的key指的是用户的id,当然条件也可以改成其他
public User getUser(String key) throws Exception {
//首先查询Redis缓存
User user = (User) redisTemplate.opsForValue().get("user:" + key);
//判断数据是否被查到
if(user != null){
//如果查到直接返回数据
return user;
}else{
//如果没查到,使用trylock方法判断是否该线程是否含有唯一标识
//这里只有一个线程能创建这个键;后面来的线程trylock会返回false,因为这个键已经存在了
Boolean b = trylock(key,"1",100,TimeUnit.SECONDS);
//判断是否获得唯一标识
if(b){
//如果有,访问数据库查询数据
User user1 = userMapper.selectById(key);
//然后加到缓存中
redisTemplate.opsForValue().set("user:"+key,user1);
//将查到的数据返回给服务端
return user1;
}else{
//如果没有,睡两秒
Thread.sleep(2000);
//重新执行该方法
//重新执行后缓存中已有数据,因为标识线程已经将数据加到Redis缓存中
return getUser(key);
}
}
}
这样就实现了互斥锁,在同一时间内它只允许一个线程访问数据库,从而防止了大量相同请求访问数据库的情况
Redis的四种企业级解决方案,分别是脑裂,缓存预热,缓存穿透,缓存击穿;在企业的面试中这些问题的提及率居高不下,可以说每个合格的后端工程师都必须了解这些解决方案;
其实还有一个缓存雪崩,但是鉴于我还没有系统学过用过,在这里就先不写了;文章中的有一些内容是我的个人观点,望请各位大佬批评指正!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。