Redis学习 - ambition0802/spring-practice GitHub Wiki

1.寻找资源

1.1.the-little-redis-book

https://github.com/karlseguin/the-little-redis-book

Redis3.0源码注释

1.2.书籍类

《redis设计与实现》

《redis开发与运维》

1.3.极客时间买的redis课程

1.4.redis.io (官网英文文档看起来就完事了)

2.《redis设计与实现》要点

2.1.Redis底层支持的数据结构

2.1.1.简单动态字符串(SDS)

如何实现,特点,防止了什么东西

2.1.2.链表

双端链表

2.1.3.字典

dict结构持有两个hashtable---->rehash机制---->渐进式Hash,防止数据量太大的时候,rehash阻塞其他操作导致服务不可用。

2.1.4.跳跃表

什么是跳跃表?

跳跃表作为有序集合(redis支持的五种键之一)的底层实现之一(说“之一”,是因为有序集合的底层还会有其他的实现。)

Redis中使用到跳跃表的地方就两个:①有序集合键。②在集群节点中用作内部数据结构。

2.1.5.整数集合

整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合中的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

整数集合 数据类型升级(int16 int32 int64) ,升级的好处是可以节约内存。

只支持数据类型的升级,不支持数据类型的降级。

2.1.6.压缩列表

压缩列表是列表value(3.2版本之前,列表的底层是ziplist或者linkedlist,从3.2版本开始改成了quicklist,详见这里)和哈希value的实现之一。

什么情况下使用压缩列表;1.小整数值;2.短字符串;3.元素少

压缩列表的数据结构以及压缩列表节点的数据结构

压缩列表每个节点通过previous_entry_length(1个字节(前一个节点长度<254)或者5个字节(前一个节点的长度>=254))保存上一个节点的长度(可以从列表尾部从后往前遍历),可能会引起连锁更新。

2.1.7.对象

redis支持的五种键值类型,都是对象,redis中每个对象都由一个redisObject结构表示。

typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 指向底层实现数据结构的指针
    void *ptr;
    // ...
} robj;

Redis中的每个对象的编码(底层数据实现)不是固定的,redis的对象根据不同的场景使用不同的编码格式,来优化数据,提高效率。

REDIS中一共有五种数据类型,这五种数据类型底层数据结构(ENCODING)变化逻辑。

2.2.Redis支持的五种数据类型及其底层实现

2.2.1.字符串

int ---> embstr ---> raw

embStr : redisObj+sdshdr(一次性分配一块内存存放两者;内存分配、回收、内存碎片)

raw :redisObj+sdshdr(分两次分配内存存放两者;内存分配、回收、内存碎片)

设置key的值为String:

set key value # set命令会删除key原先的过期时间
setex key seconds value # set + expire 两个命令结合,原子性,减少一次网络RTT(round trip time)

2.2.2.哈希

ziplist ---> hashtable

2.2.3.列表

3.2版本前是 ziplist ---> linkedlist(双向链表),3.2版本以及以后是 quicklist

2.2.4.集合

intset(整数集合,底层是int[]) ---> hashtable

2.2.5.有序集合

ziplist+hashtable ---> skiplist(用分查key会比较快O(logN))+hashtable

skiplist和hashtable通过指针共享同一个对象,不会造成内存翻倍 (勉勉强强算享元模式?)

漫画算法:什么是跳跃表?

2.2.6.其他

  1. Redis的对象系统使用引用计数来实现内存的回收机制。当对象的引用计数为0时,将被系统回收。
  2. Redis会共享0~9999的字符串对象(像JVM中的字符串常量池)。
  3. redisObj上面有保存自己最后一次被访问的时间,可用于计算对象的空转时间
  4. redis命令操作对象的时候,会进行类型检查(检查命令与被操作的对象是否匹配),如果匹配会根据对象的编码格式,底层使用不同的方法来操作该对象(命令多态)。

2.2.8.总结

高效的数据结构,是Redis响应快速的原因之一1

2.2.9.问题

问:为什么Redis要使用 intset和ziplist这两种数据结构,在查询元素的时候他们的时间复杂度是O(n)效率不会很高

答:

三个方面:

  1. 元素比较少的时候,轮询查询的效率不会很差。
  2. 占用的内存空间少(不像linkedList一样,每个节点还要保存指向前后两个节点的指针)
  3. 占用的内存连续,内存碎片少。
  4. 数组(intset底层上实际是数组)对CPU高速缓存支持更友好。

2.3.数据库

Redis中的数据库概念更确切的来说应该是命名空间。

可以在redis的配置文件中设置该Redis实例中数据库的个数(key为databases)。

Redis数据库集的概念仅在单机的时候生效?

数据库对象主要由dict和expires两个字典对象构成。dict保存KV,expires保存键(指针指向dict中具体的键)的过期时间。

删除过期键的方式: 定期删除 + 惰性删除 (双剑合并,否则单单只使用任意一种的话,都有明显的优缺点)

2.4.RDB

2.4.1.RDB相关命令

SAVE:会阻塞Redis服务进程直到RDB文件创建完毕。

BGSAVE(background save):创建(fork)一个子进程去创建RDB文件。

2.4.2.配置文件中自动RDB的配置

################################ SNAPSHOTTING  ################################
#
# Save the DB on disk:
#
#   save <seconds> <changes>
#
#   Will save the DB if both the given number of seconds and the given
#   number of write operations against the DB occurred.
#
#   In the example below the behaviour will be to save:
#   after 900 sec (15 min) if at least 1 key changed
#   after 300 sec (5 min) if at least 10 keys changed
#   after 60 sec if at least 10000 keys changed
#
#   Note: you can disable saving completely by commenting out all "save" lines.
#
#   It is also possible to remove all the previously configured save
#   points by adding a save directive with a single empty string argument
#   like in the following example:
#
#   save ""

save 900 1
save 300 10
save 60 10000

redis允许在配置文件中配置RDB后台自动备份的频率。配置语法为:

save 距离上一次成功备份时间的秒数 数据修改次数

然后看redis的默认的配置:

save 900 1 save 300 10 save 60 10000

第一次看这个配置给我整懵逼了,为什么时间越长,触发RDB的修改次数反而越小?

后来想明白了,如果时间越长,单位时间的修改次数设置的越大,那么RDB的备份可能永远不会被触发。

然后比如 save 300 10,这个命令不是“最近300秒内只要超过10次修改就备份RDB”,如果是这样子的话,Redis还要记录每一次修改的时间,然后定时任务运行的时候,根据时间戳收集最近300s内运行过的修改命令,如果修改命令的个数大于10的话才执行RDB备份,这整个逻辑会复杂很多,需要的资源较多,而且没必要。

Redis判断是否要RDB的真正做法是。记录上一次成功RDB的时间,以及从上次成功RDB到当前时间内Redis的修改次数。同时设置一个定时任务,定时任务执行的时候,轮询所有的save条件,比如save 300 10,只要满足:

now() - lastSuccRdbTime >= 300 && modifyTime >= 10

就会触发RDB的运行。

这种方式相较前面的方式,更加简单,性能更好,且完美的满足了RDB能够按需定期备份的需求。R

2.4.3.RDB文件格式

可以在redis.conf中配置对产生的RDB文件进行压缩。

对于不同类型的键之对,RDB文件会使用不同的格式(方式)来保存他们。

⭐2.4.4.RDB的基于硬盘和无盘复制

repl_backlog_size这个参数很重要,因为如果满了,就需要重新全量复制,默认是1M,所以之前网上就流传1个段子,如果一个公司说自己体量如何大,技术多么牛,要是repl_backlog_size参数是默认值,基本可以认为要不业务体量吹牛逼了,要不就没有真正的技术牛人。

主从复制的另一种方式:基于硬盘和无盘复制 可以通过这个参数设置 repl-diskless-sync 复制集同步策略:磁盘或者socket 新slave连接或者老slave重新连接时候不能只接收不同,得做一个全同步。需要一个新的RDB文件dump出来,然后从master传到slave。可以有两种情况: 1)基于硬盘(disk-backed):master创建一个新进程dump RDB,完事儿之后由父进程(即主进程)增量传给slaves。 2)基于socket(diskless):master创建一个新进程直接dump RDB到slave的socket,不经过主进程,不经过硬盘。

当基于 disk-backed 复制时,当 RDB 文件生成完毕,多个 replicas 通过排队来同步 RDB 文件。

当基于diskless的时候,master等待一个repl-diskless-sync-delay的秒数,如果没slave来的话,就直接传,后来的得排队等了。否则就可以一起传。适用于disk较慢,并且网络较快的时候,可以用diskless。(默认用disk-based)

2.5.AOF

2.5.1.文件的写入与同步

为了提高文件的写入效率,在现代操作系统中,当用户调用write函数,将一些数据写入到文件的时候,操作系统通常会将写入的数据暂时保存在一个内存缓冲区里面,等到缓冲区的空间被填满、或者超过了指定的时限之后,才真正的将缓冲区中的数据写入到磁盘里面。这种做法虽然提高了效率,但也为写入数据带来了安全问题,因为如果计算机停机,那么保存在内存缓冲区里面的写入数据将丢失。

为此,操作系统提供了fsync和fdatasync两个同步函数,他们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性。

不难观察到,这种缓冲区的设置,在我们的日常开发中随处可见,比如java.io中的BufferdWriter和BufferedReader,通过缓冲区,变相的提高系统、应用的读写IO,还有MQ,其实也可以被认为是一个消息缓冲队列,让producer的生产力不会受限于consumer的消费能力(当然实际开发的时候是要主语生产者和消费者之间的平衡)

综上,缓冲区,在我们的开发设计中,是一样很重要且需要经常被考虑到的要素。

2.5.2.AOF文件的重写

自动执行:redis.conf上可以配置定期自动执行aof(各种配置项很多,后续研究下)

手动执行:命令 bgrewriteof

说是重写实际上不会去操作老的AOF文件,而是根据当前redis中所有的KV,遍历他们,将创建一条条KV数据的命令写入到新的AOF文件中。比如有一个key为“fruit”的集合,对他进行了四次插入操作,则老的AOF会记录对应的四条插入命令。

SADD fruit apple
SADD fruit pear
SADD fruit peach
SADD fruit lemon

进行AOF重写之后,原本aof中的命令对应就变成了:

SADD fruit apple pear peach lemon

四条命令变成一条命令,瘦身成功。

2.5.3.AOF重写缓冲区

AOF重写时,是fork一个新的子进程来进行重写的。两个好处

  1. 不会阻塞REDIS服务的主线程
  2. fork出来的子进程带有服务器进程的数据副本,使用子进程而不是线程,可以避免使用锁的情况下保证数据的安全性。

在rewrite aof的时候,redis服务主线程还是在工作的,在这个期间内子进程无法知道新执行的修改命令。所以redis设置了一个AOF重写缓冲区,来保存这期间内已经执行过的修改命令。当子进程完成数据副本的rewrite之后,会向父进程发送一个信号,父进程接受到信号之后会进行一下操作:

  1. 阻塞redis主服务的线程。
  2. 将AOF重写缓冲区的所有内容写入到新的AOF文件中。这时新AOF文件和服务器的数据库状态是一致的。
  3. 将新的AOF文件重命名,原子的覆盖现有的AOF文件,完成新旧两个AOF文件的替换。(为什么着重突出原子呢,因为这个过程涉及不只一个操作:删除、替换,这两个操作必须是原子的,否则可能出现AOF缺失的情况,Redis在这里必须要保证这两个操作的原子性)

所以AOF重写的过程中,在最后为了保证AOF和服务数据库的一致性,是会阻塞redis服务的,我们在使用的时候要注意AOF重写的时机,不要影响到线上服务的正常运行。

2.5.4.RDB和AOF混合使用

RDB以一定频率执行,在两次快照RDB之间,使用AOF日志增量记录这期间的所有命令操作。

2.6.RDB与AOF时利用到的操作系统特性

主要利用了操作系统fork进程通过虚拟内存页表共享物理内存,以及COPY_ON_WRITE机制

内存为什么要分页

Linux的内存分页管理

Copy On Write机制了解一下

⭐对于Redis这种喜欢fork子进程的程序来说,Linux操作系统的Huge Page(大内存分页)建议是最好关闭的,不然在fork子进程进行AOF重写或者RDB生成的时候,如果触发COW比较频发的话,就会频繁的去申请内存分页,内存分页越大,申请所需的时间就越多。(fork ---> copy on write ---> ram page)所以Huge Page在实际使用Redis时候是强烈建议关闭的。

TODO 结合上述博客总结下

2.7.事件

Redis是一个事件驱动的服务模型。

在Redis中事件被分为文件事件和时间事件两大类。

2.7.1.文件事件

文件(file descriptor)事件是Redis对套接字(socket)操作的抽象。

Redis基于Reactor模式开发了自己的网络事件处理器,也被称为:文件事件处理器。

文件事件处理器的模型大致如下:

image-20200825171912750

其中“I/0多路复用程序”处理套接字操作是单线程的(有一个队列按照fifo的规则顺序处理socket操作,单线程无并发,当前的文件时间处理并响应返回了,再处理下一个socket操作)。

2.7.2.时间事件

Redis通过一个无序队列来保存时间时间,这里的无序并不是说没有按照添加进队列的顺序排列,而是没有按照时间的执行时间的顺序排列。

2.7.3.事件处理角度下的服务器运行流程

image-20200825172451182

2.7.4.Reactor模型

参考下面两篇blogs,好好学习:

Reactor模式详解

Java NIO深入理解ServerSocketChannel

2.8.客户端

2.8.1.相关命令

  1. client list : 列出连接到当前服务端的所有客户端的信息。fd=-1的是为伪客户端,比如说是载入AOF文件或者执行Lua脚本中的redis命令。

2.9.服务端

2.9.1.执行一条命令在客户端与服务端之间的具体过程

2.9.2.服务器的启动过程

2.9.3.serverCron定时任务

2.10.复制(Replication)

2.10.1.旧版(v2.8之前)复制

PS:v5.0开始,用replicaof命令替换了slaveof

2.10.1.1.旧版数据复制过程

  1. 客户端向副本发送slaveof命令,指定副本对应的master。
  2. 副本向master发送sync命令
  3. master执行bgsave命令生成RDB文件,并将执行bgsave之后收到的写命令缓存到缓冲区中。
  4. master将RDB文件发送给副本,副本加载RDB文件
  5. master将缓冲区的写命令发送给副本,副本加载写命令
  6. 副本与master断开又重连的话,从步骤2开始往下执行重新同步的过程。

旧版复制的缺点:每次slave断线重连到master,slave都要向master发送sync命令,进行全量同步,包括要同步之前已经同步过的数据,很没必要,整个过程很浪费资源,影响master的性能。

2.10.2.新版(v2.8及之后)复制

2.10.2.1.新版数据复制过程

新版本数据复制的命令是psync。

新版本数据的复制过程和旧版本数据复制过程(psync和sync),在SLAVE第一次连上MASTER时的数据全量复制的步骤是一样的。区别在于断线重连的过程,psync支持部分重同步(高效的处理了slave断线重连之后的复制情况)。

2.10.2.2.部分重同步的实现核心

PSYNC命的部分重同步的实现过程主要基于以下三个基础:

  1. master和slave都有记录自己的复制偏移量(replication offset)
  2. master服务器的复制积压缓冲区(replication backlog)
  3. 服务器的运行ID(runId)(用于标识每个实例的id,在slave短线重连之后,slave可以根据master的runId来判断断线前后是否是同一台master,如果是,可以尝试进行部分重同步,如果不是那就只能进行全同步了)

复制积压缓冲区,是一个固定长度的先进先出的队列。保存最近传播的最新部分的写命令,并且会给每个字节都记录响应的复制偏移量。slave断线重连后,只有到slave发送过来的复制偏移量的下一位偏移量在复制积压缓冲区中,slave才可以进行部分复制,否则还是要进行全量复制(slave和master通过psync的 协议来沟通)。所以要根据实际情况配置redis的复制积压缓冲区的大小(redis.conf中的repl-backlog-size)

2.10.3.同步的具体实现

建立套接字--->ping-pong--->AUTH--->slave发送给master自己监听的port--->同步--->同步好--->命令传播(master和slave之间的长连接,否则频繁创建连接消耗系统资源)

2.10.4.SLAVE和MASTER之间的心跳连接

在命令传播阶段,SLAVE会定期(每秒)向master发送命令

repliconf ack <replication_offset>

①告知master,slave的复制偏移量是否落后与master,如果是则master会补发slave缺失的数据。

②除此之外心跳机制还能检测slave与master之间的网络状况。

③并且让master知道自己slave的连接状况,以及其他的一些信息。

在master上执行info replication命令,可以查看每个slave的lag信息,即slave最后一次向master发送replication ack距离当前已经过了多少秒。(如果lag的值大于1,怎说明master和slave之间可能有异常,可能是redis实例有问题,也可能是网络有问题。)

2.10.5.问题

  1. 主从之间不是实时100%同步的,这种不强一致性的问题该如何解决?

2.11.Sentinel哨兵机制

2.11.1.Raft算法

Redis的哨兵机制使用了Raft算法。

Raft算法和Paxos算法一样都是关于“分布式一致性”的算法。但是Paxos算法比较灰色难懂,且实现起来很复杂。而Raft算法比较容易理解,实现起来较为简单,且算法的效率、安全性都较可靠,也经过验证。

学习Raft算法的相关资料:

Raft算法的paper(斯坦福大学,牛批,有排面)

动画介绍Raft算法

Raft算法作者录制的视频教程

2.11.2.Sentinel、Master、Slave之间的连接建立

  1. sentinel连接指定的master,和master之间建立两个连接,命令连接和订阅连接(订阅master的sentinel专用频道)
  2. sentinel为master在自己的实例中为master创建数据结构,并定期(每隔10s)向master发送info命令,获取master的相关信息以及slave信息,将master的数据保存更新到自己的实例中。
  3. 第2步中sentinel获取了master的slave的信息,sentinel在自己的实例中为slave创建数据结构,然后和slave之间建立两个连接,命令连接和订阅连接(订阅slave的sentinel专用频道),并定期(每隔10s)向slave发送info命令,获取slave相关信息以及slave对应的master信息,将slave的数据保存更新到自己的实例中。
  4. sentinel和任意master和slave建立起命令连接和订阅连接之后,会通过订阅连接订阅sentinel专用的频道,同时定期(每隔2s)通过命令连接,向master或者slave的sentinel频道,广播当前sentinel的信息(包括sentinel的基本信息以及它的master的信息)。这样通过sentinel频道,每个sentinel就能知道集群中的其他sentinel的信息了,并且会将其他sentinel的信息保存到自己的实例中。同时sentinel在知道其他sentinel的信息之后,还会和它们建立长连接(sentinel之间建立连接之后,服务之后的redis准备集群的故障检测(检查redis实例客观下线状态),leader选举,故障转移(这一套使用已经经过验证的Raft算法))。

image-20200901171644216

2.11.3.Sentinel执行故障转移的过程

Master被客观下线(还有主管下线)后,Sentinel选取主备集群中,一个数据最完整、最新的slave实例,来升级成为master。升级成功后(一直向这个slave发送info命令,通过info命令的返回信息检查它是否已经升级成为master),通过向其他slave发送replicaof命令将其他slave的master设置为这个新的master。

Q:故障转移时,以什么为依据来选取升级成为新master的slave?

答:参见https://redis.io/topics/sentinel中的“Replica selection and priority”章节.

简述下这个过程就是Sentinel会先去把不符合条件的replica过滤掉,这个条件就是,replica和master断连的时间必须小于

(down-after-milliseconds * 10) + milliseconds_since_master_is_in_SDOWN_state

其中down-after-milliseconds是sentinel判断master是否出故障的时间上限,milliseconds_since_master_is_in_SDOWN_state是sentinel已经检测到的master距今为止的断线时间。

把不符合上述条件的replica过滤后,sentinel按照下面三个条件来从replica中选择新的master:

  1. replica_priority最小(但是不能为0,replica的优先级为0表示这个replica不参与master选举)
  2. replica_priority一样,就选offset最大的。
  3. replica_priority和offset都一样,就选runId最小的。runId最小没有什么特殊意义,只是希望master的选择是明确的,不是随机的。

2.11.4.客户端订阅哨兵事件

哨兵提供很多事件(实际上就是频道啦)让客户端来订阅。

当发生”主库下线事件“、“从库重新配置事件”、”新主库切换事件“等事件时,哨兵会发送消息给订阅了这些事件的客户端,通知他们。(基于 订阅发布模式 or 事件驱动机制)

2.12.集群模式

为什么要使用集群模式将Redis的数据分片?

因为当一个Redis中数据量很大的话,会影响Redis服务的性能,比方说一个Redis实例中保存了20GB的数据,当Redis触发了自动RDB的时候,主进程要fork出一个子进程去在后台执行RDB,因为持有20GB的数据,所以在fork的时候,从主进程中拷贝这20GB数据的(多级)内存分页表给子进程会比较耗时,fork的时间将近到秒级,阻塞了主进程的读写服务,影响性能。

查看Redis最近一次fork子进程的耗时时间,可以通过 info state命令,查看latest_fork_usec的值(单位微秒)

为了解决单点Redis数据量过大的问题,可以使用集群对数据进行分片,将数据散列到多个Redis实例上,将数据量对单个Redis实例的性能影响降到最低。集群分片应运而生。

2.12.1.redis实例开启集群配置

redis实例需要将redis.conf中的cluster-enable 设置为yes,这样redis实例才可以和其他同样设置了yes的实例组成redis集群。

2.12.2.cluster meet命令组成集群

和其他redis实例组成集群,通过cluster meet命令:

cluster meet <ip> <port>

cluster meet命令的协议也和TCP协议一样要进行三次握手:

image-20200903194056688

组成集群后,在任意集群中的redis实例上执行 cluster nodes命令,可以查看集群中的节点信息:

cluster nodes

也可以使用cluster info命令来查看集群的状态:

cluster info

除了上述操作一台台Redis实例组成集群的方式之外,还可以使用redis-cli命令的 --clustere create参数一行命令创建集群,方便快捷,不过这种方式下,集群中的每个节点的槽位(slot)是默认平均分配给各个节点的。

redis-cli --cluster create ip:port ip:port ip:port ...

2.12.3.cluster主要的数据结构

clusterNode、clusterLink、clusterState、clusterMsg

2.12.4.cluster的槽位分配

16384 (https://www.cnblogs.com/rjzheng/p/11430592.html),1 char = 1 byte = 8 bit,每个bit是一个slot

image-20200903204535886

节点数不要过多

cluster addslots (两种数据结构保存槽位信息),注意不能通过redis-cli登录之后,进入命令控制台再输入cluster addslots命令来给当前redis server分配槽位,要在非redis-cli命令控制台下执行cluster addslots命令:

redis-cli -h ip -p port -c cluster addslots {0..5000}
# 注意{0..5000}之间是两个点,不要多了。

计算槽位 CRC16(KEY) & (2^14-1) (所有数字和2^n-1进行&运算,就是求这个数除以2^n-1之后的余数)

cluster keyslot 计算键最终落在那个槽位上

redis-cli -c开启客户端集群模式,可以支持槽位moved异常,重定向连接到槽位指定的节点上去

节点数据库实现

zskplist *slots_to_keys(为什么用跳跃表不用数组(16384)?节省空间,初始化的时候一个键都没有,上来就申请16384长度的数组,简直太没节操了,而且就算用了一段时间,大部分的槽其实也是没有占用的)。基于这个跳跃表实现了可以获取指定槽位上指定个数的键(可以查询某个槽位上有某个键):

cluster getkeysinslot <slot> <count>

2.12.5.重新分片

重新分片指的是槽位改派,改派过程中,相关的节点可以继续处理请求。

首先请求会发送到该槽位的源服务器上,源服务器上找不到这个key,且记录的这个key的槽位正在转移的话,源服务器会给客户端返回一个ASK错误信息(其中带上槽位改派的目标服务器的信息),客户端收到这个ASK错误之后,会向目标服务器先发送一个ASKING命令,目标服务器根据这个命令设置一个临时的标志位,然后根据这个标志位客户端向目标服务器请求目标key的请求就不会又被move到源服务器了(如果没有ASKING命令,目标服务器会将请求move到源服务器上,因为槽位正在改派中,key对应的槽位还由源服务器负责),目标服务器会处理这个请求,请求完毕后,临时的标志位复原,所以ASKING命令对于目标服务器是零时的。

综上就是槽位正在改派时,各服务器还能处理请求的大概的原理了。

2.12.6.复制与故障转移

2.12.6.1.复制

redis cluster中每个节点都可以各自在实现主备集群。

先将所有的redis实例通过cluster meet命令先组成集群。然后通过cluster replicate命令,给目标节点指定master(目标节点从master节点备份)节点:

cluster replicate <node_id>

2.12.6.2.故障检测

集群中每个节点(应该指的是集群内的有持有槽位的主节点,不包括备份用的slave节点)之间会定期发送PING消息,如果接受方在指定时间内没有回复PONG消息的话,发送方会将接收方标记为疑似下线。同时节点之间会相互发送消息来交互集群内的各个节点的信息。当集群内超过半数以上的节点任务某个节点是疑似下线(PFAIL)则集群内各节点会将这个节点标记为下线(FAIL)。

2.12.6.3.主节点选举

和Redis哨兵模式时的主节点选举差不多,都是采用Raft算法、领头选举(leader election)。从节点向集群内持有槽位(有槽位说明才是这个集群内在工作的节点,所有才有投票权)的主节点要选票,获得超过半数选片的从节点升级为主节点,如果没有选举主节点,则epoch++,进入到下一纪元的选举

2.12.6.4.sentinel内master故障转移和cluster内主节点故障转移区别

sentinel模式下进行故障转移的时候,会根据一定的规则,选择数据最完整的从节点来升级。

而cluster模式下,从节点升级为主节点却是随机的?每个从节点向那些持有槽位的主节点抢选票,谁有超过半数主节点的选票,谁就能升级,这会不会太生草了?----> cluster模式下从节点的升级其实和sentinel模式下也是类似的,不是随机的,《Redis设计与实现》这本书这个地方没有将细致,有误导性。。。cluster模式下,和主节点断开连接的时间在配置范围内的才有资格参与选举,且复制偏移量最大的从节点,最先发起抢票广播,偏移量小的节点晚点发起抢票广播,根据从节点的偏移量大小来设置每个从节点开始抢票的时间来达到区分不同复制偏移量的从节点的优先级(参考【redis】cluster集群的故障转移机制实验

2.12.6.5.集群消息通信

广播方式

Gossip协议方式

2.12.7.第三方将Redis集群化的工具

Redis原生的集群功能,在3.0版本才出现,在这之前,出现了很多第三方的Redis集群中间件,并且一直到现在都有被广泛的使用,比如 Codis,Twemproxy,ShardedJedis。

2.12.8.保证分布式系统良好的单调性

一个设计良好的分布式系统应该具有良好的单调性,即服务器的添加与移除不会造成大量的hash重定位。

一致性Hash的目的就是为了分布式系统具有良好的单调性。Redis集群中使用Hash槽的方式,其实Hash槽本质上也是一致性Hash,最终都是为了保证分布式系统的单调性。

关于一致性Hash和Hash槽可以参考下这两篇资料:

一致性哈希

hash slot(虚拟桶)

2.13.消息订阅与发布

Redis也可以作为一个消息中间件用,但是肯定没有那些主流的MQ好用,比如KAFKA,RabbitMQ,RocketMQ等,但是胜在足够简单,如果对消息丢失以及消息持久化没有要求的话,可以拿来凑活用用。消息中间件的存在,将多个服务模块之间相互解耦。

2.13.1.相关命令

# pub = publish, sub = subscribe, pat = pattern
# 订阅
subscribe "news.it"       # 普通订阅
psubscribe "news.[ie]t"   # 模式订阅(正则表达式匹配,p means pattern)
# 退订
unsubscribe "xxx"         # 普通退订
punsubscribe "xx[xy]"     # 模式退订
# 消息发布
publish "news.et" "wdnmd"

# 查看订阅信息
# 查看服务器当前的频道(不包含模式"频道"),[pattern]参数可选
pubsub channels [pattern]

# 查看服务器某些频道的订阅数总和(不包含模式"频道")
pubsub numsub [channel-1 channel-2 .... channel-n]

# 查看服务器当前模式订阅“频道”的数量
pubsub numpat

2.13.2.数据结构

普通订阅模式:dict,key是频道名,value是链表,保存订阅的客户端。

模式订阅模式:链表,每个节点保存 模式和客户端。所以客户端往任意频道发送message的时候,都要遍历模式链表,如果模式订阅过多的话,是会影响性能的。(可以缓存一份 KV分别是频道和模式节点的dict)

2.14.Redis事务

相关的命令:

  1. WATCH(乐观锁)
  2. MULTI
  3. EXEC
  4. DISCARD(取消事务,即取消MULTI)

2.15.LUA脚本

LUA脚本的原子性:Redis中,一些操作(比如使用Redis来作为分布式锁)需要保证原子性的时候,可以使用LUA脚本,或者事务。(REDIS是但单线程的服务)

2.16.排序

Redis中的ZSET,SET,MAP,LIST都是可以通过排序命令进行排序的 。

排序的底层实现,其实就是通过指针指向这些数据结构中的各个结构,然后将这些指针按照给的规则进行排序,最后按照指针的顺序,输出实指针指向的实际的元素。

2.17.二进制位数组(Bitmaps)

2.17.1.相关命令

# SETBIT
setbit key 0 1 # 设置偏移量为0的位置的bit为1
# GETBIT 
getbit key 0 # 获取偏移量为0的位置的bit值
# BITCOUNT 
bitcount key [start end (字节偏移量offset)]---> 1 # 计算bit数组里1的个数
# BITOP
bitop and result key key # 给bit数组执行与操作
bitop or result key key key # 给bit数组执行或操作
bitop xor result key key key # 给bit数组执行异或操作
bitop not result key # 给bit数组执行非操作
# BITPOS
bitpos key [0|1] [start end (字节偏移量offset)] #查看指定bit位(0 or 1)在bit数组中第一次出现的offset

2.17.2.bitcount算法实现

bitcount命令是用来查询二进制位数组中值为1的位的个数(数学上称之为计算汉明重量,有很多种计算的算法)。

理论上实现这个命令的方式有一下几种:

  1. 循环遍历。(效率极差,500M=500 * 1024 * 1024 * 8 bit = 419430400 bit = 4亿次)

  2. 查表法。(创建一个表,来保存每种bit序列和它其中的1的个数的映射关系,查询的时间复杂度为O(1),但是这是一种空间换时间的方案,在bit位比较短只有8位的时候,内存占用不大,假如bit位数组有500M的话,就需要一个保存4亿个KV的Map,需要的内存空间实在是太多了。)

  3. variable-precision SWAR算法

    算法具体怎么样就不说了,看不懂,贴个代码瞎看看吧,类似的通过巧妙的位运算来计算二进制位数组中值为1的个数的算法还有很多。

    image-20200908091056564

Redis实现bitcount命令同时使用了上述方法的方法2和方法3。当位数组比较短的时候,使用方法2,快且占用空间小,当位数组比较长的时候,使用方法3,也挺快且不占用额外的内存空间。

Redis的这种根据数据的不同规模,而采用不同的方式去处理同一个问题以达到在不同数据规模下效率都挺高的方式值得我们好好借鉴。其实在我们日常的JAVA开发中,这种处理问题的策略随处可见。举个简单的例子,比如JVM中的 整型常量池,如果整数在 -128~127 范围内,变量指针就直接指向常量池中的这个整数而不用额外申请内存空间创建新的整型对象。

2.17.3.使用注意

使用bitmaps的时候要注意设置bit位的时候,如果指定的偏移量过大,可能会造成阻塞。因为bitmaps实际的数据类型还是String,String底层的数据类型是字符数组,偏移量过大,就要申请一个大的字符数组,大块内存。

2.18.慢查询日志记录

Redis会记录慢查询的日志,一次查询的时间,不包括客户端与Redis服务端之间的网络IO,而是这个命令在Redis上开始执行到执行完成所需的时间。

在redis.conf中相关的配置项:

slowlog-log-slower-than 10000 # 时间单位微妙microseconds
slowlog-max-len 128 # 保存的slow log最大条数,fifo queue

与slowlog相关的命令

slowlog get [n] # 查看最新的n条慢查询日志
slowlog reset   # 干掉现有的slowlog

2.19.监视器

redis客户端可以向redis服务端发送monitor命令,成为服务端的监视器,之后服务端所有执行的命令都会通知给客户端。

2.20.HyperLogLog

HyperLogLog是一种Redis提供的高级数据结构,内部包含计算基数(集合中不同元素的个数)的算法,这种算法不是100%正确,有一定程度的可接受的误差。它不存储放进来的具体的value,而是根据一定的规则存放放入的value的hash值,所以HyperLogLog能只用12K的内存就能统计2^64个元素的基数,内存使用上无敌少,这是HyperLogLog存在的意义。基于HyperLogLog能够统计大量元素的基数且占用内存及其小的特性,我们可以使用HyperLogLog来统计网站每天访问的用户数,或者其他统计量巨大的场景,但是我们能做的也只有统计数量了,无法获取到存入HyperLogLog key中具体的value。

这篇blog对于HyperLogLog背后的算法进行了很详细的讲解,虽然我还是看不懂:

HyperLogLog算法的原理讲解以及Redis是如何应用它的

相关的命令:

pfadd key value
pfcount key
pfmerge dest-key source-key1 source-key2...

2.21.迁移键

相关命令

move
dump,restore
migrate

2.22.遍历键

遍历键可以通过keys,scan(针对不同的数据类型有不同的scan命令)。

两者各有优缺点吧。keys一口气能把要查询的数据全量拿到,但是如果key很多的话,时间会慢,阻塞服务。scan可以当作是一个迭代器,每次只查寻一部分数据,多次查询,将数据全部查出来,所以会降低阻塞服务的风险,但是坏处是,在多次查询的过程中,目标key可能会被修改、删除、增加,最终导致查询结构不是100%准确。

2.23.GEO

Redis提供了GEO功能,用来实现基于地理位置信息的应用,GEO底层数据结构实现是zset有序集合。

3.极客时间《Redis核心技术与实战》

3.1.建立系统观的学习方法

在课程的开篇词与基础篇中,蒋老师提到了学习一门技术,不要一开始就钻到这个技术中的细枝末节中,这样子往往会本末倒置。更好的学习方式是先建立起“系统观”,先了解宏观设计,把控全场,再由面到点,去深入探索研究一个个小的问题。这种学习方式相对来说会很高效。这和我之前在知乎看到的一个关于“如何阅读Spring源码”下的回答很像,那个答主也是建议,学习Spring,不要一开始就一头扎入源码中,设计模式、代码写法就那么几种,万变不离其中,而且Spring这么庞大,一开始就去看源码,会有一种一头扎入泥坑里的无力感。真正聪明的学习方法是先去看Spring的文档,先站在框架的层面上去了解它的设计架构、设计思想,有什么特点、解决了什么问题。再对Spring有了宏观的掌握之后,再去看源码,我的乖乖,就事半功倍了。

3.2.Redis单线程

3.2.1.Redis真的是单线程吗?

Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

3.2.1.为什么Redis要使用单线程

多线程情况下,有同步问题(保证共享资源的并发访问控制是正确的),处理起来较复杂,且线程和吞吐量并不一定是正相关的。

image-20200824195321407

3.2.2.为什么Rredis单线程这么快

  1. 主要操作在内存上完成。
  2. 高效的数据结构,比如Hashtable、Skiplist
  3. 网络IO多路复用(select/epoll)

image-20200824195925467

3.2.3.Redis潜在的性能问题

摘录自Redis核心技术与实战:03 | 高性能IO模型:为什么单线程Redis能那么快?文章下Kaito的评论

Redis单线程处理IO请求性能瓶颈主要包括2个方面:

1、任意一个请求在server中一旦发生耗时,都会影响整个server的性能,也就是说后面的请求都要等前面这个耗时请求处理完成,自己才能被处理到。耗时的操作包括以下几种: a、操作bigkey:写入一个bigkey在分配内存时需要消耗更多的时间,同样,删除bigkey释放内存同样会产生耗时; b、使用复杂度过高的命令:例如SORT/SUNION/ZUNIONSTORE,或者O(N)命令,但是N很大,例如lrange key 0 -1一次查询全量数据; c、大量key集中过期:Redis的过期机制也是在主线程中执行的,大量key集中过期会导致处理一个请求时,耗时都在删除过期key,耗时变长; d、淘汰策略:淘汰策略也是在主线程执行的,当内存超过Redis内存上限后,每次写入都需要淘汰一些key,也会造成耗时变长; e、AOF刷盘开启always机制:每次写入都需要把这个操作刷到磁盘,写磁盘的速度远比写内存慢,会拖慢Redis的性能; f、主从全量同步生成RDB:虽然采用fork子进程生成数据快照,但fork这一瞬间也是会阻塞整个线程的,实例越大,阻塞时间越久; 2、并发量非常大时,单线程读写客户端IO数据存在性能瓶颈,虽然采用IO多路复用机制,但是读写客户端数据依旧是同步IO,只能单线程依次读取客户端的数据,无法利用到CPU多核。

针对问题1,一方面需要业务人员去规避,一方面Redis在4.0推出了lazy-free机制,把bigkey释放内存的耗时操作放在了异步线程中执行,降低对主线程的影响。

针对问题2,Redis在6.0推出了多线程,可以在高并发场景下利用CPU多核多线程读写客户端数据,进一步提升server性能,当然,只是针对客户端的读写是并行的,每个命令的真正操作依旧是单线程的。

总结下:

  1. 前面的慢操作影响后面的操作。
  2. 并发量大时,事件队列的消费者的速度赶不上生产者的速度。

3.3.数据同步:主从库如何实现数据一致?

⚠️ **GitHub.com Fallback** ⚠️