Redis原理

1. 数据结构与对象

1.1 SDS

Redis自己构建了一种名为Simple Dynamic String(SDS,简单动态字符串)的字符串抽象类型,作为Redis的默认字符串表示。在Redis的数据库里,包含字符串的键值对在底层都是由SDS实现的。如下:

redis> RPUSH fruits "apple" "banana" "cherry"
  • 其中键是字符串对象,对象的底层实现是一个保存了"fruits"的SDS。
  • 其中只是一个列表对象,列表对象包含3个字符串对象,这3个字符串对象分别由3个SDS实现。

除了用作保存数据库中的字符串值外,SDS还被用作缓冲区:①AOF模块中的AOF缓冲区、②客户端状态中的输入缓冲区。

1.1.1 数据结构

其基本数据结构如下所示:

struct sdshdr {
    int len;    // 记录buf数组中已经使用的字节的数量,等于SDS所保存字符串的长度
    int free;   // 记录buf数组中未使用字节的数量
    char buf[]; // 字节数组,用于保存字符串。注意最后一个字符为:'\0',不记录在len的长度里。即len + free + 1 = 数组buf的长度
}

1.1.2 优势

C字符串使用长度为N+1的字符数组表示长度为N的字符串(字符数组最后一个元素为"\0")。这种简单的方式,不能满足Redis对字符串在安全性、效率、功能等方面的要求。而SDS具有如下优点:

  • 获取字符串长度

    C字符串不记录自身长度,所以获取字符串长度必须遍历整个字符串,复杂度O(N)。SDS的len记录SDS本身长度,复杂度O(1)。获取字符串长度不会成为Redis的性能瓶颈。如,即使对一个非常长的字符串键反复执行STRLEN命令,也不会对系统造成任何影响(其复杂度仅为O(1))。

  • 杜绝缓冲区溢出

    C字符串不记录自身长度的另一个问题是容易造成缓冲区溢出。如将字符串src拼接到dest字符串末尾的strcat函数,strcat会假定用户在执行此函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,一旦此假设不成立,就会产生缓存区溢出。
    SDS API则会先检查SDS空间是否满足修改需求,不满足,API会自动将SDS的空间扩展至执行修改所需的大小。

  • 减少字符串修改带来的内存重分配次数

    C字符串长度和底层数组长度存在关联性,因此每次增长或缩短一个C字符串,程序总会对保存这个C字符串的数组进行一次内存重分配操作:

    1. 增长字符串。执行前需要先通过内存重分配来扩展底层数组的空间大小,否则就会缓冲区溢出
    2. 缩短字符串。执行前需要先通过内存重分配来释放字符串不再使用的那部分空间,否则就会产生内存泄漏
  • 二进制数据安全

    C字符串中的字符必须符合某种编码(如ASCII),且除字符串末尾外,字符串里面不能包含空字符,否则最先被程序读入的空字符将会被误认为时字符串结尾。这些限制使得C字符串只能保存文本数据,而不能保存图片、音频、压缩文件等二进制数据。

    为确保Redis可以适用于各种场景,SDS API会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何操作。这也是我们将SDS的buf属性称为字节数组的原因:Redis不是用这个数组保存字符,而是保存一系列二进制数据。SDS使用len属性的值而非空字符来判断字符串是否结束。

1.1.3 内存分配策略

内存重分配涉及复杂算法,并可能需要执行系统调用,因此通常为一个耗时操作。SDS通过free字段(未使用空间)解除了字符串长度和底层数组长度之间的关联关系。通过free字段,SDS实现了空间预分配和惰性空间释放两种优化策略:

  • 空间预分配策略

    SDS API对SDS进行空间扩展时,不仅会分配修改所必须的空间,还会为SDS分配额外的未使用空间。额外分配的未使用空间数量的公式如下:

    1. 若修改后,SDS的len<1MB,程序分配和len属性同样大小的未使用空间,即len=free。如修改后len=13B,那么free=13B,buf的实际长度27B。
    2. 若修改后,SDS的len>1MB,程序分配1MB的未使用空间。如修改后len=30MB,那么free=1MB,buf的实际长度:30MB+1MB+1B

    通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。注意,扩展SDS空间前,会先判断未使用空间是否足够,若足够,API会直接使用未使用空间,而无需执行内存重分配。

  • 惰性空间释放

    当需要缩短SDS字符串时,程序不会立即使用内存重分配来回收缩短后多余的字节,而是使用free字段将这些字节数量记录下来,并等待将来回收(若将来对SDS又进行增长操作,这些未使用空间也可能会派上用场)。

1.2 链表的实现

C语言中没有链表结构,因此Redis构建了自己的链表,其中用到链表的实现由:列表(LRANGE)的底层实现、发布与订阅、慢查询、监视器、客户端状态信息、客户端输出缓冲区。Redis的链表特性:

  • 双端
  • 无环
  • 带表头指针(head)和表尾指针(tail)
  • 带链表长度计数器
  • 多态(可存储各种不同类型的值)

链表的数据结构如下所示:

// 链表节点
typedef struct listNode {
    struct listNode *prev;  // 前置节点
    struct listNode *next;  // 后置节点
    void *vlaue;            // 节点值
}
// 虽然仅仅使用listNode就可以组成链表,但Redis使用list结构使得操作起来更加方便:
typedef struct list {
    struct listNode *head;  // 表头节点
    struct listNode *tail;  // 表尾节点
    unsigned long len;      // 链表锁包含的节点数量
    // 节点复制函数、节点释放函数、节点值对比函数,省略
} list;

1.3 字典

C语言并没有内置Map数据结构,Redis构建了自己的实现。Redis的数据库就是使用字典来作为底层实现的,对数据库的CURD操作也是构建在对字典的操作上。

redis> SET msg "hello world"

"msg"-"hello world"键值对就保存在代表数据库的字典里。除了表示数据库外,字典还是哈希键(HSET)的底层实现之一,当一个哈希键包含的键值对比较多时,或键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。如下:

redis> HLEN website     ##website包含10086个键值对
redis> HGETALL website  ##website键底层实现是一个字典,字典包含10086个键值对

1.3.1 数据结构

Redis的字典使用哈希表作为底层实现,一个哈希表可以有多个哈希表节点,每个哈希表节点保存字典中的一个键值对。Redis的字典结构如下:

// 哈希表节点
typedef struct dictEntry {
    void *key;   // 键
    union {      // 值
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    struct dictEntry *next;  // 指向下一个哈希表节点,形成链表。坚决hash碰撞的键
} dictEntry;
// 哈希表
typedef struct dictht {
    dictEntry **table;  // 哈希表数组
    unsigned long size; // 哈希表大小
    unsigned long sizemask; // 哈希表大小掩码,用于计算索引值,总是等于size -1;
    unsigned long used; // 该哈希表已有节点的数量
} dictht;
// 字典
typedef struct dict { 
    dictType *type;  // 类型特定函数。保存了用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定的函数
    void *privdata;  // 私有数据
    dictht ht[2];    // 哈希表。是一个包含2个项的数组,每个项都是一个dictht哈希表,一般,字典只使用ht[0]哈希表,ht[1]哈希表只会在ht[0]哈希表进行rehash时使用;
    int trehashidx;  // rehash索引。当rehash不在进行时,该值为-1
}

1.3.2 哈希算法

当新的键值对添加到字典时,需要先根据键值对添加到字典时,程序需要先根据键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上。

hash = dict->type->hashFunction(key);   // 计算哈希值
index = hash & dict->ht[x].sizemask;    // 计算索引,ht[x]可以为ht[0]或ht[1]

哈希冲突

当有2个或以上数量的键被分配到了哈希表数组的同一个索引上面时,Redis的哈希表使用链式地址法来解决键冲突(next指针,单向链表,由于没有指向链表表尾的指针,所以为速度考虑,程序总是将新节点添加到链表的表头位置)。

其中链式的结构使用的是dictEntry->next指针。

1.3.3 rehash操作

哈希表的负载因子计算:

load_factor = ht[0].used / ht[0].size

随着操作不断执行,哈希表的键值对增加或减少,为了让哈希表负载因子维持在一个合理范围内,需要对哈希表进行相应的扩容或收缩操作,rehash的步骤如下:

  • 字典的ht[1]哈希表分配空间,此哈希表空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(即ht[0].used属性的值)

    扩展操作:ht[1]的大小 == 第一个 >= ht[0].used*2的2^n。
    收缩操作:ht[1]的大小 == 第一个 >= ht[0].used的2^n。

  • 将保存在ht[0]中的所有键值对rehash到ht[1]上:重新计算键的索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
  • 当ht[0]包含的所有键值对都迁移到了ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,以备下次rehash。

自动扩展

根据BGSAVE或BGREWRITEAOF命令是否在执行,服务器执行扩展操作所需负载因子并不相同:

  • 服务器目前没有执行BGSAVE或BGREWRITEAOF,且哈希表的负载因子>=1
  • 服务器目前正在执行BGSAVE或BGREWRITEAOF,且哈希表的负载因子>=5

原因:执行这两个命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数OS都采用copy-on-write技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,如此可以避免不必要的内存写入,最大限度节约内存。

自动收缩

负载因子小于0.1时,自动执行。

1.3.4 渐进式rehash

扩展或收缩需要将ht[0]的所有键值对rehash到ht[1]里,但这个rehash并非一次性、集中式完成,而是分多次、渐进式完成。原因在于,若哈希表中保存键值对数量过大(如400w),那么一次性rehash全部键值对,可能会导致服务器在一段时间内停止服务。步骤如下:

  1. 为ht[1]分配空间,字典同时持有ht[0]和ht[1]两个哈希表
  2. 字典维持一个索引计数器变量rehashidx,将其设置为0,表示rehash正式开始。
  3. 在rehash期间,任何对字典执行CRUD操作,程序除了执行指定操作外,都会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],每次rehash工作完成,程序都会将rehashidx属性值+1
  4. 随着字典操作不断执行,最终某个时间点,ht[0]的所有键值对都会被rehash到ht[1],此时程序将rehashidx设为-1,表示rehash操作完成

在rehash执行期间,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash过程中,字典的删除、查找、更新等操作都会在2个哈希表上进行

1.4 跳跃表

跳跃表是一种有序的数据结构,通过在每个节点上维持多个指向其他节点的指针,从而达到快速访问的目的。跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找。跳跃表在大部分情况下效率可媲美平衡树(跳跃表实现更为简单)。Redis使用跳跃表作为有序集合键的底层实现之一:

  1. 若有序集合的元素数量较多。
  2. 或有序集合中元素的member是比较长的字符串时,Redis就会使用跳跃表作为有序集合的底层实现。

跳表的数据结构如下:

// 节点
typedef struct zskiplistNode {
    // 层
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned int span;  // 跨度
    } level[];
    struct zskiplistNode *backward;  // 后退指针
    double score; // 分值
    robj *obj;  // 成员对象
} zskiplistNode;
// 跳表
typedef struct zskiplist {
    struct zskiplistNode *header, tail; 
    unsigned long length; // 表中节点的数量
    int level;  // 表中层数最大的节点的层数,表头结点的层数不计算在内
}

跳跃表节点的Level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度。一般来说,层数越多,访问其他节点速度越快。在每次创建跳表节点时,程序都根据幂次定律随机生成一个介于1~32之间的值,作为Level数组的大小,这个大小就是层的"高度"。

跨度

层的跨度用于记录2个节点之间的距离(指向NULL的所有跨度均为0),跨度越大相距越远。跨度和遍历无关,遍历只使用前进指针即可完成,跨度实际用于计算排位:在查找某个节点的过程中,将沿途访问过的所有节点的跨度累计,即为目标节点在跳跃表中的排位。

后退指针

节点的后退指针用于从表尾向表头访问,跟前进指针不同,每个节点只有一个后退指针,指向前一个节点。

分值和成员

分值是跳跃表中的所有节点按照分值从小到大排序。成员对象是一个指针,指向一个SDS字符串结构。

1.5 整数集合

整数集合是集合键的底层实现之一:当集合只包含整数值元素,且集合元素数量不多。如下:

redis> SADD number 1 3 5 7 9
// 集合结构
typedef struct intset {
    uint32_t encoding;  // 编码方式
    uint32_t length;    // 集合包含的元素数量,即数组长度
    int8_t contents[];  // 保存元素的数组,按值得大小从下到大有序的排列,且不包含任何重复项
} intset;

其中:countents数组并不保存int8_t类型的值,数组真正类型取决于encoding属性的值:

  • encoding=INTSET_ENC_INT16:即int16_t
  • encoding=INTSET_ENC_INT32:即int32_t
  • encoding=INTSET_ENC_INT64:即int64_t

当contents数组中原本保存的值为int16_t类型,而之后新增数据为int64_t类型时,整数集合已有的所有元素都会被转为int64_t类型。即整数集合的升级。升级的优点:

  • 提升灵活性,升级数组的方式不必担心类型错误
  • 节约内存,避免直接使用int64_t类型的数组

注:整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

1.6 压缩列表

压缩列表是Redis为节约内存而开发的。由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或整数值。压缩列表的组成部分如下:

image

  • zlbytes:记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配,或计算zlend的位置时使用。
  • zltail:记录压缩列表的尾节点距离压缩列表的起始地址有多少字节(通过zltail,程序可以无需遍历,直接定位尾节点地址)
  • zllen:记录压缩列表包含的节点数量
  • zlend:特殊值0xFF,用于标记压缩列表的末端。

压缩列表是列表键和哈希键的底层实现之一。列表键只有少量列表项,且每个项要么小整数值,要么短字符串。如:

RPUSH lst 1 3 5 10086 "hello" "world"

2 单机数据库

Redis是一个键值对数据库服务器,服务器中的每个数据库由redisDb结构表示,其中redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间

typedef struct redisDb {
    //...
    dict *dict;  // 数据库键空间,保存数据库中的所有键值对。
} redisDb;

2.1 键的过期

redisDb结构中的expires字典保存了数据库中所有键的过期时间:

  • 过期字典键:一个指针,指向键空间中的某个键对象
  • 过期字典值:long类型的UNIX时间戳,精确到毫秒

2.1.1 过期删除策略

Redis服务器实际使用惰性删除定期删除两种策略,通过配合使用两种策略,服务器可以合理使用CPU时间和避免浪费内存:

  • 惰性删除:对CPU最友好,程序在取出键时才会对键进行过期检查。缺点:对内存不友好,若键过期,而这个键仍然保留在数据库中占用内存。若数据库有许多过期键,而这些过期键又恰好未被访问,那么它们也许永远都不会被删除。
  • 定期删除:每个一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长、频率来减少删除操作对CPU时间的影响

惰性删除策略
所有读写数据库的Redis命令,在执行前都会调用expireIfNeeded函数对输入键进行检查:若键已过期,此函数将键从数据库中删除;若键未过期,此函数不做动作。

定期删除策略
activeExpireCycle函数周期执行,在规定时间内,分多次遍历服务器中各个数据库,从数据库的expires字典随机检查一部分键的过期时间,并删除其中过期键。

2.1.2 过期键处理:AOF、RDB、复制


生成RDB文件
对数据库中的键进行检查,已过期的键不会保存到新创建的RDB文件中

载入RDB文
启动Redis服务器时,若Redis开启了RDB功能,则对RDB文件进行载入。

  1. master,载入RDB文件时,对文件中保存的key进行检查,过期的key会被忽略。
  2. slave,载入RDB文件时,文件中保存的所有键无论是否过期,都会被载入到数据库中。

AOF文件
当服务器以AOF持久化模式运行时,若数据库中某个键已经过期:

  • 尚未被惰性删除或定期删除,那么AOF文件不会因为这个过期键而产生任何影响;
  • 已经被惰性删除或定期删除,那么程序会向AOF文件追加一条DEL命令,显示地记录该键被删除。

在执行AOF重写时,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中


复制
当服务器运行在复制模式下,slave的过期键删除动作由master控制:

  • master在删除一个过期键后,会显示地向所有slave发送一个DEL命令,告知slave删除这个过期键
  • slave在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是以未过期键的方式进行处理
  • slave只有在接收到master发送的DEL命令后,才会删除过期键

master控制slave来同一删除过期键,可以保证主从服务器数据的一致性。举例:msg键为过期键。

  • 若客户端从slave获取msg键的值,slave发现msg已经过期,但slave并不会删除msg键,而是继续将msg键的值返回给客户端,就好像msg键并没有过期一样。
  • 若客户端从master获取msg键的值,master发现该键过期,会删除msg键,向客户端返回空,并向slave发送DEL msg命令

2.2 RDB持久化

Redis是内存数据库,将数据库状态存储在内存里。Redis提供了RDB持久化功能,可以将Redis内存中的数据库状态保存到磁盘上,避免数据意外丢失。RDB持久化既可以手动执行,也可以根据配置选定定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件(压缩的二进制文件)中。Redis服务器在启动时会检测RDB文件,若存在,则自动载入(因此没有提供载入RDB文件的命令)。

Redis的SAVEBGSAVE命令用于生成RDB文件:

  • SAVE,阻塞服务器进程,直到RDB文件创建完毕,阻塞期间,服务器不能处理任何命令请求
  • BGSAVE,派生子进程,然后由子进程负责创建RDB文件,服务器进程继续处理命令情趣。

注:由于AOF文件的更新频率通常比RDB文件更新频率高,因此若服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。

Redis允许用户通过配置save选项,让服务器每隔一段时间自动执行一次BGSAVE命令。SAVE选项可以设置多个保存条件,只要满足任意一个条件,服务器就会执行BGSAVE命令,如下:

save 900 1
save 300 10
save 60 10000
即:服务器在900s内,对数据库进行了至少1次修改;服务器在300s内,对数据库进行了至少10次修改;服务器在60s内,对数据库进行了至少10000次修改。

2.3 AOF持久化

AOF持久化的实现共3个步骤:

  • 命令追加。当AOF持久化功能开启时,服务器在执行完写命令后,会以协议格式将被执行的命令追加到aof_buf缓冲区的末尾
  • 文件的写入和同步。Redis服务器进程就是一个事件循环:
  1. 文件事件,负责接收客户端的命令请求,以及向客户端发送命令回复
  2. 时间事件,负责执行像serverCron函数这样需要定时运行的函数

因为服务器在处理文件事件时可能会执行写命令,使一些内容被追加到aof_buf缓冲区里,所以服务器在每次结束一个事件循环前,都会调用flushAppendOnlyFile函数考虑是否需要将aof_buf缓冲区的内容写到AOF文件里。flushAppendOnlyFile函数行为由服务器配置的appendsync选项值决定(默认为everysec)

  • always,将aof_buf缓冲区的所有内容写入到AOF文件,并同步AOF文件。即每次收到写命令就立即强制同步磁盘
  • everysec,将aof_buf缓冲区的所有内容写入到AOF文件,若上次同步AOF文件时间距离当前使劲超过1s就对AOF文件进行同步。即每隔1s同步一次磁盘
  • no,将aof_buf缓冲区的所有内容写入到AOF文件,但并不对AOF文件进行同步,何时同步由操作系统决定

2.3.1 AOF文件载入与还原

创建伪客户端的原因:Redis命令只能在客户端上下文执行,所以服务器使用一个没有网络连接的伪客户端来执行来自AOF文件保存的写命令。

image

2.3.2 AOF重写

因为AOF持久化通过保存被执行的写命令来记录数据库状态的,所以AOF文件内容会越来越大,若不加以控制,AOF文件过大可能会对Redis服务器甚至宿主机造成影响。且AOF文件体积越大,数据还原时间也会越长。
为解决此问题,Redis提供了AOF文件重写功能:Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新AOF文件不会包含任何浪费空间的冗余命令。如下命令:

redis> RPUSH list "A" "B"
redis> RPUSH list "C"
redis> RPUSH list "D" "E"
redis> RPOP list
redis> RPOP list
redis> RPUSH list "F" "G"

此时AOF文件写入6条命令,去除冗余其实可以以一条RPUSH命令代替AOF文件中的6条命令。AOF重写会进行大量的写操作,所以长时间阻塞,因此Redis服务器使用子进程来处理:

  • 子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求
  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以避免使用锁的情况下,保证数据的安全性

问题描述: 在子进程进行AOF重写期间,服务器进程还需要继续处理命令请求,新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致。
解决方式: Redis服务器设置了一个AOF重写缓冲区,此缓冲区在服务器创建子进程后开始使用,当Redis服务器执行完一个写命令后,会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区。

image
如此可以保证:
①AOF缓冲区的内容会被定期写入和同步到AOF文件,对现有AOF文件的处理工作如常进行。
②从创建子进程开始,服务器执行的所有命令都会被记录到AOF重写缓冲区里。
当子进程完成AOF重写后,向父进程发送信号,父进程会调用一个信号处理函数,执行如下工作:

  • 将AOF重写缓冲区中的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前数据库状态一致
  • 对新AOF文件进行改名,原子地覆盖现有AOF文件,完成新旧交替。
    此信号处理函数执行完毕后,父进程就可以继续像往常一样接收命令请求了。整个AOF后台重写过程,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,其他时间,AOF后台进程都不会阻塞父进程。

3 复制

在Redis中,可以通过执行SLAVEOF命令或设置slaveof选项,让一个服务器(slave)去复制另一个服务器(master)。Redis的复制功能分为2种操作:①同步;②命令传播。

  • 同步操作:将slave的数据状态更新至master当前所处的数据库状态
  • 命令传播:在master的数据库状态被修改时,导致主从服务器状态出现不一致时,让主从服务器回到一致状态

3.1 旧版本复制

3.1.1 同步

当向slave发送SLAVEOF命令,要求进行复制时,slave首先执行同步操作。同步操作主要通过向master发送SYNC命令来完成,如下:

  • slave向master发送SYNC命令
  • 收到SYNC命令的maser执行BGSAVE命令,在后台生成RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令
  • 当master的BGSAVE命令执行完毕时,master会将BGSAVE命令生成的RDB文件发送给slave,slave接收并载入这个RDB文件,将自己的数据库状态更新至master执行BGSAVE命令时的数据库装填
  • master将记录在缓冲区中的所有写命令发送给slave,slave执行这些写命令,将自己的数据库状态更新至master数据库当前所处状态。

3.1.2 命令传播

同步操作完毕后,master/slave的数据库将达到一致状态,但这种一致并非一成不变,每当master执行客户端的写命令时,master的数据库就有可能被修改,并导致master和slave不在一致。未解决此问题,master需要对slave执行命令传播操作:master会将自己执行的写命令,发送给slave执行。

3.1.3 缺点

slave对master的复制可分为2种情况:
①初次复制;
②断线后重新复制。
旧版本复制功能,对于初次复制没有问题,但对于断线后的重新复制,虽然可以做到,但是效率极低:

  • T1 ~ T1000时刻:主从完成同步,并执行传播:k1...k1000
  • T1001时刻:主从服务器连接断开
  • T1001 ~T 1003时刻:master接收到k1001~k1003,共3个键
  • T1004时刻:主从服务器重新连接

此时slave向master发送SYNC命令重新同步,而该命令会让master从k1~k1003的所有键生成RDB文件。

3.2 新版本复制

Redis2.8之后,使用PSYNC命令执行复制中的同步操作PSYNC的同步操作分为2种模式:完整同步部分同步

  • 完整同步:类似SYNC,都是通过让master创建RDB文件,以及向slave发送保存在缓冲区里的写命令进行同步
  • 用于断线后重复,若条件允许,master将master/slave连接断开期间执行的写命令发送给slave,slave只接受并执行这些写命令

部分重同步的的过程如下图所示(其中+CONTINUE表示以部分重同步进行):
image

部分重同步功能由下面3个部分组成:

  • master的复制偏移量和slave的复制偏移量
  • master的复制积压缓冲区
  • 服务器的运行ID

3.2.1 复制偏移量

master和slave都分别维护一个复制偏移量。若master和slave的复制偏移量不同,那么说明master和slave并未处于一致状态:

  • master每次向slave传播N个字节的数据时,就将自己的复制偏移量的值+N。
  • slave每次收到master传播的N个字节数据时,就将自己的复制偏移量+N。

3.2.2 复制积压缓冲区

master维护的一个固定长度的FIFO队列,默认1MB。master进行命令传播时,不仅会将命令发送给所有slave,还会将命令写入到复制积压缓冲区中。

当slave重连到master时,slave通过PSYNC命令将自己的复制偏移量offset发送给master,master会根据此复制偏移量来决定对slave执行何种同步操作:

若offset之后的数据,仍然存在于复制积压缓冲区内,那么master将对slave执行部分重同步操作。
若offset之后的数据,已不存在于复制积压缓冲区内,那么master将对slave执行完整重同步操作。

复制积压缓冲区的大小:second * write_size_per_second

second: 服务器断线后重新连接上master所需要的平均时间
write_size_per_second: master平均每秒产生的写命令数量

3.2.3 服务器运行ID

服务器运行ID在服务器启动时自动生成,由40个随机的16进制字符组成。当slave对master进行初次复制时,master会将自己的运行ID传送给slave。当slave断线重连到master时,slave向当前连接的master发送之前保存的运行ID:

  • 若ID相同,master可以继续尝试执行部分重同步操作
  • 若ID不同,master将对slave执行完整重同步操作

4 集群

集群通过分片进行数据共享,并提供复制和故障转移功能。节点和单机服务器在数据库方面的一个区别:节点只能使用0号数据库,而单机服务器没有此限制。一个Redis集群由多个Node组成,刚开始每个Node相互独立,通过CLUSTER MEET命令将各个Node连接起来,构成一个集群。举例:

node1> CLUSTER MEET node2_ip node2_port
node1> CLUSTER NODES   ## 查看集群中的节点信息,可以看到node1, node2
node1> CLUSTER MEET node3_ip node3_port
node1> CLUSTER NODES   ## 查看集群中的节点信息,可以看到node1, node2, node3

Redis服务器在启动时会根据cluster-enable配置项是否为yes来决定是否开启服务器的集群模式。

4.1 结构

集群节点的数据结构如下:

struct clusterNode {
    mstime_t ctime;                      // 创建节点的时间
    char name[REDIS_CLUSTER_NAMELEN];    // 节点的名字,由40个16进制组成
    int flag;  // 节点的标识,标识节点的角色(master/slave),以及节点目前所处的状态
    uint64_t configEpoch;  // 节点当前的配置纪元,用于实现故障转移
    char ip[REDIS_IP_STR_LEN];  // 节点的IP地址
    int port;   // 节点的端口号
    clusterLink *link;   // 保存连接节点所需的有关信息
    //....
};

typedef struct clusterLink {
    mstime_t ctime;   // 连接的创建时间
    int fd;   // TCP套接字描述符
    sds sndbuf;  // 输出缓冲区,保存着等待发送给其他节点的消息
    sds rcvbuf;  // 输入缓冲区,保存着从其他节点接收到的消息
    struct clusterNode *node;   // 与这个连接相关联的节点,如果没有的话就为NULL
} clusterLink;
// 注意,每个节点保存着一个clusterState结构,用于记录当前节点视角下,集群目前所处的状态
typedef struct clusterState {
    clusterNode *myself;    // 指向当前节点的指针
    uint64_t currentEpoch;  // 集群当前的配置纪元,用于实现故障转移
    int state;  // 集群当前的状态:在线 or 下线
    int size;   // 集群中至少处理这一个槽的节点的数量
    dict *nodes;  // 集群节点名单,字典的键为节点的名字,字典的值为节点对应的clusterNode结构
    clusterNode *slots[16384]
} clusterState;

节点的数据结构大概如下所示:
image

4.2 槽指派

Redis集群通过分片保存数据库中的键值对:集群的整个数据库被分为16384个槽(slot),数据库中的每个键都属于其中一个槽,集群中每个节点可以处理:0~16384个槽。
若集群中的16384个槽都有节点在处理,集群处于上线状态;否则集群处于下线状态。上节中node1~node3节点连接在同一集群,但仍然处于下线状态,因为3个节点没有处理任何槽:

node1> CLUSTER INFO
node1> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
node2> CLUSTER ADDSLOTS 5001 5002 5003 5004 ... 10000
node3> CLUSTER ADDSLOTS 10001 10002 10003 10004 ... 16383
node1> CLUSTER INFO

4.2.1 记录节点的槽指派信息

struct clusterNode {
    /*二进制数组,数组长度为16384/8个字节,共16384个二进制位。以0位起始索引,16383为终止索引。根据索引i上的二进制位的值判断该节点是否处理槽i。二进制位为0表示不负责处理。
    如下表示处理的槽位:0 ~ 6、9、16382
    |字节 |     slots[0]    |    slots[1] ~ slots[2047]
    |索引 | 0 1 2 3 4 5 6 7 | 8 9 10 11 12 ...  16382 16383
    |值   | 1 1 1 1 1 1 1 0 | 0 1 0  0  0  ...    1     0 
    */
    unsigned char slots[16384/8];
    // 用于记录节点负责处理的槽的数量,即slots数组中值为1的二进制位的数量
    int numslots;
}

因为取出和设置slots数组中的任意二进制位的值复杂度为O(1),因此程序检查节点是否负责处理某个槽,或将某个槽指派给某节点负责,复杂度均为O(1)。

4.2.2 传播节点的槽指派信息

节点会将自己的slots数组通过消息发送给集群中的其他Node,以此来告知其他Node自己目前负责处理哪些槽。当Node1收到Node2的slots数组时,Node1会在自己的clusterState.nodes字典中查找Node2对应的clusterNode结构,并对结构中的slots数组更新或保存。

因为节点槽信息的传播,集群中任何一个节点都会知道数据库的16384个槽被指派给了集群中的哪个节点。如果只是将槽指派信息保存在各个节点的clusterNode.slots数组,那么无法高效知道某个槽是否被指派、指派到了哪个节点等问题。因为程序需要遍历clusterState.nodes字典中的所有clusterNode结构,检查这些结构的slots数组,直到找到负责处理的槽的Node为止,复杂度O(N):N为clusterState.nodes字典保存的clusterNode结构的数量。

为了解决上述问题,clusterState结构增加了slots数组,如下:

typedef struct clusterState {
    clusterNode *slots[16384];   // 每个槽对应的节点信息,如果没有对应节点,指向为null
} clusterState;

4.2.3 槽信息

综上,一共记录了两个数组信息。

struct clusterNode {
    unsigned char slots[16384/8];
}

typedef struct clusterState {
    clusterNode *slots[16384];   // 每个槽对应的节点信息,如果没有对应节点,指向为null
} clusterState;
  • clusterState.slots,记录槽的指派信息,快速定位某个槽在哪个节点上
  • clusterNode.slots,记录节点有哪些槽,集群间发送槽信息时更快。

4.2.4 重新分片

Redis集群的重新分片可以将源节点上任意数量的槽指派给目标节点,并且槽所属的键值对也会从源节点移动到目标节点
在进行重新分片过程中,源节点目标节点迁移一个槽的过程中,可能存在这种情况:属于被迁移的槽的一部分键值对保存在源节点里,而另一部分键值对保存在目标节点里。此时当客户端向源节点发送命令,且该命令恰好就属于正在被迁移的槽时:

  • 源节点会先在自己的数据库里查找制定键,若找到,则执行命令
  • 若没找到,那么该键可能已经被迁移到目标节点源节点向客户端返回一个ASK错误,引导客户端指向目标节点,并再次发送之前想要执行的命令。

4.3 复制与故障转移

Redis集群中的节点分为master和slave,其中master用于处理槽,而slave则用于复制某个master,并在master下线时,代替下线的master继续处理命令请求。

设置从节点的命令:CLUSTER REPLICATE <node_id>
该命令可以让接收命令的节点成为node_id所指定节点的slave,并开始对主节点进行复制。

一旦节点成为slave,并开始复制某个master这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个slave正在复制某个master。集群中的所有节点都会在代表master的clusterNode结构中,如下:

struct clusterNode {
    /*正在复制这个master的slave的数量*/
    int numslaves;
    /*每个数组项都指向一个正在复制这个master的slave*/
    struct clusterNode **slaves;
}

4.3.1 故障检测

集群中的每个节点都会定期地向集群中的其他节点发送PING,若接收PING的节点没有在规定时间内响应PONG,那么发送PING的节点将会把未响应PING的节点标记为疑似下线。

集群中的各个节点会通过相互发送消息来交换集群中各个节点的状态信息,如某个节点处于在线状态、疑似下线状态、已下线状态。例如

masterA通过消息得知masterB认为masterC进入了疑似下线状态时,masterA会在自己的clusterState.nodes字典中找到masterC对应的clusterNode结构,并将masterB的下线报告添加到clusterNode结构的fail_reports链表里

struct clusterNode {
    list *fail_reports;  //链表,记录所有其他节点对该节点的下线报告
}
typedef struct clusterNodeFailReport {
    struct clusterNode *node;  // 报告目标节点已经下线的节点
    // 最后一次从node节点收到下线报告的时间,程序使用该时间戳来检查下线报告是否过期
    // 与当前时间相差太久的下线报告会被删除
    mstime_t time; 
} clusterNodeFailReport;
  • 疑似下线:那么发送PING的节点将会把未响应PING的节点标记为疑似下线
  • 下线:若集群里,超过半数负责处理槽的master都将某个masterX报告为疑似下线,则该master将被标记为下线。将masterX标记为下线的master会向集群广播masterX下线的消息,所有收到该消息的节点都会立即将masterX标记为下线。
    image

4.3.2 故障转移

当一个slave发现自己正在复制的masterX进入了下线状态,slave开始对下线的master进行故障转移:

  1. masterX的所有slave里,会有一个slaveX被选中
  2. 被选中的slaveX会执行SLAVEOF no one命令,称为新的master
  3. 新的master会撤销所有对maserX的槽指派,并将这些槽全部指派给自己
  4. 新的master想集群广播PONG,让集群中其他节点知道该节点已经由slave转变为master,并且该master接管了原本由已下线的master负责的所有槽
  5. 新的master开始接受和负责处理相关槽的命令请求。

4.3.3 选举master

知识点

  • 集群的配置纪元是一个自增计数器,从0开始
  • 当集群里某节点开始一次故障转移操作时,纪元值自增1
  • 对于每个配置纪元,集群中每个负责处理槽的master都有一次投票的机会,而第一个向master要求投票的slave将获得master的投票

流程如下:

  1. 当slave发现自己正在复制的master进入已下线状态,slave会向集群广播一条消息,要求所有收到这条消息、并具有投票权的master向自己投票
  2. 若一个master具有投票权,且尚未投票给其他slave,那么该master将为要求自己投票的slave返回ACK消息,表示自己支持该slave称为新的master
  3. 每个参与选举的slave都会接受到ACK消息,并根据收到的消息来统计获得的票数。票数过半的slave将被选举为新的master
  4. 若一个配置纪元中,没有slave称为master,集群进入下一个配置纪元,继续选举
posted @ 2019-03-12 22:43  wolf_w  阅读(274)  评论(0编辑  收藏  举报