面试题篇-05-Redis相关面试题

文章目录

1. 简介

  • 想了解更多关于redis的文章,可以查看redis基础篇(入口),这里只是分享一些常见的redis面试题

2. Redis和Ehcache区别

  • ehcache直接在jvm虚拟机中缓存,速度快,效率高;但是缓存共享麻烦,集群分布式应用不方便。

  • ehcache也有缓存共享方案,不过是通过RMI或者Jgroup多播方式进行广播缓存通知更新,缓存共享复杂,维护不方便;

  • 简单的共享可 以,但是涉及到缓存恢复,大数据缓存,则不合适

  • redis是通过socket访问到缓存服务,效率比ecache低,比数据库要快很多,处理集群和分布式缓存方便,有成熟的方案。

  • 如果是单个应用或者对缓存访问要求很高的应用,用ehcache。
    如果是大型系统,存在缓存共享、分布式部署、缓存内容很大的,建议用redis。

3. Redis不是一直号称单线程效率也很高吗,为什么又采用多线程了?

Redis推出了6.0的版本,在新版本中采用了多线程模型。

3.1 Redis为什么最开始被设计成单线程的?

Redis作为一个成熟的分布式缓存框架,它由很多个模块组成

  • 网络请求模块
  • 索引模块
  • 存储模块
  • 高可用集群支撑模块
  • 数据操作模块
  • …等

很多人说Redis是单线程的,就认为Redis中所有模块的操作都是单线程的,其实这是不对的。
我们所说的Redis单线程,指的是"其网络IO和键值对读写是由一个线程完成的",也就是说,Redis中只有网络请求模块和数据操作模块是单线程的。而其他的如持久化存储模块、集群支撑模块等是多线程的

而多线程的目的,就是通过并发的方式来提升I/O的利用率和CPU的利用率。

在提升I/O利用率这个方面上,Redis并没有采用多线程技术,而是选择了多路复用 I/O技术。

Redis并没有在网络请求模块和数据操作模块中使用多线程模型,主要是基于以下四个原因:

  • 1、Redis 操作基于内存,绝大多数操作的性能瓶颈不在 CPU
  • 2、使用单线程模型,可维护性更高,开发,调试和维护的成本更低
  • 3、单线程模型,避免了线程间切换带来的性能开销
  • 4、在单线程中使用多路复用 I/O技术也能提升Redis的I/O利用率

还是要记住:Redis并不是完全单线程的,只是有关键的网络IO和键值对读写是由一个线程完成的。

3.2 为什么Redis 6.0 引入多线程

2020年5月份,Redis正式推出了6.0版本,这个版本中有很多重要的新特性,其中多线程特性引起了广泛关注。
但是,需要提醒大家的是,Redis 6.0中的多线程,也只是针对处理网络请求过程采用了多线程,而数据的读写命令,仍然是单线程处理的

为了提升QPS,很多公司的做法是部署Redis集群,并且尽可能提升Redis机器数。但是这种做法的资源消耗是巨大的。
而经过分析,限制Redis的性能的主要瓶颈出现在网络IO的处理上,虽然之前采用了多路复用技术。但是我们前面也提到过,多路复用的IO模型本质上仍然是同步阻塞型IO模型。
下面是多路复用IO中select函数的处理过程:
图片

从上图我们可以看到,在多路复用的IO模型中,在处理网络请求时,调用 select (其他函数同理)的过程是阻塞的,也就是说这个过程会阻塞线程,如果并发量很高,此处可能会成为瓶颈。

虽然现在很多服务器都是多个CPU核的,但是对于Redis来说,因为使用了单线程,在一次数据操作的过程中,有大量的CPU时间片是耗费在了网络IO的同步处理上的,并没有充分的发挥出多核的优势。

如果能采用多线程,使得网络处理的请求并发进行,就可以大大的提升性能。多线程除了可以减少由于网络 I/O 等待造成的影响,还可以充分利用 CPU 的多核优势。

所以,Redis 6.0采用多个IO线程来处理网络请求,网络请求的解析可以由其他线程完成,然后把解析后的请求交由主线程进行实际的内存读写。提升网络请求处理的并行度,进而提升整体性能。
但是,Redis 的多 IO 线程只是用来处理网络请求的,对于读写命令,Redis 仍然使用单线程来处理。

那么,在引入多线程之后,如何解决并发带来的线程安全问题呢?
这就是为什么我们前面多次提到的"Redis 6.0的多线程只用来处理网络请求,而数据的读写还是单线程"的原因。
Redis 6.0 只有在网络请求的接收和解析,以及请求后的数据通过网络返回给时,使用了多线程。而数据读写操作还是由单线程来完成的,所以,这样就不会出现并发问题了。

4. Redis有哪些数据结构呀?

StringHashListSetZSetHyperLogLogGeoPub/Sub

5. Redis分布式锁是什么?

由于redis的键值对读写是由一个线程完成的,可以使用setnx命令 当作分布式锁操作;
同时也可以把setnx和expire合成一条指令,方便控制锁的有效期;

6. 如何从10亿数据里面查询某个key(假设存在10w个)?

  • 使用keys指令可以扫出指定模式的key列表。
    • 缺点: Redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。
  • 使用scan指令可以无阻塞的提取出指定模式的key列表
  • 缺点: 但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。
    其次,在对键进行增量式迭代的过程中, 键可能会被修改, 所以增量式迭代命令只能对被返回的元素提供有限的保证 。

7. 如何使用Redis做异步队列?

一般使用list结构作为队列,rpush生产消息,lpop消费消息。

  • 当lpop没有消息的时候,要适当sleep一会再重试;
  • 也可以使用指令叫blpop,在没有消息的时候,它会阻塞住直到消息到来

8. 如何使用Redis做延时队列?

使用Zset,拿时间戳作为score消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。

9. 使用pub/sub主题订阅者模式有什么缺陷?

使用pub/sub主题订阅者模式,可以实现 1:N 的消息队列。
但是在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如RocketMQ等。

10. 如何批量插入数据到redis(pipeline)?

pipeline可以将多次IO往返的时间缩减为一次,前提是pipeline执行的指令之间没有因果相关性。
使用redis-benchmark进行压测的时候可以发现影响redis的QPS峰值的一个重要因素是pipeline批次指令的数目。

11. 如何基于Redis 做分布式限流?

11.1 基于Redis的数据结构zset

其实限流涉及的最主要的就是滑动窗口,即在一定的时间范围内,允许通过多少次请求;

基于上面的想法,可以将请求 放入到一个 zset集合当中,不懂zset怎么玩的可以看下这篇文章:Zset

 public void flowControl() {
        String key = "这里是业务唯一标识";
        //限流时长
        long intervalTime = 1000 * 60 * 60L;

        //获取当前的时间差
        long currentTimeMillis = System.currentTimeMillis();

        if (redisTemplate.hasKey(key)) {
            //查询限流时长内,存在的个数
            int count = redisTemplate.opsForZSet().rangeByScore(key, currentTimeMillis - intervalTime, currentTimeMillis).size();
            if (count > 20) {
                System.out.println("一小时之内只允许请求20次");
                //TODO 兜底响应处理
                return;
            }
        }
        redisTemplate.opsForZSet().add(key, UUID.randomUUID().toString(), intervalTime);

        //TODO 处理业务...
    }

在这里插入图片描述
注意: 这里需要做好清理zset失效的机制,不然会造成大量的数据存储;

11.2 基于Redis的数据结构List

当输出速率大于输入速率,那么就是超出流量限制了
在这里插入图片描述

    @Scheduled(fixedDelay = 1000, initialDelay = 0)
    public void setIntervalTimeTask() {
        //每秒往里面推一个元素,那么一个小时就是推送3600个,当输出的速度大于写入的速度,那么就限流了
        redisTemplate.opsForList().rightPush(key, UUID.randomUUID().toString());
    }


    public void flowControl2() {
        Object result = redisTemplate.opsForList().leftPop(key);
        if (result == null) {
            //TODO 此时队列里面没有了元素,说明超出了限制
            //TODO 兜底响应处理
            return;
        }
        //TODO 处理业务...
    }

注意: 这里需要做好清理List失效的机制,不然会造成大量的数据存储;

11.3 基于Redis的incr命令

日常的开放平台API一般常有限流,利用redis的incr命令可以实现一般的限流操作。如限制某接口每秒请求次数上限1000次;

@Resource
    private RedisTemplate redisTemplate;
    /**
     * 1秒内最大1000次
     * @param key  可以设计为业务标识及接口标识+秒级时间戳组合。
     * @param expireMillis 过期时间60s
     * @return 是否继续业务处理
     */
    public Boolean limiter(String key, Long expireMillis) {
        Long count = redisTemplate.opsForValue().increment(key, 1L);
        if (1 == count) {
            redisTemplate.expire(key, expireMillis, TimeUnit.SECONDS);
        }
        if (count > 1000) {
            return Boolean.TRUE;
        }
        return Boolean.FALSE;
    }

11.4 小总结

  1. 使用Zset和List做分布式限流,可以完美的解决动态滑动窗口,但是弊端是数据量大的时候,要额外的去做失效机制处理;
  2. 使用incr做分布式限流,就不能真正意义上解决动态滑动时间窗口的限流,而是粗略的限流,但是站在业务角度,有时候是可以容忍这种粗略的限流发生,毕竟都是为了保护接口;

12. Redis是怎么持久化的?

RDB做镜像全量持久化AOF做增量持久化

因为RDB会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据,所以需要AOF来配合使用。在redis实例重启时,会使用RDB持久化文件重新构建内存,再使用AOF重放近期的操作指令来实现完整恢复重启之前的状态。

  • 这里很好理解,把RDB理解为一整个表全量的数据,AOF理解为每次操作的日志就好了,服务器重启的时候先把表的数据全部搞进去,但是他可能不完整,你再回放一下日志,数据不就完整了嘛。
  • 不过Redis本身的机制是 AOF持久化开启且存在AOF文件时,优先加载AOF文件;AOF关闭或者AOF文件不存在时,加载RDB文件;加载AOF/RDB文件城后,Redis启动成功;AOF/RDB文件存在错误时,Redis启动失败并打印错误信息

RDB:RDB 持久化机制,是对 Redis 中的数据执行周期性的持久化。

AOF:AOF 机制对每条写入命令作为日志,以 append-only 的模式写入一个日志文件中,因为这个模式是只追加的方式,所以没有任何磁盘寻址的开销,所以很快,有点像Mysql中的binlog。

两种方式都可以把Redis内存中的数据持久化到磁盘上,然后再将这些数据备份到别的地方去,RDB更适合做冷备AOF更适合做热备,比如我杭州的某电商公司有这两个数据,我备份一份到我杭州的节点,再备份一个到上海的,就算发生无法避免的自然灾害,也不会两个地方都一起挂吧,这灾备也就是异地容灾,地球毁灭他没办法。

tip:两种机制全部开启的时候,Redis在重启的时候会默认使用AOF去重新构建数据,因为AOF的数据是比RDB更完整的

13. RDB 和 AOF 的区别?

  • RDB优点
    他会生成多个数据文件,每个数据文件分别都代表了某一时刻Redis里面的数据,这种方式适合做冷备,

    • redis通过创建子进程来进行RDB操作,也就是 cow思想,cow指的是copy on write,指的是子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来。
    • RDB对Redis的性能影响非常小,是因为在同步数据的时候他只是fork了一个子进程去做持久化的,而且他在数据恢复的时候速度比AOF来的快。
  • RDB缺点
    RDB都是快照文件,都是默认五分钟甚至更久的时间才会生成一次

    • 这意味着你这次同步到下次同步这中间五分钟的数据都很可能全部丢失掉。AOF则最多丢一秒的数据,数据完整性上高下立判。
    • 还有就是RDB在生成数据快照的时候,如果文件很大,客户端可能会暂停几毫秒甚至几秒,你公司在做秒杀的时候他刚好在这个时候fork了一个子进程去生成一个大快照,哦豁,出大问题。
  • AOF优点
    RDB五分钟一次生成快照,但是AOF是一秒一次去通过一个后台的线程fsync操作,那最多丢这一秒的数据。

    • AOF在对日志文件进行操作的时候是以append-only的方式去写的,他只是追加的方式写数据,自然就少了很多磁盘寻址的开销了,写入性能惊人,文件也不容易破损。
    • AOF的日志是通过一个叫非常可读的方式记录的,这样的特性就适合做灾难性数据误删除的紧急恢复了,比如公司的实习生通过flushall清空了所有的数据,只要这个时候后台重写还没发生,你马上拷贝一份AOF日志文件,把最后一条flushall命令删了就完事了。
  • AOF缺点
    一样的数据,AOF文件比RDB还要大。

    • AOF开启后,Redis支持写的QPS会比RDB支持写的要低,他不是每秒都要去异步刷新一次日志嘛fsync,当然即使这样性能还是很高,我记得ElasticSearch也是这样的,异步刷新缓存区的数据去持久化,为啥这么做呢,不直接来一条怼一条呢,那我会告诉你这样性能可能低到没办法用的,大家可以思考下为啥哟。

14. Redis的同步机制

Redis可以使用主从同步从从同步

  • 第一次同步时,主节点做一次bgsave,并同时将后续修改操作记录到内存buffer,待完成后将RDB文件全量同步到复制节点
  • 复制节点接受完成后将RDB镜像加载到内存。
  • 加载完成后,再通知主节点将期间修改的操作记录同步到复制节点进行重放就完成了同步过程。
  • 后续的增量数据通过AOF日志同步即可,有点类似数据库的binlog。

15. Redis的过期策略与内存淘汰策略

15.1 过期策略

Redis 在存储数据时,如果指定了过期时间,缓存数据到了过期时间就会失效,那么 Redis 是如何处理这些失效的缓存数据呢?这就用到了 Redis 的过期策略 - 定期删除 + 惰性删除

15.1.1 key 设置且到了过期时间后,该 key 保存的数据还占据内存么?

当 key 过期后,该 key 保存的数据还是会占据内存的

因为每当我们设置一个 key 的过期时间时,Redis 会将该键带上过期时间存放到一个过期字典中。
当 key 过期后,如果没有触发 Redis 的删除策略的话,过期后的数据依然会保存在内存中的,这时候即便这个 key 已经过期,我们还是能够获取到这个 key 的数据。

15.1.2 定期删除

Redis 默认每隔 100ms 就随机抽取部分设置了过期时间的 key,检测这些 key 是否过期,如果过期了就将其删除。

  • 100ms 是怎么来的?
    定期任务是 Redis 服务正常运行的保障,它的执行频率由 hz 参数的值指定,默认为10,即每秒执行10次。
    hz 10
    • 5.0 之前的 Redis 版本,hz 参数一旦设定之后就是固定的了。hz 默认是 10。这也是官方建议的配置。如果改大,表示在 Redis 空闲时会用更多的 CPU 去执行这些任务。官方并不建议这样做。但是,如果连接数特别多,在这种情况下应该给与更多的 CPU 时间执行后台任务。
      Redis 5.0之后,有了 dynamic-hz 参数,默认就是打开。当连接数很多时,自动加倍 hz,以便处理更多的连接。
      dynamic-hz yes
  • 为什么是随机抽取部分 key,而不是全部 key?随机抽取部分检测,部分是多少?
    因为如果 Redis 里面有大量 key 都设置了过期时间,全部都去检测一遍的话 CPU 负载就会很高,会浪费大量的时间在检测上面,甚至直接导致 Redis 挂掉。所有只会抽取一部分而不会全部检查。
    • 随机抽取部分检测,部分是多少?是由 redis.conf 文件中的 maxmemory-samples 属性决定的,默认为 5

正因为定期删除只是随机抽取部分 key 来检测,这样的话就会出现大量已经过期的 key 并没有被删除,这就是为什么有时候大量的 key 明明已经过了失效时间,但是 Redis 的内存还是被大量占用的原因 ,为了解决这个问题,Redis 又引入了"惰性删除策略"。

15.1.3 惰性删除

惰性删除不是去主动删除,而是在你要获取某个 key 的时候,Redis 会先去检测一下这个 key 是否已经过期,如果没有过期则返回给你,如果已经过期了,那么 Redis 会删除这个 key,不会返回给你。

15.2 内存淘汰策略

“定期删除 + 惰性删除” 就能保证过期的 key 最终一定会被删掉 ,但是只能保证最终一定会被删除,要是定期删除遗漏的大量过期 key,我们在很长的一段时间内也没有再访问这些 key,那么这些过期 key 不就一直会存在于内存中吗?不就会一直占着我们的内存吗?这样不还是会导致 Redis 内存耗尽吗?由于存在这样的问题,所以 Redis 又引入了"内存淘汰机制"来解决。

官网上给到的内存淘汰机制是以下几个:

  • volatile-lru:当内存不足执行写入操作时,在设置了过期时间的键空间中,移除最近最少(最长时间)使用的 key。

  • allkeys-lru:当内存不足执行写入操作时,在整个键空间中,移除最近最少(最长时间)使用的 key。(这个是最常用的)

  • volatile-lfu:当内存不足执行写入操作时,在设置了过期时间的键空间中,移除最不经常(最少次)使用的key。

  • allkeys-lfu:当内存不足执行写入操作时,在整个键空间中,移除最不经常(最少次)使用的key。

  • volatile-random -> 当内存不足执行写入操作时,在设置了过期时间的键空间中,随机移除某个 key。

  • allkeys-random -> 当内存不足执行写入操作时,在整个键空间中,随机移除某个 key。

  • volatile-ttl -> 当内存不足执行写入操作时,在设置了过期时间的键空间中,优先移除过期时间最早(剩余存活时间最短)的 key。

  • noeviction:不删除任何 key, 只是在内存不足写操作时返回一个错误。(默认选项,一般不会选用)

15.2.1 什么时候触发 内存淘汰机制?

  • redis.conf 配置文件中的 maxmemory 属性限定了 Redis 最大内存使用量,当占用内存大于 maxmemory 的配置值时会执行内存淘汰机制。
  • 当达到设置的内存使用限制时,Redis 将根据选择的内存淘汰机制(maxmemory-policy)删除 key。

15.2.2 如何设置 Redis 内存淘汰机制?

  • 内存淘汰机制由 redis.conf 配置文件中的 maxmemory-policy 属性设置,没有配置时默认为 noeviction:不删除任何 策略
    maxmemory-policy noeviction

15.3 其他场景对过期 key 的处理

15.3.1 快照生成 RDB 文件时

过期的 key 不会被保存在 RDB 文件中。

15.3.2 服务重启载入 RDB 文件时

Master 载入 RDB 时,文件中的未过期的键会被正常载入,过期键则会被忽略。
Slave 载入 RDB 时,文件中的所有键都会被载入,当主从同步时,再和 Master 保持一致。

15.3.3 AOF 文件写入时

因为 AOF 保存的是执行过的 Redis 命令,所以如果 Redis 还没有执行 del,AOF 文件中也不会保存 del 操作,当过期 key 被删除时,DEL 命令也会被同步到 AOF 文件中去。

15.3.4 重写 AOF 文件时

执行 BGREWRITEAOF 时 ,过期的 key 不会被记录到 AOF 文件中。

15.3.5 主从同步时

Master 删除 过期 Key 之后,会向所有 Slave 服务器发送一个 DEL 命令,Slave 收到通知之后,会删除这些 Key。

Slave 在读取过期键时,不会做判断删除操作,而是继续返回该键对应的值,只有当 Master 发送 DEL 通知,Slave 才会删除过期键,这是统一、中心化的键删除策略,保证主从服务器的数据一致性。

16. Redis高可用集群演进

16.1 单机

单机式Redis存在以下问题,因此需要Redis集群化来解决这些问题
在这里插入图片描述
类似于MySQL,单节点Redis并发能力有限,需要搭建主从结构:即,主Redis进行写操作,从Redis执行读操作,并且主从之间要保持数据同步。
在这里插入图片描述

16.2 全量同步&增量同步

16.2.1 全量同步

主从第一次建立连接时,会执行全量同步,将master节点的所有数据都拷贝给slave节点。

在同步的过程中,需要用到上文提到的RDB文件。
还会用到repl_baklog文件:通过bgsave生成RDB时,是子进程复制出的数据进行存盘,但如果此时主线程趁此时进行写数据,那这些新的数据不就没有同步进slave里了吗??
因此,redis会在此时通过repl_baklog记录bgsave期间的所有命令,再发给slave进行补充,保证数据的完整。
在这里插入图片描述

问题:slave向master请求数据同步,master如何知道这是第一次来连接?

通过以下两个概念作为判断依据:

  • Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
    此为关键:这个id如果不同,说明是第一次!

  • 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一致了。
在这里插入图片描述

16.2.2 增量同步

当主从的Replication Id相同时,我们可以理解为,此时slave数据集至少是master的子集了。
主从数据剩余部分具体相差多少呢?可以通过offset偏移量来进行记录。
repl_baklog中会记录Redis处理过的命令日志及offset,包括master当前的offset,和slave已经拷贝到的offset。每次在第二阶段,master就去发送:从 已经拷贝过的offset开始 到 当前offset的数据,如图所示:
在这里插入图片描述
repl_baklog这个文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖,但因为slave(绿色部分已经同步了,因此master可以直接覆盖也无妨)

但是如果slave宕机过久,导致master把尚未同步的红色部分覆盖了(即,红色超过了一整圈):例如下图,此时只能全量同步了。
在这里插入图片描述

16.2.3 全量同步和增量同步区别

  • 全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave。
  • 增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave

什么时候执行全量同步?

  • slave节点第一次连接master节点时
  • slave节点断开时间太久,repl_baklog中的offset已经被覆盖时

什么时候执行增量同步?

  • slave节点断开又恢复,并且在repl_baklog中能找到offset时

16.3 主从哨兵模式

从机宕机后,通过repl_baklog的offset来进行恢复从机的数据。

但如果,宕机是主机而不是从机,该怎么办?显然这时候需要指定新的主机了!而redis哨兵机制能够自动监控主机的健康状态、以及在主机挂掉后指定从机。
在这里插入图片描述

16.3.1 原理

  • 监控:Sentinel 会不断检查您的master和slave是否按预期工作
    这里sentinel会反复向主机发送ping,如果在规定时间内收到pong,说明主机还是好的,反之反之。如果有超过半数的哨兵认为主机挂了,那说明主机挂了。

  • 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主,具体怎么选新的主机呢?
    首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
    然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
    如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
    最后是判断slave节点的运行id大小,越小优先级越高

  • 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端

故障转移步骤有哪些?

  • 首先选定一个slave作为新的master,执行slaveof no one
  • 然后让所有节点都执行slaveof 新master
  • 修改故障节点配置,添加slaveof 新master

16.4 高可用集群模式

对于哨兵模式,其每个节点存储的数据是一样的,浪费内存,并且不好在线扩容。
当有海量数据需要存储、高并发的写问题出现时候,就要采取分片集群的方式来解决问题了

redis集群是一个由多个主从节点群组成的分布式服务器群,具有复制、高可用、分片等特性。

  • Redis集群不需要哨兵也能完成主从切换和故障转移。
  • Redis集群具有水平扩展的特性。
  • Redis集群的性能和高可用性优于哨兵模式,而且配置相对简单。

在这里插入图片描述

16.4.1 分片集群的特征

  • 集群中有多个master,每个master保存不同数据;
  • 每个master都可以有多个slave节点;
  • master之间通过ping监测彼此健康状态(代替哨兵),如果挂掉了会故障转移。
  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点;
    在这里插入图片描述

16.4.2 散列插槽

插槽算法把整个数据库被分为16384个slot(槽),每个进入Redis的键值对,根据key进行散列,分配到这16384插槽中的一个。使用的哈希映射也比较简单,用CRC16算法计算出一个16 位的值,再对16384取模。数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点都可以处理这16384个槽。

集群中的每个节点负责一部分的hash槽,比如当前集群有A、B、C个节点,每个节点上的哈希槽数 =16384/3,那么就有:

节点A负责0~5460号哈希槽
节点B负责5461~10922号哈希槽
节点C负责10923~16383号哈希槽

即: 数据key不是与节点绑定,而是与插槽绑定。 这样,三个节点分别处理不同的槽位置的数据,在存、取数据时,会根据运算得到的槽,自动切换节点。

16.4.3 动态扩容/缩容

我们知道,实际上数据不是存储在节点上的,而是插槽上的。因此,如果想增加新的节点时,给新的节点分配插槽即可。如果想删掉旧的节点时,先把插槽转移给别的节点,再把没有插槽的空节点删了即可。

  • 动态扩容举例
    使用redis-cli的add-node命令新增一个主节点8007(master),前面的ip:port为新增节点,后面的ip:port为集群中已存在的节点
src/redis-cli --cluster add-node 192.168.100.100:8007 192.168.100.100:8001

当添加节点成功以后,新增的节点不会有任何数据,因为它还没有分配任何的slot(hash槽),我们需要为新节点手工分配hash槽。使用redis-cli的rehash命令为8007分配hash槽,找到集群中的任意一个主节点,对其进行重新分片工作。

src/redis-cli --cluster reshard 192.168.100.100:8001
How many slots do you want to move (from 1 to 16384)? 600
(ps:需要多少个槽移动到新的节点上,自己设置,比如600个hash槽)
What is the receiving node ID? 2728a594a0498e98e4b83a537e19f9a0a3790f38
(ps:把这600个hash槽移动到哪个节点上去,需要指定节点id)
Please enter all the source node IDs.
Type ‘all’ to use all the nodes as source nodes for the hash slots.
Type ‘done’ once you entered all the source nodes IDs.
Source node 1:all
(ps:输入all为从所有主节点(8001,8002,8003)中分别抽取相应的槽数指定到新节点中,抽取的总槽数为600个)
… …
Do you want to proceed with the proposed reshard plan (yes/no)? yes
(ps:输入yes确认开始执行分片任务)

槽位迁移后,对应槽位中的数据也会迁移!

  • 动态缩容举例
    例如,我们将(1)中的8007节点删除: 因为主节点8007的里面是有分配了hash槽的,所以我们这里必须先把8007里的hash槽放入到其他的可用主节点中去,然后再进行移除节点操作,不然会出现数据丢失问题。

src/redis-cli --cluster reshard 192.168.100.100:8007
How many slots do you want to move (from 1 to 16384)? 600
What is the receiving node ID? baf0c2f3afde2410e34351a8261a703f1394cee9
(ps:这里是需要把数据移动到哪?8001的主节点id)
Please enter all the source node IDs.
Type ‘all’ to use all the nodes as source nodes for the hash slots.
Type ‘done’ once you entered all the source nodes IDs.
Source node 1:4b339ad25b4884c2ff6de8a8ec2bc8766f8faf0b
(ps:这里是需要数据源,也就是我们的8007节点id)
Source node 2:done
(ps:这里直接输入done 开始生成迁移计划)
… …
Do you want to proceed with the proposed reshard plan (yes/no)? Yes
(ps:这里输入yes开始迁移)

至此,我们已经成功的把8007主节点的数据迁移到8001上去了,我们可以看一下现在的集群状态如下图,你会发现8007下面已经没有任何hash槽了,证明迁移成功!

最后我们直接使用del-node命令删除8007主节点即可

 src/redis-cli  --cluster del-node 192.168.100.100:8007 4b339ad25b4884c2ff6de8a8ec2bc8766f8faf0b

16.5 Redis cluster节点之间的通信机制

redis cluster之间采用gossip协议进行通信,维护集群的数据信息(集群节点信息,主从角色,节点数量,节点槽位等)

判断节点故障超时时长

  • 当经常发生网络波动,导致节点暂时不可连接,但很快又恢复正常,如果没有设置这个故障超时时长,会导致主从频繁切换。需要在配置文件中配置 cluster-­node­-timeout ,当某个节点持续断连时长超过这个设置值时,系统才会判定节点故障,再进行主从切换

16.6 Redis集群选举原理

  • 当slave发现master出现故障时,便需要广播选举信息,期待成为新的master节点。
  • slave发现master变为FAIL, 将自己记录的集群currentEpoch加1,并广播FAILOVER_AUTH_REQUEST 信息
  • 其他节点收到该信息,只有master响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个epoch只发送一次ack
  • 尝试failover的slave收集master返回的FAILOVER_AUTH_ACK
  • slave收到超过半数master的ack后变成新Master
  • slave广播Pong消息通知其他集群节点
    其他master主节点选举时,采取先到先选,也就是收到需要选举的从节点广播的信息,只响应第一个收到的;如果两个从节点收到的票数一样,则进行下一轮选举,并且为了避免这种情况的发生,redis采取了延时计算。

16.7 其他问题

  • 集群脑裂问题
    脑裂问题:如果现在主节点1由于网络波动被从节点认定为Fail,从节点选举产生新的主节点2,但实际上主节点1还是可以进行数据存放,这时候就产生了两个主节点;在网络分区恢复后,其中一个主节点会变为从节点,从而在通信未恢复的时间里对主节点存放的数据,将会丢失。
    过半机制:min‐replicas‐to‐write 1
    //写数据成功最少同步的slave数量,这个数量可以模仿大于半数机制配置,比如集群总共三个节点可以配置1,加上leader就是2,超过了半数加上这个配置,存放数据时需要至少min‐replicas‐to‐write 1个slave同步成功才是数据存放成功。这个配置会影响集群的可用性。

  • 集群是否完整才能对外提供服务?
    当redis.conf的配置cluster-require-full-coverage为no时,表示当负责一个插槽的主库下线且没有相应的从库进行故障恢复时,集群仍然可用,如果为yes则集群不可用。

  • Redis集群为什么至少需要3个节点,并且master节点数推荐为奇数个?
    至少需要3个节点是因为主从切换过程中,选举时至少需要过半数的master支持才能选举出新的master节点。推荐奇数个节点是为了节省机器资源,比如:4个节点,此时最多只能挂1个节点,挂2个节点的话无法进行选举;3个节点时,也只允许挂1个节点。所以奇数个master节点数主要是从节省机器资源的角度出发。

17. 缓存一致性问题怎么解决?

17.1 先删缓存,再更新数据库---延时双删

先删除缓存,数据库还没有更新成功,此时如果读取缓存,缓存不存在,去数据库中读取到的是旧值,缓存不一致发生。
在这里插入图片描述
解决方案:
延时双删的方案的思路是,为了避免更新数据库的时候,其他线程从缓存中读取不到数据,就在更新完数据库之后,再sleep一段时间,然后再次删除缓存。

sleep的时间要对业务读写缓存的时间做出评估,sleep时间大于读写缓存的时间即可。

流程如下:

  1. 线程1删除缓存,然后去更新数据库
  2. 线程2来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程1还没有更新完成,所以读 到的是旧值,然后把旧值写入缓存
  3. 线程1,根据估算的时间,sleep,由于sleep的时间大于线程2读数据+写缓存的时间,所以缓存被再次删除
  4. 如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值
    在这里插入图片描述

17.2 先更新数据库,再删除缓存---消息队列

先更新数据库,成功后往消息队列发消息,消费到消息后再删除缓存,借助消息队列的重试机制来实现,达到最终一致性的效果。
在这里插入图片描述
这个解决方案其实问题更多。

  1. 引入消息中间件之后,问题更复杂了,怎么保证消息不丢失更麻烦
  2. 就算更新数据库和删除缓存都没有发生问题,消息的延迟也会带来短暂的不一致性,不过这个延迟相对来说还是可以接受的

问题: 为什么是删除,而不是更新缓存?
我们以先更新数据库,再删除缓存来举例。

如果是更新的话,那就是先更新数据库,再更新缓存。

举个例子:如果数据库1小时内更新了1000次,那么缓存也要更新1000次,但是这个缓存可能在1小时内只被读取了1次,那么这1000次的更新有必要吗?

反过来,如果是删除的话,就算数据库更新了1000次,那么也只是做了1次缓存删除,只有当缓存真正被读取的时候才去数据库加载。

18. Redis中默认有多少个哈希槽

  • Redis 集群中内置了 16384 个哈希槽,当需要在 Redis 集群中放置一个 key-value时,redis 先对 key 使用 crc16 算法算出一个结果,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,redis 会根据节点数量大致均等的将哈希槽映射到不同的节点。

  • Redis 集群没有使用一致性hash, 而是引入了哈希槽的概念

    • Redis 集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽.集群的每个节点负责一部分hash槽。这种结构很容易添加或者删除节点,并且无论是添加删除或者修改某一个节点,都不会造成集群不可用的状态。
  • 使用哈希槽的好处就在于可以方便的添加或移除节点

    • 当需要增加节点时,只需要把其他节点的某些哈希槽挪到新节点就可以了;

    • 当需要移除节点时,只需要把移除节点上的哈希槽挪到其他节点就行了;

    • 在这一点上,我们以后新增或移除节点的时候不用先停掉所有的 redis 服务。

假设集群总共有2的14次方,16384个哈希槽,那么每一个哈希槽中存的key 和 value是什么?

  • 当你往Redis Cluster中加入一个Key时,会根据crc16(key) mod 16384计算这个key应该分布到哪个hash slot中,一个hash slot中会有很多key和value。你可以理解成表的分区,使用单节点时的redis时只有一个表,所有的key都放在这个表里;改用Redis Cluster以后会自动为你生成16384个分区表,你insert数据时会根据上面的简单算法来决定你的key应该存在哪个分区,每个分区里有很多key。

19. Redis Cluster为什么有16384个槽?

Redis Cluster的工作原理:我们让两个redis节点之间进行通信的时候,需要在客户端执行下面一个命令:

127.0.0.1:7000>cluster meet 127.0.0.1:7001

如下图所示
在这里插入图片描述
思很简单,让7000节点和7001节点知道彼此存在!
在握手成功后,两个节点之间会定期发送ping/pong消息,交换数据信息,如下图所示。
在这里插入图片描述
在redis节点发送心跳包时需要把所有的槽信息放到这个心跳包里,以便让节点知道当前集群信息,在发送心跳包时使用char进行bitmap压缩后是2k(16384÷8÷1024=2kb),也就是说使用2k的空间创建了16k的槽数。

虽然使用CRC16算法最多可以分配65535(2^16-1)个槽位,65535=65k,压缩后就是8k(8 * 8 (8 bit) * 1024(1k) = 8K),也就是说需要需要8k的心跳包,作者认为这样做不太值得;并且一般情况下一个redis集群不会有超过1000个master节点,所以16k的槽位是个比较合适的选择

  • 如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。
    如上所述,在消息头中,最占空间的是myslots[CLUSTER_SLOTS/8]。
    当槽位为65536时,这块的大小是:
    65536÷8÷1024=8kb
    因为每秒钟,redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,这个ping消息的消息头太大了,浪费带宽。
  • redis的集群主节点数量基本不可能超过1000个。
    如上所述,集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。因此redis作者,不建议redis cluster节点数量超过1000个。
    那么,对于节点数在1000以内的redis cluster集群,16384个槽位够用了。没有必要拓展到65536个。
  • 槽位越小,节点少的情况下,压缩率高
    Redis主节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中,会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。
    如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。
    在这里插入图片描述

20. 缓存穿透解决方案-布隆过滤器

正常情况下,我们去查询数据都是存在。那么请求去查询一条压根儿数据库中根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,但是请求每次都会打到数据库上面去。这种查询不存在数据的现象我们称为缓存穿透

试想一下,如果有黑客会对你的系统进行攻击,拿一个不存在的id 去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉。

解决思路:

  • 缓存空值
    之所以会发生穿透,就是因为缓存中没有存储这些空数据的key。从而导致每次查询都到数据库去了。那么我们就可以为这些key对应的值设置为null 丢到缓存里面去。后面再出现查询这个key 的请求的时候,直接返回null 。这样,就不用在到数据库中去走一圈了,但是别忘了设置过期时间。

  • BloomFilter 布隆过滤器
    类似于一个hbase set 用来判断某个元素(key)是否存在于某个集合中。
    这种方式在大数据场景应用比较多,比如 Hbase 中使用它去判断数据是否在磁盘上。
    还有在爬虫场景判断url 是否已经被爬取过。
    这种方案可以加在第一种方案中,在缓存之前在加一层 BloomFilter ,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在再走查缓存 -> 查 DB。

21. InnoDB为什么不用跳表,Redis为什么不用B+树?

Innodb是MySQL的执行引擎,MySQL是一种关系型数据库,而Redis是一种非关系型数据库。
这两者之间比较大的区别是:关系型数据库以表的形式进行存储数据,而非关系型数据库以Key-value的形式存储数据。

在InnoDB中,索引是采用B+树实现的,在Redis中,ZSET是采用跳表(不只是跳表)实现的,无论是B+树,还是跳表,都是性能很好的数据结构,那么,为什么InnoDB为什么不用跳表,Redis为什么不用B+树?

  • 我们都知道,MySQL是基于磁盘存储的,Redis是基于内存存储的。

  • 而之所以Innodb用B+树,主要是因为B+树是一种磁盘IO友好型的数据结构,而Redis使用跳表,是因为跳表则是一种内存友好型的数据结构。

21.1 什么是跳跃表?

跳跃表(skiplist)是一种有序随机化的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

21.2 B+树次磁盘友好?

  • 首先,B+树的叶子节点形成有序链表,可以方便地进行范围查询操作。对于磁盘存储来说,顺序读取的效率要高于随机读取,因为它可以充分利用磁盘预读和缓存机制,减少磁盘 I/O 的次数。

  • 其次,由于B+树的节点大小是固定的,因此可以很好地利用磁盘预读特性,一次性读取多个节点到内存中,这样可以减少IO操作次数,提高查询效率。

  • 还有就是,B+树的叶子节点都存储数据,而非数据和指针混合,所以叶子节点的大小是固定的,而且节点的大小一般都会设置为一页的大小,这就使得节点分裂和合并时,IO操作很少,只需读取和写入一页。

  • 所以,B+树在设计上考虑了磁盘存储的特点和性能优化,我曾经分析过,当Innodb中存储2000万数据的时候,只需要3次磁盘就够了。(具体分析在我出的面试宝典中。)

  • 而跳表就不一样了,跳表的索引节点通过跳跃指针连接,形成多级索引结构。这导致了跳表的索引节点在磁盘上存储时会出现数据分散的情况,即索引节点之间的物理距离可能较远。对于磁盘存储来说,随机访问分散的数据会增加磁头的寻道时间,导致磁盘 I/O 的性能下降。

21.3 为啥Redis用跳表?

  • 既然B+树这么多优点,为啥Redis要用跳表实现ZSET呢(不只是跳表,详见下面链接)?而不是B+树呢?

    • 主要是因为Redis是一种基于内存的数据结构。他其实不需要考虑磁盘IO的性能问题,所以,他完全可以选择一个简单的数据结构,并且性能也能接受的 ,那么跳表就很合适。

    • 因为跳表相对于B+树来说,更简单。相比之下,B+树作为一种复杂的索引结构,需要考虑节点分裂和合并等复杂操作,增加了实现和维护的复杂度。

    • 而且,Redis的有序集合经常需要进行插入、删除和更新操作。

    • 跳表在动态性能方面具有良好的表现,特别是在插入和删除操作上。相比之下,B+树的插入和删除需要考虑平衡性,所以还是成本挺高的。

22. Redis 为什么这么快?

决定Redis 请求效率的因素主要是三个方面,分别是网络、cpu、内存;

22.1 网络

在网络层面,Redis 采用多路复用的设计,提升了并发处理的连接数,不过这个阶段,
{如图}Server 端的所有IO 操作,都是由同一个主线程处理的这个时候IO 的瓶颈就会影响到Redis 端的整体处理性能。
在这里插入图片描述
所以从Redis6.0 开始{如图},在多路复用及层面增加了多线程的处理,来优化 IO 处理的能力。
不过,具体的数据操作仍然是由主线程来处理的,所以我们可以认为 Redis 对于数据IO的处理依然是单线程。
在这里插入图片描述

22.2 Cpu

从CPU 层面来说,Redis 只需要采用单线程即可,原因有两个。

  • 如果采用多线程,对于 Redis 中的数据操作,都需要通过同步的方式来保证线程安全性,这反而会影响到 redis 的性能;
  • 在Linux 系统上Redis 通过 pipelining 可以处理 100w 个请求每秒,而应用程序的计算复杂度主要是 O(N) 或 O(log(N)) ,不会消耗太多 CPU

22.3 内存

从内存层面来说,Redis 本身就是一个内存数据库,内存的IO 速度本身就很快,所以内存的瓶颈只是受限于内存大小。

最后,Redis 本身的数据结构也做了很多的优化,比如压缩表、跳跃表等方式降低了时间复杂读,同时还提供了不同时间复杂度的数据类型。

23. Redis 存在线程安全问题吗?为什么?

Redis Server 本身是一个线程安全的K-V 数据库,也就是说在 Redis Server 上执行的指令,不需要任何同步机制,不会存在线程安全问题。
(如图)虽然Redis 6.0 里面,增加了多线程的模型,但是增加的多线程只是用来处理网络IO 事件,对于指令的执行过程,仍然是由主线程来处理,所以不会存在多个线程通知执行操作指令的情况。
在这里插入图片描述至于为什么Redis 没有采用多线程来执行指令,我认为有几个方面的原因。

  • Redis Server 本身可能出现的性能瓶颈点无非就是网络 IO、CPU、内存。但是 CPU不是Redis 的瓶颈点,所以没必要使用多线程来执行指令。
  • 如果采用多线程,意味着对于 redis 的所有指令操作,都必须要考虑到线程安全问题,也就是说需要加锁来解决,这种方式带来的性能影响反而更大。

第二个,从Redis 客户端层面。

  • 虽然Redis Server 中的指令执行是原子的,但是如果有多个 Redis 客户端同时执行多个指令的时候,就无法保证原子性。
  • 假设两个redis client 同时获取Redis Server 上的key1, 同时进行修改和写入,因为多线程环境下的原子性无法被保障,以及多进程情况下的共享资源访问的竞争问题,使得数据的安全性无法得到保障。
    在这里插入图片描述
    当然,对于客户端层面的线程安全性问题,解决方法有很多,比如尽可能的使用Redis里面的原子指令,或者对多个客户端的资源访问加锁,或者通过 Lua 脚本来实现多个指令的操作等等。

24. Redis 的内存淘汰算法和原理是什么?

Redis 里面的内存淘汰策略,是指内存的使用率达到maxmemory 上限的时候的一种内存释放的行为。
Redis 里面提供了很多中内存淘汰算法,归纳起来主要就四种

  • 1.Random 算法,随机移除某个 key
  • 2.TTL 算法 ,在设置了过期时间的键中,把更早过期时间的 key 有限移除
  • 3.LRU 算法,移除最近很少使用的 key
  • 4.LFU 算法,移除最近很少使用的 key

如图)LRU 是比较常见的一种内存淘汰算法,在 Redis 里面会维护一个大小为 16 的侯选池,这个侯选池里面的数据会根据时间进行排序,然后每一次随机取出 5 个key放入到这个侯选池里面,当侯选池满了以后,访问的时间间隔最大的 key 就会从侯选池里面取出来淘汰掉。
在这里插入图片描述

通过这样的一个设计,就可以把真实的最少访问的 key 从内存中淘汰掉。
但是这样的一种 LRU 算法还是存在一个问题,假如一个 key 的访问频率很低,但是最近一次偶尔被访问到,那么 LRU 就会认为这是一个热点 Key,不会被淘汰。

所以在Redis4 里面,增加了一个 LFU 的算法,相比于 LRU,LFU 增加了访问频率这个维度来统计数据的热点情况。
(如图)LFU 的主要设计是,使用了两个双向链表形成一个二维双向链表,一个用来保存访问频率,另一个用来保存访问频率相同的所有元素。

当添加元素的时候,访问次数默认为 1,于是找到相同访问频次的节点,然后添加到相同频率节点对应的双向链表头部。
当元素被访问的时候,就会增加对应 key 的访问频次,并且把当前访问的节点移动到下一个频次节点。
有可能出现某个数据前期访问次数很多,然后后续就一直不用了,如果单纯按照访问频率,这个key 就很难被淘汰,所以在 LFU 中通过使用频率和上次访问时间来标记数据的热度,如果有有读写,就增加访问频率,如果一段时间内没有读写,就减少访问频率。
在这里插入图片描述
所以,通过LFU 算法改进之后,就可以真正达到非热点数据的淘汰了。当然,LFU 也有缺点,相比LRU 算法,LFU 增加了访问频次的维护,以及实现的复杂度要比 LRU 更高。

25. 请说一下你对分布式锁的理解,以及分布式锁的实现?

分布式锁,是一种跨进程的跨机器节点的互斥锁,它可以用来保证多机器节点对于共享资源访问的排他性。
在本质上,他们都需要满足锁的几个重要特性:

  • 排他性,也就是说,同一时刻只能有一个节点去访问共享资源。
  • 可重入性,允许一个已经获得锁的进程,在没有释放锁之前再次重新获得锁。
  • 锁的获取、释放的方法
  • 锁的失效机制,避免死锁的问题

Redis,它里面提供了 SETNX 命令可以实现锁的排他性,当 key 不存在就返回 1,存在就返回 0。然后还可以用 expire 命令设置锁的失效时间,从而避免死锁问题。

当然有可能存在锁过期了,但是业务逻辑还没执行完的情况。 所以这种情况,可以写一个定时任务对指定的 key 进行续期。
Redisson 这个开源组件,就提供了分布式锁的封装实现,并且也内置了一个 Watch Dog 机制来对key 做续期。

我认为Redis 里面这种分布式锁设计已经能够解决 99%的问题了,当然如果在Redis搭建了高可用集群的情况下出现主从切换导致key 失效,这个问题也有可能造成
多个线程抢占到同一个锁资源的情况,所以 Redis 官方也提供了一个RedLock 的解决办法,但是实现会相对复杂一些。

在我看来,分布式锁应该是一个CP 模型,而Redis 是一个AP 模型,所以在集群架构下由于数据的一致性问题导致极端情况下出现多个线程抢占到锁的情况很难避免。
那么基于CP 模型又能实现分布式锁特性的组件,我认为可以选择Zookeeper 或者 etcd

  • 在数据一致性方面,zookeeper 用到了zab 协议来保证数据的一致性,etcd用到了raft 算法来保证数据一致性。
  • 在锁的互斥方面,zookeeper 可以基于有序节点再结合 Watch 机制实现互斥和唤醒,etcd 可以基于Prefix 机制和Watch 实现互斥和唤醒。

26. Redis 哨兵机制和集群有什么区别?

Redis 集群有几种实现方式,一个是主从集群、一个是 Redis Cluster。
(如图)主从集群,就是在 Redis 集中包括一个Master 节点和多个Slave 节点。 Master 负责数据的读写,Slave 节点负责数据的读取。
Master 上收到的数据变更,会同步到Slave 节点上实现数据的同步。通过这种架构实现可以 Redis 的读写分离,提升数据的查询性能。
在这里插入图片描述Redis 主从集群不提供容错和恢复功能,一旦Master 节点挂了,不会自动选出新的 Master,导致后续客户端所有写请求直接失败。

所以Redis 提供了哨兵机制,专门用来监听Redis 主从集群提供故障的自动处理能力(如图)。
哨兵会监控Redis 主从节点的状态,当 Master 节点出现故障,会自动从剩余的Slave节点中选一个新的Master。
在这里插入图片描述
哨兵模式下虽然解决了 Master 选举的问题,但是在线扩容的问题还是没有解决。
于是就有了第三种集群方式,Redis Cluster(如图),它实现了Redis 的分布式存储,也就是每个节点存储不同的数据实现数据的分片。

在Redis Cluster 中,引入了Slot 槽来实现数据分片,Slot 的整体取值范围是0~16383,每个节点会分配一个 Slot 区间
当我们存取Key 的时候,Redis 根据key 计算得到一个Slot 的值,然后找到对应的节点进行数据的读写。
在高可用方面,Redis Cluster 引入了主从复制模式, 一个Master 节点对应一个或多个Slave 节点,当Master 出现故障,
会从Slave 节点中选举一个新的 Master 继续提供服务。
在这里插入图片描述
在这里插入图片描述
Redis Cluster 虽然解决了在线扩容以及故障转移的能力,但也同样有缺点,比如

  • 客户端的实现会更加复杂
  • Slave 节点只是一个冷备节点,不提供分担读操作的压力
  • 对于Redis 里面的批量操作指令会有限制

小总结:

  • Redis 哨兵集群是基于主从复制来实现的,所以它可以实现读写分离,分担 Redis读操作的压力
    而Redis Cluster 集群的Slave 节点只是实现冷备机制,它只有在Master 宕机之后才会工作。
  • Redis 哨兵集群无法在线扩容,所以它的并发压力受限于单个服务器的资源配置。 Redis Cluster 提供了基于Slot 槽的数据分片机制,可以实现在线扩容提升写数据的性能

从集群架构上来说,Redis 哨兵集群是一主多从, 而Redis Cluster 是多主多从

27. Redis 主从复制的原理

Redis 主从复制,是指在Redis 集群里面(如图),Master 节点和Slave 节点数据同步的一种机制。
简单来说就是把一台 Redis 服务器的数据,复制到其他 Redis 服务器中。
其中负责复制数据的来源称为 master,被动接收数据并同步的节点称为 slave
在这里插入图片描述
在Redis 里面,提供了全量复制和增量复制两种模式。
全量复制一般发生在 Slave 节点初始化阶段,这个时候需要把 master 上所有数据都复制一份。
具体的工作原理是:

  • Slave 向Master 发送SYNC 命令,Master 收到命令以后生成数据快照
  • 把快照数据发送给Slave 节点,Salve 节点收到数据后丢弃旧的数据,并重新载入新的数据

需要注意,在主从复制过程中,Redis 并没有采用实现强数据一致性,因此会存在一定时间的数据不一致问题。
在这里插入图片描述
增量复制,就是指Master 收到数据变更之后,把变更的数据同步给所有Slave 节点。增量复制的原理是,Master 和Slave 都会维护一个复制偏移量(offset),用来表示 Master 向Slave 传递的字节数。

每次传输数据,Master 和Slave 维护的Offset 都会增加对应的字节数量。 Redis 只需要根据Offset 就可以实现增量数据同步了。

小总结:
Redis 主从复制包括全量复制和增量复制。

  • 全量复制是发生在初始化阶段,从节点会主动向主节点发起一个同步请求,主节点收到请求后会
    会生成一份当前数据的快照发送给从节点,从节点收到数据进行加载后完成全量复制。
  • 增量复制是发生在每次 Master 数据发生变化的过程中,会把变化的数据同步给所有的从节点。
    增量复制是通过维护 Offset 这个复制偏移量来实现的。

28. 为什么 Redis 集群的最大槽数是 16384 个?

Redis-Cluster 集群模式使用了哈希槽(hash slot)来实现数据的分片,hash slot 的默认长度是 16384.
应用程序去存储一个 key 的时候,会通过 CRC16 计算后取模路由到对应 hash slot 所在的节点。
在这里插入图片描述
对于Redis 中的Hash Slot 为什么是 16384 这个问题,我认为有几个考虑因素:

  • 对于网络通信开销的平衡:Redis 集群中每个节点会发送心跳消息,而心跳包中会携带节点的完整配置,它能够以幂等的方式来更新配置。如果采用 16384 个插槽,而每个插槽信息占用的位数为 1,
    因此每个节点需要维护的配置信息占用空间大小就是16384/节点数/8KB,假设是 3个节点的集群,
    则每个节点需要维护的配置信息占用空间大小为 2KB,其次,CRC16 算法产生的hash 值有 16 位,如果按照 2^16 次方计算得到 65536 个槽,那就会导致每个节点维护的配置信息占 8kb。
    8kb 数量的心跳数据看起来不大,但是这个心跳包每秒都需要把当前节点的信息同步给集群中的其他节点相比于 16384 个hash slot,整体增加了 4 倍,这样会对网络带块带来极大的浪费。
  • 集群规模的限制:Redis Cluster 不太可能扩展到超过 1000 个主节点,太多可能会导致网络拥堵等问题,因此,在这个限制条件下,采用 16384 个插槽的范围比较合适;
  • 16384 个插槽可以确保每个 master 节点都有足够的插槽,同时也可以保证插槽数目不会过多或过少,从而保证了 Redis Cluster 的稳定性和高性能。

总之,16384 个槽的数量是经过考虑和实践得出的,既可以满足Redis 集群的性能要求,又可以保证管理复杂度和通信开销的可控性。

29. Redis的大 key问题

29.1 什么是Redis的大 Key?

  1. String类型的值大于10kb;
  2. hash,list, set, zset 元素个数超过5000个;

29.2 如何找到Redis的大 key?

  1. String类型通过命令查找
redis -cli -h 127.0.0.1 -p6379  -a "passwd" --bigkeys
  1. Rdb Tools工具
    rdb dump.rdb -c memory --bytes 10240 -f redis.csv

29.3 Redis的大 key 带来的问题?

  • 内存占用过高
    • 大key会占用过多的内存空间,导致可用内存不足,从而触发内存淘汰策略。在极端情况下,可能导致内存耗尽,Redis实例崩溃,影响系统的稳定性。
  • 性能下降:
    • 大key会占用大量内存空间,导致内存碎片增加,进而影响Redis的性能。对于大key的操作,如读取、写入、删除等,都会消耗更多的CPU时间和内存资源,进一步降低系统性能。
  • 阻塞其他操作:
    • 某些对大key的操作可能会导致Redis实例阻塞。例如,使用DEL命令删除一个大Key时,可能会导致Redis实例在一段时间内无法响应其他客户端请求,从而影响系统的响应时间和吞吐量。
  • 连接池耗尽:

29.4 如何删除Redis的大 key?

  1. 直接删除大 key 会造成阻塞,因为redis的数据操作模块是单线程的,阻塞期间,其他所有的请求可能都会超时,会造成redis的连接耗尽,产生各种异常;
  2. 低峰期删除:凌晨,观察QPS,选择低的时候,无法彻底解决阻塞问题;
  3. 分批次删除:对于hash, 使用hscan扫描法,对于集合采用srandmember每次随机抽取数据进行删除,对于有序集合可以使用zremrangebyrank直接删除,对于列表直接pop即可;
  4. 异步删除法:用unlink 代替del来删除,这样redis会将这个key放入一个异步线程中,进行删除,这样不会阻塞主线程;

29.5 总结

采用异步删除法:用unlink 代替del来删除,这样redis会将这个key放入一个异步线程中,进行删除,这样不会阻塞主线程;

30. Redis集群处理节点的故障和重新加入以及网络分区

在Redis集群中,节点的故障和重新加入会通过以下步骤进行处理:

  • 节点故障处理:
    • 当一个节点故障时,集群会自动检测到这个节点的故障,并将该节点标记为"FAIL"状态。
    • 如果故障节点是主节点,集群会从该节点的从节点中选举一个新的主节点。
    • 如果故障节点是从节点,集群会将该故障节点从其他节点的从节点列表中移除。
    • 当故障节点恢复正常后,集群会将其重新加入,并将它标记为"PFAIL"状态。
    • 如果故障节点重新加入集群后,由于与其他节点的数据同步时间等因素,它可能会以从节点的身份加入集群。
  • 节点重新加入处理:
    • 如果一个节点因为故障离开集群,然后重新加入,集群会自动将该节点重新加入到集群中。
    • 如果离开的节点是主节点,集群会从该节点的从节点中选举一个新的主节点。
    • 如果离开的节点是从节点,集群会将它添加到其他节点的从节点列表中。
      在重新加入过程中,集群会判断该节点的数据是否最新,如果不是最新的,它会进行数据同步以保证数据一致性。重新加入的节点会以从节点的身份加入集群,根据情况可能会再次被选举为主节点。

30.1 在面临网络分区时,Redis集群采用以下机制来保持一致性和可用性

  • 分区决策机制:
    Redis集群使用Gossip协议进行节点间的通信,每个节点通过交换信息来了解集群的状态。当网络分区发生时,每个节点可以通过接收到的信息了解到其他节点的状态,从而进行决策。

  • 主节点选举:
    当网络分区发生时,可能会导致主节点与从节点之间的通信中断,这会影响到数据一致性。为了解决这个问题,Redis集群会通过在分区期间进行主节点选举来确保数据的可用性和一致性。集群会选择一部分节点作为主节点,而其他节点则会被设置为从节点。

  • 从节点复制:
    在发生网络分区时,Redis集群的从节点会尝试与主节点重新建立连接。一旦连接恢复,主节点会将在分区期间更新的数据传输给从节点,以确保数据的一致性。

  • 分区解决机制:
    当网络分区解决后,Redis集群会自动检测到这一变化,并尝试将分区中的主节点与从节点重新连接。一旦连接成功,集群将自动将从节点设置为主节点的从属节点,并恢复数据同步,从而实现整个集群的一致性和可用性。

31. redis-sorted set(zset)实现

sorted set与set结构一样均不允许重复的元素,但与set不同的是sorted set除了member(元素)之外,每个member都会关联一个分数score。sorted set根据score对元素进行升序排列,如果有多个元素的分数相同,那么按照member的字典序升序排列,sorted set中元素不允许相同,但score允许相同。

适用场景:

  • 排行榜,以用户id为member,充值总金额或得分作为score,那么就可以得到排行榜,但是需要注意的是sorted set是升序排列的,所以如果想要取前10的话,需要从最后一个元素开始。
  • 消息延迟发送,将消息体作为member,发送时间戳以score的形式存储,通过定时任务扫描sorted set,对score小于等于当前时间的score对应的消息体进行发送。

底层实现:
sorted set有两种实现方式

  • 一种是ziplist压缩表
  • 一种是zset(dict、skiplist)

redis.conf有两个配置来控制,当sorted set中的元素个数小于128时(即元素对member score的个数,共256个元素),使用ziplist若ziplist中所有元素的总长度超过64字节时使用zset
在这里插入图片描述
ziplist是一个双向链表按照score升序排列,当插入一个(member score)对时,ziplist中插入两个数据项,数据在前,score在后ziplist只能顺序(正 逆)查找,每一步前进两个数据项(member score)。
在这里插入图片描述
当元素数量或member长度达到限制时,将转为使用zset(dict、skiplist)实现

  • dict中提供通过member查询score
  • skiplist提供从score到member的查询

skiplist的代码结构如下所示:


//skiplist数据结构定义
#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25
typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;
 
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
 } zskiplist;

skiplist也是一种list的形式,但不同的是它是分层的,在开头有两个有两个常量ZSKIPLIST_MAXLEVEL代表每个节点它最多有32层,ZSKIPLIST_P表示当节点有第n层的情况下,它拥有第n+1层的概率为p。zskiplist 结构体中的header和tail表示skiplist有一个头节点一个尾节点,这两个节点中都不存储元素,length表示该调表有多少个元素(member score),level表示该跳表中最大有多少层。一个节点有多少层是由一个随机函数计算的,如下

randomLevel()
    level := 1
    // random()返回一个[0...1)的随机数
    while random() < p and level < MaxLevel do
        level := level + 1
    return level
zskiplistNode结构体表示skiplist中的节点的实现形式,该list中一个节点至少有一层,而单从第一层而言,该list是一个双向链表。backward指针指向前一个元素;*obj表示元素中的member,score表示它的分数,当两个元素的score相同时,按照member的字典序升序排列;刚才我们说过skiplist中的元素拥有多层,level数组中forward表示它每一层所指向的下一个元素是什么,span则表示元素与它当层的下个元素之间跨过了几个元素(不包括当前元素,包括指向的元素)。下图为一个简单的skiplist:

在这里插入图片描述
每个向后指的线上面的数字就表示span,表示当前元素在某一层到下个元素跨越了几个元素。下面以查找89为例,该skiplist最大有3层,从head的第三层开始,到78,78后无元素,再从78的第2层到87,87.5第2层后无元素,走87.5的第一层,找到89,路线即为图中标红的部分。如果我们想求89的排名,即把路线的span加起来,(2+2+1)-1=4,从0开始,这是从小到大,降序的顺序为6-2-2-1=1。插入的话与查找相似,即在查找之后再加入一个修改前后元素指针的操作。

  • 当通过member查找score时使用的是dict,时间复杂度为o(1)
  • 当用score查询member时使用的是skiplist时间复杂度为o(lgn)。

为什么redis用skiplist不用平衡树,首先用score查询member,它们的时间复杂度均为o(lgn);但是从实现复杂度而言,平衡树的增删有可能会造成子树的调整,逻辑较复杂,但是skiplist仅需要调整前后节点的指针。

32. redis的热key 怎么解决?

32.1 热key是什么问题,如何导致的?

一般来说,我们使用的缓存Redis都是多节点的集群版,对某个key进行读写时,会根据该key的hash计算出对应的哈希槽slot,根据这个哈希槽slot就能找到与之对应的分片(一个master和多个slave组成的一组redis集群)来存取该K-V。

但是在实际应用过程中,对于某些特定业务或者一些特定的时段(比如电商业务的商品秒杀活动),可能会发生大量的请求访问同一个key。
所有的请求(且这类请求读写比例非常高)都会落到同一个redis server上,该redis的负载就会严重加剧,此时整个系统增加新redis实例也没有任何用处,因为根据hash算法,同一个key的请求还是会落到同一台新机器上,该机器依然会成为系统瓶颈,甚至造成整个集群宕掉,若此热点key的value 也比较大,也会造成网卡达到瓶颈,这种问题称为 “热key” 问题。
在这里插入图片描述
热key会给集群中的少部分节点带来超高的负载压力,如果不正确处理,那么这些节点宕机都有可能,从而会影响整个缓存集群的运作,因此我们必须及时发现热key、解决热key问题。

32.2 热key探测(如何发现热key)

热key探测,看到由于redis集群的分散性以及热点key带来的一些显著影响,我们可以通过由粗及细的思考流程来做热点key探测的方案。

32.2.1 集群中每个slot的qps监控

热key最明显的影响是整个redis集群中的qps并没有那么大的前提下,流量分布在集群中slot不均的问题,那么我们可以最先想到的就是对于每个slot中的流量做监控,上报之后做每个slot的流量对比,就能在热key出现时发现影响到的具体slot。

虽然这个监控最为方便,但是粒度过于粗了,仅适用于前期集群监控方案,并不适用于精准探测到热key的场景。

32.2.2 redis基于LFU的热点key发现机制

redis 4.0以上的版本支持了每个节点上的基于LFU的热点key发现机制,使用redis-cli –hotkeys即可,执行redis-cli时加上–hotkeys选项。可以定时在节点中使用该命令来发现对应热点key。
在这里插入图片描述
如下所示,可以看到redis-cli –hotkeys的执行结果,热key的统计信息,这个命令的执行时间较长,可以设置定时执行来统计。

32.2.3 基于Redis客户端做探测

由于redis的命令每次都是从客户端发出,基于此我们可以在redis client的一些代码处进行统计计数,每个client做基于时间滑动窗口的统计,超过一定的阈值之后上报至server,然后统一由server下发至各个client,并且配置对应的过期时间。

这个方式看起来更优美,其实在一些应用场景中并不是那么合适,因为在client端这一侧的改造,会给运行的进程带来更大的内存开销,更直接的来说,对于Java和goLang这种自动内存管理的语言,会更加频繁的创建对象,从而触发gc导致接口响应耗时增加的问题,这个反而是不太容易预料到的事情。最终可以通过各个公司的基建,做出对应的选择。

32.3 如何解决热key

32.3.1 对特定key或slot做限流

一种最简单粗暴的方式,对于特定的slot或者热key做限流,这个方案明显对于业务来说是有损的,所以建议只用在出现线上问题,需要止损的时候进行特定的限流。

32.3.2 使用二级(本地)缓存

本地缓存也是一个最常用的解决方案,既然我们的一级缓存扛不住这么大的压力,就再加一个二级缓存吧。由于每个请求都是由service发出的,这个二级缓存加在service端是再合适不过了,因此可以在服务端每次获取到对应热key时,使用本地缓存存储一份,等本地缓存过期后再重新请求,降低redis集群压力。以java为例,guavaCache就是现成的工具。

32.3.3 拆key

如何既能保证不出现热key问题,又能尽量的保证数据一致性呢?拆key也是一个好的解决方案。

我们在放入缓存时就将对应业务的缓存key拆分成多个不同的key。如下图所示,我们首先在更新缓存的一侧,将key拆成N份,比如一个key名字叫做"good_100",那我们就可以把它拆成四份,“good_100_copy1”、“good_100_copy2”、“good_100_copy3”、“good_100_copy4”,每次更新和新增时都需要去改动这N个key,这一步就是拆key。

对于service端来讲,我们就需要想办法尽量将自己访问的流量足够的均匀,如何给自己即将访问的热key上加入后缀。几种办法,根据本机的ip或mac地址做hash,之后的值与拆key的数量做取余,最终决定拼接成什么样的key后缀,从而打到哪台机器上;服务启动时的一个随机数对拆key的数量做取余。
在这里插入图片描述

32.3.4 其他解决方案

目前市面上已经有了不少关于hotKey相对完整的应用级解决方案,其中京东在这方面有开源的hotkey工具,原理就是在client端做洞察,然后上报对应hotkey,server端检测到后,将对应hotkey下发到对应服务端做本地缓存,并且这个本地缓存在远程对应的key更新后,会同步更新,已经是目前较为成熟的自动探测热key、分布式一致性缓存解决方案,京东零售热key https://gitee.com/jd-platform-opensource/hotkey
在这里插入图片描述

33. 在sortedSet中根据 根据key查询score是怎么做到的

ZSCORE key member

这个命令将返回一个指定 member 在 key 对应的 Sorted Set 中的分数(score)。如果 member 不存在,那么这个命令将返回 nil。

34. Redis底层数据结构的几种介绍

提到Redis的“数据结构”,可能是在两个不同的层面来理解。

  • 第一个层面,是从使用者的角度。比如:string、list、hash、set、sorted set。
    这一层面也是Redis暴露给外部的调用接口。

  • 第二个层面,是从内部实现的角度,属于更底层的实现。如:dictsdsziplistquicklistskiplist

在这里插入图片描述

Redis是以k-v形式存储的内存数据库,其中key和value都是以对象(object)的形式进行存储。对象分为:string、list、hash、set和zet五种对象,这五种对象的底层实现依赖于自己实现的一些数据结构,如:sds、quicklist、ziplist、hashtable、skiplist等。注意:key只能是string对象。

  • String: int(整数)+sds(简单动态字符串)
  • List: quicklist(快速列表:ziplist+linkedlist的升级)
  • Hash: dict( 字典表)+ziplist(压缩列表)
  • Set: dict( 字典表)+intset(整数集合)
  • Zset dict( 字典表)+skiplist(跳跃表)

注意:字典表底层其实就是 HashTable

34.1 String—sds(简单动态字符串)

SDS(Simple Dynamic String,简单动态字符串)

  • Redis没有使用C语言传统的字符串表示方式(以’\0’结尾的字符数组),而是自己实现了sds的抽象类型,Redis默认使用sds作为字符串的表示
set msg "hello, world"

其中,key为保存字符串“msg”的sds,value为保存字符串“hello,world”的sds。
除了用来保存字符串的值外,sds还被用作buffer(缓冲区),比如:AOF持久化模块中的AOF缓冲区,及客户端的输入缓冲区等。

34.1.1 sds的定义

每个src/sds.h/sdshdr结构表示一个sds对象。sdshdrxx会根据字符串的实际长度,选取合适的结构,最大化节省内存空间。获取字符串长度时间复杂度O(1)。

attribute ((packed)) : 告诉编译器,不要因为内存对齐而在结构体中填充字节,以保证内存的紧凑,这样sds - 1就可以得到flags字段,进而能够得到其头部类型。如果填充了字节,则就不能得到flags字段。

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; // 已经使用的字节数
    uint8_t alloc; // 实际可以存储的字节最大长度,不包括SDS头部和结尾的空字符
    unsigned char flags; // flags中的低3个bit决定使用哪种结构存储字符串,高5bit未使用
    char buf[]; // 柔性数组,用来保存实际的字符串
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

34.1.2 空间预分配

用来优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。

34.1.3 惰性空间释放

用来优化SDS字符串缩短操作:当SDS的API对一个SDS进行缩短时,程序并不立即回收多出来的字节,而是通过alloc和len的差值,将这些字节数量保存起来,等待将来使用。

34.1.4 二进制安全

C语言的字符串中的字符必须符合某种编码(如:ASCII),并且除了字符串末尾的空字符,其他位置不能包含空字符,否则,会出现数据被截断的情况,比如:
在这里插入图片描述

如果使用C字符串所用的函数来识别,只能读取到“hello”,后面的“world”会被忽略,这个限制使得C字符串只能保存文本数据,而不能保存图片、视频、压缩文件等二进制数据。

SDS的API都会以二进制的方式来处理SDS存放在buf数组里的数据,Redis使用这个数组保存的是一系列二进制数据,而不是保存字符。SDS使用len属性的值判断字符串是否结束,而不是空字符,即SDS是二进制安全的。
在这里插入图片描述

34.2 List—quicklist(快速列表)

34.2.1 quicklist简介

Redis 中的 list 数据类型在版本 3.2 之前,其底层的编码是 ziplist 和 linkedlist 实现的,但是在版本 3.2 之后,重新引入了一个 quicklist 的数据结构,list 底层都由 quicklist 实现。

在早期的设计中, 当 list 对象中元素的长度比较小或者数量比较少的时候,采用 ziplist 来存储,当 list 对象中元素的长度比较大或者数量比较多的时候,则会转而使用双向列表 linkedlist 来存储。

快速列表 quicklist 可以看成是用双向链表将若干小型的 ziplist 连接到一起组成的一种数据结构。

说白了就是把 ziplist 和 linkedlist 结合起来。每个双链表节点中保存一个 ziplist,然后每个 ziplist 中存一批list 中的数据(具体 ziplist 大小可配置),这样既可以避免大量链表指针带来的内存消耗,也可以避免 ziplist 更新导致的大量性能损耗,将大的 ziplist 化整为零。

34.2.2 ziplist(压缩列表) 和 linkedlist(双端链表) 的优缺点

双端链表 linkedlist 便于在表的两端进行 push 和 pop 操作,在插入节点上复杂度很低,但是它的内存开销比较大。

  • 首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;
  • 其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。

压缩列表 ziplist 存储在一段连续的内存上,所以存储效率很高。但是,它不利于修改操作

  • 插入和删除操作需要频繁的申请和释放内存。特别是当 ziplist 长度很长的时候,一次 realloc 可能会导致大批量的数据拷贝。

因此,在 Redis3.2 以后,采用了 quicklist,quicklist 是综合考虑了时间效率与空间效率而引入的新型数据结构。

34.2.3 quicklist 极端情况

  • 情况一: 当 ziplist 节点过多的时候,quicklist 就会退化为双向链表。效率较差;效率最差时,一个 ziplist 中只包含一个 entry,即只有一个元素的双向链表。(增加了查询的时间复杂度)

  • 情况二:当 ziplist 元素个数过少时,quicklist 就会退化成为 ziplist,最极端的时候,就是 quicklist 中只有一个 ziplist 节点。(当增加数据时,ziplist 需要重新分配空间)

所以说:quicklist 其实就是综合考虑了时间和空间效率引入的新型数据结构。(使用 ziplist 能提高空间的使用率,使用 linkedlist 能够降低插入元素时的时间)

34.3 Hash— ziplist(压缩列表) + dict(字典表)

Redis中hash数据类型使用了两种编码格式:ziplist(压缩列表)、dict(字典表,底层:hashtable(哈希表)
)

在redis.conf配置文件中,有以下两个参数,意思为:当节点数量小于512并且字符串的长度小于等于64时,会使用ziplist编码。

hash-max-ziplist-entries 512    
hash-max-ziplist-value 64  

34.3.1 哈希表(hashtable)

Redis中的字典(dict)使用哈希表作为的底层实现,一个哈希表里可以有多个哈希表的节点,每个节点保存字典中的一个键值对。

哈希表结构定义如下:

typedef struct dictht {
    dictEntry **table;  // 哈希表数组 每个元素都是 dictEntry 的指针,指向 dictEntry;
    unsigned long size; // 哈希表大小
    unsigned long sizemask; // 用来计算索引值 always: sizemask = size - 1
    unsigned long used; // 哈希表已有节点的数量
} dictht;

哈希表节点定义如下:
哈希表节点使用 dictEntry 结构表示,每个 dictEntry 结构都保存着一个键值对和冲突后的链表的下一个节点。

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; // 保存下一个 dictEntry 的地址,形成链表
} dictEntry;

其中,value 是一个联合体,可以保存多种数据类型。当value类型为 uint64_t 、int64_t 或 double时可以直接存储。其他类型需要在其他位置申请一段空间来存放,并用val指向这段空间来使用。

字典结构定义如下:

// location: dict.h
typedef struct dict {
    dictType *type;  // 指向 dictType 结构的指针
    void *privdata;  // 存储私有数据的指针,在 dictType 里面的函数会用到
    dictht ht[2];    // 两个哈希表,扩容时使用,后面会结合源码详细说明
    long rehashidx;  // 值为-1时,表示没有进行rehash,否则保存rehash执行到那个元素的数组下标 
    int16_t pauserehash; // >0 表示rehash暂停,<0 表示编码错误 
} dict;

dictType 结构定义如下

dictType 结构体定义了一系列操作key-value键值对的方法的函数指针,在实际运行时传入指定函数,就能实现预期的功能,有点运行时多态绑定的味道。

// 操作特性键值对的函数簇
typedef struct dictType {
    uint64_t (*hashFunction)(const void *key); // 计算哈希值的函数
    void *(*keyDup)(void *privdata, const void *key);  // 复制key的函数
    void *(*valDup)(void *privdata, const void *obj);  // 复制value的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2); // 对比key的函数
    void (*keyDestructor)(void *privdata, void *key);  // 销毁key的函数
    void (*valDestructor)(void *privdata, void *obj);  // 销毁value的函数
    int (*expandAllowed)(size_t moreMem, double usedRatio); // 扩容
} dictType;

字典整体结构可以用下图来描述:
在这里插入图片描述

34.3.1.1 hash冲突

当两个或两个以上的键被分配到哈希数组的同一个索引上面时,我们称这些键发生了冲突。
Redis的哈希表使用拉链法解决hash冲突。

34.3.1.2 负载因子

负载因子 = used / size ;

  • used 是哈希数组存储的元素个数
  • size 是哈希数组的长度。

负载因子越小,冲突越小;负载因子越大,冲突越大。

34.3.1.3 rehash

随着命令的不断执行,哈希表保存的减值对会逐渐增加或者减少,为了让哈希表的负载因子维持在一个合理的范围内,当哈希表中的键值对过多或过少时,需要对哈希表的大小进行相应的扩展和收缩。而哈希表的扩展和收缩可以通过rehash来执行。

rehash 就是将 ht[0] 中的节点,通过重新计算哈希值和索引值放到 ht[1] 哈希表指定的位置上。

  • 扩容
    • 如果负载因子大于1,就会触发扩容,扩容的规则是每次翻倍;
    • 如果正在fork,执行持久化则不会扩容,但是,如果负载因子大于5,会立马扩容。
  • 缩容
    • 如果负载因子小于0.1,就会触发缩容。缩容的规则是:恰好包含used的2^n。

rehash的详细步骤:

  • 为 ht[1] 哈希表分配空间,此时字典同时拥有ht[0] 和 ht[1] 两个字典
  • 将字典中的rehashidx设置为0,表示开始rehash
  • 在rehash期间,每次对字典的增删改查,除了执行指定的命令外,还会顺带将ht[0] 中 rehashidx 索引上的所有键值对都rehash到ht[1]中,执行完rehash,rehashidx属性加一。
    注意:新增的键值对只能插入到ht[1]哈希表中,保证ht[0]的键值对只减不增。
  • 随着操作的不断进行,最终ht[0]哈希表中的所有键值对都被rehash到ht[1]中。
  • 此时,将ht[0]释放掉,让ht[0] 指向ht[1],并设置rehashidx 为 -1,表示rehash完成。
34.3.1.4 渐进式rehash

当哈希表中的元素过多时,如果一次性rehash到ht[1],庞大的计算量,可能导致redis服务在一段时间不可用。

为了避免rehash对服务器带来的影响,redis分多次、慢慢的将ht[0]哈希表中的键值对rehash到ht[1]哈希表,这就是渐进式rehash。

核心思想:将整个rehash过程均摊到每次命令的执行中

34.3.2 ziplist(压缩列表)

当一个哈希键只包含少量键值对,并且每个键值对的键和值要么是小整数,要么是短字符串,Redis就会采用压缩列表作为哈希键的底层实现。

34.3.2.1 压缩列表的构成

压缩列表是Redis为节约内存而开发的,是由一系列特殊编码的连续内存组成。
一个压缩列表可以包含任意多个节点,每个节点可以保存一个小整数或者一个短的字符串。

ziplist 的结构如下:
在这里插入图片描述
各字段说明:

  • zlbytes(4 字节): ziplist占用总的字节数
  • zltail(4 字节): ziplist 最后一个 entry 距离起始位置偏移的字节数
  • zllen(2 字节): ziplist 中 entry 的个数
  • zlend(1 字节):结束符(ziplist 以0xFF作为结束)

举个栗子:
在这里插入图片描述
说明:ziplist 的总长度为96字节(0x60的十进制),最后一个entry距离ziplist起始位置偏移了75字节(0x4B的十进制),ziplist中此时有3(0x03的十进制)个entry。
在这里插入图片描述
各字段说明:
pre_entry_len:上一个entry的长度。占用的字节数取决于上一个节点的长度

  • 如果上一个节点的长度小于254字节,pre_entry_len就占1个字节;
  • 如果大于等于254,pre_entry_len就占5个字节,而且,第一个字节会被设置为0xFE(十进制的254)。

encoding:记录该节点content属性保存数据的类型及长度。开头说了 ziplist 用来存储小整数或者短的字符串。encoding规则如下:
存储字符串:
encoding的长度有1字节、2字节、和5字节:主要根据高位的00/01/10来区分不同的编码。
在这里插入图片描述
存储小整数:
在这里插入图片描述

小总结:

  • ziplist是Redis为了节约内存而实现的一种顺序型数据结构
  • 压缩列表中的节点用来存储较短的字符串或小整数
  • 添加和删除节点可能会引发连锁更新,但这种概率很低

34.4 Set— intset(整数集合) + dict(字典表)

34.4.1 intset(整数集合)

当一个集合只包含整数值元素,并且元素的个数不多时,Redis会使用整数集合作为集合键的底层实现。
在这里插入图片描述

34.4.2 整数集合的实现

整数集合可用保存的数据类型有:int16_t int32_t 和 int64_t 的整数值,并且保证集合中不会出现重复元素。
整数集合定义如下:

// src/intset.h
typedef struct intset {
    uint32_t encoding; // 编码方式,后面会详细解释
    uint32_t length;   // 集合中元素的个数,也就是contents数组的长度
    int8_t contents[]; // 保存元素的数组
} intset;

/* Note that these encodings are ordered, so:
 * INTSET_ENC_INT16 < INTSET_ENC_INT32 < INTSET_ENC_INT64. */
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

分析:
contents数组是整数集合的底层实现:整数集合中的每一个元素就是contents数组中的一个元素,每个元素在数组中按照从小到大的顺序排列,并且没有重复元素。

虽然,contents数组被声明为 int8_t 类型的数组,但实际上contents数组并不保存任何int8_t 类型的值,contents数组实际存储的类型取决于encoding的值。encoding的取值可以是:

  • INTSET_ENC_INT16、INTSET_ENC_INT32 或 INTSET_ENC_INT64,每种编码的取值范围如下:
  • INTSET_ENC_INT16 取值范围:[−2^15 ,2^15−1] 即:[-32768, 32767 ]
  • INTSET_ENC_INT32 取值范围:[−2^31 ,2^31−1] 即:[-2147483648, 2147483647]
  • INTSET_ENC_INT64 取值范围:[−2^63 ,2^63−1] 即:[-9223372036854775808, 9223372036854775807]

说明:n比特有符号整数的表示范围为:[−2^(n−1) ,2^(n−1)−1]

整数集合图示
在这里插入图片描述

小总结:

  • 整数集合的底层实现是数组,这个数组以有序、无重复的方式存储元素,在需要时会根据新添加元素的类型升级数组的类型。
  • 只支持升级操作,不支持降级操作
  • 整数集合是有序集合的底层实现之一

34.5 ZSet— skiplist(跳跃表) + dict(字典表)

34.5.1 跳表简介

跳跃表(skiplist)是一种随机化的数据, 由 William Pugh 在论文《Skip lists: a probabilistic alternative to balanced trees》中提出, 跳跃表以有序的方式在层次化的链表中保存元素, 效率和平衡树媲美 —— 查找、删除、添加等操作都可以在对数期望时间下完成, 并且比起平衡树来说, 跳跃表的实现要简单直观得多。

跳表是一个随机化的数据结构,在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。

它或可以被看做二叉树的一个变种,它在性能上和红黑树,AVL树不相上下,但是跳表的原理非常简单,在Redis和LeveIDB中都有用到。

34.5.2 跳表引入

首先,普通单链表来说,即使链表是有序的,我们要查找某个元素,也需要从头到尾遍历整个链表。这样效率很低,时间复杂度是O(n)。
在这里插入图片描述
那么有没有方法提升查询效率呢?我们可以尝试为链表建立“索引”来提升查询效率。如下图,我们在原始链表的基础上,每两个元素提取一个索引,down指向原始链表的节点:
在这里插入图片描述
此时,假如我们要查询值为19的节点,我们从索引层开始遍历,当遍历到16时,下个节点的值为23,所以,19一定在这两个节点之间。我们通过16节点的down指针来到原始链表,将继续遍历,直到找到值为19的节点。在没有建“索引”之前,我们需要遍历8次,才能找到19,而在建立“索引”后,需要6次就能找到,也就是,索引帮我们减少了查询的次数。

在这里插入图片描述

34.5.3 跳表与红黑树,AVL树等平衡数据结构的比较

跳表与红黑树和AVL树相比,效率不相上下,但是它胜在实现起来比较简单,我们可以很快的实现出来。跳表在更新的时候需要改动的地方很少,而红黑树和AVL树需要改动的地方很多。如果在多线程的情况下,红黑树和AVL树在维持平衡的时候,需要的锁资源很多,越是在靠近根节点的地方越容易产生竞争。但是跳表的操作更加局部性一点,需要锁住的资源很少。

34.5.4 跳表性质

1、由很多层组成
2、每一层都是一个有序链表
3、最底层的链表包含所有元素
4、如果一个元素出现在第i层的链表中,则它在i-1层中也会出现。
5、上层节点可以跳转到下层。

34.5.5 跳表在Redis中的应用

ZSet结构同时包含一个字典和一个跳跃表,跳跃表按score从小到大保存所有集合元素。字典保存着从member到score的映射。这两种结构通过指针共享相同元素的member和score,不会浪费额外内存。

redis中的zset在元素少的时候用ziplist来实现,元素多的时候用skiplist和dict来实现。

一个skiplist的生成过程:
在这里插入图片描述
由于层数是每次随机出来的,所以新插入一个节点并不会影响其他节点的层数。插入一个节点只需要修改节点前后的指针即可,降低了插入的复杂度。

刚刚创建的skiplist包含4层链表,假设我们依然查找23,查找路径如下。插入的过程也需要经历一个类似查找的过程,确定位置后,再进行插入操作。

在这里插入图片描述

34.5.6 Redis为什么用skipList来实现有序集合,而不是红黑树?

redis中zset常用的操作有如下几种,插入数据,删除数据, 查找数据,按照区间输出数据,输出有序序列。

1.插入数据,删除数据,查找数据,输出有序序列这几种操作红黑树也能实现,时间复杂度和跳表一样。但是按照区间输出数据,红黑树的效率没有跳表高,跳表可以在O(logn)的时间复杂度定位区间的起点,然后向后遍历极快。
2.跳表和红黑树相比,比较容易理解,实现比较简单,不容易出错。
3.可以通过设置参数,改变索引构建策略,按需平衡执行效率和内存消耗。

34.5.7 ZSet中的字典和跳表布局:

在这里插入图片描述

摘自:https://blog.csdn.net/yyz_1987/article/details/124359462?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522170228250416800182776385%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=170228250416800182776385&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allfirst_rank_ecpm_v1~rank_v31_ecpm-1-124359462-null-null.142v96pc_search_result_base5&utm_term=%E8%B7%B3%E8%A1%A8%E7%9A%84%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E6%98%AF%E6%80%8E%E6%A0%B7%E7%9A%84&spm=1018.2226.3001.4187

35. Redis布隆过滤器原理与实践

布隆过滤器(Bloom Filter) 是 1970 年由布隆提出的。它 实际上 是一个很长的二进制向量和一系列
随机映射函数 ,实际上你也可以把它简单理解为一个不怎么精确的 set 结构,当你使用它的 contains
方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合理,
它的精确度可以控制的相对足够精确,只会有小小的误判概率。

当布隆过滤器说某个值存在时,这个值 可能不存在;当它说不存在时,那么 一定不存在。打个比方,当它说 不认识你时,那就是真的不认识,但是当它说认识你的时候,可能是因为你长得像它认识的另外一个朋友 (脸长 得有些相似),所以误判认识你。

35.1 原理

当一个元素加入布隆过滤器中的时候,会进行如下操作:

  • 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
  • 根据得到的哈希值,在位数组中把对应下标的值置为 1。

当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作:

  • 对给定元素再次进行相同的哈希计算;
  • 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中, 如果存在一个值不为 1,说明该元素不在布隆过滤器中。

35.2 如何选择哈希函数个数和布隆过滤器长度

  • 如果布隆过滤器的长度太小,所有的 bit 位很快就会被用完,此时任何查询都会返回“可能存在”;
  • 如果布隆过滤器的长度太大,那么误判的概率会很小,但是内存空间浪费严重。

类似的,哈希函数的个数越多,则布隆过滤器的 bit 位被占用的速度越快;哈希函数的个数越少, 则误判的概率又会上升。因此,布隆过滤器的长度和哈希函数的个数需要根据业务场景来权衡。

三个参数

  • 哈希函数的个数k;
  • 布隆过滤器位数组的容量m;
  • 布隆过滤器插入的数据数量n;

关于参数的设定,可以参考大佬的 https://blog.csdn.net/jiaomeng/article/details/1495500

主要的数学结论有:

  • 为了获得最优的准确率,当k = ln2 * (m/n)时,布隆过滤器获得最优的准确性;

35.3 优缺点

  • 优点:优点很明显,二进制组成的数组,占用内存极少,并且插入和查询速度都足够快。

  • 缺点:随着数据的增加,误判率会增加;还有无法判断数据一定存在;另外还有一个重要缺点,无法删除数据;当初始化的值设置好之后,一旦超过,只能重建;

35.4 使用场景

  • 大数据判断是否存在:这就可以实现出上述的去重功能,如果你的服务器内存足够大的话,那么使用 HashMap 可 能是一个不错的解决方案,理论上时间复杂度可以达到 O(1)的级别,但是当数据量起来之后,还是只能考虑布隆过滤器。
  • 解决缓存穿透(背景中提到的问题):利用布隆过滤器我们可以预先把数据查询的主键,比如用户 ID 或文章 ID
    缓存到过滤器中。当根据 ID 进行数据查询的时候,我们先判断该 ID 是否存在,若存在的话,则进行下一步处
    理。若不存在的话,直接返回,这样就不会触发后续的数据库查询。需要注意的是缓存穿透不能完全解决,我们只
    能将其控制在一个可以容忍的范围内。
  • 爬虫/ 邮箱等系统的过滤:平时不知道你有没有注意到有一些正常的邮件也会被放进垃圾邮件目录中,这就是使用布隆过滤器 误判 导致的。
  • Google Chrome 使用布隆过滤器识别恶意 URL。

35.5 基本操作

布隆过滤器有两个基本指令

  • bf.add 添加元素
  • bf.exists 查询元素是否存在

它的用法和 set 集合的 sadd 和 sismember 差不多。注意 bf.add 只能一次添加一个元素,
如果想要一次添加多个,就需要用到 bf.madd 指令。同样如果需要一次查询多个元素是否存在,
就需要用到 bf.mexists 指令。
在这里插入图片描述
bf.reserve 有三个参数,分别是 key、error_rate (错误率) 和 initial_size:

  • error_rate 越低,需要的空间越大,对于不需要过于精确的场合,设置稍大一些也没有关系,
    比如上面说的推送系统,只会让一小部分的内容被过滤掉,整体的观看体验还是不会受到很大影响的;
  • initial_size 表示预计放入的元素数量,当实际数量超过这个值时,误判率就会提升,所以需要
    提前设置一个较大的数值避免超出导致误判率升高;

如果不适用 bf.reserve,默认的 error_rate 是 0.01,默认的 initial_size 是 100。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Alan0517

感谢您的鼓励与支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值