声明: 本文是在作者学习黑马程序员的课程的同时,结合黑马程序员所提供的资料所总结出来的有关redis中缓存和集群的一些知识点,其中关于集群主要阐述理论,对于其实现方法没有进行代码实现。侵权删。
课程链接如下:
目录
缓存
缓存就是数据交换的缓冲区(称作Cache [ ] ),是存贮数据的临时地方,一般读写性能较高
缓存穿透
什么是缓存穿透?
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,如果一次性有非常多的请求同时访问数据库,就可能会导致数据库宕机。
解决方法
1.缓存空对象
当客户端请求不存在的数据时,会首先请求redis中的缓存,如果缓存中不存在,就会请求数据库,如果数据库中也不存在,在数据库将查询结果返回给客户端时,也需要给redis返回一个对象,这样,下次客户端再次访问这个不存在的数据,就可以在redis中找到这个数据,就不需要去访问数据库。
例如以模拟写入商品信息为例:
我们只需要在数据库获取数据时,如果取到空值,不直接返回404,而是将空值("")也存入redis中,并且在判断缓存是否命中时,判断命中的值是不是我们传入的空值。如果是,直接结束,不是就返回商铺信息
@Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public Result queryShopById(Long id) { //1.去redis中查询商品是否存在 String key = CACHE_SHOP_KEY+id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)){ //2.存在,直接返回给用户 Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } //判断命中的是否为空字符串 /** * (这里重点讲一下为什么是不等于:因为我们获取到的shopJson为null是,上面的isNotBlank方法会返回false,导致成为了不命中的效果) */ if (shopJson != null){ //说明 shopJson=="" 是一个空值,这就是我们缓存数据库中不存在的那个数据 return Result.fail("商品信息不存在!"); } //3.不存在,带着id去数据库查询是否存在商品 Shop shop = getById(id); if (shop == null){ // 4.将空值存入redis stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES); //5.不存在,返回错误信息 Result.fail("商品信息不存在!"); } //6.存在,存入redis(并设置超时时间) stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES); //7.返回商品信息 return Result.ok(shop); } }
优点:实现简单,维护方便
缺点:额外的内存消耗、可能造成短期的不一致
2.布隆过滤
布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,假设布隆过滤器判断这个数据不存在,则直接返回。
bitmap(位图):相当于是一个以 位(bit)为单位的数组,数组中每个单元只能存储二进制的 0 或 1。
布隆过滤器使用多个hash函数获取hash值,根据hash值将数组对应位置改为1。
在查询一个数据是否存在时,根据这个数据的hash值使用hash获取hash值,判断计算的到的多个hash值在数组中对应的位置是否都为1,如果都为1,则表示存在,否则表示不存在。
因为布隆过滤采用了hash计算,那么就无法避免hash冲突,如果一个数据不存在的hash值计算出来的位置在数组中恰好已经是1了,那么就会产生误判的问题,这个不存在的数据就会造成缓存穿透问题
为了减轻误判问题带给数据库的压力,我们一般可以设置布隆过滤的误判率,大概不会超过5%,这样小概率的误判不至于在高并发的情况下给数据库带来过大的压力。
误判率:数组越小误判率就越大,数组越大误判率就越小,但同时带来了更多的内存消耗。
优点:内存占用较少,没有多余key
缺点:实现复杂,存在误判可能
Guava工具实现布隆过滤器
guava是由谷歌公司提供的工具包,里面提供了布隆过滤器的实现。
1.导入依赖
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>30.1.1-jre</version> </dependency>
2.测试代码
public static void main(String[] args) { // 初始化布隆过滤器,设计预计元素数量为100_0000L,误差率为1% BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), 100_0000, 0.01); int n = 100_0000; for (int i = 0; i < n; i++) { bloomFilter.put(String.valueOf(i)); } int count = 0; for (int i = 0; i < (n * 2); i++) { if (bloomFilter.mightContain(String.valueOf(i))) { count++; } } System.out.println("过滤器误判率:" + 1.0 * (count - n) / n); }
执行结果:
过滤器误判率:0.010039
Redis实现布隆过滤
redis中借助Redisson中封装好的方法实现布隆过滤
1.导入依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.17.4</version> </dependency>
2.测试代码
public static void main(String[] args) { //配置Redisson Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); // redis有密码时打开 config.useSingleServer().setDatabase(0); RedissonClient client = Redisson.create(config); //使用Redisson创建一个布隆过滤器,名字为bulon RBloomFilter<Object> bloomFilter = client.getBloomFilter("bulon"); //初始化布隆过滤器,设计预计元素数量为1000000(100万),误差率为1% int n = 1000000; bloomFilter.tryInit(n, 0.01); for (int i = 0; i < n; i++) { bloomFilter.add(String.valueOf(i)); } int count = 0; for (int i = 0; i< (n * 2); i++) { if (bloomFilter.contains(String.valueOf(i))) { count++; } } System.out.println("过滤器误判率:" + 1.0 * (count - n) / n); }
输出:
过滤器误判率:0.0211
缓存击穿
什么是缓存击穿?
redis缓存中一个存在过期时间(ttl)的key,这个key在过期的同时,有大量的并发请求来访问这个key,这个时候redis中已经不存在这个key的缓存了,所以这些大量的并发请求就只能去数据库中进行查询,这一瞬间的大量并发请求可能直接将数据库压垮。
这种一般存在于热点代码
解决方法
1.加互斥锁
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
代码模拟实现
private boolean tryLock(String key) { //使用redis中的setnx命令 Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } private void unlock(String key) { stringRedisTemplate.delete(key); }
public Shop queryWithMutex(Long id) { String key = CACHE_SHOP_KEY + id; // 1、从redis中查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get("key"); // 2、判断是否存在 if (StrUtil.isNotBlank(shopJson)) { // 存在,直接返回 return JSONUtil.toBean(shopJson, Shop.class); } //判断命中的值是否是空值 if (shopJson != null) { //返回一个错误信息 return null; } // 4.实现缓存重构 //4.1 获取互斥锁 String lockKey = "lock:shop:" + id; Shop shop = null; try { boolean isLock = tryLock(lockKey); // 4.2 判断否获取成功 if(!isLock){ //4.3 失败,则休眠重试 Thread.sleep(50); return queryWithMutex(id); } //这里还要再次判断是否存在 双重检索 //4.4 成功,根据id查询数据库 shop = getById(id); // 5.不存在,返回错误 if(shop == null){ //将空值写入redis stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES); //返回错误信息 return null; } //6.写入redis stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES); }catch (Exception e){ throw new RuntimeException(e); } finally { //7.释放互斥锁 unlock(lockKey); } return shop; }
2.逻辑过期
我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis 的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据,比如线程4,在线程2释放锁之后,就可以返回新的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据(已经过期的数据)。
代码实现
封装一个实体类,加入逻辑过期时间
@Data public class RedisData { private LocalDateTime expireTime; private Object data; }
//功能代码 private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); public Shop queryWithLogicalExpire( Long id ) { String key = CACHE_SHOP_KEY + id; // 1.从redis查询商铺缓存 String json = stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isBlank(json)) { // 3.存在,直接返回 return null; } // 4.命中,需要先把json反序列化为对象 RedisData redisData = JSONUtil.toBean(json, RedisData.class); Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); // 5.判断是否过期 if(expireTime.isAfter(LocalDateTime.now())) { // 5.1.未过期,直接返回店铺信息 return shop; } // 5.2.已过期,需要缓存重建 // 6.缓存重建 // 6.1.获取互斥锁 String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); // 6.2.判断是否获取锁成功 if (isLock){ CACHE_REBUILD_EXECUTOR.submit( ()->{ try{ //重建缓存-新建一个线程去执行 this.saveShop2Redis(id,20L); }catch (Exception e){ throw new RuntimeException(e); }finally { unlock(lockKey); } }); } // 6.4.返回过期的商铺信息 return shop; }
缓存雪崩
什么是缓存雪崩?
在同一时间有大量的key过期或者redis宕机,导致大量的请求全部转发到数据库,造成数据库的严重压力
缓存雪崩是同一时间大量key过期,缓存穿透是某一个key过期
解决方法
-
给不同的Key的TTL添加随机值
-
利用Redis集群提高服务的可用性 (哨兵模式、集群模式)
-
给缓存业务添加降级限流策略 (nginx或spring cloud gateway)
-
给业务添加多级缓存 (Guava或Caffeine)
双写一致
什么是双写一致?
当修改数据库中的数据时,也要同时更新Redis缓存中的数据,数据库和redis中的数据要保持一致。
实现方法
先删除缓存还是先更新数据库?
其实不论是先删除缓存还是先更新数据库,在多线程下都会产生问题,问题显示如下
1.先删除缓存,再更新数据库
线程1先执行修改操作,首先先删除redis中的缓存,但此时线程2执行读操作,因为线程是交替执行的,所以此时线程2开始执行,因为redis中的缓存已经删除了,线程2就会去数据库中进行查询并写入缓存的操作,线程2时间片结束,线程1开始执行,执行修改数据库中的数据操作。这一套流程下来,就导致数据库和缓存中的数据不一致,下一次查询时还是返回redis中的脏数据。
2.先更新数据库,后删除缓存
线程1执行查询操作,线程2执行更新操作,这种情况是假设查询的数据设置了过期时间,并且在查询的时候已经过期了。
前置条件:缓存刚好失效 (1)线程1查询数据库,得一个旧值 (2)线程2将新值写入数据库 (3)线程2删除缓存 (4)线程1将查到的旧值写入缓存
发生上述情况有一个先天性条件,就是步骤(2)的写数据库操作比步骤(1)的读数据库操作耗时更短,才有可能使得步骤(3)先于步骤(4)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(2)耗时比步骤(1)更短,这一情形很难出现。
所以一般情况下,使用这种方式就可以了
方法1:延时双删
但是为了保证不会出现更新数据库后,数据库和缓存中的数据不一致的情况下,我们可以使用延时双删的方法
步骤:
1)先删除缓存
2)再修改数据库
3)休眠(一定的时间)毫秒
4)再次删除缓存
针对上面的情形,我们应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
如果数据库使用了使用mysql的读写分离架构怎么办?
在这种情况下,还是两个线程发起请求,一个线程1进行更新操作,另一个线程2进行查询操作。 (1)线程1进行写操作,删除缓存 (2)线程1将数据写入数据库了, (3)线程2查询缓存发现,缓存没有值 (4)线程2去从库查询,这时,还没有完成主从同步,因此查询到的是旧值 (5)线程2将旧值写入缓存 (6)数据库完成主从同步,从库变为新值
(7)延迟一段时间后,在次删除缓存
但是延迟双删问题因为延时时间的不确定性,还是有可能出现脏数据的情况
方法2:加互斥锁
因为一般放入缓存中的数据都是读多写少,所以我们可以在读取redis中的数据时,加入共享锁,在修改redis中的数据时加入排他锁
读操作
写操作
这种方法保证了数据的强一致性,但是性能较低
方法3:异步通知
异步通知保证数据的最终一致性
(1)基于MQ的异步通知:使用MQ中间件,更新数据之后,通知缓存删除;
(2)基于Canal的异步通知:利用canal中间件,不需要修改业务代码,伪装为mysql的一个从节点,canal通过读取binlog数据更新缓存;
备注:二进制日志(BINLOG)记录了所有的DDL(数据定义语言)语句和DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句;
持久化
1.RDB持久化
RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。快照文件称为RDB文件,默认是保存在当前运行目录。
1.1.执行时机
RDB持久化在四种情况下会执行:
-
执行save命令
-
执行bgsave命令
-
Redis停机时
-
触发RDB条件时
1)save命令
执行下面的命令,可以立即执行一次RDB:
save命令会导致主进程执行RDB,这个过程中其它所有命令都会被阻塞。只有在数据迁移时可能用到。
2)bgsave命令
下面的命令可以异步执行RDB:
这个命令执行后会开启独立进程完成RDB,主进程可以持续处理用户请求,不受影响。
3)停机时
Redis停机时会执行一次save命令,实现RDB持久化。
4)触发RDB条件
Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下:
# 900秒内,如果至少有1个key被修改,则执行bgsave , 如果是 save "" 则表示禁用RDB save 900 1 save 300 10 save 60 10000
RDB的其它配置也可以在redis.conf文件中设置:
# 是否压缩 ,建议不开启,压缩也会消耗cpu,磁盘的话不值钱 rdbcompression yes # RDB文件名称 dbfilename dump.rdb # 文件保存的路径目录 dir ./
1.2.RDB原理
bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入 RDB 文件。
fork采用的是copy-on-write技术:
-
当主进程执行读操作时,访问共享内存;
-
当主进程执行写操作时,则会拷贝一份数据,执行写操作。
1.3.小结
RDB方式bgsave的基本流程?
-
fork主进程得到一个子进程,共享内存空间
-
子进程读取内存数据并写入新的RDB文件
-
用新RDB文件替换旧的RDB文件
RDB会在什么时候执行?save 60 1000代表什么含义?
-
默认是服务停止时
-
代表60秒内至少执行1000次修改则触发RDB
RDB的缺点?
-
RDB执行间隔时间长,两次RDB之间写入数据有丢失的风险
-
fork子进程、压缩、写出RDB文件都比较耗时
2.AOF持久化
AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。
2.1.AOF配置
AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF:
# 是否开启AOF功能,默认是no appendonly yes # AOF文件的名称 appendfilename "appendonly.aof"
AOF的命令记录的频率也可以通过redis.conf文件来配:
# 表示每执行一次写命令,立即记录到AOF文件 appendfsync always # 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案 appendfsync everysec # 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘 appendfsync no
三种策略对比:
2.2.AOF文件重写
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
如图,AOF原本有三个命令,但是set num 123 和 set num 666
都是对num的操作,第二次会覆盖第一次的值,因此第一个命令记录下来没有意义。
所以重写命令后,AOF文件内容就是:mset name jack num 666
Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:
# AOF文件比上次文件 增长超过多少百分比则触发重写 auto-aof-rewrite-percentage 100 # AOF文件体积最小多大以上才触发重写 auto-aof-rewrite-min-size 64mb
3.RDB与AOF对比
RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。
总结
RDB是一个快照文件,它是把redis内存存储的数据写到磁盘上,当redis实例宕机恢复数据的时候,方便从RDB的快照文件中恢复数据。
AOF的含义是追加文件,当redis操作写命令的时候,都会存储这个文件中,当redis实例宕机恢复数据的时候,会从这个文件中再次执行一遍命令来恢复数据
RDB因为是二进制文件,在保存的时候体积也是比较小的,它恢复的比较快,但是它有可能会丢数据,我们通常在项目中也会使用AOF来恢复数据,虽然AOF恢复的速度慢一些,但是它丢数据的风险要小很多,在AOF文件中可以设置刷盘策略,我们当时设置的就是每秒批量写入一次命令
数据过期策略
Redis中使用的过期策略:惰性删除和定期删除配合使用
1.惰性删除
设置key的过期时间之后,我们不去管他,当我们需要使用这个key的时后才去检查它的过期时间,如果已经过期,就删除。
优点:对CPU友善,只会在使用key的时候进行过期检查,对于很多用不到的key不用浪费时间去检查
缺点:对内存不友好,如果一个key已经过期,但是一直没有使用,这个key就会一直存在内存中,不会释放。
2.定期删除
每隔一段时间,我们就从redis中取出一定数量的key进行检查,删除其中过期的key
定期删除有两种模式
(1)SLOW模式
SLOW模式是定时任务,执行频率默认为10hz,每次的执行时间不超过25ms,可以通过修改配置文件reids.conf中的hz选项来进行调整
(2)FAST模式
FAST模式执行频率不固定,但两次执行的间隔不低于2ms,每次耗时不超过1ms
优点:可以通过限制删除操作的执行时长和频率来减少对CPU的影响,也可以有效释放过期key占用的内存
缺点:难以确定删除操作的执行时长和频率
数据淘汰策略
当Redis中的内存使用超过最大使用内存后,再次插入数据,就会使用内存淘汰策略删除,来保证redis的正常运行
8种淘汰策略
redis中的淘汰策略分为以下8种
(1)noeviction(不淘汰): 当内存不足以容纳新写入数据时,新写入操作会报错。 (2)allkeys-lru(最近最少使用): 从键空间中选择最近最少使用的键淘汰。 (3)allkeys-lfu(最不经常使用): 从键空间中选择最不经常使用的键淘汰。 (4)allkeys-random(随机淘汰): 从键空间中随机选择键淘汰。 (5)volatile-lru(最近最少使用): 从设置了过期时间的键中选择最近最少使用的键淘汰。 (6)volatile-lfu(最不经常使用): 从设置了过期时间的键中选择最不经常使用的键淘汰。 (7)volatile-random(随机淘汰): 从设置了过期时间的键中随机选择键淘汰。 (8)volatile-ttl(最短过期时间): 从设置了过期时间的键中选择离过期时间最近的键淘汰。
实际就是三大类,noeviction、allkey-xxx、volatile-xxx
LRU与LFU
LRU
LRU(Least Recently Used)最近最少使用。用当前时间减去最后一次访问时间,值越大的则优先淘汰。
LRU 策略维护一个访问时间顺序的队列,每次访问数据时,将数据移动到队列的末尾。当需要淘汰数据时,从队列的头部淘汰最久未被访问的数据。
LFU
LFU(Least Frequently Used)最近最少频率使用。会统计每个key在最近一段时间内的访问频率,值越少则优先淘汰
LFU 策略需要为每个数据维护一个访问频率计数器,每次访问数据时,将对应的计数器值加一。当需要淘汰数据时,选择访问频率最低的数据进行淘汰。
集群
在Redis中提供的集群方案有三种,分别是:主从集群,哨兵集群,分片集群
主从集群
可以实现高并发读
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离,主从集群一般是1主多从,主节点负责写操作,从节点负责读操作。
主从数据同步原理
全量同步
主从第一次建立连接时,会执行全量同步,将master节点的所有数据都拷贝给slave节点,流程:
-
Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
-
offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新
因为slave原本也是一个master,有自己的replid和offset,当第一次变成slave,与master建立连接时,发送的replid和offset是自己的replid和offset。
master判断发现slave发送来的replid与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了。
master会将自己的replid和offset都发送给这个slave,slave保存这些信息。以后slave的replid就与master一致了。
因此,master判断一个节点是否是第一次同步的依据,就是看replid是否一致
完整流程描述:
-
slave节点请求全量同步
-
master节点判断replid,发现不一致,执行全量同步
-
master将完整内存数据生成RDB,发送RDB到slave
-
slave清空本地数据,加载master的RDB
-
master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
-
slave执行接收到的命令,保持与master之间的同步
增量同步
全量同步需要先做RDB,然后将RDB文件通过网络传输给slave,成本太高了。因此除了第一次做全量同步,其它大多数时候slave与master都是做增量同步。
什么是增量同步?就是只更新slave与master存在差异的部分数据。如图:
流程
1.从节点请求主节点同步数据,主节点判断不是第一次请求,获取从节点的offset值
2.主节点从repl_baklog中获取offset值之后的数据,发送给从节点进行数据同步
master怎么知道slave与自己的数据差异在哪里呢?
这就要说到repl_baklog文件了。
这个文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。
repl_baklog中会记录Redis处理过的命令日志及offset,包括master当前的offset,和slave已经拷贝到的offset:
红色为未同步的数据,绿色为已经同步的数据
slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。
随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset,直到数组被填满,此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步到slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是红色部分。
但是,如果slave出现网络阻塞,导致master的offset远远超过了slave的offset,如果master继续写入新数据,其offset就会覆盖旧的数据,直到将slave现在的offset也覆盖:
棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset都没有了,无法完成增量同步了。只能做全量同步。
repl_baklog的大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次做全量同步。
哨兵集群
实现高可用和高并发读
Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。
哨兵集群结构和作用
哨兵集群的结构就是在主从集群的基础上加入了哨兵
哨兵的作用如下:
-
监控:Sentinel 会不断检查您的master和slave是否按预期工作
-
自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
-
通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端
集群监控原理
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
•主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
•客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。
集群故障恢复原理
一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:
-
首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
-
然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
-
如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
-
最后是判断slave节点的运行id大小,越小优先级越高。
当选出一个新的master后,该如何实现切换呢?
流程如下:
-
sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master
-
sentinel给所有其它slave发送slaveof 192.168.150.101 7002 命令,让这些slave成为新master的从节点,开始从新的master上同步数据。
-
最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点
集群脑裂问题
集群脑裂是由于主节点由于网络延迟等原因不能响应sentinel的心跳检测,所以sentinel就会通过选举的方式提升一个slave节点为主节点,此时集群中就会出现两个master节点,就像大脑分裂了一样,但此时客户端连接的主节点依旧是老的主节点,客户端与这个老的主节点之间进行写操作,不能与新的主节点同步数据,如果网络恢复,老的master节点重新被sentinel检测到,sentinel就会将这个老的主节点降为从节点,就会导致老的主节点中的数据无法同步,产生数据丢失问题。
解决:我们可以修改redis中的配置,可以设置最少的从节点数量以及缩短主从数据同步的延迟时间,达不到要求就拒接请求,就可以避免大量的数据丢失。
分片集群
主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:
-
海量数据存储问题
-
高并发写的问题
使用分片集群可以解决上述问题,如图:
分片集群特征:
-
集群中有多个master,每个master保存不同数据
-
每个master都可以有多个slave节点
-
master之间通过ping监测彼此健康状态
-
客户端请求可以访问集群任意节点,最终都会被转发到正确节点
哈希槽
Redis分片集群引入了哈希槽的概念,Redis集群有16384个哈希槽,数据key不是与节点绑定,而是与插槽绑定。redis会根据key的有效部分计算插槽值,分两种情况:
-
key中包含"{}",且“{}”中至少包含1个字符,“{}”中的部分是有效部分
-
key中不包含“{}”,整个key都是有效部分
例如:key是name,那么就根据name计算,如果是{aaa}name,则根据aaa计算。计算方式是利用CRC16算法得到一个hash值,然后对16384取余,得到的结果就是slot值。