Redis (3)主从复制、集群、Redis常见问题

Redis(3)主从复制、Redis应用问题(缓存穿透、击穿、雪崩,实现分布式锁)

redis主从复制

image-20220724165919755
  • master/slaver机制:master数据更新后根据配置和策略,自动同步slave。master以写为主,slave以读为主,实现读写分离,容灾快速恢复

  • 配置过程:

    • 开启daemonize、修改Pid文件名 pidfile、指定端口port、修改Log文件名、修改dbfilename、Appendonly 关掉或者换名字
    • slave-priority:从机的优先级,越小优先级越高,选举主机时使用。默认100
    • 查看主从配置信息:info replication
    • 从机执行slaveof <ip> <port>成为某个实例的slave——会停止从旧的主节点复制,丢弃已经同步的旧的数据,并开始从新的节点同步数据。如果slaveof no one,则slave成为master,不丢弃已复制的数据(Redis 5.0后,用replicaof)
    • master挂掉,重启即可;slave挂掉需重设,或者修改conf文件以永久生效
  • 一主二从时,主机挂掉,重启后仍为master并且有两个slave,slave仍然为slave

  • 链式时,将master去中心化,a为master,b为a的slave,c为b的slave;如果一个slave的master换了一个ip,则slave会清除前面的数据,重新拷贝最新的

复制原理

  • slave连接到master后发送一个psync命令(三个组件支持,主从节点各自的offset,主节点的复制积压缓冲区,主节点的runid)

    • offset:master和slave会维护自己的主从复制偏移量,master有写入命令时,offset=offset+命令的字节长度,slave收到master的命令后,也会增加自己的offset,并将offset发送给master,此时master同时有自己和slave的offset,以判断是否数据一致获得原master数据最全

    • 复制积压缓冲区:master中一个固定长度的先进先出队列,默认大小1MB,slave连接时创建。master响应写命令时,命令发送给从节点的同时也写入复制缓冲区

    • runid:每个redis实例启动后都会随机生成一个40位的runid

      image-20220724204517953
  • master接到命令启动bgsave,持久化得到rdb文件,传送rdb到slave,到slave加载数据完成这段时间,master的写命令放入缓冲区。slave清理自己的rdb,加载master的rdb,如果slave开启了aof,则异步rewrite aof文件,完成一次全量复制;之后master将缓冲区的数据发送给slave,实现增量复制

  • 全量复制:slave接收到数据库文件数据后,存盘并加载到内存

  • 增量复制:slave将自己的offset发送给master(offset见下文),master对比自己和slave的offset和消息中的runid,如果offset后面的数据在缓冲区中,则master将新的修改命令依次传给slave,完成同步,否则进行全量复制

  • 如果是重新连接master,将会自动执行一次全量复制

哨兵(sentinel)

image-20220724191302466
  • slave在master挂掉后,根据投票数自动成为master(上面需要主动执行slave no one)
  • sentinel实时监控所有redis实例是否可用(sentinel通常也会集群部署)
    • 每个Sentinel节点以每秒一次的频率,向它所知的master、slave以及其他的Sentinel实例发送一个PING命令
    • 如果一个实例距离最后一次有效回复PING命令的时间超过down-after-milliseconds所指定的值,那么这个实例会被Sentinel标记为主观下线
    • 如果一个master被标记为主观下线,正在监视这个服务器的所有Sentinel节点,以每秒一次的频率确认master的确进入了主观下线状态
    • 如果一个master被标记为主观下线,并且有足够数量的Sentinel(至少要达到配置文件指定的数量)在指定的时间范围内同意这一判断,那么这个master被标记为客观下线
    • 每个Sentinel会以每10秒一次的频率向它已知的所有master和slave发送INFO命令,当一个master被标记为客观下线时,Sentinel向下线master的所有slave发送INFO命令的频率,会从10秒一次改为每秒一次
    • Sentinel和其他Sentinel协商客观下线的master的状态,如果处于SDOWN状态,则投票自动选出新的master,剩余slave指向新的master进行数据复制
  • 配置(需要sentinel.conf文件)
    • 文件中sentinel monitor mymaster 127.0.0.1 6379 3,mymaster为自定义的被监控的服务器名,3为至少有多少个哨兵同意迁移,即有多少个哨兵认为mater挂掉
    • 启动:redis-sentinel ./sentinel.conf
  • 当master挂掉,根据slave-priority选举最小的为master(选择条件为:priority小的、偏移量大的、runid小的),master重启后成为slave

参考:

面试必问的 Redis:主从复制

面试官:请讲一下Redis主从复制的功能及实现原理

redis集群

  • 上面的内容,master只有一个,写入都由master负责,成为性能瓶颈

    image-20220725082757766
  • cluster通过分片方式保存key-value,保证高可用、高性能、高可扩展性,没有leader节点

  • 每个节点都拥有全部集群的信息,每个节点都要配置集群中其他节点的信息(自动配置)

  • 架构细节:

    • 所有的redis节点彼此互联(PING-PONG

    • 节点失效判定,需要集群中超过半数的节点检测失效

    • 客户端与任何一个可用redis节点直连

    • cluster把所有的物理节点映射到[0-16383]slot上,cluster维护node<->slot<->value

      • 集群创建时,给每个redis节点分配hash槽,集群总共有16384个hash槽,每个slot都有序号
      • 对于一个key,计算crc16(key)的值,对其取%16384,将key-value数据存储到指定序号的hash槽中
  • 投票:

    • 节点失效判断:集群中所有master参与投票,半数以上master与一个master节点通信超过cluster-node-timeout,认为该master节点挂掉

    • 集群失效判断:

      • 如果集群任意master挂,,且挂掉master没有slave,集群进入fail状态——集群的[0-16383]slot映射不完全时进入fail
      • 集群超过半数以上master挂掉,无论是否有slave,集群进入fail状态
  • 命令:

    • 创建集群:./redis-cli --cluster create 192.168.10.135:7001 192.168.10.135:7002 192.168.10.135:7003 192.168.10.135:7004 192.168.10.135:7005 192.168.10.135:7006 --cluster-replicas 1,其中cluster-replicas表示每个主节点有几个从节点,执行后每个节点目录下会有nodes.conf,记录所有其他节点的信息、主从节点、槽分配等

    • 连接集群:./redis-cli –h 127.0.0.1 –p 7001 –c(7001为集群中一个节点的redis端口)

    • 查看集群:cluster info

    • 查看集群节点:cluster nodes

    • 添加master:./redis-cli --cluster add-node 127.0.0.1:7007 127.0.0.1:7001

    • 重新分配hash槽(添加主节点后,需要重新分配):./redis-cli --cluster reshard 127.0.0.1:7007,输入要分配的槽数目、接收槽的结点id(这里通过cluster nodes查看7007结点id)、源节点id(可以为all)、yes(开始分配槽)

    • 为7007添加从节点7008:./redis-cli --cluster add-node 127.0.0.1:7008 127.0.0.1:7007 --cluster-slave --cluster-master-id d1ba0092526cdfe66878e8879d446acfdcde25d8

    • 删除节点(需要先将该节点占有的slot分配出去):/redis-cli --cluster del-node 127.0.0.1:7008 41592e62b83a8455f07f7797f1d5c071cffedb50

  • Jedis连接集群:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public void testJedisCluster() throws Exception {
    //创建一连接,JedisCluster对象,在系统中是单例存在
    Set<HostAndPort> nodes = new HashSet<>();
    nodes.add(new HostAndPort("192.168.10.133", 7001));
    nodes.add(new HostAndPort("192.168.10.133", 7002));
    nodes.add(new HostAndPort("192.168.10.133", 7003));
    nodes.add(new HostAndPort("192.168.10.133", 7004));
    nodes.add(new HostAndPort("192.168.10.133", 7005));
    nodes.add(new HostAndPort("192.168.10.133", 7006));
    JedisCluster cluster = new JedisCluster(nodes);
    //执行JedisCluster对象中的方法,方法和redis一一对应。
    cluster.set("cluster-test", "my jedis cluster test");
    String result = cluster.get("cluster-test");
    System.out.println(result);
    //程序结束时需要关闭JedisCluster对象
    cluster.close();
    }

redis应用问题与解决

  • 用户发起请求,系统查询redis集群,如果redis有数据则返回,如果没有则查询数据库,如果有数据则存入redis,redis再返回给用户,没有则直接返回给用户

缓存穿透

  • key对应的数据在数据库和redis中都没有,用户仍然不断发起请求,使得每次请求都会到数据库,从而压垮数据库——比如,用一个不存在的用户id获取用户信息,不论缓存还是数据库都没有

    image-20220724194918560
  • 由于redis缓存是在不命中时被动写的,并且出于容错考虑如果数据库查不到数据则不写入缓存,导致不存在的数据每次请求都要到数据库查询,失去了缓存的意义

  • 方案:

    • 业务层校验:校验用户发过来的请求参数
    • 对空值缓存:一个查询返回的数据为空(无论数据是否不存在),仍然把null缓存——null的过期时间很短,不超过五分钟
    • 设置可访问的名单(白名单):bitmaps定义一个白名单,名单id作为bitmaps的偏移量,如果访问id不在bitmaps里面则不允许访问
    • 采用布隆过滤器(Bloom Filter):
      • 过滤器是一个很长的二进制向量(位图)和一系列随机映射函数(哈希函数),用于检索一个元素是否在(或者可能在)一个集合中,空间效率和查询时间都远超过一般算法,但有一定的误识别率并且删除困难
      • 所有可能存在的数据都hash到一个足够大的Bloom Filter,一定不存在的数据会被filter拦截,避免了对底层存储的查询压力
    • 实时监控:发现Redis的命中率开始急速降低时,排查访问对象和访问的数据,设置黑名单限制访问

缓存击穿

  • 热点key(一定时间内超高并发地访问)对应的数据存在,但在redis中过期,此时大量并发请求过来,大并发的请求同时从后端DB加载数据并加载到缓存,压垮数据库——例如,秒杀时间点,大量用户访问一个商品,但商品信息在redis中过期,大量请求同时到达数据库

    image-20220724194938420
  • 方案:

    • 预先设置热门数据:一些热门数据提前存入到redis

    • 实时调整:监控热门数据,实时调整key的过期时长

    • 设置热点数据永不过期

    • 互斥锁:

      1. key的value为空时,不立即去load db,先对该key上锁(利用redis中某些带成功操作返回值的命令,如setnx,set一个mutexkey,此时该命令返回值为ok),再load db,完成后释放锁(删除这个mutexkey)

      2. 若线程B也请求该key,此时setnx命令返回值为fail,说明线程A在load db,则线程B睡眠100ms后重试

        image-20220724203340084

缓存雪崩

  • key对应的数据存在,但redis中数据大面积同时过期,或Redis宕机,此时大量请求直接发到数据库,压垮数据库(缓存雪崩与缓存击穿的区别,雪崩针对很多key缓存,击穿则是某一个热点key)

    image-20220724203434682
  • 方案:

    • 构建多级缓存:nginx缓存 + redis缓存 +其他缓存(ehcache等)

    • 使用锁或队列:加锁或者队列,保证不会有大量的线程同时对数据库读写,不适用高并发情况

    • 数据预热:对于即将来临的大量请求,提前走一遍系统,数据提前缓存在Redis中,并设置不同的过期时间

    • key的过期时间均匀分布:原有的失效时间基础上增加一个随机值

分布式锁

  • 跨JVM的互斥机制控制共享资源的访问

  • 主流实现方案:

    • 基于数据库实现分布式锁
    • 基于缓存(Redis等)——性能最高
    • 基于Zookeeper——可靠性最高
  • setnx作为分布式锁!

    image-20220725154441848
  • 释放方式可以主动del该key,或者设置过期时间,过期时间在上锁后设置。为了实现上锁和设置过期时间的原子性,防止上锁后服务器突然出问题导致无法设置过期时间,需要上锁的同时,设置过期时间:set <key> nx ex <time>

    image-20220725154626299
  • 防止释放其他服务器的锁:设置uuid,即获取锁时,设置一个指定的唯一值,释放前再次获取这个值,判断是否为自己的锁

    • index1业务逻辑没执行完,3秒后锁被自动释放。index2获取到锁,执行业务逻辑,3秒后锁被自动释放。index3获取到锁,执行业务逻辑。index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁,导致index3的业务只执行1s就被别人释放
    image-20220725154851786
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    public void testLock(){
    String uuid = UUID.randomUUID().toString();
    //1获取锁,setne
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
    //2获取锁成功、查询num的值
    if(lock){
    Object value = redisTemplate.opsForValue().get("num");
    //2.1判断num为空
    if(StringUtils.isEmpty(value)){
    return;
    }
    //2.2有值就转成成int
    int num = Integer.parseInt(value+"");
    //2.3把redis的num加1
    redisTemplate.opsForValue().set("num", ++num);
    //2.4释放锁,del
    //判断比较uuid值是否一样
    String lockUuid = (String)redisTemplate.opsForValue().get("lock");
    if(lockUuid.equals(uuid)) {
    redisTemplate.delete("lock");
    }
    }else{
    //3获取锁失败、每隔0.1秒再获取
    try {
    Thread.sleep(100);
    testLock();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }
  • LUA脚本保证删除的原子性,即判断uuid一致和锁删除要是原子的(上面不具有原子性,index1判断uuid一致,但此时刚好过期,锁自动删除,index2获取锁,然后index1刚好删除这个锁)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    public void testLockLua() {
    //1 声明一个uuid ,将做为一个value 放入我们的key所对应的值中
    String uuid = UUID.randomUUID().toString();
    //2 定义一个锁:lua 脚本可以使用同一把锁,来实现删除!
    String skuId = "25"; // 访问skuId 为25号的商品 100008348542
    String locKey = "lock:" + skuId; // 锁住的是每个商品的数据

    // 3 获取锁
    Boolean lock = redisTemplate.opsForValue().setIfAbsent(locKey, uuid, 3, TimeUnit.SECONDS);

    // 第一种: lock 与过期时间中间不写任何的代码。
    // redisTemplate.expire("lock",10, TimeUnit.SECONDS);//设置过期时间
    // 如果true
    if (lock) { // 执行的业务逻辑
    // 获取缓存中的 num 数据
    Object value = redisTemplate.opsForValue().get("num");
    // 如果是空直接返回
    if (StringUtils.isEmpty(value)) {
    return;
    }
    int num = Integer.parseInt(value + "");
    // num+1 放入缓存
    redisTemplate.opsForValue().set("num", String.valueOf(++num));

    /*lua脚本解锁*/
    // 定义lua 脚本
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    // redis执行lua
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
    redisScript.setScriptText(script);
    // 设置返回值类型为Long(
    // lua在判断的时候,返回的0为整数,这里如果不设置为long,默认返回 String 类型,和0的类型不匹配
    redisScript.setResultType(Long.class);
    // 第一个参数是script 脚本,第二个参数是要判断的key,第三个参数是ARGV
    redisTemplate.execute(redisScript, Arrays.asList(locKey), uuid);
    } else {
    // 其他线程等待
    try {
    // 睡眠
    Thread.sleep(1000);
    // 睡醒了之后,调用方法。
    testLockLua();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }
  • 为了确保分布式锁可用,要确保锁的实现同时满足:

    • 互斥性。在任意时刻,只有一个客户端持有锁
    • 无死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁
    • 加锁和解锁必须是同一个客户端
    • 加锁和解锁必须具有原子性