Redis设计与实现

0. 前言

本文主要讲redis底层的实现细节,如redis提供的不同数据对象底层的数据结构是什么样,redis的RDB,AOF,事务是如何实现的。以及大家经常关心的redis为什么是单线程。还有多机数据库下redis是如何做到复制,多个redis是如何分配主从数据库进行交互,还有我们常见的哨兵和redis集群,redis如何实现的哨兵模式和redis集群的实现。

文章适合对redis有一定了解,但想继续探究redis底层很多功能到底是如何实现的读者。

本文大多内容摘自书籍《Redis设计与实现》,笔者认为这是本非常优秀的书,让人一读就爱不释手。作者真正做到了深入浅出,让很多编程水平一般的读者也能读懂redis的设计与实现,一窥redis的底层。

1. 数据结构

对redis有一定了解的知道,Redis提供了5种数据对象,分别为字符串对象(REDIS_STRING)、列表对象(REDIS_LIST)、哈希对象(REDIS_HASH)、集合对象(REDIS_SET)和有序列表对象(REDIS_ZSET)。

这些对象的底层都是由一定数据结构实现,具体信息如下表:

对象类型编码对象
REDIS_STRINGREDIS_ENCODING_INT使用整数值实现的字符串对象
REDIS_STRINGREDIS_ENCODING_EMBSTR使用embstr编码的简单动态字符串实现的字符串对象
REDIS_STRINGREDIS_ENCODING_RAW使用简单动态字符串(SDS)实现的字符串对象
REDIS_LISTREDIS_ENCODING_ZIPLIST使用压缩列表实现的列表对象
REDIS_LISTREDIS ENCODING LINKEDLIST使用双端链表实现的列表对象
REDIS_HASHREDIS_ENCODING_ZIPLIST使用压缩列表实现的哈希对象
REDIS_HASHREDIS_ENCODING_HT使用字典实现的哈希对象
REDIS_SETREDIS_ENCODING_INTSET使用整数集合实现的集合对象
REDIS_SETREDIS_ENCODING_HT使用字典实现的集合对象
REDIS_ZSETREDIS_ENCODING_ZIPLIST使用压缩列表实现的有序集合对象
REDIS ZSETREDIS ENCODING SKIPLIST使用跳跃表和字典实现的有序集合对象

因此,我们先从实现redis的几个数据结构入手,这些数据结构都是构成上述数据对象的基础。

1.1 SDS

字符串是redis中最基本最常用的一种数据格式,我们常见的数据库的键,部分数据结构的值,如字符串对象的值,字典每个元素的键和值等都是字符串数据格式。在redis中字符串对象一种重要的编码实现是SDS。SDS全称为simple dynamic string即简单动态字符串,redis使用它来存储字符串。

在redis中SDS的结构如下:

struct sdshdr{
    //用于记录buf数组中已使用字节数量
    //等于SDS结构所保存字符串的长度
    int len;
    //记录buf数组中未使用字节数量
    int free;
    //字节数组 用于保存字符串
    char buf[];
}

另外SDS遵循C字符串以空格字符结尾习惯,保存空字符的1字节空间不算在SDS的len属性里。

相比于传统的C语言字符串,SDS结构的好处如下:

  1. 常数复杂度获取字符串长度。

    C字符串不记录自身的长度信息,所以如果想获取长度需要遍历C字符串直到字符串结尾的空字符为止,时间复杂度O(n)。

    但SDS在len属性中记录了字符串长度,时间复杂度仅为O(1)。

  2. 杜绝缓冲区的溢出。

    假设我们在对两个字符串执行如下函数:

    char *strcat(char *dest,const char *src);

    由于C字符串不记录自身长度,因此strcat假定调用者在执行这个函数时,dest分配了足够内存,否则就会造成内存溢出。

    但SDS的API会在SDS进行修改时先检查SDS空间是否满足修改需求,如果不满足会先将SDS的空间扩展至执行修改所需大小,然后才执行实际的修改。

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

    数组长度是固定的,因此在C语言中对一个C字符串增长或缩短都需要对这个新的C字符串进行一次内存重写分配。由于redis字符这一数据结构是数据库中十分基础重要的数据结构,经常被频繁的修改,因此SDS通过未使用空间解决了字符串长度和底层数组长度这一关联。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种策略。

    • 空间预分配

      用于优化SDS字符串增长操作:当一个SDS的API需要对SDS进行修改且需要扩展SDS空间时,程序不仅会为SDS分配修改所必要的空间,还会额外分配一些未使用的空间。

      • 如果修改后的SDS长度小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS的len与free相同。比如SDS修改后13字节,那么程序会为这个SDS分配13+13+1 = 27字节(1是空字符,表明结尾)。
      • 如果对 SDS修改后长度大于1MB,那么程序会分配1MB的未使用空间。比如SDS修改后大小30MB,那么实际SDS的buf数组长度为30MB+1MB+1byte。

      通过空间预分配,redis可以减少连续执行字符串增长操作所需的内存重分配次数。

    • 惰性空间释放

      用于优化SDS的字符串缩短操作,当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配回收缩短后多出来的字节,而是使用free属性将这些未使用字节记录起来,以待将来使用。

      通过惰性空间释放策略,SDS避免了缩短字符串所需的内存重分配操作,并为将来可能的字符串增长提供了优化。

      当然SDS也提供了真正释放空间的API,在需要释放SDS未使用空间时进行释放,因此不必担心惰性空间释放策略带来的内存浪费。

  4. 二进制安全

    常见的C字符串必须符合某种编码,并且除了字符串末尾,字符串中间不能包含空字符,否则会被认为是字符串的结尾。这些限制使得C字符串只能保存文本数据,而不能保存图片音频视频压缩文件等二进制数据。

    但SDS的API都是二进制安全的,程序不会对其中的任何数据做限制过滤或者假设。因此使用二进制安全的SDS,redis不仅可以保存文本,还可以保存任意格式的二进制数据。

  5. 兼容部分C字符串函数

    由于SDS会在保存数据的末尾添加空字符,因此很多C字符串函数SDS可以直接拿来用,redis无需再为SDS编写一套函数库了。

1.2 链表

链表是一个常用的数据结构,在redis中我们熟知的列表数据对象的底层就是一个链表,除此以外发布订阅,慢查询也用到了链表。redis使用的编程语言C语言中没有内置这一数据结构,因此redis自己实现了链表。

typedef struct listNode{
    struct listNode *prev;
    struct listNode *next;
    void *value;
}listNode;

redis使用上述listNode数据结构表示一个链表节点,可以看到redis使用的是双向链表。

typedef struct list{
    listNode *head;
    listNode *tail;
    unsigned long len;
    void *(*dup)(void *ptr);
    void *(*free)(void *ptr);
    void *(*match)(void *ptr,void *key);
}

list结构即为redis的链表,链表提供了表头指针和表尾指针以及链表长度。剩下的函数是链表的一些行为,包括节点复制,节点删除和匹配等。

由此redis的链表结构特点如下:

  • 双端。包含prev和next指针。
  • 无环。表头的prev和表尾的next都指向NULL。
  • 带表头和表尾指针。
  • 带链表长度。
  • 多态。使用void*来保存节点的值,因此可以保存不同呢数据类型的值。

1.3 字典

很多高级语言中都内置了字典这一数据结构,所谓字典即用于保存key-value键值对的数据结构,我们通常可以通过键查找到对应的值。字典在redis中的使用非常广泛,比如redis数据库就是通过字典作为底层实现的,还有我们熟知的哈希键也是通过字典实现的。redis使用的编程语言C语言中没有内置这一数据结构,所以redis自己实现了这一数据结构。

1.3.1 字典的实现

redis中字典是以哈希表作为底层实现,熟悉Java中HashMap的同学来说对这一结构并不陌生,哈希表的实现如下:

typedef struct dictht{
    //哈希表数组,每个元素都是一个哈希桶
    dictEntry ** table;
    //哈希表大小 即table数组的长度
    unsigned long size;
    //掩码 始终等于size-1
    unsigned long sizemask;
    //已有节点数量 即总元素的数量
    unsigned long used;
} 

table是一个数组,数组中每一个元素都是一个指向dictEntry的指针。dictEntry我们通常称为哈希表节点,每个哈希节点中保存着当前节点的key value值。其结构如下:

typedef struct dictEntry{
    void *key;
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    }v;
    //指向当前哈希桶中的下一个节点,形成链表
    struct dictEntry *next;
}

由此可见,哈希表其实是一个数组,每个数组上又是一个链表,链表内的元素就是哈希节点,这个链表又称为哈希桶,每个哈希桶上的节点通过next指针串联起来。

在redis中的字典这一数据结构如下:

typedef struct dict{
    //函数集合,用于操作字典的函数
    dictType *type;
    //私有数据 保存了传给特定类型函数需要的参数
    void *privdata;
    //哈希表 两个
    dictht ht[2];
    //rehash索引 不进行rehash时值为-1
    int rehashidx;
}dict;

可以看到一个redis字典中使用了两个哈希表,一般情况下只使用ht[0],只有在rehash时才使用ht[1],rehashidx记录了rehash的进度,我们稍后说到rehash时再讨论。

1.3.2 哈希

哈希表的思想比较简单,就是为了在最短的时间,最好是O(1)的时间复杂度下根据key值查询到value值,想象下如何才能做到O(1)?我们知道按数组下标查询数组元素的时间复杂度就是O(1),如array[3]的值。因此借助这一思想,哈希表会先将每个key值映射为一个数字,然后再用这个数字与当前数组大小做取模运算(就是我们前面看到的掩码)得到的数必然对应数组的一个下标,即数组索引。因此就将这一元素存储到数组对应索引的位置,将key值映射为一个数字这一算法我们称为哈希算法。下次我们要查找key值对应的value值时只需再用哈希算法加取模运算得出索引,在对应的数组索引下取这一数据即可,这就实现了O(1)的时间复杂度。

但我们知道,在将key值映射为一个数字时,难免会出现多个key值都对应为一个数字的情况,尤其在哈希表的数组大小过小的情况下,因此这种多个key对应一个数字的情况,我们称为键冲突,解决办法是每个哈希表的元素都是一个链表集合,即相同哈希值的键通过链表串起来,因此每个哈希桶都是当前状态下哈希值相同的键的集合,比如我们将"aaa"这一键加入哈希表,计算得出"aaa"索引为5,因此"aaa"被加入到array[5]的位置,现在再插入"bbb"键,如果计算得出"bbb"的索引也是5,那么此时哈希表就变为:array[5]的位置上是一个链表,链表内有两个元素,"aaa"和"bbb"。因此在我们查找一个key值时,除了先将这个key通过哈希算法得出数组下角标然后取出这个值,如果值是一个长度大于1的链表,还需要遍历取出的这个链表,直到找到链表上的这一key。

明白了这一思想,不难得出哈希表的效率很大程度取决于哈希算法是否能将键均匀的分布到数组的每一个位置以及当前哈希表的数组大小。也不难理解为什么redis采用这种数组加链表的数据结构了。假设键冲突发生的足够频繁,即所有数据的哈希值都是一个数值,那么哈希表就退化为了链表,此时查询的时间复杂度为O(n)。因此在很多其他哈希表实现中会引入其他数据结构,比如Java8以后的HashMap引入了红黑树,保证了即使键冲突比较严重,其时间复杂度也是O(logn),感兴趣的同学可以自己看下JDK的源码。

在redis中采用的哈希算法为MurmurHash2算法,这一算法优点是即使输入的键有规律,算法仍然能给出一个很好的随机分布性,并且算法的计算速度非常快。

1.3.3 rehash

解决了哈希算法的效率我们还需要解决哈希表大小如何确定?很多时候一个哈希表到底需要多大的数组我们一开始是不清楚的,因此往往哈希表的大小是动态的,即随着插入哈希表的键值越来越多或从哈希表中删除的元素越来越多,哈希表需要能自适应的扩容或收缩。这一行为我们称为rehash,也即扩容或收缩后,数组大小被修改,掩码自然也被修改,此时需要对哈希表内的每个元素重新计算哈希值,然后重新将其放到新的数组的位置。

redis对字典的rehash步骤如下:

  1. 为字典ht[1]哈希表分配空间,这个新分配的哈希表空间大小取决于要进行的操作和当前ht[0]的大小:

    • 如果是扩展操作,那么ht[1]大小为 第一个大于等于ht[0].used*2的2^n
    • 如果是收缩操作,那么ht[1]大小为第一个大于等于ht[0].used的2^n
  2. 将保存在ht[0]上的所有键值对重新计算键的索引值,然后将键值对放到ht[1]哈希表的指定位置上。
  3. 当ht[0]包含的所有键值对都迁移到ht[1]后,释放ht[0],将ht[1]设为为ht[0],并在ht[1]创建一个空白的哈希表,为下一次rehash做准备。

知道了哈希表如何rehash我们还需要知道redis中哈希表什么时候rehash。

  1. 扩展:

    • 当服务器没有在做BGSAVE或BGREWRITEAOF(后续讲到持久化我们会将这两个命令)命令时,并且哈希表负载因子大于等于1
    • 当服务器正在做BGSAVE或BGREWRITEAOF(后续讲到持久化我们会将这两个命令)命令时,并且哈希表负载因子大于等于5
  2. 收缩:

    哈希表负载因子小于等于0.1

其中哈希表负载因子 = ht[0].used / ht[0].size

1.3.4 渐进式rehash

不同于大多数的哈希表的rehash,redis中的rehash是渐进式rehash。即redis中的rehash不是一次性,集中式完成,而是分多次渐进式完成,这一主要原因是假设服务器中字典的元素数量过大,一次性rehash会耗费很长的时间,这样会影响其他客户端的指令。因此redis采用分多次渐进式地将ht[0]键值对慢慢地rehash到ht[1]。

渐进式rehash步骤如下:

  1. 为ht[1]分配空间
  2. 在字典中维持一个索引计数器rehashidx,并将它的值设为0,表示rehash正式开始。
  3. 在rehash进行期间,每次对当前字典的增删改查程序除了执行指定的操作外,还会顺带将ht[0]哈希表rehashidx索引上的所有键值对rehash到ht[1]上,当此次rehash完成后,程序将rehashidx属性值+1
  4. 随着对字典操作的不断执行,最终某个时刻,ht[0]上的所有键值对都被rehash到ht[1]上,这时程序将rehashidx设为-1,表示rehash工作完成。

可以看出渐进式rehash将一个哈希表的rehash过程以一个哈希桶为单位,将rehash的计算工作拆分到了每次对字典的增删改查上,避免了集中式rehash的长期执行。

另外在渐进式rehash时,字典的删除,修改,查询操作会在ht[0]和ht[1]上进行,如果在ht[0]中没找到就去ht[1]中寻找,但对字典的新增操作都会被保存到ht[1]中,这样保证了ht[0]的键值对只减不增,随着rehash的进行最终变为空表。

1.4 跳跃表

《Redis设计与实现》一书中对于跳表这一数据结构讲解的不够清晰(至少在我看来如此)。本节我会先讲一下有序集合这一Redis对象的意义,再说一下跳表的实现。

1.4.1 有序集合的作用

Redis集合的功能是可以将多个信息都放入集合中,如果有相同信息不会被重复放入。但很多时候这一数据结构是不满足我们需求的,我们往往还想要一个"权重"的概念。

比如你现在是旧浪微博的后端程序员,你们公司打算推出一个叫热搜的功能,会实时根据事件的热度排出热度最高的10话题。

丰富经验的你首先想到了使用Redis,但应该用Redis的哪种数据类型呢,集合只能存储事件的名字,存不了热度,也就自然没法排序。字典看起来不错,key可以是事件名,value是事件热度。但字典天生是无序的,每次用户查询最受欢迎的事件时我们还得自己排一遍序。这一数据结构需要能同时存储事件名和热度两个信息,并且还要自动按照热度排序,这样我每次取得时候只取top10的信息就可以了。

这一对象就是有序集合。有序集合保存得时候,一条记录包含Member和Score两个信息,Member叫做成员,Score叫做分值,其中Score是Double数据类型,且有序集合天然是按Score排序的,这样你的热搜功能就可以实现如下:

假设现在有一个热搜叫蜡笔小新出轨,热度值是50000,那么你就可以将这一信息通过有序集合保存:

$ zadd 热搜 50000 蜡笔小新出轨

后续又有一些新的别的热搜

$ zadd 热搜 10000 中国航母下水
$ zadd 热搜 3000 美团小哥跑腿

这时如果有人点击热搜功能,想查看目前最火的10个热搜,你只需要向Redis执行

$ ZREVRANGE 热搜 0 9

可以看到有序集合就是在原来集合的基础上对集合内的每个元素加了一个Score的维度,并对Score进行排序。有序集合常见的作用就是排行榜。

1.4.2 跳表

跳表(SkipList,全称跳跃表)是用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。

跳表用于高性能的插入,删除和查询操作,跳表的插入,删除,和查找操作时间复杂度都是O(logn),性能与红黑树相当,但实现却更为简单。常用的redis的有序集合就是通过跳表实现的。

要想理解跳表我们可以先从一个基本的链表开始:

img

上图是一个排好序的有序链表,如果我们想从中查找10这个元素,就需要从头挨个链表,1,3,4,5,6,7,8,9,10。时间复杂度为O(n),效率比较低。这时就有人想,能不能从链表中每两个元素抽出来一个元素,做为索引

img

那么当我们再查找元素10时,先从上层索引开始1,4,7,9到9的时候发现9的后继节点13大于10,于是向下寻找通过 9 找到原始链表的 9,然后再往后遍历找到了我们要找的 10,遍历结束。加了一级索引后遍历顺序为1,4,7,9,10。提升了检索顺序。

那如果我们再在原来索引上加一层索引:

img

那么遍历顺序就变为了1,7,9,10,效率更高

由此如果链表有n个元素,那么我们就建立logn-1层索引,每次遍历就近似于二分查找,时间复杂度就降为了O(logn)。

img

了解了跳表是什么,我们来看跳表增删改查的实现:

首先我们先定义跳表的节点:

/**
 * 跳表节点
 * 每个节点拥有两个指针 向下的指针和向右的指针
 * @author coderZoe
 * @date 2022/2/8 13:45
 */
public class SkipNode<T> {
    int key;
    T value;
    SkipNode<T> right;
    SkipNode<T> down;

    public SkipNode(int key, T value) {
        this.key = key;
        this.value = value;
    }
}

然后定义跳表:

/**
 * 跳表
 * @author coderZoe
 * @date 2022/2/8 13:45
 */
public class SkipList<T> {
    private SkipNode<T> head;
    /**
     * 当前跳表的高度/层数
     */
    int highLevel;
    /**
     * 随机数 用于掷硬币判断是否新增索引
     */
    private Random random;
    /**
     * 最高允许的层数
     */
    private final int MAX_LEVEL = 32;

    /**
     * 初始化时构造头节点,将头节点设为最小值Integer.MIN
     */
    public SkipList() {
        random = new Random();
        head = new SkipNode<>(Integer.MIN_VALUE,null);
        highLevel = 0;
    }
}

跳表查询:

public SkipNode<T> get(int key){
    SkipNode<T> temp = head;
    while (temp != null){
        if(temp.key == key){
            return temp;
        }
        if(temp.right==null){
            temp = temp.down;
        }
        if(temp.right.key > key){
            temp = temp.down;
        }else {
            temp = temp.right;
        }
    }
    return null;
}

查询操作先从跳表的头节点开始遍历

如果当前节点key与要查询key一致,直接返回。否则判断当前节点右侧节点是否是空节点,如果是就向下遍历。否则判断右侧节点是否大于要遍历的key,如果是就向下遍历。否则,即右侧节点小于等于要查询的key,就向右遍历。

循环上述步骤直至找到或当前节点为空。

跳表的删除:

public void delete(int key){
    SkipNode<T> temp = head;
    while (temp != null){
        if(temp.right == null){
            temp = temp.down;
        }else if(temp.right.key == key){
            temp.right = temp.right.right ;
        }else if(temp.right.key > key){
            temp = temp.down;
        }else {
            temp = temp.right;
        }
    }
}

对于一个链表节点,如果我们要删除当前节点,通常是 node.prev.next = node.next; node.next.prev = node.prev; 但由于SkipList在横向是单向链表,无法找到prev节点,因此如果我们要删除某个节点,则只能通过这个节点的前置节点删除 如:node.next = node.next.next;这样就把node.next节点删除了。

另外,因为有些节点可能会出现多次(因为索引),所以当删除这个节点时,一定也要把这个节点下面相同的节点也要删除 。由此,其逻辑如下:

先从头节点开始遍历, 如果当前节点右侧节点为空,则向下遍历。否则(即右侧节点不为空),如果当前节点的右侧节点key值与要删除key值相同,则删除当前节点的右侧节点,并且向下遍历(删除相同节点)。 否则(即右侧节点不等于要删除的节点) 如果右侧节点大于当前要删除的key 则向下遍历, 否则(即当前节点的右侧节点小于等于要删除key,还可以向右移动)则向右遍历

重复上述流程直至当前节点为空。

跳表的插入:

public void add(SkipNode<T> node){
    //更新操作
    //这里可以看到,只更新一个节点的value,即使下面还有很多节点也不关心
    SkipNode<T> skipNode = get(node.key);
    if(skipNode != null){
        skipNode.value = node.value;
        return;
    }
    //栈 存储向下查找过程中的节点,用于一会的插入
    Stack<SkipNode<T>> stack = new Stack<>();
    SkipNode<T> temp = head;
    //从head节点一直向右下查找,记录所有down过程的节点,直到找到最后一层
    while (temp != null){
        if(temp.right == null){
            stack.add(temp);
            temp = temp.down;
        }else if(temp.right.key> node.key){
            stack.add(temp);
            temp = temp.down;
        }else {
            temp = temp.right;
        }
    }
    SkipNode<T> downNode = null;
    //最底层层数为1,每创建一次索引level++
    int level = 1;
    //从栈中取节点,开始执行插入操作
    while (!stack.isEmpty()){
        //如果了解跳表机制的话就会知道每次栈里弹出来的节点都是当前需要插入节点的左侧节点
        SkipNode<T> leftNode = stack.pop();
        //创建一个当前节点用于一会的插入
        SkipNode<T> thisNode = new SkipNode<>(node.key, node.value);

        //先设置当前节点的向下节点 如果是最底层节点,down指针就是空
        //如果是索引(非最底层),则down指针就是while循环上一次的刚插入的节点
        thisNode.down = downNode;
        //重置down节点用于下一次(即创建当前插入节点的索引时)
        downNode = thisNode;
        //插入到横向队列
        thisNode.right = leftNode.right;
        leftNode.right = thisNode;

        //开始判断是否要为当前插入节点创建索引
        //不继续
        if(level > MAX_LEVEL||random.nextDouble()>0.5){
            break;
        }
        //继续
        level++;
        //判断如果创建这个新索引,那么跳表层数是否要大于当前跳表层数了 如果是就创建一个新的头节点
        if(level>highLevel){
            SkipNode<T> newHead = new SkipNode<>(Integer.MIN_VALUE, null);
            newHead.down = head;
            head = newHead;
            highLevel++;
            //加入栈,此时这个newHead就是下一次新索引的leftNode
            stack.add(newHead);
        }
    }

}

跳表的插入相对复杂,比如当我们在最底层的合适位置插入节点后,由于跳表结构的改变,需不需要再对这个节点建立索引,如果建立了索引,那还需不需要更上一层的索引。因此我们首先需要解决一个问题:当插入元素后,要否要为当前层增加索引。跳表采用了一个比较随机的做法:回想跳表的结构,基本是每两个元素增加一个索引,这样当查询时,时间复杂度为O(logn) 因此我们也可以认为每个元素有1/2的概率插入索引,由此每当插入节点时,都做个二分的随机判断,如果是就建立索引,否则不建立索引。这样根据概率分布,在数据量较大时,查询时基本接近O(logn)的时间复杂度。

解决了第一个问题我们再来看第二个问题:如果要对一个节点建立索引,那么这个节点的向上节点指针,并且还要有向左节点指针,这样才能连起来连链表 但SkipNode中只有向下和向右的指针,这样要如何插入一个索引?跳表采用栈这一数据结构,记录历史遍历的向下节点,由此可以从栈中得到之前的节点做链表插入操作。

现在还有第三个问题 假设当前跳表为3层,如果新加的索引在第4层,根据前面我们知道,每次遍历都从头节点开始,从头节点向右下遍历,因此如果有更高高度的索引节点,我们也需要更新头节点,新建一个节点,让其down指针指向头节点,更新头节点为这个新创建的节点,其right指针指向新的索引节点。

上面代码中有几个细节需要说明:

  1. 对于跳变内已有元素,更新其值,由于搜寻时,只要搜到就立即返回,并不会搜寻所有的相等节点,因此更新也只是更新一个节点 即更新只更新这个节点的最上层节点的值,底下的不更新。
  2. 如果画一下跳表的插入流程就可以看到栈里保存的每一个元素都是当前要插入节点的左侧节点
  3. 每次新插入的节点都将作为下一次创建索引的down指针节点
  4. 在创建新索引之前我们先判断如果要创建了这个节点(还没创建)是否超出了当前高度 如果是就先创建一个新高的头节点,再创建这个索引,并且这个头节点就是这个索引的左节点,因此level是从1开始数,而highLevel是从0开始数的。

如果对MySQL的B+树有了解的同学会很自然的联想到,跳表的实现与B+树有些像,都是在链表的基础上加索引。

1.5 整数集合

在第一章开头我们讲到Redis集合这一数据对象其中一个底层实现是整数集合,当一个集合只包含整数值元素,并且这 个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

在Redis中一个整数集合采用intset. h/intset结构表示

typedef struct intset{    
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

其中encoding指的是当前集合保存的整型数据的编码格式,是个枚举值,主要包括:int16_t,int32_t和int64_t

length代表的是当前集合的元素格式,contents就是实际存储的集合元素,大家可以将contents理解为一个字节数组,不要被它的int8_t误导。当encoding是int16_t,那么contents就以16个字节为一个整体代表一个整型元素,同理int32_t是以32字节代表一个整型,int64_t代表64字节一个整型。

当我们需要查看集合内的元素时,首先根据encoding编码格式得到当前多少字节表示一个整型(元素的整型类型),然后根据length得到集合内元素个数。比如现在encoding等于int32_t,length等于5,那么就从contents中以32字节为基本单位连续获得5个基本单位的内容,每个基本单位都对应一个整型。

之所以选择encoding这种可变的编码格式是为了省内存空间,我们知道int16_t表示整数范围[-32768,32767],int32_t表示整数范围[-2147483648,2147483647],int64_t表示整数范围[-9223372036854775808,9223372036854775807]。很多时候我们只是存储一些很小的数字,如果采用int64_t编码格式,每个数字使用8字节,会造成很大的内存消耗,如果采用int16_t就会减少4倍的内存空间。

很容易想到,一个集合内的编码格式是会改变的,比如一开始集合内的元素为(1,2,3,4,5),我们使用int16_t保存即可,但此时214748364插入到集合中,int16_t无法保存这一数据类型,此时就需要更改编码格式,对于将编码格式由较小字节编码到较大字节编码格式的转化称为升级,反之称为降级。

  1. 升级

    升级分为三个步骤:

    1)根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。

    2)将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放 置元素的过程中,需要继续维持底层数组的有序性质不变。

    3)将新元素添加到底层数组里面。

    这里需要补充一下:整数集合其实内部是有序的,排序的好处是当有新元素插入的时候,可以很快定位到集合内有没有这个元素,方便去重,保证集合的唯一性。

  2. 降级

    不支持降级。

1.6 压缩列表

列表数据对象和哈希数据对象的底层实现之一都是压缩列表,当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就 会使用压缩列表来做列表键的底层实现。

与整数集合一样,压缩列表也是为了节省内存而出现的。

  1. 压缩列表结构

    zlbyteszltailzllenentrylentry2...entryNzlend

    上述结构即为压缩列表的数据结构,其中:

    属性类型长度用 途
    zlbytesuint32_t4字节记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算zlend的位置时使用
    zltailuint32_t4字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个 偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址
    zllenuintl6_t2字节记录了压缩列表包含的节点数量:当这个属性的值小于UINT16_MAX ( 65535 )时,这个属性的值就是压缩列表包含节点的数量;当这个值等于 UINT16 MAX时,节点的真实数量需要遍历整个压缩列表才能计算得出
    entryX列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定
    zlenduint8 t1字节特殊值0xFF (十进制255 ),用于标记压缩列表的末端

    我们举个例子:

    image-20220614172147822

    上图中

    • zlbytes属性的值为0x50 (十进制80 ),表示压缩列表的总长为80字节
    • zltail属性的值为0x3c (十进制60 ),这表示如果我们有一个指向压缩列表起始地址的指针P,那么只要用指针p加上偏移量60,就可以计算出表尾节点 entry3的地址
    • 列表zllen属性的值为0x3 (十进制3 ),表示压缩列表包含三个节点
  2. 压缩列表节点结构

    previous_entry_lengthencodingcontent

    上述为一个压缩列表节点的结构,其中:

    • previous_entry_length属性以字节为单位,记录了压缩列表中前一个节 点的长度。previous_entry_length属性的长度可以是1字节或者5字节:

      • 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度 为1字节:前一节点的长度就保存在这一个字节里面。
      • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长 度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度。

      因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。

      压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length 属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。

      image-20220614173110629

    • encoding

      每个压缩列表节点可以保存一个字节数组或者一个整数值。其中:

      字节数组可以是以下 三种长度的其中一种:

      • 长度小于等于63(2^6-1)字节的字节数组
      • 长度小于等于16383(2^14-1)字节的字节数组
      • 长度小于等于4294967295(2^32-1)字节的字节数组

      而整数值则可以是以下六种长度的其中一种:

      • 4位长,介于0至12之间的无符号整数
      • 1字节长的有符号整数
      • 3字节长的有符号整数
      • int16_t类型整数
      • int32_t类型整数
      • int64_t类型整数

      基于此,encoding属性就记录了节点的content属性保存数据的类型以及长度

      对于节点是整数值的情况,encoding采用1个字节表示:

      编码编码长度content属性保存的值
      110000001字节int16 t类型的整数
      110100001字节int32 t类型的整数
      111000001字节int64 t类型的整数
      111100001字节3字节有符号整数
      111111101字节1字节有符号整数
      1111xxxx1字节使用这一编码的节点没有相应的content属性,因为编码本身的xxxx四 个位已经保存了一个介于0和12之间的值,所以它无须content属性

      可以看到encoding的上述六种情况就对应节点是整数的六种长度。

      对于节点是字节数组的情况根据content的长度,encoding存在1字节,3字节,5字节的长度。encoding字段会用前两位表示自己到底是1字节还是3字节还是5字节长

      编码字节长度
      00encoding长1字节,content为字节数组,要求content长度小于等于63字节
      01encoding长2字节,content为字节数组,要求content长度小于等于16383字节
      10encoding长5字节,content为字节数组,要求content长度小于等于4294967295字节
      11encoding长1字节,content为整型

      前两位11的我们在上面的整型已经见到了,对于表示字节的encoding,例子如下:

      编 码编码长度content属性保存的值
      00xxxxxx1字节长度小于等于63字节的字节数组
      01xxxxxx xxxxxxxx2字节长度小于等于16 383字节的字节数组
      10xxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx5字节长度小于等于4 294 967 295的字节数组
    • content

      节点的content属性保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。

1.7 Redis对象

Redis对象就是由上述的这些数据结构实现。这种将实现与对象分离的形式一大好处就是同一Redis对象可以在不同的使用场景下由不同的数据结构实现。如,哈希对象在数据量较小且键值长度都较小的情况下会采用压缩列表实现,反之才会用散列表实现。

在Redis中,Redis对象的实现为:

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

其中类型就是我们熟悉的五个Redis对象:

类型常量对象的名称
REDIS_STRING字符串对象
REDIS_LIST列表对象
REDIS_HASH哈希对象
REDIS_SET集合对象
REDIS_ZSET有序集合对象

编码可以理解为实现对象的数据结构,在Redis中每种Redis对象都至少有两种不同的编码:

类型编码对象
REDIS STRINGREDIS ENCODING INT使用整数值实现的字符串对象
REDIS_STRINGREDIS_ENCODING_EMBSTR使用embstr编码的简单动态字符串实现的字符串对象
REDIS_STRINGREDIS_ENCODING_RAW使用简单动态字符串实现的字符串对象
REDIS_LISTREDIS_ENCODING_ZIPLIST使用压缩列表实现的列表对象
REDIS_LISTREDIS ENCODING LINKEDLIST使用双端链表实现的列表对象
REDIS_HASHREDIS_ENCODING_ZIPLIST使用压缩列表实现的哈希对象
REDIS_HASHREDIS_ENCODING_HT使用字典实现的哈希对象
REDIS_SETREDIS_ENCODING_INTSET使用整数集合实现的集合对象
REDIS_SETREDIS_ENCODING_HT使用字典实现的集合对象
REDIS_ZSETREDIS_ENCODING_ZIPLIST使用压缩列表实现的有序集合对象
REDIS_ZSETREDIS ENCODING SKIPLIST使用跳跃表和字典实现的有序集合对象

下面我们就详细的说下Redis对象的实现。

1.7.1 字符串对象

不同的场景下,字符串对象的实现不同,字符串对象共有三种实现:int、raw、embstr。

  1. int

    如果保存的是整数值,且这个整数值可以用long来表示,Redis就使用int编码类型来实现字符串对象。

    $ set number 10086

    image-20220621090634607

  2. raw

    如果保存的是字符串且字符串长度大于32,那么就采用SDS结构来保存,并将对象的编码格式设为raw。如:

    image-20220621090825437

  3. embstr

    如果保存的是字符串且字符串长度小于等于32,那么将采用embstr编码来保存。embstr与raw的都是使用SDS来保存字符串,区别是raw编码格式的redisObject与sdshdr结构在创建时内存是单独申请的,如先申请redisObject对象内存,再申请sdshdr结构内存,然后通过sdshdr处的内存指针访问sdshdr结构信息。embstr结构只会申请一次内存,也即一片连续的内存,既包含redisObject也包含sdshdr结构。

    image-20220621091530148

采用不同的编码实现带来的一个问题就是同一个Redis对象可能会进行编码格式的转化。字符串对象的编码转换是int或embstr编码类型会转为raw编码类型。

如:

redis> SET number 10086
OK
redis> OBJECT ENCODING number
"int
redis> APPEND number " is a good number!"
(integer) 23
redis> GET number
"10086 is a good number!"
redis> OBJECT ENCODING number
"raw"

上述number对象从int编码改为了raw编码。

embstr比较特殊,Redis中embstr编码是只读的,因此如果对embstr编码的对象做任何修改操作,Redis都会将embstr编码改为raw。

1.7.2 列表对象

列表对象的编码是ziplist或linkedlist。它们分别对应压缩列表和双端列表数据结构。

在如下情况下,Redis使用ziplist编码来是实现列表对象:

  • 列表对象保存的所有字符串元素的长度都小于64字节
  • 列表对象保存的元素数量小于512个

不能同时满足这两个条件的列表对象需要使用 linkedlist 编码。

1.7.3 哈希对象

哈希对象的编码是ziplist或hashtable。它们分别对应压缩列表和字典数据结构。

采用压缩列表实现哈希对象时,哈希对象中的键值对在压缩列表中保存为两个节点,这两个节点紧挨在一起,键节点在前,值节点在后。如:

image-20220621092922338

在如下情况下,Redis使用ziplist编码来是实现哈希对象:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
  • 哈希对象保存的键值对数量小于512个

不能满足这两个条件的哈希对象需要使用 hashtable 编码。

1.7.4 集合对象

集合对象的编码是intset或hashtable。它们分别对应整数集合和字典数据结构。

在如下情况下,Redis使用intset编码来是实现集合对象:

  • 集合对象保存的所有元素都是整数值
  • 集合对象保存的元素数量不超过512个

不能满足这两个条件的集合对象需要使用hashtable编码。

1.7.5 有序集合对象

有序集合对象的编码是ziplist或skiplist。它们分别对应压缩列表和跳表数据结构。

与哈希对象相同,Redis采用ziplist保存有序集合对象时,有序集合的Score和Member会占用两个节点,且两个节点紧挨在一起。成员在前,分值在后。但有序集合还有一点要求,ziplist中的节点都是有序的,分值较小的在表头方向,分值较大的在表尾方向。

image-20220621093939803

另外需要补充的是,有序集合中还有一个字典结构,这个字典的key为成员,value是分值,这一数据结构是为了实现ZSCORE这一命令,根据成员查询分值。

在如下情况下,Redis使用ziplist编码来是实现哈有序集合对象:

  • 有序集合保存的元素数量小于128个
  • 有序集合保存的所有元素成员的长度都小于64字节

不能满足这两个条件的有序集合对象需要使用 skiplist编码。

2. 单机数据库

2.1 数据库

这一节我们主要讲下Redis中的数据库,以及对一个数据库执行增删改查会发生什么。另外重点提一下Redis的键过期机制。

2.1.1 键空间

Redis中可以有多个数据库,每个数据库的数据彼此隔离,没有影响。Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db 数组中,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一 个数据库

struct redisServer{
    //...
    // 一个数组,保存着服务器中的所有数据库
    redisDb *db;
    // ...
}

另外在初始化服务器时,程序会根据dbnum属性来决定创建多少个数据库。默认情况下dbnum=16。

struct redisServer{
    // ...
    //服务器的数据库数量
    int dbnum;
    // ...
}

默认情况下,一个客户端连接上Redis服务器时,使用的数据库是0号数据库,客户端可以通过执行select命令切换到其他数据库

在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针:

typedef struct redisClient{
    // ...
    //记录客户端当前正在使用的数据库
    redisDb *db;
    //...
} redisClient;

redisClient.db指针指向redisServer.db数组的其中一个元素,而被指向的元素就是客户端的目标数据库。

通过修改redisClient.db指针,让它指向服务器中的不同数据库,从而实现切换目标数据库的功能——这就是SELECT命令的实现原理。

刚才我们说过每个redisDb结构代表一 个数据库,其内部维护了一个字典,这个字典就是当前数据库的所有键值对,我们将这个字典称为键空间

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

其中键空间的键是一个字符串对象,而键空间的值可以是字符串对象、列表对象、哈希表对象、 集合对象和有序集合对象中的任意一种Redis对象。

假设我们当前执行了

redis> SET message "hello world"
OK
redis> RPUSH alphabet "a" "b" "c"
(integer)3
redis> HSET book name "Redis in Action"
(integer) 1
redis> HSET book author "Josiah L. Carlson"
(integer) 1
redis> HSET book publisher "Manning" 
(integer) 1 

则当前Redis键空间内容就如下:

image-20220314163331332

因此我们对Redis数据库的增删改查操作都是对这个字典的操作。

2.1.2 键过期

键过期是Redis中一个很实用的功能,其应用场景非常广泛,比如常见的存储用户登录的token,并为其设置过期时间,在一段时间后用户就需要重新登录。

redis中通过EXPIRE命令或者PEXPIRE命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间,在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键。

redis> SET key value
OK
redis> EXPIRE key 5
(integer) 1
redis> GET key // 5 秒之内
"value"
redis> GET key // 5 秒之后
(nil)

以下是Redis提供的四个不同设置超时时间的命令:

  • EXPIRE 命令用于将键key的生存时间设置为ttl秒。
  • PEXPIRE 命令用于将键key的生存时间设置为ttl毫秒。
  • EXPIRE AT 命令用于将键key的过期时间设置为timestamp所指定的秒数时间戳。
  • PEXPIREAT 命令用于将键key的过期时间设置为timestamp 所指定的毫秒数时间戳。

另外redis提供了SETEX命令,可以原子的新增键并设置键的过期时间。

在Redis中对于过期时间的保存就是一个UNIX时间戳,即当前键过期的时间点。redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典。

typedef struct redisDb{
    // ...
    //过期字典,保存着键的过期时间
    dict *expires;
    // ...
} redisDb;

其中expires的key是一个指向当前键空间某个键对象的指针,value是当前键的过期时间(UNIX时间戳)。一个带过期字典的redisDb大致如下:

image-20220314165532583

(为了展示方便,上图键空间和过期字典中重复出现了两次alphabet键对象和 book键对象。在实际中,键空间的键和过期字典的键都指向同一个键对象,所以不会出现任何重复对象,也不会浪费任何空间)

另外Redis还支持移除一个键的过期时间,其实现也很简单,就是从expires中删除对应的键的记录。还有我们常用的TTL或PTTL命令,计算键还剩的生存时间,其实现也是通过当前时间戳减去expires中记录的时间戳。

我们知道对于过期的键Redis会进行删除,但Redis是如何删除的呢?这就是我们要讨论的过期键的删除策略:

一般的删除会分三种情况:

  • 定时删除:在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。
  • 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检査取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。
  • 定期删除:每隔一段时间,程序就对数据库进行一次检査,删除里面的过期键。至于要删除多少过期键,以及要检査多少个数据库,则由算法决定。

定时删除是一种典型的内存友好型但CPU不友好的策略,定时删除保证了过期的键一定会被删除掉,避免了内存的浪费,但如果很多数据其删除时间在同一时刻,那么就意味着同一时刻会执行多个键的删除,会导致某一时刻CPU占用率很高。

懒惰删除则相反,是一种对CPU友好但对内存不友好的策略。只有在需要用到键时查看一下是否过期,但很多键可能用不到但已经过了时间了依然没被内存回收,某些情况可能会导致某些键永远不会被删除。

定期删除看起来很合理,但问题就是定期检查和执行删除的频率。如果频率过快,就和定时删除差不多,如果频率过慢,就和懒惰删除很像。

在Redis服务器中,采用懒惰删除和定期删除两种策略。

  • 懒惰删除

    Redis中惰性删除策略由db.c/expirelfNeeded函数实现,所有读写数据库的 Redis命令在执行之前都会调用expirelfNeeded函数对输入键进行检査,学过过滤器的同学不难想到,这个函数就是一个过滤器。如果输入键已经过期,那么expirelfNeeded函数将输入键从数据库中删除,如果输入键未过期,那么expirelfNeeded函数不做动作。

    image-20220314171333793

    image-20220314171406631

  • 定期删除

    过期键的定期删除策略由redis.c/activeExpireCycle函数实现,Redis中的时间事件会轮询执行redis.c/serverCron函数,activeExpireCycle函数会随着redis.c/serverCron函数的调用而被调用。(serverCron默认每100ms执行一次,这一内容见2.4节)

    但不同的是,定期删除并不像我们想的会遍历所有的数据库中的expires字典中的所有信息,而是在规定的时间分多次抽测。其伪代码如下:

    #默认每次检查的数据库数量
    DEFAULT_DB_NUMBERS = 16
    #默认每个数据库检查的键数量
    DEFAULT_KEY_NUMBERS = 20
    #全局变量,记录检查进度
    current_db = 0
    def activeExpireCycle():
        #初始化要检查的数据库数量
        #如果服务器的数据库数量比DEFAULT_DB_NUMBERS要小
        #那么以服务器的数据库数量为准
        if server.dbnum < DEFAULT_DB_NUMBERS :
            db_numbers = server.dbnum
        else:
            db_numbers = DEFAULT_DB_NUMBERS
        #遍历各个数据库
        for i in range(db_numbers):
            #如果current_db的值等于服务器的数据库数量
            #这表示检查程序已经遍历了服务器的所有数据库一次
            #将current_db重置为0,开始新的一轮遍历
            if current_db == server.dbnum: current_db = 0
            #获取当前要处理的数据库
                redisDb = server.db[current_db]
                #将数据库索引增1,指向下一个要处理的数据库
                current_db += 1
                #检查数据库键
                for j in range(DEFAULT_KEY_NUMBERS):
                #如果数据库中没有一个键带有过期时间,那么跳过这个数据库
                    if redisDb.expires.size () == 0: break
                    #随机获取一个带有过期时间的键
                    key_with_ttl = redisDb.expires.get_random_key()
                    #检查键是否过期,如果过期就删除它
                    if is_expired(key_with_ttl):
                        delete_key(key_with_ttl)
                    #已达到时间上限,停止处理
                    if reach_time_limit(): return

    也即函数会对所有库轮询,每次从库中抽查一定数量的数据(如默认的20条),如果轮询过一遍所有的库则再次重头轮询,一直重复到一定时间结束,也即当前函数是一个执行固定时间的函数

    固定时间的目的是为了保证一次操作不占用过久的时间。另外采取随机抽查方式有可能在不同的轮询中抽查到相同库中的相同key值,并且在极端的情况下,某些键依然可能会永远不会被删除(不被访问的键无法懒删除并且随机抽查从未被抽中过)。

过期键也会对RDB和AOF的持久化有影响(见2.2和2.3节)

在生成RDB文件时程序会对数据库中的键进行检查,保证过期的键不会保存到RDB中。在RDB加载时,对于已经过期的键,主服务器会直接忽略,不会加载进服务器。但从服务器不关心键是否过期,会直接加载进数据库。(主从见3.1节)这是因为在主从复制中,从服务器的过期键是由主服务器控制的,从服务器不会主动检查键的过期情况,而是在主服务器检测到键过期后由主服务器向从服务器发送一条DEL命令。

在AOF写入时,对于惰性删除或定期删除未删除但已经过期的键,AOF文件并不做过期处理,只是在其被惰性删除或定期删除后向AOF文件中追加一条DEL命令。AOF的重写会对过期键进行检查,对过期的键不写入新的AOF文件中。

2.2 RDB

对于内存数据库,如何将数据持久化到磁盘,以保证服务器关闭后数据也不会丢失,redis提供了RDB持久化功能,将redis内存中数据写到磁盘。

RDB即快照,我们在某个时刻将服务器当前的数据写入到一个RDB文件中,由于RDB文件是保存在磁盘因此即使redis服务器关闭或者计算机停机,只要RDB文件存在,Redis服务器就可以用它来还原数据库状态。RDB持久化既可以手动执行也可以定期自动执行。

2.2.1 SAVE/BGSAVE

在Redis中SAVEBGSAVE命令都可以用来生成RDB文件,SAVE会阻塞当前Redis服务器进程直到RDB文件创建完毕,在服务器阻塞期间,不处理任何命令请求。BGSAVE会派生出一个子进程,由子进程负责创建RDB文件,服务器进程(父进程)继续接收和处理客户端请求。

创建RDB的工作由redis中rdb.c/rdbSave函数完成,SAVE和BGSAVE命令其伪代码如下:

def SAVE():
    # 创建RDB文件
    rdbSave()
def BGSAVE():
    # 创建子进程
    pid = fork()
    if pid == 0:
        # 子进程创建RDB文件
        rdbSave()
        # 创建完成后通知父进程
        signal_parent()
    else pid > 0:
        # 父进程继续处理请并且等待子进程完成的信号
        handle_request_and_wait_signal()
    else:
        # 处理出错
        handle_fork_error()

服务器在执行SAVE命令时会被阻塞,客户端发送的所有命令请求都会被拒绝,只有在SAVE完成,服务器才重新接收请求,此时客户端发送的请求才会被处理。

BGSAVE中RDB的保存工作是子进程完成的,因此BGSAVE时,Redis服务器依然可以处理客户端请求。但在BGSAVE时,服务器对SAVE,BGSAVE和BGREWRITEAOF指令方式会和平常不同:

  • BGSAVE期间会拒绝执行SAVE和BGSAVE命令,因为SAVE和BGSAVE都会调用rdbSave()函数,会导致竞争条件。
  • BGAVE期间,客户端发送的BGREWRITEAOF指令会延迟到BGSAVE执行完毕后执行,而BGREWRITEAOF期间BGSAVE会被拒绝执行。这两个指令并没有竞争,之所以不并行的执行主要是考虑性能,因为这两个指令都需要大量的磁盘写入。

在Redis服务器启动的时候会自动检测到RDB文件然后读取文件内容并载入数据到数据库,但一般来说AOF的更新频率大于RDB,因此如果服务器开启了AOF,服务器启动时会优先使用AOF文件还原数据,只有在AOF关闭时,服务器才会用RDB还原数据

2.1.2 RDB保存条件

刚才我们提到RDB可以手动也可以定期执行,用户可以为Redis配置多个RDB保存条件,只要任意一个条件满足,服务器就会自动执行BGSAVE命令。

举例,配置如下:

save 900 1
save 300 10
save 60 10000

上述三个条件分别为:

  • 服务器在900秒内,对数据库进行了至少一次修改
  • 服务器在300秒内,对数据库进行了至少10次修改
  • 服务器在60秒内,对数据库进行了至少10000次修改

上述三个条件任意一个被满足都会执行BGSAVE。

redis服务器在启动的时候会将我们配置的保存条件加载到redisServer的saveparams属性中:

struct redisServer{
    //...
    struct saveparam *saveparams;
    //...
}

saveparams是一个数组,数组中每一个元素都是saveparam元素

struct saveparam{
    //秒数
    time_t seconds;
    //修改数
    int changes;
}

除了saveparams外,服务器还维护着一个dirty计数器以及一个lastsave属性

struct redisServer{
    //...
    //修改计数器
    long long dirty;
    //上一次执行SAVE或BGSAVE时间
    time_t lastsave;
}

dirty计数器用于上一次成功执行BGSAVE或SAVE后服务器对数据库进行了多少次修改操作,每当服务器对数据库执行修改操作时dirty都会+1。

lastsave是一个UNIX时间戳,记录服务器上一次成功执行SAVE或BGSAVE的时间。

redis中一个周期执行函数serverCron会每隔100ms执行一次,该函数用于对当前服务器进行维护,其中就包括检查根据设置的save配置是否满足执行BGSAVE,以下为伪代码:

def serverCron():
   # ...
   # 遍历所有保存条件
   for saveparam in server.saveparams:
    #如果任一一组条件满足就执行BGSAVE
    if(server.dirty >= saveparam.changes and save_interval >saveparam.senconds)
        BGSAVE()

2.1.3 RDB文件结构

对于Redis中RDB文件的结构以及Redis启动时对RDB的分析加载或者说RDB的编解码内容,感兴趣的读者可以参考《Redis设计与实现》中第10章,10.3与10.4节中内容。这里笔者不再赘述。

2.3 AOF

AOF即Append Only File,AOF持久化通过保存redis服务器所执行的写命令来记录数据库的状态。

2.3.1 AOF实现

AOF的持久化分别通过命令追加,文件写入和文件同步三个步骤来完成。

  • 命令追加

    redisServer内部有一个aof缓冲区:

    struct redisServer{
        //aof缓冲区 记录将要保存的aof信息
        sds aof_buf;
    }

    当AOF的持久化功能打开时,服务器会在执行完一个写命令后,以一定的格式将这个写命令追加到缓冲区中。

  • 文件的写入与同步

    redis服务器进程是一个事件循环,这个事件循环中分为文件事件和时间事件,文件事件负责接收客户端请求,时间事件执行一些定时运行的函数(这点我们在下一节事件中再详细的说,读者只需要知道有这么回事就行)。有时候一个事件循环中,文件事件可能会执行写命令,将写指令追加到aof_buf中,因此在每一次的事件循环结束前,redis都会调用flushAppendOnlyFile函数,考虑是否要将aof_buf中的内容写入到aof文件中,其过程伪代码表示如下:

    def eventLoop():
        while true:
            # 处理文件事件 对于写命令会将命令写入到aof_buf中
            processFileEvents();
            # 处理时间事件
            processTimeEvents();
            #考虑是否将aof_buf内容写入到aof文件中
            flushAppendOnlyFile();

    其中是否将aof文件写入到磁盘这一判断依据是由用户来配置的,在服务器配置的appendfsync选项的值决定

    appendfsync选项的值flushAppendOnlyFile函数的行为
    always将aof缓冲区所有内容写入并同步到aof文件
    everysec将aof缓冲区所有内容写入到aof文件,如果上次aof同步时间超过1秒钟,那么将对AOF文件进行同步,这个同步由一个单独的线程负责
    no将aof缓冲区所有内容写入到aof文件,但并不对aof文件执行同步,何时同步由操作系统决定。

    其中appendfsync默认值是everysec。

    这里很多同学可能对上述内容有疑问,写入到文件不就结束了吗,文件同步又是什么意思?

    这是因为在现在操作系统中,当用户调用write将一些数据写入到文件时,操作系统通常并不会真的写入,而是将这一数据暂存在操作系统自己管理的一个缓冲区中(Page Cache)。当缓冲区满了,或者超过指定时限后,操作系统才会真正将缓冲区中数据写到磁盘,换言之,操作系统为避免每次write都执行IO,操作系统自己也会有一个缓冲区,每次调用write并不会立马写入到磁盘。

    这种做法虽然提高了效率,但也为写入数据带来了安全问题,如果计算机发生宕机,那么保存在缓冲区中的数据也将丢失。为此,操作系统提供了fsync和fdatasync两个函数,其可以强制操作系统立即将缓冲区的数据写入到硬盘,从而确保安全性。

    回到上面的appendfsync选项,无论是哪个选项,在一次事件循环结束后,其都会执行类似于write(aof_buf)的操作,但appendfsync的配置点在于他们调用操作系统的强制刷新函数策略不同,always是每写入一条命令就强制刷新到磁盘,everysec是每秒钟会将操作系统缓冲区的内容写入到磁盘,而no则完全不关心什么时候写入到磁盘,完全由操作系统自己决定,也即我们之前说的当缓冲区满了,或者超过指定时限后,操作系统才会真正将缓冲区中数据写到磁盘。

    在实际的使用场景中,我们一般只会用everysec这一配置,对于always,其好处是如果服务器一旦发生宕机,我们最多只会丢失一个事件循环中的写命令,也即最多丢失一次的写命令。但如果写请求过于密集,频繁的IO操作耗时同时缩短磁盘的寿命。对于no,虽然其效率是最高的,文件的同步完全由操作系统控制,但如果redis服务器一旦宕机,我们无法判断上次丢失的aof文件丢失了多少,是什么时候丢失的。

2.3.2 AOF文件载入和数据还原

之前我们在RDB时讲过,如果服务器开启了AOF,则会优先使用aof文件来还原数据库。redis使用aof还原数据库步骤如下:

  • 创建一个不带网络连接的伪客户端
  • 对AOF文件进行分析,并读取一条写命令
  • 通过伪客户端执行被读出的写命令
  • 一直重复步骤2和3直至所有写命令都被处理完

2.3.3 AOF重写

AOF不同于RDB,RDB会某个时刻记录下当前数据库的状态,但AOF会从服务器启动开始便记录所有被执行的写命令。随着服务器运行时间的流逝,AOF文件越来越多,文件体积也越来越大,如果不控制,过大的AOF文件会对Redis服务器有影响同时在数据还原时需要的时间也会更多。

在很多时候,我们会执行一些可以相互抵消的命令,比如

$ set msg hello
$ del key msg;

对于这一情况,redis服务器并没有存储msg键信息,但aof文件却会有两条记录信息。针对上述情,redis提供了AOF重写功能,通过该功能,redis可以创建一个新的AOF来替代原有的AOF文件,新旧两个文件的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令,所 以新AOF文件的体积通常会比旧AOF文件的体积要小得多。

  • AOF文件重写的实现

    AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。

    一般我们会从数据库中读取一个键的值,然后通过一个命令记录键值对,来代替之前对这个键的多条写操作,这就是AOF重写实现的原理。

    比如现在数据库中有一个集合animals,其内有dog,cat,tiger元素,那么就会变为sadd animals "dog" "cat" "tiger"这一条写入语句。

    其整个过程通过伪代码可以定义如下:

    def aof_rewrite(new_aof_file_name):
        # 创建新AOF文件
        f = create_file(new_aof_file_name);
        # 遍历当前redis服务器下所有的db(redis可以包含多个db,一般我们默认用db0)
        for(db in redisServer.db):
            # 忽略空的db
            if(db.is_empty):continue;
            # 先切数据库
            f.write_command("SELECT"+db.id);
            for key in db:
                # 忽略过期的键
                if key.is_expired():continue;
                #根据键的不同类型,调用不同的方法将键值写入AOF文件
                if key.type==String:
                    rewrite_string(key);
                else if key.type==List:
                    rewrite_list(key)
                else if key.type==Hash:
                    rewrite_hash(key)
                else if key.type==Set:
                    rewrite_set(key)
                else if key.type==SortedSet:
                    rewrite_sorted_set(key)
                # 如果键带有过期时间,将过期时间信息也写入
                if key.have_expire_time():
                    rewrite_expire_time(key);
        # 写入完成 关闭文件
        f.close();
        
    def rewrite_string(key):
        #使用GET命令获取字符串键的值
        value = GET(key);
        #使用SET命令重写字符串键
        f.write_command(SET, key, value);
    
    def rewrite_list(key):
        #使用乙皿ZGE命令获取列表键包含的所有元素
        item1, item2 itemN = LRANGE(key, 0, -1)
        #使用RPUSH命令重写列表键
        f.write_command(RPUSH, key, item1, item2,.., itemN)
    
    def rewrite_hash(key):
        #使用HGETALL命令获取哈希键包含的所有键值对
        field1, value1, field2, value2, ...,fieldN, valueN = HGETALL(key)
        #使用HMSET命令重写哈希键
        f.write_command(HMSET, key, field1, value1, field2, value2,...,fieldN,valueN)
        
    def rewrite_set(key);
        #使用SMEMBERS命令获取集合键包含的所有元素
        elem1, elem2, ..., elemN = SMEMBERS(key)
        #使用SADD命令重写集合键
        f.write_command(SADD, key, elem1, elem2,...elemN)
    
    def rewrite_sorted_set(key):
        #使用ZRANGE命令获取有序集合键包含的所有元素
        member1, scorel, member2 score2,...,memberN, scoreN = ZRANGE(key, 0, -1,"WITHSCORES**)
        #使用ZADD命令重写有序集合键
        f.write_command(ZADD, key, score1, member1, score2, member2,...,scoreN,memberN)
                                                                     
    def rewrite_expire_time(key):
        #获取毫秒精度的键过期时间戳
        timestamp = get_expire_time_in_unixstamp(key)
        #使用PEXPTREAT命令重写键的过期时间
        f.write_command (PEXPIREAT, key, timestamp)
    
    

    aof_rewrite函数生成的新AOF文件只包含还原当前数据库状态所必须的命令, 所以新AOF文件不会浪费任何硬盘空间。

    我们知道当redis服务器重启时,会对AOF文件通过一个伪客户端逐条读取AOF的写记录,如果一条写记录过长,可能会造成客户端输入缓冲区溢出的问题,因此实际在进行AOF重写时,会先检查键对应的元素数量,如果元素数量超过redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD 常量的值,那么重写程序将使用多条命令来记录键的值,而不单单使用一条命令。目前版本中,RED1S_AOF_REWRITE_ITEMS_PER_CMD常量的值为64。

2.3.4 AOF后台重写

AOF重写需要进行大量的IO操作,调用AOF重写的线程势必会长时间阻塞,又由于redis采用单个线程来处理redis的命令请求,因此对于AOF的重写,redis将其放到了子进程中执行。其目的为:

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

但在AOF后台重写期间,服务器进程还会继续处理命令请求,接收的请求中可能依然包括写命令,这就需要在AOF重写时记录下这期间的写命令,当AOF重写后再将记录的结果写入AOF文件,以此保证服务器数据状态与AOF文件的一致性。

Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建AOF重写子进程开始后使用,当Redis服务器执行完一个写命令时,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区,这样保证了:

  • AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行
  • 从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里面。

当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:

  • 将AOF重写缓冲区中的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致。
  • 对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两 个AOF文件的替换。

这里可能很多同学有疑问,既然有AOF缓冲区了,那为何还需要一个AOF重写缓冲区?直接通过AOF缓冲区记录写命令,在AOF重写完成后将AOF缓冲区信息写进去不就可以吗?

首先我们需要明确,AOF缓冲区是为了AOF文件写入准备的,在每一个循环事件结束后都会将AOF缓冲区的数据写入到文件后,也即每一次循环事件结束AOF缓冲区内容都会被清空,无法保存多个写命令。其次,在AOF重写期间,我们是需要继续维护原来的AOF文件的,如果AOF后台重写失败,那么原来的AOF文件也需要保证是正常可使用,与数据库当前状态一致的。因此在AOF重写期间,AOF缓冲区其实是没有任何变化的,依然是在一个事件循环中将缓冲区信息写入文件(是写入文件不是磁盘),维护原来的AOF文件的一致性,变的是我们采用AOF重写缓冲区保存AOF重写期间所有的写命令,然后在AOF重写结束后将AOF缓冲区内容写入到新的AOF文件。

注意,对于这种后台子进程做IO操作,主进程通过一段内存来保存子进程操作期间的数据,等子进程结束后再结合保存的数据做到同步这一情况,Redis有多处使用,我们后面讲到的主从复制也是这样一个典型的情况

2.3.5 混合持久化

市面上的书籍很少讲到Redis的混合持久化方案,这是在Redis4.0后引入的新特性。

我们知道RDB形式持久化保存的是RDB快照,RDB采用二进制+数据压缩的形式写入磁盘,文件体积小,数据恢复速度也快,AOF记录的是每一次写命令,数据最全,但文件体积大,数据恢复的慢。

混合持久化就是利用了上述两种持久化各自的优点。

上面我们说在AOF重写时,会先读取出数据库中的每一个键对应的值,然后把它转为写命令,写入到新的AOF文件中。混合持久化就是在这一步有些不同,在AOF重写时,混合持久化会先以RDB的格式在AOF文件中写入一个数据快照,再把这一期间产生的写命令(AOF重写缓冲区的信息),追加到AOF文件中,这样AOF的体积会比以前更小,恢复速度也会更快。也即混合持久化不再是将当前数据库状态转为写命令写入,而是直接以RDB的形式写入,后续的各种写命令不变,依然采用写追加,这样生成的AOF文件前一部分是RDB,后一部分是写命令。

2.4 事件

本章我们将了解大家通常说的Redis单线程是什么意思,以及Redis如何通过单线程来处理那么多客户端请求,这个单线程一共做了哪些事情?另外笔者还会引入Redis6.0后的“多线程”。

Redis服务器是一个事件驱动程序,所谓事件驱动就是说将这个程序所有的功能都划分为事件,每当有一个事件到来时,我们都为这个事件配置一个处理器。

Redis服务器需要处理以下两种事件:

  • 文件事件(file event) : Redis服务器通过套接字与客户端(或者其他Redis服务器) 进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其 他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来 完成一系列网络通信操作。
  • 时间事件(time event) : Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。

换句话说,文件事件是处理网络请求的,比如我们执行set msg hello操作等,而时间事件用于执行一些周期性或定时性的信息,比如我们前面说的过期键的删除,定期清除连接失效的客户端等。

2.4.1文件事件

Redis基于Reactor模型开发了自己的文件事件处理器,不了解Reactor或IO多路复用的同学可以看下todo。

通过IO多路复用,Redis保证了一个线程可以监听多个套接字,每个套接字往往会产生连接(accept),读取(read),写入(write)和关闭(close)等操作(这里很多书籍作者有个问题是会将这些套接字的不同事件统一放在一起说,不区分ServerSocket和ClientSocket。实际上对于accept事件,只有ServerSocket才会出现,而一般对于ClientSocket我们主要只监听read事件,可能还会有write和close)。

image-20220314144059806

上述我们看到IO多路复用程序监听所有的套接字,并且将产生事件(accept,read,write,close等)的套接字交给文件事件分派器处理,文件事件分派器根据不同的事件请求,交由不同的事件处理器处理。

Redis为不同的网络请求编写了不同的处理器:

  • 连接应答处理器

    连接应答处理器主要解决当套接字连接上当前服务器时,即对于ServerSocket触发accept事件时的处理。在这一步中服务器会创建一个客户端的Socket,并且将这个Socket的read事件与命令请求处理器关联,使得下一次在客户端变得可读时,直接调用命令请求处理器(对应于IO多路复用中的accept事件)。

  • 命令请求处理器

    负责从当前客户端的Socket中读取出客户端发过来的请求数据。刚才我们说过,当一个客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端套接字的read事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,套接字就会产生read事件,从而引发命令请求处理器执行,并执行相应的套接字读入操作(对应于IO多路复用中的read事件)。

  • 命令回复处理器

    负责将服务器执行命令后的回复通过套接字写给客户端,当服务器需要将回复传给客户端时,会将客户端的套接字与write事件关联,当客户端套接字准备好收服务器返回的信息时,就会触发一个write事件,从而引发命令回复处理器,并执行相应的套接字写入操作(对应于IO多路复用中的write事件)。(在大部分的情况下,我们是不监听客户端Socket的可写事件的,一般我们默认认为客户端的Socket都是可写入的,但有时可能因为某些原因,比如网卡阻塞等,客户端的并不是一直都是可写入状态的,因此Redis在这里只有可写入时才会执行写入操作)

因此一次完整的客户端与服务端的请求过程如下:

假设一个Redis服务器正在运作,那么这个服务器的监听套接字的accept事件,而该事件所对应的处理器为连接应答处理器。如果这时有一个Redis客户端向服务器发起连接,那么监听套接字将产生accept事件,触发连接应答处理器执行。处理器会对客户端的连接请求进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的read事件与命令请求处理器进行关联,使得客户端可以向主服务器发送命令请求。

之后,假设客户端向主服务器发送一个命令请求,那么客户端套接字将产生read事件,引发命令请求处理器执行,处理器读取客户端的命令内容,然后传给相关程序去执行。执行命令将产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的write事件与命令回复处理器进行关联。

当客户端尝试读取命令回复的时候, 客户端套接字将产生write事件,触发命令回复处理器执行,命令回复处理器将命令回复全部写入到套接字。之后,服务器就会解除客户端套接字的write事件与命令回复处理器之间的关联。

2.4.2 时间事件

时间事件分为两类:定时和周期性事件。

对于一个事件,其主要为:id(事件的id),when(事件执行的时间)和具体的处理函数。

redis将所有的时间事件都放在一个无序链表中,每当时间事件执行时,会遍历整个链表,查找当前已经到达的可执行的事件,并执行相应的事件处理器。

image-20220314152021331

这里我们需要注意的是Redis保存时间事件的链表是无序链表,也即不是按事件的到达时间排序的,并不会把最近的事件放在链表表头,因此时间事件处理器需要遍历整个链表查找可执行的事件。

如果对数据结构和算法敏感的同学,可能会立马得出上面是一个时间复杂度为O(n)的查找。但Redis之所以那么设计是因为上述链表中并没有很多节点(实际上只有一个节点),在链表节点比较少的情况下,遍历链表而不是维护链表的有序性可能效率会更高。

对于Redis时间事件,其伪代码如下:

def processTimeEvents():
    #遍历服务器中的所有时间事件
    for time_event in all_time_event():
        #检查事件是否已经到达
        if time_event.when <= unix_ts_now():
            #事件已到达
            #执行事件处理器,并获取返回值 
            retval = time_event.timeProc()
            #如果这是一个定时事件
            if retval == AE_NOMORE:
                #那么将该事件从服务器中删除 
                delete_time_event_from_server(time_event)
            #如果这是一个周期性事件
            else:
            #那么按照事件处理器的返回值更新时间事件的when属性
            #让这个事件在指定的时冋之后再次到达
            update_when(time_event, retval)

刚才我们说过,无序链表中实际只有一个节点,就是redis中出名的serverCron函数,在Redis2.6版本,服务器默认规定serverCron每秒运行10次,平均每间隔100毫 秒运行一次。从Redis2.8开始,用户可以通过修改hz选项来调整serverCron的每秒执行次数, 具体信息请参考示例配置文件redis.conf关于hz选项的说明。

serverCron函数主要工作内容包括:

  • 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
  • 清理数据库中的过期键值对。
  • 关闭和清理连接失效的客户端。
  • 尝试进行AOF或RDB持久化操作。
  • 如果服务器是主服务器,那么对从服务器进行定期同步。
  • 如果处于集群模式,对集群进行定期同步和连接测试。

2.4.3 Redis服务器的调度与执行

我们刚才介绍了Redis的文件事件和时间事件,另外我们知道Redis是单线程的,事实上就是Redis服务器在这个单线程中,对两个事件进行了调度,决定何时进行文件事件何时进行时间事件,其伪代码如下:

def aeProcessEvents ():
    #获取到达时间离当前时间最接近的时间事件
    time_event = aeSeachNearestTimer()
    #计算最接近的时间事件距离到达还有多少毫秒
    remaind_ms = time_event.when - unix_ts_now()
    #如果事件巳到达,那么remaind_ms的值可能为负数,将它设定为0
    if remaind_ms < 0: remaind_ms = 0
    #根据remaind_ms的值,创建timeval结构
    timeval = create_timeval_with_ms(remaind_ms)
    #阻塞并等待文件事件产生,最大阻塞时间由传入的timeval结构决定
    #如果remaind_ms的值为0,那么aeApiPoll调用之后马上返回,不阻塞
    aeApiPoll(timeval) 
    #处理所有巳产生的文件事件
    processFileEvents()
    #处理所有已到达的时间事件 
    processTimeEvents()

上述流程比较简单,我们可以从以下两个情况讨论,在调用aeSeachNearestTimer()得到的最新的一次时间事件已经到达,即timeval小于等于0,那么aeApiPoll(timeval)不会阻塞,函数会直接执行当前时间下的文件事件和时间事件。

如果最新一次时间事件还未到达,即timeval大于0,线程会在aeApiPoll(timeval)处做一个等待,等待最长时间是最新一次时间事件的到达时间,因此如果在等待期间产生了文件事件会立即返回,直接处理这个文件事件,如果一直等待到timeval还未有文件事件,此时已经到了最新一次的时间事件的可执行时间,因此会执行时间事件。

另外,上述函数还需要循环执行,加上服务器的初始化和服务器关闭后的清理操作,就构成了Redis服务器的主函数,其伪代码如下:

def main():
    # 初始化服务器
    init_server()
    # 一直处理事件,直至服务器关闭
    while sever_is_not_shutdown():
        aeProcessEvents()
    # 服务器关闭,清空一些数据
    clean_server()

这一过程也就是我们之前在AOF中讲到的

redis服务器进程是一个事件循环,这个事件循环中分为文件事件和时间事件,文件事件负责接收客户端请求,时间事件执行一些定时运行的函数 。

2.4.4 Redis多线程

关于redis6.0后的“多线程”,这里有一篇文章,感兴趣的读者可以自行查阅:Redis6.0的多线程究竟是咋回事? - 知乎 (zhihu.com)

2.5 redis客户端

Redis服务器往往可以同时连接多个客户端,在Redis中客户端clients属性是一个链表,也即:

struct redisServer{
    // ...
    // 一个链表,保存了所有客户端状态
    list * clients;
    // ...
};

其中list中的每一个元素都对应一个redisClient

image-20220614210906005

redisClient中的属性比较多,比如:

  • 客户端的套接字描述符
  • 客户端的名字
  • 客户端的标志值(flag)
  • 指向客户端正在使用的数据库的指针,以及该数据库的号码
  • 客户端当前要执行的命令、命令的参数、命令参数的个数,以及指向命令实现函数的指针
  • 客户端的输入缓冲区和输出缓冲区
  • 客户端的复制状态信息,以及进行复制所需的数据结构
  • 客户端执行、BRPOPBLPOP等列表阻塞命令时使用的数据结构
  • 客户端的事务状态,以及执行WATCH命令时用到的数据结构
  • 客户端执行发布与订阅功能时用到的数据结构
  • 客户端的身份验证标志
  • 客户端的创建时间,客户端和服务器最后一次通信的时间,以及客户端的输出缓冲区大小超出软性限制(softlimit)的时间

上述属性中部分信息我们已经知道,部分还未涉及,后面我们会讲到,本节也会讲部分上述属性。

  1. 套接字描述符

    redisClient的fd来记录套接字描述符。

    typedef struct redisClient {
        //...
        int fd;
        // ...
    } redisClient;

    客户端往往也分为伪客户端和普通客户端,伪客户端处理的命令请求来源于AOF 文件或者Lua脚本,而不是网络,所以这种客户端不需要套接字连接,自然也不需 要记录套接字描述符,因此伪客户端的fd是-1。

    普通客户端的fd属性的值为大于-1的整数。

  2. 输入缓冲区

    客户端状态的输入缓冲区用于保存客户端发送的命令请求,输入缓冲区的大小会根据输入内容动态地缩小或者扩大,但它的最大大小不能超过 1GB,否则服务器将关闭这个客户端。

  3. 命令与命令参数

    在服务器将客户端发送的命令请求保存到客户端状态的输入缓冲后,服务器将对命令请求的内容进行分析,并将得岀的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性:

    typedef struct redisClient {
        //...
        robj **argv;
        int argc;
        //...
    } redisClient;

    argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数。argc属性则负责记录argv数组的长度。

    如,当我们执行

    > set key value

    argv与argc状态如下:

    image-20220614213426394

  4. 命令的实现函数

    针对不同的数据结构与操作内容,往往会有不同的命令,如SET,GET,LRANGE

    每个命令在Redis中均有一个命令实现函数,Redis采用字典这一数据结构来存储,其中Key为命令,Value为redisCommand结构,redisCommand结构保存了命令的实现函数、命令的标志、命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息。

    image-20220614213844727

    Redis服务器通过上一步分析出的argv[0]得到命令,再查询字典得到这个命令对应的redisCommand结构,将其保存起来:

    typedef struct redisClient {
        // ...
        struct redisCommand *cmd;
        // ...
    } redisClient;

    后续执行指令时,服务器就可以使用cmd属性所指向的redisCommand结构,以及argv、argc 属性中保存的命令参数信息,调用命令实现函数。

  5. 输出缓冲区

    执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面,每个客户端都有两个输出缓冲区可用,一个缓冲区的大小是固定的,另一个缓冲区的大小是可变的。

    • 固定缓冲区用于保存那些长度比较小的回复,比如OK、简短的字符串值、整 数值、错误回复等等。
    • 可变大小的缓冲区用于保存那些长度比较大的回复,比如一个非常长的字符串值, 一个由很多项组成的列表,一个包含了很多元素的集合等等。
  6. 身份验证

    Redis服务端可以设置密码,这样客户端在连接时就需要身份验证。客户端状态的authenticated属性用于记录客户端是否通过了身份验证:

    typedef struct redisClient {
        // ...
        int authenticated;
        // ...
    } redisClient;

    如果authenticated的值为0,那么表示客户端未通过身份验证;如果authenticated的值为1,那么表示客户端已经通过了身份验证。

  7. 时间

    最后,客户端还有几个和时间有关的属性:

    typedef struct redisClient {
        //...
        time_t ctime;
        time_t lastinteraction;
        time_t obuf_soft_limit_reached_time;
        // ...
    } redisClient;
    • ctime属性记录了创建客户端的时间,这个时间可以用来计算客户端与服务器已经连 接了多少秒
    • lastinteraction属性记录了客户端与服务器最后一次进行互动(interaction)的时间, 这里的互动可以是客户端向服务器发送命令请求,也可以是服务器向客户端发送命令回复。lastinteraction属性可以用来计算客户端的空转(idle)时间,也即是,距离客户端与服务器最后一次进行互动以来,已经过去了多少秒
    • obuf_soft_limit_reached_time属性记录了输出缓冲区第一次到达软性限制 (soft limit)的时间

2.6 redis服务端

本章我们将主要从三点讲解Redis服务器:

  • 当我们向服务器发送一条命令请求并收到服务器的响应,这期间服务器做了什么?
  • 时间事件中的serverCron函数都做了什么?
  • 服务器在初始化启动的时候都做了什么?

2.6.1 命令请求执行过程

当我们发送这条指令时,对于服务端其大体执行了如下流程:

  1. 读取收到的指令保存到客户端的输入缓冲区中
  2. 对输入缓冲区中的命令进行解析,得到命令与参数等信息
  3. 调用命令对应的命令执行器执行命令
  4. 将执行结果返回给客户端

我们以发送一条如下指令为例来详细讲解上述流程:

$ set key value

首先我们在2.5节中已经讲了,每个客户端都会有一个输入缓冲区,客户端请求的信息会被先放到输入缓冲区中

然后服务器会解析输入缓冲区中的命令,主要包括设置解析出命令和命令参数,这些信息会被赋给redisClient的argv和argc字段,这一信息2.5节我们也说过。

比较复杂和核心的内容是查找命令实现器,以及命令实现器的执行过程,命令实现器的流程如下:

  1. 查找命令实现器

    服务器会根据argv[0]从字典中查找命令实现器,一个命令实现器就是一个redisCommand结构。redisCommand的各主要属性如下:

    属性名类型作用
    namechar *命令的名字,比如set
    procredisCommandProc *函数指针,指向命令的实现函数,比如setCommand。 redisCoinmandProc 类型的定义为 typedef void redisCo mmandProc(redisClient *c);
    arityint命令参数的个数,用于检查命令请求的格式是否正确。如 果这个值为负数-N,那么表示参数的数量大于等于N。注意 命令的名字本身也是一个参数,比如说SET msg hello world命令的参数是"SET", "msg". hello world", 而不仅仅是"msg"和"hello world"
    sflagschar *字符串形式的标识值,这个值记录了命令的属性,比如这个 命令是写命令还是读命令,这个命令是否允许在载人数据时使 用,这个命令是否允许在Lua脚本中使用等等
    flagsint对sflags标识进行分析得出的二进制标识,由程序自动生 成。服务器对命令标识进行检查时使用的都是flags属性而不 是sflags属性,因为对二进制标识的检查可以方便地通过位操作来完成
    callslong long服务器总共执行了多少次这个命令
    millisecondslong long服务器执行这个命令所耗费的总时长
  2. 执行前的预备操作

    预备操作主要是在执行前的一些异常校验,类似于拦截器的功能,包括:

    • 检査客户端状态的cmd指针是否指向NULL,如果是的话,那么说明用户输入的 命令名字找不到相应的命令实现,服务器不再执行后续步骤,并向客户端返回一个 错误。
    • 根据客户端cmd属性指向的redisCommand结构的arity属性,检査命令请求 所给定的参数个数是否正确,当参数个数不正确时,不再执行后续步骤,直接向客户端返回一个错误。比如说,如果redisCommand结构的arity属性的值为-3, 那么用户输入的命令参数个数必须大于等于3个才行。
    • 检查客户端是否已经通过了身份验证。
    • 如果服务器打开了maxmemory功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存回收,从而使得接下来的命令可以顺利执行。如果内存回收失败,那么不再执行后续步骤,向客户端返回一个错误。
    • 如果服务器上一次执行BGSAVE命令时出错,并且服务器打开了 stop-writes-on-bgsave-error功能,而且服务器即将要执行的命令是一个写命令,那么服务 器将拒绝执行这个命令,并向客户端返回一个错误。
    • 如果客户端当前正在用SUBSCRIBE命令订阅频道,或者正在用PSUBSCRIBE命令订阅模式,那么服务器只会执行客户端发来的SUBSCRIBEPSUBSCRIBEUNSUBSCRIBEPUNSUBSCRIBE四个命令,其他命令都会被服务器拒绝。
    • 如果服务器正在进行数据载入,那么客户端发送的命令必须带有1标识(比如 INFOSHUTDOWNPUBLISH等等)才会被服务器执行,其他命令都会被服务器拒绝。
    • 如果服务器因为执行Lua脚本而超时并进入阻塞状态,那么服务器只会执行客户端发来的SHUTDOWN nosave命令和SCRIPT KILL命令,其他命令都会被服务器拒绝。
    • 如果客户端正在执行事务,那么服务器只会执行客户端发来的EXEC、DISCARD、 MULTI、WATCH四个命令,其他命令都会被放进事务队列中。
    • 如果服务器打开了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器。当完成了以上预备操作之后,服务器就可以开始真正执行命令了。
  3. 调用命令实现函数

    找到了命令实现函数,也得到了参数,并且校验了异常,那么直接调用函数,将参数传进去即可。函数的执行结果会被保存到客户端的输出缓冲区中,同时还会为客户端的套接字关联命令回复处理器,这个处理器负责将信息返回给客户端

  4. 执行完命令的后续工作

    命令执行完后,还需要一些后续的工作,包括:

    • 如果服务器开启了慢査询日志功能,那么慢査询日志模块会检査是否需要为刚刚执行完的命令请求添加一条新的慢査询日志。
    • 根据刚刚执行命令所耗费的时长,更新被执行命令的redisCommand结构的 milliseconds属性,并将命令的redisCommand结构的calls计数器的值增一。
    • 如果服务器开启了 AOF持久化功能,那么AOF持久化模块会将刚刚执行的命令请求写入到AOF缓冲区里面。
    • 如果有其他从服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传 播给所有从服务器。

上述流程全部完成后,为客户端套接字关联的命令回复处理器会在客户端套接字变为可写状态时,将输出缓冲区中的信息写回给客户端。

2.6.2 serverCron函数

我们在第2.4节事件中已经讲了Redis的时间事件,目前Redis时间事件只执行了一个函数就是serverCron,本节就重点说下serverCron函数的执行内容。

  1. 更新服务器时间缓存

    Redis中会有很多地方频繁的用到当前系统时间,如果每次获取系统时间都执行一次系统调用是很费时的。因此为降低系统时间的获取频率,Redis采用轮询获取系统时间,并将获得的时间缓存起来。

    默认情况下serverCron函数每100ms执行一次,也即每100ms更新一下时间,这个时间精度是很低的,因此Redis下一些对于精度要求不高的操作会直接获取缓存内的时间,如日志打印、更新服务器的LRU时钟、决定是否执行持久化任务、计算服务器上线时间等,一些都时间精度要求比较高的指令,如设置键过期还是会直接进行系统调用。

  2. 更新LRU时钟

    Redis服务器中有个lruclock属性,它保存了当前服务器的时钟

    struct redisServer {
        //...
        unsigned lruclock:22;
    };

    这个信息会被serverCron函数会每10s更新一次

    当Redis服务器内存占用过大时,Redis有时会更新内存,对内存的一些信息进行删除,删除的算法就是LRU算法。LRU算法执行的时候需要获得当前Redis对象的最后一次被访问时间,因此每个Redis对象上都会有个lru属性记录这一信息。

    typedef struct redisObject{
        unsigned lru:22;
    } robj;

    借助redisServer中的LRU时钟和redisObject中的LRU时钟就可以轻松算出数据库键的空转时间(未被访问的时间),也就可以更好的执行LRU算法,删除空转过久的数据库键。

  3. 更新服务器每秒执行指令次数

    trackOperationsPerSecond函数是用来统计并更新当前服务器每秒执行的指令数,它会每100ms被serverCron函数调用一次。

    trackOperationsPerSecond的实现如下:

    首先redisServer内会保存上一次执行trackOperationsPerSecond时的时间,同时保存上一次trackOperationsPerSecond执行时Redis服务器执行的总共的指令数和当前Redis服务器执行的指令数

    通过

    (当前执行的指令数 - 上一次统计时执行的指令数)/(当前时间-上一次执行时间) 可以得到本次平均每ms Redis服务器执行的指令数,将这个值乘以1000,就是每秒Redis服务器 执行的指令数。

    其次redisServer内保存着一个环形数组,数组默认长度16,数组的每个元素代表着1s钟Redis执行的指令数。trackOperationsPerSecond计算出每秒Redis服务器执行的指令数后会将结果设置进数组。由于是环形数组,所以会有个index指向本次更新需要更新的数组元素位置,更新完后index++;当index等于16时,重新设index为0。

    当我们通过INFO命令查询Redis服务器每秒执行的指令数时,Redis服务器会将上述环形数组加起来/环形数组长度,即取平均值。

  4. 更新服务器内存峰值

    Redis服务器内会有一个stat_peak_memory属性记录了服务器的内存峰值大小

    struct redisServer{
        // ...
        //已使用内存峰值
        size_t stat_peak_memory;
        // ...
    };

    每次serverCron函数执行时都会获得当前服务器的内存使用情况并于stat_peak_memory属性对比,如果大于stat_peak_memory就更新。客户端可以通过INFO memeory指令查看当前Redis的内存使用情况。

  5. 处理SIGTERM信号

    SIGTERM信号即终止服务器运行的信号,当服务端收到这个信号后,会将服务器内的shutdown_asap属性设置为1

    struct redisServer {
        // ...
        //关闭服务器的标识
        //值为1时,关闭服务器
        //值为0时,不做动作
        int shutdown_asap;
    };

    serverCron函数每次执行时会检查shutdown_asap状态,如果shutdown_asap等于1,会关闭Redis服务器,这种情况下关闭的Redis服务器在关闭前会做一些其他操作,如RDB持久化。

  6. 管理客户端资源

    • 如果客户端与服务器之间的连接已经超时(很长一段时间里客户端和服务器都没有互动),那么服务器释放这个客户端。
    • 如果客户端在上一次执行命令请求之后,输入缓冲区的大小超过了一定的长度,那 么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区, 从而防止客户端的输入缓冲区耗费了过多的内存。
  7. 管理数据库资源

    定期删除数据库中的过期键或对字典数据结构做收缩操作。

  8. 执行延迟的BGREWRITEAOF

    我们在2.2节RDB时讲过,如果服务器在执行BGSAVE时收到BGREWRITEAOF,服务器会将BGREWRITEAOF命令的执行时间延迟到BGSAVE命令执行完毕之后。Redis服务器中aof_rewrite_scheduled这一属性记录了这一信息

    struct redisServer{
    
        // ...
        //如果值为1,那么表示有BGREWRITEAOF命令被延迟了
        int aof rewrite scheduled;   
    };

    因此serverCron函数每次执行时会检查aof_rewrite_scheduled,如果aof_rewrite_scheduled等于1,那么会执行被推迟的BGREWRITEAOF

  9. 检查持久化操作状态

    我们知道BGSAVEBGREWRITEAOF指令都会由一个子进程来完成,Redis服务器内会包含rdb_child_pid和aof_child_pid两个属性,这两个属性用于记录响应子进程的pid。

    struct redisServer{
    // ...
    //记录执行BGSAVE命令的子进程的ID:
    //如果服务器没有在执行BGSAVE,
    //那么这个属性的值为-1。
    pid_t rdb_child_pid;
    //记录执行BGREWRITEAOF命令的子进程的ID。:
    //如果服务器没有在执行BGREWRITEAOF,
    //那么这个属性的值为-1
    pid_t aof_child_pid;
    // ...
    };

    每次serverCron函数执行时,都会检查rdb_child_pid和aof_child_pid 两个属性的值,检查情况分为如下两种:

    • 至少有一个pid不等于-1(实际最多只会有一个,BGSAVEBGREWRITEAOF不会同时进行)

      此时代表有持久化操作,serverCron函数会检查子进程是否有向主进程发来信号,如果发来信号代表持久化完成,此时服务器就需要做一些善后工作,如替换RDB文件,替换AOF文件等。否则就什么都不做。

    • 两个pid都等于-1

      这种情况下代表服务器当前没有持久化操作,serverCron函数首先会检查是否有BGREWRITEAOF的延迟执行,如果有就执行BGREWRITEAOF,否则检查是否满足RDB的持久化保存条件,如果是就执行BGSAVE,否则检查是否满足AOF的重写,如果是就执行BGREWRITEAOF,否则不做任何事。

      image-20220615205436559

  10. 将AOF缓存区的内容写入到AOF文件

    AOF一节中讲过。

2.6.3 服务器初始化

服务器初始化时会执行很多内容,我们这里只讲一下重点的内容

  1. 载入配置项

    我们可以在终端中直接输入配置,如:

    $ redis-server --port 10086 

    还可以直接指定配置文件启动,如:

    $ redis-server redis.conf

    无论哪种方式,上述配置信息都会在启动时载入并生效。

  2. 还原数据库状态

    载入RDB或AOF文件来还原数据库的状态。如果开启AOF就用AOF文件,否则就用RDB文件。

  3. 执行事件循环

3. 多机数据库

3.1 复制

市面上大部分的数据库都支持主从复制的功能,Redis也不例外。对于主从复制其主要解决了两个问题:

  1. 如果主节点数据库发生宕机,我们可以手动将从节点提升为主节点继续提供服务,缩短服务器的不可用时间,这也是Redis哨兵模式的基础。
  2. 提升性能。让从服务器分担一部分读请求。或者做到读写分离,主服务器用来写入而从服务器用来读取,可以提升服务器的整体性能。

在Redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(replicate)另一个服务器,我们称呼被复制的服务器为主服务器(master),而对主服务器进行复制的服务器则被称为从服务器(slave)。进行复制的主从服务器中的数据库将保存相同的数据,这一概念称为数据库状态一致

Redis在2.8版本以前使用的是旧版复制功能,而从2.8版本开始使用的是新版的复制功能,相比于旧版,新版主要解决了频繁断线重连的问题。我们先从旧版复制讲起,看看Redis是如何实现SLAVEOF功能的

3.1.1 旧版复制

Redis的复制分为同步和命令传播两个阶段

  • 同步

    image-20220315090754446

    其流程如上图所示,具体步骤如下:

    1) 从服务器向主服务器发送SYNC命令。

    2) 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令

    3) 当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态。

    4) 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。

    举个例子如下:

    时间主服务器从服务器
    T0服务器启动服务器启动
    T1执行 SET k1 v1
    T2执行 SET k2 v2
    T3执行 SET k3 v3
    T4 向主服务器发送SYNC命令
    T5接收到从服务器发来的SYNC命令,执行BGSAVE命令, 创建包含键k1、k2、 k3的RDB文件,并使用缓冲区记录接下来执行的所有写命令
    T6执行SET k4 v4,并将这个命令记录到缓冲区里面
    T7执行SET k5 v5,并将这个命令记录到缓冲区里面
    T8BGSAVE命令执行完毕,向从服务器发送RDB文件
    T9 接收并载入主服务器发来的RDB文 件,获得k1、k2、k3三个键
    T10向从服务器发送缓冲区中保存的写命令SET k4 v4和 SET k5 v5
    T11 接收并执行主服务器发来的两个SET 命令,得到k4和k5两个键
    T12同步完成,现在主从服务器两者的数据库都包含了键 k1、k2、k3、k4 和 k5同步完成,现在主从服务器两者的 数据库都包含了键k1、k2、k3、k4 和k5

    我们在AOF重写时也提

    对于这种后台子进程做IO操作,主进程通过一段内存来保存子进程操作期间的数据,等子进程结束后再结合保存的数据做到同步这一情况,Redis有多处使用。

    这里我们又看到这一用法。

  • 命令传播

    在主服务器执行客户端发送的写命令时,主服务器的数据库就有可能会被修改。主服务器需要对从服务器执行命令传播操作:主服务器会将自己执行的写命令,也即是造成主从服务器不一致的那条写命令,发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态

    上述就是Redis旧版功能的实现,但我们想象一下如果从服务器与主服务器间网络不是那么好,可能会频繁的掉线重连,对于这种情况可能就是:

    时间主服务器从服务器
    T0主从服务器完成同步主从服务器完成同步
    T1执行并传播SET k1 v1执行主服务器传来的SET k1 v1
    T2执行并传播SET k2 v2执行主服务器传来的SET k2 v2
    .........
    T10085执行并传播SET k10085 V10085执行主服务器传来的SET k10085 V10085
    T10086执行并传播SET k10086 V10086执行主服务器传来的SET k10086 V10086
    T10087主从服务器连接断开主从服务器连接断开
    T10088执行 SET k10087 V10087断线中,尝试重新连接主服务器
    T10089执行 SET k10088 V10088断线中,尝试重新连接主服务器
    T10090执行 SET k10089 V10089断线中,尝试重新连接主服务器
    T10091主从服务器重新连接主从服务器重新连接
    T10092 向主服务器发送SYNC命令
    T10093接收到从服务器发来的SYNC命令,执行BGSAVE命 令,创建包含键k1至键k10089的RDB文件,并使用缓冲区记录接下来执行的所有写命令
    T10094BGSAVE命令执行完毕,向从服务器发送RDB文件
    T10095 接收并载入主服务器发来的RDB文件, 获得键k1至键k10089
    T10096因为在BGSAVE命令执行期间,主服务器没有执行任何写命令,所以跳过发送缓冲区包含的写命令这一步
    T10097主从服务器再次完成同步主从服务器再次完成同步

    可以看到每次主从服务器重连成功后,主服务器都需要再次执行BGSAVE,并将RDB文件发给从服务器,如果主服务器当前数据库保存数据量比较大,则需要频繁的IO操作(磁盘IO和网络IO)。另外如果像上例流程,由于从服务器当前已经保存了k1~k10086的数据,丢失的只是k10087~k10089的数据。也许每次重连后并不需要完全重新执行一遍复制流程,因此这也是新版复制功能解决的问题点。

3.1.2 新版复制

Redis2.8版本后通过PSYNC来代替SYNC,其中p代表partial即部分的意思。PSYNC具有完全重同步和部分重同步的功能

  • 完全重同步:与我们刚才介绍的SYNC相同,主服务器向从服务器发送BGSAVE后的RDB文件,并将缓冲区的内容也发给从服务器
  • 部分重同步:对于一些短时间的断线重连情况,从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态

可以看到部分重同步主要可以做到将主从断开期间的指令保存并在重连后发给从服务器。

部分重同步的实现主要由三个部分构成:复制偏移量、复制积压缓冲区和服务器运行ID

  • 复制偏移量

    在主从服务器复制初始时,主服务器会向从服务器发送一个复制偏移量,这个偏移量由主从服务器分别维护。在命令传播阶段,每次主服务器向从服务器传播N个字节数据时,就会将自己的复制偏移量+N,同时从服务器接收到主服务器传播来的N个字节时,从服务器也会将自己的复制偏移量+N

    因此我们可以通过对比主从服务器的复制偏移量,程序可以很容易的知道主从服务器是否处于一致状态。

  • 复制积压缓冲区

    复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为1MB。

    当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里面,也即主服务器的复制积压缓冲区里面会保存着一部分最近传播的写命令,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量,例如下表:

    偏移量...1008710088100891009010091100921009310094100951009610097...
    字节值...'*'3'\r''\n''$'3'\r''\n''S''E''T'...

    当从服务器重新连上主服务器时,从服务器会通过PSYNC命令将自己的复制偏移量(offset)发送给主服务器,主服务器会根据这个复制偏移量来决定对从服务器执行何种同步操作:

    • 如果offset偏移量之后的数据(也即是偏移量offset+1开始的数据)仍然存在于复制积压缓冲区里面,那么主服务器将对从服务器执行部分重同步操作。
    • 相反,如果offset偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器将对从服务器执行完整重同步操作。
  • 服务器ID

    每个Redis服务器,不论主服务器还是从服务器,都会有自己的运行ID,这个ID由服务器在启动时自动生成,由40个随机16进制字符组成(类似于UUID)。

    当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器则会将这个运行ID保存起来。

    当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行ID:

    • 如果从服务器保存的运行ID和当前连接的主服务器的运行ID相同,那么说明从服务器断线之前复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作。
    • 相反地,如果从服务器保存的运行ID和当前连接的主服务器的运行ID并不相同, 那么说明从服务器断线之前复制的主服务器并不是当前连接的这个主服务器,主服务器将对从服务器执行完整重同步操作。

综上,PSYNC的命令实现流程为:

  • 如果从服务器以前没有复制过任何主服务器,或者之前执行过SLAVEOF no one 命令,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC ? -1命令, 主动请求主服务器进行完整重同步(因为这时不可能执行部分重同步)。
  • 相反地,如果从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复 制时将向主服务器发送PSYNC 命令:其中runid是上一次 复制的主服务器的运行ID,而offset则是从服务器当前的复制偏移量,接收到这个命令的主服务器会通过这两个参数来判断应该对从服务器执行哪种同步操作。

根据情况,接收到PSYNC命令的主服务器会向从服务器返回以下三种回复的其中一种:

  • 如果主服务器返回+FULLRESYNC 回复,那么表示主服务器将与从服务器执行完整重同步操作:其中runid是这个主服务器的运行ID,从服务器会将这个ID保存起来,在下一次发送PSYNC命令时使用;而offset则是主服务器当前的复制偏移量,从服务器会将这个值作为自己的初始化偏移量。
  • 如果主服务器返回+CONTINUE回复,那么表示主服务器将与从服务器执行部分重同步操作,从服务器只要等着主服务器将自己缺少的那部分数据发送过来就可以了。
  • 如果主服务器返回-ERR回复,那么表示主服务器的版本低于Redis 2.8,它识别不 了 PSYNC命令,从服务器将向主服务器发送SYNC命令,并与主服务器执行完整同步操作。

    image-20220315100955644

3.1.3 复制功能的实现

了解了新旧复制后,我们再来看下整个复制功能的实现:

  1. 从服务器通过SLAVEOF命令向主服务器发送复制请求

    $ SLAVEOF <master_ip> <master_port>

    在从服务器发送上述命令时,会将主服务器的IP和端口保存起来

    struct redisServer {
        // ...
        //主服务器的地址 
        char *masterhost;
        //主服务器的端口 
        int masterport;
        // ...
    };
  2. 建立套接字

    从服务器会根据刚才设置的IP和端口,创建连向主服务器的套接字,这个套接字如果创建成功,从服务器会为这个套接字关联一个专门用于处理复制工作的文件事件处理器。这个处理器将负责执行后续的复制工作,比如接收RDB文件,以及接收主服务器传播来的写命令,诸如此类。同理主服务器在接到从服务器的连接(accept)时,会将从服务器看作是自己的一个客户端。

  3. 发送PING命令

    套接字连接成功后,从服务器会向主服务器发一条PING命令,其主要有两个作用:

    • 虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信,通过发送PING命令可以检査套接字的读写状态是否正常。
    • 因为复制工作接下来的几个步骤都必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送PING命令可以检查主服务器能否正常处理命令请求。

    同样主服务器也会对从服务器的PING命令进行返回,返回情况如下:

    • 如果主服务器向从服务器返回了一个命令回复,但从服务器却不能在规定的时限 (timeout)内读取岀命令回复的内容,那么表示主从服务器之间的网络连接状态不佳,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
    • 如果主服务器向从服务器返回一个错误,那么表示主服务器暂时没办法处理从服务 器的命令请求,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
    • 如果从服务器读取到"PONG" 回复,那么表示主从服务器之间的网络连接状态正常, 并且主服务器可以正常处理从服务器(客户端)发送的命令请求,在这种情况下,从服务器可以继续执行复制。
  4. 身份验证

    有时主服务器具有密码保护的功能,此时从服务器在复制的时候就需要身份验证。如果主服务器配置文件中设置了requirepass选项,从服务器的masterauth需要设置同样的密码,保证从服务器可以通过主服务器的身份验证。从服务器会通过AUTH命令发送masterauth中配置的密码。

    关于masterauth在redis.conf中其注释如下:

    # If the master is password protected (using the "requirepass" configuration
    # directive below) it is possible to tell the slave to authenticate before
    # starting the replication synchronization process, otherwise the master will
    # refuse the slave request.

    requirepass在redis.conf中注释如下:

    # Warning: since Redis is pretty fast an outside user can try up to
    # 150k passwords per second against a good box. This means that you should
    # use a very strong password otherwise it will be very easy to break.

    可以看到在redis建议大家设置一个比较复杂的密码,避免被暴力破解。

  5. 同步

    这一步也就是我们刚才说的PSYNC。另外我们刚才一直说从服务器是主服务器的客户端,但是在这一步,主服务器还会是从服务器的客户端,这里主要有几个目的:

    • 对于完全重同步,主服务器需要将BGSAVE时保存在缓冲区中的数据发给从服务器
    • 对于部分重同步,主服务器需要将积压缓冲区内对应偏移量的指令发给从服务器
    • 后续的命令传播,在主服务器接到一条影响数据库状态的写指令后,会将这条命令传播到从服务器
  6. 命令传播

    主服务器接到一条影响数据库状态的写指令后,会将这条命令传播到从服务器。

3.1.4 心跳

想象这样一种情况,如果主服务器在向从服务器传播命令时,信息并没有传达到,那么就会造成主从的数据不一致性。Redis通过心跳机制,保证了主从服务器数据的一致性:

Redis的心跳是由从服务器主动向主服务器发起的命令,这个命令1s钟发送一次

REPLCONF ACK <replication_offset>

这个命令主要有三个作用:

  • 检测主从服务器的网络连接状态

    如果主服务器超过1s钟没有收到从服务器发送的心跳包,我们就可以认为主从之间连接出现了问题。主服务器会为每个从服务器维护一个lag值,该值记录了从服务器上一次发送心跳距现在的时间,单位为秒。

  • 辅助实现min-slaves选项

    如果主服务器下多个从节点挂掉,可能会触发一定的安全保护机制。如:

    min-slaves-to-write 3
    min-slaves-max-lag 10

    上面配置是在说如果可写的从服务器数量小于3或者超过三个从服务器的延迟时间大于10秒,那么主服务器就会开启保护,拒绝执行写入操作。

  • 检测命令丢失

    从服务器向主服务器发送心跳包时,会将自己的复制偏移量一起提交,如果主服务器发现从服务器的复制偏移量小于自己的复制偏移量,那么主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。

笔者注:

通过复制偏移量来监测丢失的指令,然后重发丢失指令,看起来很好但实际可能有bug。假设此时主从之间都维护一个偏移量为1000,在1s钟主服务器向从服务器传播了三条写命令,每条写命令20字节,则此时主服务器偏移量1060,但第二条写命令丢失,此时从服务器的偏移量1040。这时一次心跳发送,主服务器检测到从服务器的偏移量是1040,会将1040-1060的偏移量字节发给从服务器,也即第三条指令。但实际上从服务器缺失的是第二条指令,这样主从的数据可能会不一致。

3.2 Sentinel

在介绍主从的时候,和大家说了主从一大好处是如果主节点数据库发生宕机,我们可以手动将从节点提升为主节点继续提供服务,缩短服务器的不可用时间。哨兵模式就是在此基础上推出的。

想象一下,如果主服务器在某个时间挂了,那我们如何才能知道呢?很可能在某个功能发现不好使之后才知道,在发现后我们还需要手动的切换主节点,让某个从节点担任主节点的功能。如果要是半夜,那还要半夜赶去公司做这种工作。有没有一种自动能监测到主服务器是否挂了,以及如果一旦发现主服务器挂了,可以自动将某个合适的从服务器切为新的主服务器的机制?这就是Sentinel,也就是大家说的哨兵模式。

Sentinel(哨冈、哨兵)是Redis的高可用性(high availability)解决方案:由一个或多个Sentinel 实例(instance)组成的 Sentinel系统(system)可以监视任意多个主服务器, 以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

其大致流程可以描述为:

image-20220315143410142

当server1(主节点)的下线时长超过用户设定的下线时长上限时,Sentinel系统就会对 server1执行故障转移操作:

  • 首先,Sentinel系统会挑选server1属下的其中一个从服务器,并将这个被选中的从服务器升级为新的主服务器。
  • 之后,Sentinel系统会向server1属下的所有其他从服务器发送新的复制指令,让它们成为新的主服务器的从服务器,当所有从服务器都复制新的主服务器时,故障转移操作执行完毕。
  • 另外,Sentinel还会继续监视已下线的server1,并在它重新上线时,将它设置为新的主服务器的从服务器。

本节我们将从以下三点介绍sentinel:

  1. Sentinel的启动流程
  2. Sentinel与其他多个Sentinel以及多个主服务器与主服务器下的多个从服务器建立连接通信并获取他们的信息
  3. Sentinel是如何检测到主节点挂了,然后又是如何选举出新的主节点以及如何完成故障转移的。

3.2.1 Sentinel初始化

我们可以通过

$ redis-server sentinel.conf --sentinel

或者

$ redis-sentinel sentinel.conf

来启动一个Sentinel。

当sentinel启动时,主要会做如下初始化操作:

  1. 初始化服务器

    我们首先需要明白的是Sentinel本质上也是一个Redis服务器,只不过它比较特殊。有些Redis服务器的功能Sentinel不具备,比如sentinel不需要数据库,因此也就不需要再启动的时候加载RDB或AOF。大家可以理解为Sentinel与Redis服务器是两个不同的功能,但Redis作者将这两个功能都写到了同一个程序redis-server里。在以Redis服务器启动redis-server时,就会执行Redis服务器需要的那部分代码,以sentinel启动时,会执行sentinel需要的那部分代码。

    sentinel各功能使用情况如下:

    功 能使用情况
    数据库和键值对方面的命令,比如SET、DEL、FLUSHDB不使用
    事务命令,比如MULTIWATCH不使用
    脚本命令,比如EVAL不使用
    RDB持久化命令,比如SAVEBGSAVE不使用
    AOF持久化命令,比如BGREWRITEAOF不使用
    复制命令,比如SLAVEOFSentinel内部可以使用,但客户端不可以使用
    发布与订阅命令.比如PUBLISHSUBSCRIBESUBSCRIBEPSUBSCRIBEUNSUBSCRIBEPUNSUBSCRIBE 四个命令在Sentinel内部和客户端都可以使用,但PUBLISH命令只 能在Sentinel内部使用
    文件事件处理器(负责发送命令请求、处理 命令回复)Sentinel内部使用,但关联的文件事件处理器和普通Redis服务器不同
    时间事件处理器(负责执行serverCron 函数)Sentinel内部使用,时间事件的处理器仍然是serverCron函数,serverCron 函数会调用 sentinel. c/sentinelTimer 函数,后者包含了 Sentinel要执行的所有操作
  2. 将普通Redis代码换为Sentinel专用代码

    我们刚才已经说了sentinel启动时会有一些自己专有的功能,比如Redis服务器的命令set/get等都不能在sentinel下使用,因此sentinel启动时肯定会替换redis.c/redisCommandTable为sentinel.c/sentinelcmds。

  3. 状态初始化

    在使用sentinel专门的代码后,服务器会初始化一个sentinel. c/ sentinelState结构(后面简称"Sentinel状态”),这个结构保存了服务器中所有和 Sentinel功能有关的状态

    struct sentinelstate {
        //当前纪元,用于实现故障转移
        uint64_t current_epoch;
        //保存了所有被这个sentinel监视的主服务器
        //字典的键是主服务器的名字
        //字典的值则是一个指向sentinelRedisInstance结构的指针 
        dict *masters;
        //是否进入了TILT模式?
        int tilt;
        //目前正在执行的脚本的数量
        int running_scripts;
        //进入TILT模式的时间 
        mstime_t tilt_start_time;
        //最后一次执行时间处理器的时间 
        mstime_t previous_time;
        // 一个FIFO队列,包含了所有需要执行的用户脚本 
        list *scripts_queue;
    } sentinel;

    这里主要需要解释的是dict *masters;,sentinel监视的主服务器是一个字典,这是因为一个sentinel可以监视任意多组主从redis。也即不仅一组主从Redis可以被多个sentinel监视,一个sentinel也可以监视多组主从Redis。

  4. 初始化sentinel的监视主服务器列表

    在上面我们已经说了dict *masters,它是一个字典,用来存储Redis主服务器,key值为主服务器名,value是一个sentinel.c/sentinelRedisInstance结构。sentinelRedisInstance这一结构异常重要:每个sentinelRedisInstance都代表一个Redis/Sentinel实例,我们会在下面的Sentinel获取其他节点信息中频繁使用这一结构。

    一个sentinel不仅能得到主节点的信息,还会获得从节点的信息以及sentinel系统中其他sentinel的信息。这些信息都会存到sentinelRedisInstance中。sentinelRedisInstance结构属性比较多,其中部分属性如下:

    typedef struct sentinelRedisInstance{
        //标识值,记录了实例的类型,以及该实例的当前状态
        int flags;
        //实例的名字
        //主服务器的名字由用户在配置文件中设置
        //从服务器以及Sentinel的名字由Sentinel自动设置
        //格式为 ip:port,例如 ”127.0.0.1:26379”
        char *name;
        //实例的运行id
        char *runid;
        //配置纪元,用于实现故障转移
        uint64_t config_epoch;
        //实例的地址
        sentinelAddr *addr;
        //sentinel down-after-milliseconds 选项设定的值
        //实例无响应多少毫秒之后才会被判断为主观下线(subjectively down) 
        mstime_t down_after_period;
        //sentinel monitor <master-name> <IP> <port> <quorum> 选项中的quorum参数 
        //判断这个实例为客观下线(objectively down)所需的支持投票数量 
        int quorum;
        //sentinel parallel-syncs <master-name> <number> 选项的值
        //在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
        int parallel_syncs;
        //sentinel failover-timeout <master-name> <ms> 选项的值
        //刷新故障迁移状态的最大时限
        mstime_t failover_timeout;
        
        //主服务器下的从服务器实例
        //字典的键是从服务器的IP加端口,值也是一个指向sentinelRedisInstance结构的指针 
        dict *slaves;
        //监测主服务器的sentinel实例(包含自己本身)
        //字典的键是sentinel的IP加端口,值也是一个指向sentinelRedisInstance结构的指针 
        dict *sentinels;
        // ...
    } sentinelRedisInstance;

    其中sentinelAddr *addr信息如下:

    typedef struct sentinelAddr {
        char *ip;
        int port;
    } sentinelAddr;

    另外在启动sentinel前我们需要在sentinel.conf中配置如下内容:

    sentinel monitor master1 127.0.0.1 6379 2
    sentinel down-after-milliseconds master1 30000
    sentinel parallel-syncs master1 1
    sentinel failover-timeout master1 900000

    上述配置信息在sentinel启动后都会被保存到sentinelRedisInstance实例中

    如:

    image-20220616134237009

  5. 创建向主服务器的连接

    通过上一步我们已经得到了配置文件中主服务器的IP和端口,这时sentinel会作为主服务器的客户端与主服务器建立网络连接。

    Sentinel会向每个被监视的主服务器创建两个连向主服务器的连接:

    • 一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复。
    • 另一个是订阅连接,这个连接专门用于订阅主服务器的_sentinel_:hello频道(发布订阅的内容见发布订阅)

    命令连接与订阅连接的功能不太一样,一般而言命令连接是一对一通信,而订阅连接是类似于广播的形式,所有订阅者都会收到消息,不同的功能会采用不同的连接类型。

3.2.2 Sentinel获取节点信息

之前我们说过,一个sentinel不仅能得到主节点的信息,还会获得从节点的信息以及sentinel系统中其他sentinel的信息。本小节就主要来说下sentinel信息的获取

  1. 获取主服务器信息

    sentinel默认以10s一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并通过分析INFO命令的返回来获取主服务器的信息,如下是在单机的redis6.2.6版本中输入INFO命令获得内容

    # Server
    redis_version:6.2.6
    redis_git_sha1:00000000
    redis_git_dirty:0
    redis_build_id:b61f37314a089f19
    redis_mode:standalone
    os:Linux 3.10.0-957.21.3.el7.x86_64 x86_64
    arch_bits:64
    multiplexing_api:epoll
    atomicvar_api:atomic-builtin
    gcc_version:10.2.1
    process_id:1
    process_supervised:no
    run_id:61204dac3e842c82f365e77f0c2732d54620d2fd
    tcp_port:6379
    server_time_usec:1655361578882972
    uptime_in_seconds:60
    uptime_in_days:0
    hz:10
    configured_hz:10
    lru_clock:11194410
    executable:/data/redis-server
    config_file:
    io_threads_active:0
    
    # Clients
    connected_clients:1
    cluster_connections:0
    maxclients:10000
    client_recent_max_input_buffer:16
    client_recent_max_output_buffer:0
    blocked_clients:0
    tracking_clients:0
    clients_in_timeout_table:0
    
    # Memory
    used_memory:873608
    used_memory_human:853.13K
    used_memory_rss:4747264
    used_memory_rss_human:4.53M
    used_memory_peak:931688
    used_memory_peak_human:909.85K
    used_memory_peak_perc:93.77%
    used_memory_overhead:830376
    used_memory_startup:809880
    used_memory_dataset:43232
    used_memory_dataset_perc:67.84%
    allocator_allocated:1053240
    allocator_active:1273856
    allocator_resident:3833856
    total_system_memory:16656801792
    total_system_memory_human:15.51G
    used_memory_lua:37888
    used_memory_lua_human:37.00K
    used_memory_scripts:0
    used_memory_scripts_human:0B
    number_of_cached_scripts:0
    maxmemory:0
    maxmemory_human:0B
    maxmemory_policy:noeviction
    allocator_frag_ratio:1.21
    allocator_frag_bytes:220616
    allocator_rss_ratio:3.01
    allocator_rss_bytes:2560000
    rss_overhead_ratio:1.24
    rss_overhead_bytes:913408
    mem_fragmentation_ratio:5.71
    mem_fragmentation_bytes:3916424
    mem_not_counted_for_evict:0
    mem_replication_backlog:0
    mem_clients_slaves:0
    mem_clients_normal:20496
    mem_aof_buffer:0
    mem_allocator:jemalloc-5.1.0
    active_defrag_running:0
    lazyfree_pending_objects:0
    lazyfreed_objects:0
    
    # Persistence
    loading:0
    current_cow_size:0
    current_cow_size_age:0
    current_fork_perc:0.00
    current_save_keys_processed:0
    current_save_keys_total:0
    rdb_changes_since_last_save:0
    rdb_bgsave_in_progress:0
    rdb_last_save_time:1655361518
    rdb_last_bgsave_status:ok
    rdb_last_bgsave_time_sec:-1
    rdb_current_bgsave_time_sec:-1
    rdb_last_cow_size:0
    aof_enabled:0
    aof_rewrite_in_progress:0
    aof_rewrite_scheduled:0
    aof_last_rewrite_time_sec:-1
    aof_current_rewrite_time_sec:-1
    aof_last_bgrewrite_status:ok
    aof_last_write_status:ok
    aof_last_cow_size:0
    module_fork_in_progress:0
    module_fork_last_cow_size:0
    
    # Stats
    total_connections_received:1
    total_commands_processed:1
    instantaneous_ops_per_sec:0
    total_net_input_bytes:31
    total_net_output_bytes:20324
    instantaneous_input_kbps:0.00
    instantaneous_output_kbps:0.00
    rejected_connections:0
    sync_full:0
    sync_partial_ok:0
    sync_partial_err:0
    expired_keys:0
    expired_stale_perc:0.00
    expired_time_cap_reached_count:0
    expire_cycle_cpu_milliseconds:0
    evicted_keys:0
    keyspace_hits:0
    keyspace_misses:0
    pubsub_channels:0
    pubsub_patterns:0
    latest_fork_usec:0
    total_forks:0
    migrate_cached_sockets:0
    slave_expires_tracked_keys:0
    active_defrag_hits:0
    active_defrag_misses:0
    active_defrag_key_hits:0
    active_defrag_key_misses:0
    tracking_total_keys:0
    tracking_total_items:0
    tracking_total_prefixes:0
    unexpected_error_replies:0
    total_error_replies:0
    dump_payload_sanitizations:0
    total_reads_processed:2
    total_writes_processed:1
    io_threaded_reads_processed:0
    io_threaded_writes_processed:0

    可以看到我们能获得许多的信息,除此以外对于主从结构,主服务器的INFO信息还会包含从服务器的信息。如:

    slave0:ip=127.0.0.l,port=11111,state=online,offset=43,lag=0 slavel:ip=127.0.0.l,port=22222,state=online,offset=43,lag=0 slave2:ip=127.0.0.1,port=33333,state=online,offset=43,lag=0

    因此一条INFO的返回主要包含主服务器本身信息和从服务器信息

    • 主服务器信息中,比较关键的是run_id,如果主服务器重启,那么run_id就会与之前不同,sentinel监测到这一情况后就会更新实例中主服务器信息。
    • 获得的从服务器信息会被用于更新主服务器实例结构中的slaves字典,字典的键是ip:port的形式,值是一个指向sentinelRedisInstance结构的指针。

      sentinel在分析INFO中从服务器信息时会判断实例是否存在,如果存在就更新实例信息,否则就创建一个新的从服务器实例。

      下图是一个主服务器实例的信息

      image-20220616150154354

      从图中也可以看出主从服务器的flags信息也不一样,这也标识了当前实例是主服务器还是从服务器。

  2. 获取从服务器信息

    在上一步中我们已经通过主服务器信息获得了从服务器的IP和端口并创建了从服务器实例。sentinel创建一个从服务器实例后,还会建立自己到从服务器的命令连接和订阅连接。

    同样,建立连接后,也会以每10s一次的频率向从服务器发送INFO命令,获得从服务器的信息。

    sentinel会将获得信息更新进从服务器的实例中。

  3. 获取其他sentinel信息

    正常情况下主服务器和从服务器会被多个sentinel服务器监控,sentinel获得其他sentinel服务器的信息是通过发布订阅实现的。

    首先sentinel会以2s一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送如下信息格式的命令

    $ PUBLISH _sentinel_:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"

    其中s_开头的信息是当前sentinel的信息,它们分别为:

    参 数意 义
    s_ipSentinel 的 IP 地址
    s_portSentinel的端口号
    s_runidSentinel的运行ID
    s epochSentinel 当前的配置纪元(configuration epoch )

    m_开头的信息是当前监控的主服务器的信息,它们意义如下:

    参 数意 义
    m_name主服务器的名字
    m_ip主服务器的IP地址
    m_port主服务器的端口号
    m_epoch主服务器当前的配置纪元

    除此以外,我们之前还说过每个sentinel会与Redis服务器建立订阅连接,也即sentinel会向Redis服务器发送:

    $ SUBSCRIBE _sentinel_:hello

    可以看到每个sentinel还会订阅_sentinel_:hello频道。

    这样一来就完全不同,首先每个sentinel会通过_sentinel_:hello频道发送自己的信息,其次每个sentinel又会订阅_sentinel_:hello频道,因此一个sentinel通过_sentinel_:hello频道发送的信息会被其他sentinel收到(包括它自己也会收到),这样一来,sentinel就能获得其他sentinel的信息。

    获得的信息会被用于更新主服务器中sentinels字典,字典的键为ip:port,值为一个指向sentinelRedisInstance结构的指针 ,sentinel会判断当前实例是否存在,如果存在就更新对应的sentinel实例的信息,否则就创建一个新的sentinel实例并加入到字典中。

    如:

    image-20220616153315096

    同时,当sentinel发现一个新的sentinel并创建对应的实例后,还会建立一个与这个新sentinel的网络连接,最终sentinel系统下每个sentinel都有与其他sentinel的网络连接。

    sentinel感知到监测同一个Redis服务器的其他sentinel并与其建立网络连接非常关键,这会在故障的判断与转移中用到。

3.2.3 监测与故障转移

  1. 主观下线

    主观下线即为主观的认为下线,这是一个sentinel单独的判断结果。

    默认情况下sentinel会以每秒1次的频率向所有建立的网络连接发送PING命令(包括主从服务器和其他sentinel),通过实例的PING命令回复来判断实例是否在线。PING命令的回复包括如下几种:

    • 有效回复

      +PONG、-LOADING、-MASTERDOWN都属于有效回复

    • 无效回复

      除有效回复以外或超时未回复的都属于无效回复。

    我们之前说过,在sentinel启动的时候有一个配置为:

    sentinel down-after-milliseconds master1 30000

    上述配置即为sentinel判断实例主观下线的时间,如果一个实例在down-after-milliseconds毫秒内连续向sentinel返回无效回复,那么sentinel就会主观判断这个实例已经下线,在sentinelRedisInstance中将其内的flags属性加上SRI_S_DOWN标识。

  2. 客观下线

    主观下线是一个sentinel得出的结果,在得出主观下线后,会询问其他sentinel是否也认为实例已经下线,如果得到了足够数量的已下线的判断后,就认为节点为客观下线。

    询问的方式是通过发送SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>指令,其中

    参 数意 义
    ip被Sentinel判断为主观下线的主服务器的IP地址
    port被Sentinel判断为主观下线的主服务器的端口号
    current_epochSentinel当前的配置纪元,用于选举领头Sentinel,下面会说
    runid可以是符号或者Sentinel的运行ID。 符号代表命令仅仅用于检测主服务器的客观下线状态,而Sentinel的运行ID则用于选举领头Sentinel

    收到上述命令的sentinel会通过命令里的IP和端口检查实例是否已经下线,然后向请求的sentinnel返回如下三个参数信息:

    <down_state> <leader_runid> <leader_epoch>

    其中

    参 数意 义
    down_state返回目标Sentinel对主服务器的检査结果,1代表主服务器已下线,0代表主服务器未下线
    leader_runid可以是符号或者目标Sentinel的局部领头Sentinel的运行ID: 符号代表命令仅仅用于检测主服务器的下线状态,而局部领头Sentinel的运行ID则用于选举领头Sentinel
    leader_epoch目标Sentinel的局部领头Sentinel的配置纪元,用于选举领头Sentinel

    当sentinel收到返回后,会根据返回的情况统计其他sentinel同意实例已经下线的数量,当数量到达一定数量时(quorum),就会将实例的flags加上SRI_O_DOWN标识,代表服务器已客观下线。

  3. 选举sentinel

    如果主服务器已经判断为客观下线,就需要进行故障转移,在故障转移前需要选举出一个领头的sentinel来执行这个故障转移操作。

    选择领头sentinel的方案如下:

    • 所有在线的Sentinel都有被选为领头Sentinel的资格,换句话说,监视同一个主服务器的多个在线Sentinel中的任意一个都有可能成为领头Sentinel。
    • 每次进行领头Sentinel选举之后,不论选举是否成功,所有Sentinel的配置纪元 (configuration epoch )的值都会自增一次。配置纪元实际上就是一个计数器,并没有什么特别的。
    • 在一个配置纪元里面,所有Sentinel都有一次将某个Sentinel设置为局部领头 Sentinel的机会,并且局部领头一旦设置,在这个配置纪元里面就不能再更改
    • 每个发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头Sentinel。
    • 当一个 Sentinel (源 Sentinel)向另一Sentinel (目标 Sentinel)发送 SENTINEL is-master-down-by-addr命令,并且命令中的runid参数不是*符号而是源 Sentinel的运行ID时,这表示源Sentinel要求目标Sentinel将前者设置为后者的局部领头Sentinel 。
    • Sentinel设置局部领头Sentinel的规则是先到先得:最先向目标Sentinel发送设置要求的源Sentinel将成为目标Sentinel的局部领头Sentinel,而之后接收到的所有设置要求都会被目标Sentinel拒绝。
    • 目标 Sentinel 在接收到 SENTINEL is-master-down-by-addr 命令之后,将向源Sentinel返回一条命令回复,回复中的leader_runid参数和leader_epoch 参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元。
    • 源Sentinel在接收到目标Sentinel返回的命令回复之后,会检查回复中leader_ epoch参数的值和自己的配置纪元是否相同,如果相同的话,那么源Sentinel继续取出回复中的leader_runid参数,如果leader_runid参数的值和源Sentinel的运行ID 一致,那么表示目标Sentinel将源Sentinel设置成了局部领头Sentinel。
    • 如果有某个Sentinel被半数以上的Sentinel设置成了局部领头Sentinel,那么这个 Sentinel成为领头Sentinel。举个例子,在一个由10个Sentinel组成的Sentinel系 统里面,只要有大于等于10/2 + 1=6个Sentinel将某个Sentinel设置为局部领头 Sentinel,那么被设置的那个Sentinel就会成为领头Sentinel。
    • 因为领头Sentinel的产生需要半数以上Sentinel的支持,并且每个Sentinel在每个 配置纪元里面只能设置一次局部领头Sentinel,所以在一个配置纪元里面,只会出现 一个领头 Sentinel 。
    • 如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举,直到选出领头Sentinel为止。

    内容可能比较多,我们简单总结如下:

    1. 配置纪元就是代表着一次执行,如果本次选举不成功,那么就将配置纪元+1,进行下一次的选举。
    2. 每个sentinel如果监测到了主观下线都可以通过SENTINEL is-master-down-by-addr要求其他sentinel给自己投票。
    3. 投票规则是先到先得,且每人只有一票,只有票数过半的sentinel才会被选举为领头。
    4. 通过SENTINEL is-master-down-by-addr的返回,可以知道返回的sentinel有没有为自己投票。

    一般而言,先监测到主服务器节点主观下线的sentinel能更早的发出SENTINEL is-master-down-by-addr,也更有可能被其他sentinel投票,也即更可能成为领头sentinel。

  4. 故障转移

    选举出领头sentinel后,领头的sentinel就会对主服务器执行故障转移操作。故障转移主要是如下步骤:

    1. 在从服务器中选出一个服务器作为新的主服务器
    2. 将其他从服务器改为复制新的主服务器
    3. 将已下线的旧主服务器设为新主服务器的从服务器,当旧主服务器重新上线后,会成为新主服务器的从服务器。

    这里需要解释的为选新主服务器的方案:

    1. 删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。
    2. 删除列表中所有最近五秒内没有回复过领头Sentinel的INFO命令的从服务器, 这可以保证列表中剩余的从服务器都是最近成功进行过通信的。删除所有与已下线主服务器连接断开超过down-after-milliseconds 10 毫秒的从服务器:down-after-milliseconds选项指定了判断主服务器下线所需的 时间,而删除断开时长超过down-after-milliseconds 10毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,列表中剩余的从服务器保存的数据都是比较新的。(down-after-milliseconds还会用于主从间的超时判断)
    3. 领头Sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。
    4. 如果有多个具有相同最高优先级的从服务器,那么领头Sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器进行排序,并选出其中偏移量最大的从服务器(复制偏移量最大的从服务器就是保存着最新数据的从服务器)。
    5. 如果有多个优先级最高、复制偏移量最大的从服务器,那么领头Sentinel将按照运行ID对这些从服务器进行排序,并选出其中运行1D最小的从服务器。

3.3 集群

Redis集群功能是Redis在2015年Redis3.0首次引入的功能,由于出现的比较晚,很多大公司都自己实现了Redis的集群版本,如Codis,Tendis等。考虑到Redis集群功能较为复杂,市面上使用的比例不是很高,且本篇笔记写到这里已经很长了,所以关于Redis集群部分笔者打算后续再单独开一贴讲解,就不在本篇讲述了。

4. 其他功能

4.1 发布订阅

想象你家想订阅《今日早报》,那么你只需要给报社打电话,告诉他你要订阅的报纸,那么以后每天早上你都会收到一份报社的报纸。对于报社而言,每当报纸印刷好后,都需要将印刷好的报纸放到每个订阅者的家门口。如果某天你不想再看《今日早报》,就给报社打电话告诉他要退订报纸,报社收到消息后就不会再给你送报纸。这就是一个发布订阅功能。

一个客户端可以订阅一到多个频道,当消息被发送时,该消息会被发给所有订阅了该频道的订阅者。Redis通过PUBLISHSUBSCRIBEPSUBSCRIBE等命令组成了发布订阅功能。

发布订阅功能的实现如下:

4.1.1 订阅

Redis服务器内部有一个字典pubsub_channels

struct redisServer {
    // ...
    //保存所有频道的订阅关系
    dict *pubsub_channels;
    //...
}

字典的key为频道名,Value是一个链表,链表上的每一个节点都是一个Redis客户端。也即链表上是所有订阅了当前频道的客户端集合。

每当客户端给服务端发送SUBSCRIBE命令来订阅频道时,服务器都会通过pubsub_channels将当前客户端与频道信息相关联。

其伪代码如下:

def subscribe (*all_input_channels):
    #遍历输入的所有频道
    for channel in all_input_channels:
        #如果channel不存在于pubsub_channels字典(没有任何订阅者)
        #那么在字典中添加channel键,并设置它的值为空链表
        if channel not in server.pubsub_channels:
            server.pubsub_channels[channel] = []
        #将订阅者添加到频道所对应的链表的末尾
        server.pubsub_channels[channel].append(client)

4.1.2 退订

客户端通过发送UNSUBSCRIBE命令来退订频道,Redis服务端首先会从pubsub_channels中根据频道找到订阅者链表,然后遍历链表找到对应的客户端,从链表中删除当前客户端。

如果删除后,订阅链表成了空链表,Redis就从pubsub_channels中删除频道和对应的键(释放内存)。

4.1.3 模式订阅

Redis支持模糊批量订阅频道,通过PSUBSCRIBE命令可以订阅所有符合某个模式的频道,如:

$ PSUBSCRIBE "abc*"

代表当前客户端要订阅所有以abc开头的频道(类似于加了通配符的字符串匹配)。

Redis服务端会将模式订阅的信息保存在pubsub_channels属性中:

struct redisServer {
    //...
    //保存所有模式订阅关系 
    list *pubsub_patterns;
    //...
};

pubsub_patterns是一个链表,链表中每个节点的信息如下:

typedef struct pubsubPattern{
    //订阅模式的客户端
    redisClient *client;
    //被订阅的模式
    robj *pattern;
} pubsubPattern;

包含了当前客户端实例和添加的订阅模式。

模式订阅的逻辑实现如下:

def psubscribe (*all_input_patterns):
    #遍历输入的所有模式
    for pattern in all_input_patterns:
        #创建新的pubsubPattern结构
        #记录被订阅的模式,以及订阅模式的客户端
        pubsubPattern = create_new_pubsubPattern() 
        pubsubPattern.client = client 
        pubsubPattern.pattern = pattern
        #将新的pubsubPattern追加到pubsub_patterns链表末尾 
        server.pubsub_patterns.append(pubsubPattern)

注:这里可能很多人会有疑惑,比如模式订阅为什么是这种实现方式,为什么需要一个单独的链表存储客户端的模式频道,当客户端输入频道的时候直接查看当前所有的频道,然后将符合模式的频道加入之前的pubsub_channels不可以吗?

事实上是不行的,因为当客户端输入模式频道后直接在当前所有的频道中匹配,在匹配上的频道中将客户端加进去,但是这只能保证客户端监听现在已有的符合条件的频道,如果后续又有新增符合的频道就无法被监听。比如现在已有频道abc,acb,bca。客户端监听a*频道,此时abc和acb都会被客户端监听到,但是如果后面又出现了axy频道,那么也应该被客户端监听到,因此我们就不能直接匹配而是将客户端输入的频道进行原始保存。

4.1.4 模式退订

客户端通过发送PUNSUBSCRIBE完成模式退订,其逻辑比较简单,就是从pubsub_patterns遍历的得到对应的pattern和client,然后删除节点。其伪代码如下:

def punsubscribe (*all_input_patterns):
    #遍历所有要退订的模式
    for pattern in all_input_patterns:
        #遍历pubsub_patterns链表中的所有pubsubPattern结构 
        for pubsubPattern in server.pubsub_patterns:
            #如果当前客户端和pubsubPattern记录的客户端相同 
            #并且要退订的模式也和pubsubPattern记录的模式相同 
            if client == pubsubPattern.client and  pattern == pubsubPattern.pattern:
                #那么将这个pubsubPattern从链表中删除 
                server.pubsub_patterns.remove(pubsubPattern)

4.1.5 消息发送

客户端可以通过PUBLISH命令发送消息,服务器需要将客户端发送的消息转发给所有订阅频道的客户端,除此以外,如果存在与当前频道符合的pattern频道,那服务器也要将消息发给pattern模式的订阅者。

  1. 发给订阅者

    pubsub_channels根据频道作为Key值找到所有监听当前频道的客户端链表,然后将消息发给这个链表中的每个客户端。其伪代码如下:

    def channel_publish(channel, message):
        #如果channel键不存在于pubsub_channels字典中
        #那么说明channel频道没有任何订阅者
        #程序不做发送动作,直接返回
        if channel not in server.pubsub_channels:
            return
        #运行到这里,说明channel频道至少有一个订阅者
        #程序遍历channel频道的订阅者链表
        #将消息发送给所有订阅者
        for subscriber in server.pubsub_channels[channel]:
            send_message(subscriber, message)
  2. 发给模式订阅者

    遍历整个pubsub_patterns链表,查找匹配的channel频道,并将消息发给匹配的对应的客户端,其伪代码如下:

    def pattern_publish(channel, message):
        #遍历所有模式订阅消息
        for pubsubPattern in server.pubsub_patterns:
            #如果频道和模式相匹配
            if match(channel, pubsubPattern.pattern):
                #那么将消息发送给订阅该模式的客户端 
                send_message(pubsubPattern.client, message)

4.1.6 Redis与传统消息队列的区别

鉴于Redis的发布订阅模式,,很多人可能会用Redis做消息队列,但实际上Redis并不适合做消息队列,连Redis作者也呼吁不要拿Redis做消息队列,Redis不适合做消息队列的原因有二:

  1. 发布订阅模式不同于生产消费模式,如果在将消息转发的时候订阅的客户端下线了,那么Redis并不做任何多余处理,也即如果下线那么下线期间所有的消息都将收不到。
  2. 如果生产速度大于消费速度,会造成消费的客户端消息积压,当输出缓冲区大到一定程度,Redis就会将客户端断开,也即Redis没有传统消息队列的削峰填谷的能力。

不过即使呼吁了很多遍,依然很多人拿Redis做消息队列,没有办法,Redis作者就真的在Redis上实现了一版消息队列,这是在Redis5.0引入的Stream类型,由于本篇笔记篇幅过长,相关内容我会单独再开一篇讲解。

4.2 事务

一提到事务,大家就会想起事务的ACID四大性质,Redis也提供了事务的支持。本节我们会来先看下Redis是如何实现事务然后再来讲下Redis事务对ACID的支持。

4.2.1 事务的实现

Redis通过MULTI、EXEC、WATCH、DISCARD等命令来实现事务,其中MULTI是开启事务的意思,类似于MySQL事务的begin,EXEC是执行的意思,类似于MySQL事务的commit。

一个简单的Redis事务是先键入MULTI指令,然后键入多条Redis命令,再最后输入EXEC。它们分别代表了Redis事务执行的三个阶段:

  • 事务开始
  • 命令入队
  • 事务执行

下面我们详细说下这三个阶段:

  1. 事务开始

    redis> MULTI
    OK

    MULTI指令代表着事务开启,此时客户端的状态从非事务切为事务状态,当收到MULTI指令时,Redis服务端会将redisClient的flags |= REDIS_MULTI标识。

  2. 命令入队

    当事务开启后,客户端发来的所有命令都会进入都不会立马执行而是会进入一个命令队列,直到客户端发来的是包含EXEC、DISACRD、WATCH、或MULTI等关键字的命令才会执行。

    其中事务队列的实现如下:

    每个redisClient中都包含一个mstate属性:

    typedef struct redisClient{
        //...
        //事务队列
        multiState mstate;
        //...
    }

    multiState就是一个multiCmd元素的数组,其实现如下:

    typedef struct multiState{
        //事务队列,FIFO顺序 
        multiCmd *commands;
        //已入队命令计数
        int count;
    }

    multiCmd代表了一条指令:

    typedef struct multiCmd{
        //参数
        robj **argv;
        //参数数量
        int argc;
        //命令指针
        struct redisCommand *cmd;
    } multiCmd;

    可以看到事务队列的本质是一个数组,采用先进先出的方式来管理命令。

  3. 事务执行

    当服务端收到客户端的EXEC命令时,就会遍历客户端的事务队列,一条条取出命令来执行并将所有的执行结果返回给客户端。

    其实现伪代码如下:

    def EXEC():
        #创建空白的回复队列
        reply_queue =[]
        #遍历事务队列中的每个项
        #读取命令的参数,参数的个数,以及要执行的命令
        for argv, argc, cmd in client.mstate.commands :
            #执行命令,并取得命令的返回值
            reply = execute_command(cmd, argv, argc)
            #将返回值追加到回复队列末尾
            reply_queue.append(reply)
        #移除REDIS_MULTI标识,让客户端回到非事务状态
        client.flags &= ~REDIS_MULTI
        #清空客户端的事务状态,包括:
        #1 )清零入队命令计数器
        #2 )释放事务队列
        client.mstate.count = 0 
        release_transaction_queue(client.mstate.commands)
        #将事务的执行结果返回给客户端 
        send_reply_to_client(client, reply_queue)

在Redis事务中还有一个比较关重要的关键字是WATCH。很多时候我们在执行事务的时候,需要在一定条件下执行,如果条件从开启事务到事务执行这段时间均未改变就可以执行事务,否则就放弃执行,WATCH就是用来干这个的。

WATCH可以在事务执行前监视Redis中任意数量的数据库键,如果在EXEC命令执行时,被监视的键改动了,那么事务就拒绝执行,否则就正常执行。

如:

时间客户端A客户端B
T1WATCH “name”
T2MULTI
T3SET "name" "peter"
T4 SET "name" "john"
T5EXEC

由于客户端A WATCH了键name,客户端B在T4时刻修改了name,T5时刻客户端A的EXEC并不会执行,因为Redis检测到了name键被修改。

WATCH命令的实现很简单:

Redis数据库中保存着一个watched_key字典:

typedef struct redisDb{
    // ...
    //正在被WATCH命令监视的键
    dict *watched_keys;
    // ...
} redisDb;

字典的key值是键名,value是一个链表,链表中的每个节点都是一个客户端,也即链表代表了正在监视当前键的客户端集合。

image-20220620162239054

上图中“name”键被C1和C2客户端监视(WATCH)。

每条Redis的写操作(SET、LPUSH、SADD等等)执行完成后,都会对watched_keys的检查。如果watched_keys中包含所修改的数据库键,那么就将所有监视这个key的redisClient的flags标志打开REDIS_DIRTY_CAS

上述逻辑伪代码如下:

def touchWatchKey(db, key):
    #如果键key存在于数据库的watched_keys字典中 
    #那么说明至少有一个客户端在监视这个key
    if key in db.watched_keys:
        #遍历所有监视键key的客户端
        for client in db.watched_keys[key]:
            #打开标识 
            client.flags |= REDIS_DIRTY_CAS

当客户端发送EXEC要执行某个事务时,服务器会先判断客户端的REDIS_DIRTY_CAS是否打开,如果打开则代表客户端监视的键中至少有一个被修改了,此时服务器会拒绝执行事务。否则服务器将从事务队列中挨个取出指令执行。

4.2.2 ACID

  1. 原子性

    Redis事务的执行是原子执行的,由于Redis是单线程的,因此某个事务执行的时候,不会执行其他客户端的指令,直到事务执行完。但Redis事务不支持回滚。也即如果事务队列中任何一条指令失败,Redis会停止执行事务但不会回滚这条事务之前的操作,Redis不支持回滚的原因如下:

    Redis的作者在事务功能的文档中解释说,不支持事务回滚是因为这种复杂的功能和 Redis追求简单高效的设计主旨不相符,并且他认为,Redis事务的执行时错误通常都是编程错误产生的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现, 所以他认为没有必要为Redis开发事务回滚功能。
  2. 一致性

    一致性的概念比较抽象,简单来说就是数据库中的信息与当前现实世界的信息一致,如果现实中信息修改,数据库也要对应修改,不会出现现实与数据库对应不上的情况或数据库中信息不符合现实世界客观规律的情况,数据库的AID特性均是为了C服务,此处我们不必纠结Redis一致性的实现。

  3. 隔离性

    Redis采用单线程运行事务,因此事务与事务间一定是隔离的。

  4. 持久性

    Redis的AOF与RDB可以对数据进行持久化,但只有在AOF模式下且appendfsync为always时Redis才具有持久性(只有这样才能保证每个被执行完的指令都持久化到了磁盘)。

最后修改:2022 年 06 月 21 日
如果觉得我的文章对你有用,请随意赞赏