Redis设计与实现随笔(第一二部分)

Redis设计与实现

第一部分:数据结构与对象

本笔记所遇到的数据结构和API都能在Github上找到源代码

2 : 简单动态字符串

Redis自己构建了一种名为简单动态字符串(SDS)的抽象类型,并将其设置为Redis默认字符串。

2.1 SDS的定义
#include <sds.h>
struct sdshdr{
    // 记录buf数组中已使用字节的数量
    // 等于SDS所保存的字符串长度
    int len;
    // 记录buf数组中未使用字节的数量
    int free;
    
    // 字节数组,用于保存字符串
    char buf[];
}

tag: 由于SDS遵循C字符串以空字符结尾的惯例, 保存空字符的1字节空间不计算在SDS的Len属性里面,并且为空字符自动生成一个字节空间

2.2 SDS和C字符串的区别

​ 由于Redis对字符串在安全性,效率以及功能上都有需求, 因此SDS比传统字符串有着不少优化。

  • 常数复杂度获取字符串长度

    C字符串获取长度靠strlen(O(n)), Redis是靠着sds.len直接获得。

  • 杜绝缓冲区的溢出

    C字符串容易造成缓冲区的溢出,而SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS的API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求。如果不满足,那么就会执行空间扩展工作。

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

    • C字符串被增长或者缩短时,程序都要对该字符串进行一次内存重分配操作。

    • 针对未使用空间sds.free, SDS解除了字符串长度和底层数组长度之间的关联。从而实现了空间预分配和惰性空间释放两种优化策略。

      • 空间预分配: 当API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会分配额外的未使用空间

        // 分配机制
        struct sdshdr sds;
        int needlen;			// sds字符串需要存储的字符串长度
        if(sds.len < 1024*1024)	// 1024*1024 = 1MB
        {
            // 分配空间至len
            sds.len = needlen;
            sds.free = sds.len // 保持一样
            //so strlen(sds.buf) = len + free + 1;
        }else{
            sds.len = needlen;
            sds.free = 1024*1024;
        }
        
      • 惰性空间释放: 用于优化SDS的字符串缩短操作。 API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量纪录起来,并等待将来使用。

  • 二进制安全

    • C字符串中的字符必须符合某种编码(如ASCII),并且除了字符串的末尾之外,字符串里面不能含有空字符(这个是因为程序读C字符串遇到空字符就默认这是字符串结尾)。 由于这些限制,C字符串只能保存文本数据,而不能保存像图片、音频、视频压缩文件这些二进制数据。

    • SDS中的buf数组,既可以保存一系列字符,也可以保存一系列二进制数据。(其实可以理解为该数组可以保存空字符串,因为长度是已知的,程序不需要通过空字符串的位置判断这个字符串是否结束)

  • 兼容部分C字符串函数

    #include <string.h>
    strcat(s_string, sds->buf);
    // 可以使用str相关的api
    
  • 总结

    • C字符串:
      • 获取字符串长度的时间复杂度是O(N)
      • API不安全,可能会造成缓冲区溢出
      • 修改字符串长度N次必然需要执行N次内存重分配
      • 只能保存文本数据
      • 可以使用所有<string.h>的库函数
    • SDS:
      • 获取字符串长度的时间复杂度是O(1)
      • AP安全,不会造成缓冲区溢出
      • 修改字符串长度N次最多需要执行N次内存重分配
      • 能保存文本数据和二进制数据
      • 可以使用一部分<string.h>的库函数
2.3 SDS API

​ | 函数 | 作用 | 时间复杂度

  • sdsnew 创建一个包含给定C字符串的SDS O(N), N = strlen(str)

  • sdsempty 创建一个不包含任何内容的空SDS O(1)

  • sdsfree 释放给定的SDS O(N), N = strlen(str)

  • sdslen 返回SDS已使用空间字节数 O(1), get from sds->len

  • sdsavail 返回SDS未使用空间字节数 O(1), get from sds->free

  • sdsdup 创建一个给定SDS的副本(copy) O(N), N = strlen(str)

  • sdsclear 清空SDS保存的字符串内容 O(1), 惰性空间释放策略! -> mark

  • sdscat 将给定C字符串拼接到SDS字符串的末尾 O(N),N = strlen(str)

  • sdscatsds 将给定SDS字符串拼接到另一个SDS字符串末尾 O(N),N = strlen(str)

  • sdscpy 将给定C字符串复制到SDS里面,

    覆盖SDS原有字符串 O(N),N = strlen(str)

  • sdsgrowzero 用空字符将SDS扩展至给定长度 O(N),N为扩展新增的字节数

  • sdsrange 保留SDS给定区间内的数据,

    ​ 不在区间内的数据会被覆盖或清除 O(N),N为保留数据的字节数

  • sdstrim 接收一个SDS和一个C字符串作为参数, O(N*M),M为SDS的长度,N为给定C字符串的长度

    ​ 从SDS左右两端分别移除所有在C字符串中出现过的字符

  • sdscmp 比较两个SDS字符串是否相同 O(N), N为两个SDS中较短的那个SDS的长度

3. 链表

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。 由于C语言没有内置这种数据结构,所以Redis构建了自己的链表实现。

当一个列表键包含了数量比较多的元素,又或者列表中的包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。

在这一章中,我们只介绍Redis的链表结构和相应的API

3.1 链表和链表节点的实现

链表我们都很熟悉,一般可以用一个struct来实现

/* 双端链表节点 */
struct ListNode{
    void* value; // node 节点值
    ListNode* prev;	// 前驱
    ListNode* next; // 后置
};

虽然只要使用多个ListNode就能够组成链表。但是这个双端链表给的信息不是很多,Redis一般使用这个Struct来表示节点

#include <adlist.h>
struct list{
	// 表头结点
    ListNode* head;
    // 表尾结点
    ListNode* tail;
    // 链表所包含的节点数量
    unsigned long len;
    // 节点复制函数
    void *(*dup)(void *ptr);
    // 节点值释放函数
    void (*free)(void *ptr);
    // 节点值对比函数
    int (*match)(void *ptr, void *key);
	
};

Redis的链表特性可以总结如下:

  • 双端:链表节点有prevnext指针,可以O(1)时间获取节点的前驱和后置节点。
  • 无环:不是环状链表
  • 带表头指针和表尾指针:有着head和tail节点
  • 带链表长度计数器:len属性对链表进行技术,程序获得链表种节点数量的复杂度为O(1)
  • 多态:void* value 节点值的类型多态。并且可以通过三个节点函数来设置类型。链表可以用于保存各种不同类型的值。
3.2 链表和链表节点的API

​ | 函数 | 作用 | 时间复杂度

  • listSetDupMethod 将给定的函数设置为链表的节点值复制函数 O(1)

  • listGetDupMethod 返回链表当前正在使用链表节点值复制函数 O(1)

  • listSetFreeMethod 将给定的函数设置为链表的节点值释放函数 O(1)

  • listGetFree 返回链表当前正在使用链表节点值释放函数 O(1)

  • listSetMatchMethod 将给定的函数设置为链表的节点值对比函数 O(1)

  • listGetMatchMethod 返回链表当前正在使用链表节点值对比函数 O(1)

    详情请见《Redis设计与实现》 P22

3.3 总结
  • 链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视器等。
  • Redis链表结构实现
  • Redis链表结构特性
  • Redis链表API功能

4. 字典

4.0 字典的介绍

字典又称符号表,映射。是一种用于保存键值对的抽象数据结构。(key-value)

在字典中,一个key和一个value进行关联。每个key是独一无二的,用户通过寻找键来得到与之关联的值。

字典也是哈洗剪的底层实现之一。

4.1 字典的实现

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

  • 哈希表:

    #include <dict.h>
    struct dictht{
        // 哈希表数组
        // table是一个数组, 数组中存放的元素是一个指向dicEntry结构的指针
        // dicEntry 结构保存着一个键值对
        dictEntry **table;
        
        // 哈希表大小
        unsigned long size;
        
        // 哈希表大小掩码,用于计算索引值
        // 总是等于size-1
        unsigned long sizemask;
        
        // 该哈希表已有节点的数量
        unsigned long used;
    };
    
  • 哈希表节点

    struct dictEntry{
      	// 键
        void* key;
        
        // 值
        union{
            void *val;
            uint64_t u64;
            int64_t s64;
        }v;
        
        // 指向下一个哈希表节点,形成链表
        // 这个指针可以将多个 哈希值相同 的键值对链接在一起,一次来解决键冲突的问题
        dicEntry *next;
    };  
    
  • 字典

    struct dict{
        // 类型特定函数
        dicType* type;
        
        // 私有数据
        void privdata;
        
        // 哈希表
        // 字典只使用ht[0], h[1]哈希表会在对ht[0]哈希表进行rehash时使用。
        dictht ht[2];
        
        // rehash 索引
        // 当rehash不在进行时,设置为-1
        int trehashidx;      /* rehashing not in progress if rehashidx == -1 */
       
    };
    
    • type属性是一个指向dicType的指针,每个dicType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
    • privdata属性则保存了需要传给那些类型特定函数的可选参数
    struct dictType{
    	// 计算哈希值的函数
        unsigned int (*hashFunction) (const void* key);
        
        // 复制键的函数
        void* (*keyDup) (void *privdata, const void* key);
        
        // 复制值的函数
        void* (*valDup)(void *privdata, const void* obj);
        
        // 对比键的函数
        int (*keyCompare) (void *privdata, const void* key1, const void* key2);
        
        // 销毁键的函数
        void (*keyDestructor) (void *privdata, void *key);
        
        // 销毁值得函数
        void (*valDestructor) (void *privdata, void *obj);
    };
    
4.2 哈希算法

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

hash = dict->type->hashFunction(key);
// ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemark;
4.3 解决键冲突

当有两个或以上数量的键被分配到了哈希数组的同一索引上面时,我们称这些键发生了冲突。

Redis的哈希表使用链地址法解决冲突。(依靠dictEntry* next)

由于dicEntry节点组成的链表没有指向链尾指针,为了速度考虑,我们这里使用头插法

4.4 rehash
  • rehash的目的: 随着操作不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或收缩,这个时候用到了rehash。

  • rehash的步骤:

    • 为字典ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)
      • 如果执行扩展操作, *ht[1]的大小为第一个大于等于ht[0].used 2 的 2^n(2的n次方幂)
      • 如果执行收缩操作,ht[1]的大小为第一个大于等于ht[0].used 的 2^n(二者是除以2的关系)
    • 将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对房知道ht[1]的指定位置上。
    • 当ht[0]上全部键值对都转移到ht[1]上之后,释放ht[0],将ht[1]设置为ht[0], ht[1]设置为空白哈希表,为下一次rehash做好准备。
  • 扩展收缩的条件

    • 扩展操作条件

      • 服务器目前没有执行BGSAVE或者BGREWRITEAOF命令,并且负载因子大于等于1
      • 服务器目前正在执行BGSAVE或者BGREWRITEAOF命令,并且负载因子大于等于5
      # 上面两个命令相当于服务器正在创建服务器进程的子进程,由于写时复制的问题,在子程序存在期间,服务器会提高执行扩展操作使用的负载因子。从而避免在子进程存在期间进行扩展操作,以此节约内存。
      
      # 负载因子 = 哈希表已保存节点数量 / 哈希表大小
      load_factor = ht[0].used / ht[0].size
      
    • 收缩操作条件: 负载因子小于等于0.1

4.5 渐进式rehash

​ rehash动作不是一次性集中式地完成的,而是分多次、渐进式地完成的。

  • 渐进式rehash步骤
    • 为ht[1]分配空间,让字典同时持有两个hash表
    • 字典中维持的索引计数器变量rehashidx从-1变为0,表示rehash工作正式开始
    • 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定操作以外,还会顺带ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性自增1
    • 随着字典不断执行,ht[0]所有键值对都会被rehash至ht[1],这时程序将rehashidx属性设置为-1
  • 特点:
    • 渐进式rehash采取分而治之的方式,将rehash键值对的计算工作均摊到字典的每个添加、删除、查找和更新操作上,从而避免集中式rehash的庞大计算量。
    • 在渐进式rehash的过程中,字典的删除查找更新等操作会在两个哈希表上进行。 当查找时,优先查找ht[0],再查找ht[1]。 当添加时,一律保存在ht[1]字典中,ht[0]不增加。
4.6 字典API

参见P36

5. 跳跃表

5.0 跳跃表的介绍

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

​ 跳跃表TC: 平均O(logN),最坏 O(N)进行节点查找,可以通过顺序性操作来批量处理节点。(效率高,大部分和平衡树媲美,但是比平衡树简单)

一般用于有序集合键的底层实现,或者用于集群节点中作为内部数据结构。

5.1 跳跃表的实现
Redis中的跳跃表由以下两个结构定义,其中一个结构用于表示跳跃表节点,另一个结构用于保存跳跃表相关信息。
#include <redis.h>

// 跳跃表节点
struct zskiplistNode {
    // 后退指针
    struct zskiplistNode* backward;
    // 分值
    double score;
    // 成员对象
    robj *obj
    
    struct zskiplistLevel{
        // 前进指针
        struct zskiplistNode* forward;
        // 跨度
        unsigned int span;
    }
};

// 跳跃表节点相关信息
struct zskiplist{
    struct zskiplistNode* header;	// 指向跳跃变的表头节点
    struct zskiplistNode* tail;	// 指向跳跃表的表尾节点
    int level;				// 记录跳跃表内,层数最大的那个节点层数(表头节点不计算在内)O(1)
    int length;				// 记录跳跃表的长度,即包含节点的数量(表头节点不计入在内) O(1)
};
  • 跳跃表节点:

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

    • 前进指针:每个层都有指向表尾方向的前进指针(level[i].forward),用于从表头向表尾方向访问节点。

    • 跨度: 用于记录两个节点之间的距离:

      • 两个节点之间跨度越大,相距越远。
      • 指向nullptr的跨度都为0,因为没有连向任何节点。
    • 后退指针:用于表尾向表头访问的节点,每个节点只有一个后退指针,只能退一个节点,后退指针最多退到头指针的后一个指针(头指针退不到)

    • 分值:跳跃表中所有节点都是按照分值从小到大来排序

    • 成员:obj属性,指向一个字符串对象,而字符串对象则保存着一个SDS值。

另外可以保存相同分值的节点,分值相同的节点将按照成员对象在字典序中的大小进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较小的节点则会排在后面,成员对象一定是唯一的

5.2 跳跃表API

​ 参见P44

5.3 重点回顾
  • 跳跃表是有序集合的底层实现之一
  • Redis跳跃表由zskiplistzskiplistNode 两个结构组成,这两个结构一定要记得。
  • 每个跳跃表节点的层数是1-32之间的随机数。
  • 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的。
  • 节点按照分值大小排序,分值相同,则按照成员对象大小进行排序。

6. 整数集合

6.0 整数集合的介绍

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

6.1 整数集合的实现

​ 整数集合可以保存类型为int16_t, int32_t或者 int64_t的整数值,并且保证集合中不会出现重复元素

#include <intset.h>
typedef struct intset{
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint_t length;
    
    // 保存元素的数组
    int8_t contents[];
};
  • contents 数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(items),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
  • length属性记录了整数集合包含的元素数量。

虽然contents数组类型是int8_t, 但是这数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值。

  • encoding属性
    • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型数组大小。
    • 同样的,如果为INTSET_ENC_INT32,则int32_t; 如果为INTSET_ENC_INT64,则int64_t
6.2 升级

​ 大概我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升,才能将新元素添加到整数集合里去。

​ 升级并添加新元素共分为三步:

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

  • 将原有的元素转换成新元素的类型,并将类型转换后的元素放在正确的位上,在放置元素的过程中,底层数组有序性质不改变

  • 将新元素加入底层数组。

由于引发升级的新元素的长度总是比整数集合现有所有元素的长度都打,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素。

  • 大于现有元素,插入到最后面
  • 小于现有元素,插入到最前面
6.3 升级的好处
  • 提升整数集合的灵活性: 我们可以随意的将不同类型的整数添加到集合中,而不必担心出现类型错误。
  • 节约内存:只有在升级的时候才会扩展内存,尽量节约内存。
6.4 降级:

整数集合不支持降级操作,一旦升级了,就算数组中的高位数被删除了,维持的依旧是高位

6.5 整数集合API

​ 参见P51

7. 压缩列表

7.0 压缩列表的介绍

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

7.1 压缩列表的构成

压缩列表是为了节约内存而开发的,是有一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点保存一个字节数组或者一个整数值。

|  属性     |   类型   |   长度   |          用途
- zlbytes	 uint32_t   4bytes 		记录整个压缩列表占用的内存字节数:对压缩列表进行内存重分配或者计算zlend位置时使用。
- zltail	 uint32_t  	4bytes		记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过该偏移量确定表尾节点位置-O(1)
- zllen      uint16_t   4bytes      记录压缩列表包含的节点数量,当节点数大于uint16_MAX时,真实节点数必须遍历得到。
- entryX 	 列表节点	  不定		压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
- zlend 	 uint8_t    1bytes		特殊值0xFF(255),用于标记压缩列表的末端。

7.2 压缩列表节点的构成

​ 每个压缩节点可以保存一个字节数组或者一个整数值。

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

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

整数值则可以是以下六种长度之一

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

每个压缩列表节点都有previous_entry_length、 encoding、 content组成

  • previous_entry_length: 以字节为单位,记录了压缩列表中的前一个节点的长度。(可以是1字节也可以是5字节,取决于前一个节点的长度)

    由于压缩列表是顺序型存储,当我们知道某个节点起始地址的指针,那么通过这个指针和previous_entry_length就可以往前回溯,最终达到头结点

  • encoding: 记录了节点的content属性所保存的类型以及长度

    • 值最高00,01,10这种编码表示节点content属性保存着的是字节数组
    • 值最高位以11开头的是整数编码
  • content: 负责保存节点值。

7.3 连锁更新

​ 在一些临界值特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”。(在previous_entry_length的长度介于1字节和5字节之间的时候,添加节点或者删除节点,可能会造成连锁更新)

​ 因为连锁更新在最坏情况下需要对压缩列表执行N此空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2).但是最坏的情况基本不会发生,因此我们放心使用API

7.4 压缩列表API

​ 见P59

8. 对象

8.0 对象的介绍

​ 在2-7章中,我们介绍了SDS,链表,字典,跳跃表,整数集合以及压缩列表。这些都是Redis所用到的主要数据结构。但是Redis的键值对并不是直接使用这些数据结构的,而是基于这些数据结构创建了一个对象系统。这个系统包含 字符串对象, 列表对象, 哈希对象, 集合对象和有序集合对象这五种类型的对象。

​ 使用对象的好处:

  • Redis在执行命令之前,可以通过对象的类型来直接判断执行命令的合法性。
  • 针对不同的使用场景,可以为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。
  • Redis对象实现基于引用计数技术的内存回收机制。通过该机制可以实现对象共享机制。
  • Redis的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,在服务器启用了maxmemory功能情况下,空转时长较大的键会优先被服务器删除。
8.1 对象的类型和编码

​ Redis通过对象来表示数据库中的键和值,因此每当我们创建一个键值对时,至少创建两个对象,一个是键对象,一个是值对象。

for example
redis > set msg "hello world"
OK

--> key : msg (字符串对象)
--> value: "hello wordl" (字符串对象)

Redis中的每个对象都有一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type属性,encoding属性和ptr属性。

typedef struct redisObject {
	// type
    unsigned type : 4;
    // encode
    unsigned encoding : 4;
    
    // 指向底层实现数据结构的指针
    void *ptr;
    
    // ...
};
  • 类型:type类型常量有五种,type为其中一种。

    REDIS_STRING | REDIS_LIST | REDIS_HASH | REEDIS_SET | REDIS_ZSET
    字符串对象		列表对象		哈希对象	 集合对象		有序集合对象
    

    因此当我们称呼一个数据库键为“字符串”键时,我们指的是“这个数据库键所对应的值为字符串对象

  • 编码和底层实现

    对象的ptr指针指向对象的底层数据结构,这些数据结构由对象的encoding属性决定。encoding属性记录了对象使用的编码。

    编码常量				|			编码所对应的底层数据结构
    REDIS_ENCODING_INT				long类型的整数
    REDIS_ENCODING_EMBSTR			embstr编码的的简单动态字符串
    REDIS_ENCODING_RAW				简单动态字符串(sds)
    REDIS_ENCODING_HT				字典
    REDIS_ENCODING_LIKNEDLIST		双端链表
    REDIS_ENCODING_ZIPLIST			压缩列表
    REDIS_ENCODING_INTSET			整数集合
    REDIS_ENCODING_SKIPLIST			跳跃表和字典
    

    每种类型的对象都至少使用了两种不同的编码作为其底层数据结构。即 类型, 编码, 和对象三者有着不同的对应关系

    类型					|  				编码					|			对象
    
    REDIS_STRING			REDIS_ENCODING_INT						使用整数值实现的字符串对象
    REDIS_STRING			REDIS_ENCODING_EMBSTR					使用embstr编码的SDS实现的字符串对象
    REDIS_STRING			REDIS_ENCODING_RAW						使用SDS实现的字符串对象
    	
    REDIS_LIST				REDIS_ENCODING_ZIPLIST					使用压缩列表实现的列表对象
    REDIS_LIST				REDIS_ENCODING_LIKNEDLIST				使用双端链表实现的列表对象
    
    REDIS_HASH				REDIS_ENCODING_ZIPLIST					使用压缩列表实现的哈希对象
    REDIS_HASH				REDIS_ENCODING_HT						使用字典实现的哈希列表
    
    REEDIS_SET				REDIS_ENCODING_INTSET					使用整数集合实现的集合对象
    REEDIS_SET				REDIS_ENCODING_HT						使用字典实现的集合对象
    
    REDIS_ZSET				REDIS_ENCODING_ZIPLIST					使用压缩列表实现的有序集合列表
    REDIS_ZSET				REDIS_ENCODING_SKIPLIST					使用跳跃表和字典实现的有序集合对象
    

    通过encoding来设定对象所使用的的编码,而不是为特定类型的对象关联一种固定的编码,极大提升了Redis的灵活性和效率,因为Redis可以根据不同的场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。(数据少用一个编码,数据多转换编码)

这里介绍下embstr,这个编码方式是专门用于保存的是一个字符串值,并且这个字符串值的长度小于等于39字节。即专门用于保存短字符串的优化编码模式

8.2 字符串对象

​ 字符串对象可以是 int, raw , embstr

如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象将整数型保存在ptr中(将void* 设置为long),并将字符串对象的编码设置为int。

至于raw和embstr的选取。embstr我们都知道是一种专门用于保存短字符串的优化编码。

优化原因:

  • raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而在embstr只会调用一次内存分配函数,连续空间包括redisObject结构和sdshdr结构。

  • 同样的,释放embstr编码字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次。

  • 因为embstr编码的字符串对象的所有数据都保存在一块连续内存中,使用缓存会更高效

		字符串对象保存各类型值得编码方式
	值									|				编码
可以用long类型保存的整数							int
可以用long double类型保存的浮点数					embstr或者raw
字符串值,或者长度太大的long, long double 		   embstr或者raw

编码的转换:

​ 字符串在条件满足的时候,会对编码进行转换。这个很好的理解,但是注意一点:

由于Redis没有为embstr编码的字符串对象编写任何相应的修改程序,所以embstr编码字符串时只读的。要想修改embstr,那么它会先变成Raw编码的字符串对象

  • 字符串命令的实现

    字符串键的值为字符串对象,所以用于字符串键的所有命令都是针对字符串对象来构建的。

    字符串命令有:

    • SET

    • GET

    • APPEND

    • INCRBYFLOAT

    • INCRBY

    • DECRBY

    • STRLEN

    • SETRANGE

    • GETRANGE.

这些命令具体意思和用法都可以参见原书P68页(实在太多了)

8.3 列表对象

​ 列表对象的编码可以是ziplist 或者linkedlist

ziplist编码底层数据结构为压缩列表,每个压缩列表节点保存了一个列表元素。

linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点保存着一个字符串对象,每个字符串对象里面保存了一个列表元素。(二者需要区分)

另外,需要注意的是:字符串对象是Redis中五种类型对象中唯一一个能被其他四个对象嵌套的对象。

  • 编码转换:

    当列表对象可以同时满足以下两个条件时,列表对象用ziplist编码。

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

    如果不满足,那么就会使用linkedlist编码(全部元素都使用)

    (注:以上两个条件的上限值是可以修改的,具体请看配置文件说明)

  • 列表命令的实现

    因为列表键的值为列表对象,所以用于列表键的所有命令都是针对列表对象构建的。

    以下为部分列表建命令

    • LPUSH
    • RPUSH
    • LPOP
    • RPOP
    • LINDEX
    • LLEN
    • LINSERT
    • LREM
    • LTRIM
    • LSET

    具体的实现方法以后补充

8.4 哈希对象

​ 哈希对象的编码可以是ziplist或者 hashtable

ziplist编码的哈希对象使用压缩列表作为底层实现,当有新键值对要加入哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾。

​ 因此,哈希表在压缩列表中存储键和值总是挨着的,并且先添加的键值对离表头更近。

hashtable编码的哈希对象使用字典作为底层实现,哈希对象的每个键值对都使用一个字典键值对来保存:

字典中的键和值都是字符串对象,对象分别保存了键值对的键和值。

  • 编码转换

    当哈希对象可以同时满足以下两个条件时,哈希对象用ziplist编码。

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

如果不满足,那么就会使用hashtable编码(全部元素)

(注:以上两个条件的上限值是可以修改的,具体请看配置文件说明)

  • 哈希命令的实现

    因为哈希建的值为哈希对象,所以用于哈西家你的所有命令都是针对哈希对象和来构建的

    哈希命令:

    • HSET
    • HGET
    • HEXISTS
    • HDEL
    • HLEN
    • HGETALL
8.5 集合对象

​ 集合对象的编码可以使intset 或者hashtable

intset编码的集合对象使用整数集合作为底层实现,集合对象的所有元素都保存在整数集合里面。

hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串,每个字符串包含了一个集合元素,而字典的值全部设置为NULL。

  • 编码转换

    当列表对象可以同时满足以下两个条件时,列表对象用intset编码。

    • 集合对象保存的所有元素都是整数值
    • 结合对象保存的元素数量小于512个

    如果不满足,那么就会使用hashtable编码(全部元素都使用)

    (注:以上第二个条件的上限值是可以修改的,具体请看配置文件说明)

  • 集合命令的实现

    因为集合键的值为集合对象,所有用于集合间的所有命令都是针对集合对象构建的。

    集合命令的实现方法

    • SADD
    • SCARD
    • SISMEMBER
    • SMEMBERS
    • SRANDMEMBER
    • SPOP
    • SREM
8.6 有序集合

​ 有序集合的编码可以是ziplist或者skiplist

ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素则保存元素的分值。

​ 压缩列表的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的位置,而分值较大的元素则被放置在靠近表尾的位置。

skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表

typedef struct zset{
    // 跳跃表部分
    // zsl跳跃表按分值从小到大保存了所以集合元素,每个跳跃表节点都保存了一个集合元素:
    // 跳跃表节点的object属性保存了元素成员,score属性保存了元素的分值。
    zskiplist* zsl;
    
    // 字典部分
    // dict字典为有序集合创建了一个从成员到分值的映射,每个键值对都保存着集合元素,这保证O(1)复杂度找到
    // 给定成员的分值
    dict* dict;
};

zset结构同时使用跳跃表和字典来保存有序集合,这两种数据结构都会通过指针来共享相同的成员和分值,所以同时使用两个结构保存集合元素不会产生任何重复成员或分值,也不会因此浪费额外内存

为何要同时使用两种结构? 是为了我们将两种优点集齐。字典能够O(1)复杂度查找成员分值,而字典是无序的,skiplist能够实现有序集合。

  • 编码转换

    当有序集合对象可以同时满足以下两个条件时,列表对象用ziplist编码。

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

    如果不满足,那么就会使用skiplist编码(全部元素都使用)

    (注:以上两个条件的上限值是可以修改的,具体请看配置文件说明)

  • 有序集合命令的实现

    因为有序集合键的值为有序集合对象,所有用于有序集合键的所有命令都是针对有序集合对象来构建。

    有序集合命令:

    • ZADD
    • ZCARD
    • ZCOUNT
    • ZRANGE
    • ZREVRANGE
    • ZRANK
    • ZREVRANK
    • ZREM
    • ZSCORE
8.7 类型检查与命令多态

Redis用于操作间的命令基本上可以分为两种类型。

其中一种命令可以对任何类型的键执行,比如说DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令。另一种命令只能对特定类型的键执行。就相当于前面的一些命令。

  • 类型检查的实现

    类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的:

    • 执行命令前,服务器检查输入数据库键的值对象是否为执行命令所需的类型,是的话,服务器执行指定命令
    • 否则,拒绝执行命令,并向客户端返回一个类型错误。
  • 多态命令的实现

    多态命令分为两种,一种是DEL这种,基于类型多态——一个命令可以同时用于处理多种不同类型的。而LLEN这种是基于编码的多态——一个命令可以同时用于处理多种不同的编码

8.8 内存回收

​ C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(这里的引用计数就是shared_ptr 所实现的技术),这里就不赘述。

typedef struct redisObject{
    // ...
    
    //引用计数
    int refcount;
    
    // ...
}
8.9 对象共享

​ 除了实现引用计数内存回收机制之外,引用计数属性还能带来对象共享作用。

在Redis中,让多个键共享同一个值对象需要执行一下两个步骤:

  • 将数据库键的值指针指向一个现有的值对象
  • 将被共享的值对象引用计数增一

共享对象机制对于节约内存非常有用,数据库中保存的相同值对象越多,对象共享机制就能节约越多的内存。

但是,Redis只能共享整数值对象,字符串的对象不共享。因为验证共享对象和目标对象是否相同是需要复杂度的,在整数值对象中复杂度是O(1), 在字符串对象就是O(N),因此为了CPU的考虑,所有只对整数值的字符串对象进行共享。

8.10 对象的空转时长
typedef struct redisObject{
    // ...
    
    //引用计数
    unsigned lru : 22;
    
    // ...
}

空转时长是通过将当前时间减去键的值对象的LRU时间计算得出的。

8.11 重点回顾
  • Redis数据库每个键值对的键和值都是一个对象
  • Redis共有字符串,列表,哈希,集合,有序集合五种类型的对象,每种类型的对象至少都有两种或以上的编码方式,不同的编码可以在不同的使用场景上优化对象的使用效率。
  • 服务器在执行某些命令之前,会先检查给定键的类型能否执行指定的命令,而检查一个键的类型就是检查键的值对象的类型
  • Redis的对象系统带有引用计数实现内存回收机制,当一个对象不在被使用时,该对象所占用的内存就会被自动释放。
  • Redis会共享值为0-9999的字符串对象
  • 对象会记录自己最后一次被访问的时间,这个时间可以用于计算对象的空转时间。

第二部分: 单机数据库的实现

9. 数据库

9.0 介绍

​ 本章将对Redis服务器的数据库进行详细介绍。说明服务器保存数据库的方法,客户端切换数据库的方法,数据库保存键值对的方法,以及针对数据库的添加、删除、查看、更新操作的实现方法等。此外,服务器针对键的过期的方法。

9.1 服务器中的数据库

​ Redis服务器将所有数据库都保存在服务器状态结构的db数组中。每一个db数组项代表着一个服务器

struct redisServer{
    // ...
    
    // 一个数组,保存着服务器中所有的数据库
    redisDb *db;
  	// 服务器的数据库数量, 有服务器配置的database选项决定,默认情况下,该选项的值为16.即redis服务器会创建16个数据库。
    int dbnum;
    
    // ...
};
9.2 切换数据库

​ 每个Redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者数据库读命令的时候,目标数据库会成为这些命令的操作对象。 默认情况下,操纵的都是0号数据库。但是客户端可以用SELECT命令切换数据库。

为什么可以这样呢? 这是因为在服务器内部,还有另外一个结构标志着客户端的状态。

struct redisClient{
    // ...
    
    // 记录客户端当前正在使用的数据库
    // 该指针指向redisServer.db数组其中的一个项,这个项就是客户端的目标数据库。
    // 使用SELECT 2,可以将目标数据库改为2号数据库
    redisDb *db;
    
    // ...
};
9.3 数据库键空间

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

struct redisDb {
  	// ...
    
    // 数据库键空间,保存着数据库中的所有键值对
    dict *dict;
    
    // ...
};
  • 键空间的键就是数据库的键,每个键都是一个字符串对象。
  • 键空间的值就是数据库的值,这个值可以是五种对象的任意一个。

由于键空间是字典结构,那么可以对这个字典进行添加删除更新取值等操作。

  • 添加新建:SET命令等(如HSET)

  • 删除键:DEL命令

  • 更新键:SET命令等(如HSET)

  • 对键取值:GET命令等(如LRANGE)

    # 		命令  键    值
    
    # 添加
    redis > SET date "2013.12.1"
    OK
    
    # 删除	
    redis > DEL book
    (integer) 1
    
    # 更新
    redis > SET message "blah blah"
    OK
    redis > HSET book page 320
    (integer) 1
    
    # 取值
    redis > GET message
    "hello world"
    
    redis > LRANGE alphabet 0 -1
    1) "a"
    2) "b"
    3) "c"
    
  • 其他键操作:如清空数据库的FLUSHDB,返回数据库键数量的DBSIZE命令等。

  • 读写键空间的维护操作

    • 在读取一个键时(读和写操作都要对键进行读取),服务器会根据键是否存在更新服务器的键空间命中次数和不命中次数。(这两个可以在INFO stats命令中查看keyspace_hits属性和keyspace_misses属性中查看。

    • 在读取一个键的之后,服务器会更新键的LRU时间,这个时间可以计算键的闲置时间,使用命令OBJECT idletime<key>可以查看键key的闲置时间。

    • 如果服务器读取一个键时发现这个键已经过期了,那么服务器会先删除这个键,再执行余下的操作。

    • 如果有客户端使用WATCH命令监视某个键,那么服务器对该键进行修改之后,会将这个键标记为脏(dirty),从而让事务注意到这个键已经被修改了。

    • 服务器每次修改一个键之后,都会对脏键计数器增加1,这个计数器会触发服务器的持久化和复制操作

    • 如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器会按照配置发送相应的数据库通知。

9.4 设置键的生存时间或过期时间
  • 命令

​ 通过EXPIRE命令或者PEXPIRE命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间(TTL)——在经过指定的时间后,服务器会自动的将这个键给删除。(P是毫秒)

redis> set key value
OK
redis> expire key 5
(integer) 1
redis> get key    // 5秒之内
"value"
redis> get key 	  // 5秒之后
(nil)

​ 同样的,通过EXPIREAT命令或PEXPIREAT命令,客户端可以以秒或者毫秒精度给数据库中的某个键设置过期时间(expire time)—— 过期时间是个时间戳,当时间到来之后,服务器会自动删除这个键。

redis> set key value
ok
redis> expireat key 1377257300	// 1377257300 是一个时间戳,表示没到这个时间是存在的

​ 以上两个是生存时间和过期时间命令的用法。现在我们可以去了解下这些命令的实现方式。

实际上,EXPIRE,PEXPIRE, EXPIREAT三个命令都是使用PEXPIREAT命令实现的。他们都会通过一定的转换来得到这个命令。

  • 过期字典

    redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典是过期字典。

    • 过期字典的键是一个指针,指向键空间中的某个键对象(即某个数据库键)
    • 过期字典的值是一个long long类型的整数,这个整数保存了键所保存的数据库键的过期时间——一个毫秒精度的UNIX时间戳。
  • 移除过期时间

    通过PERSIST命令可以移除一个键的过期时间(该命令是PEXPIREAT命令的反操作), 在过期字典中查找给定的键,并解除键和值的关联。

  • 计算生存时间

    通过TTL/PTTL以秒/毫秒来计算并返回键的剩余生存时间。

    # 伪代码
    def pttl(key):
        # 键不存在
        if key not in redisDb.dict:
            return -2
        # 尝试获得键的过期时间
        # 如果键没有设置过期时间,那么 expire_time_in_ms 将为 None
        expire_time_in_ms = redisDb.expires.get(key)
        
        # 键没有设置过期时间
        if expire_time_in_ms is None:
            return -1
        
        # 获得当前时间
    	now_ms = get_current_unix_timestamp_in_ms()
        # 过期时间减去当前时间,得出的差就是键的剩余生存时间
        return (expire_time_in_ms - now_ms)
    
  • 过期键的判定

    通过检查过期字典,程序可以检查一个给定键是否过期

    • 检查给定键是否存在于过期字典: 如果存在,那么取得键的过期时间。
    • 检查当前UNIX时间戳是否大于键的过期时间:如果是的话,那么键已经过期,否则键为过期。
9.5 过期键的删除策略

​ 上节了解到过期键会保存在过期字典中,那么问题来了,采用什么策略来删除过期键呢? 以下有三种可能答案

  1. 定时删除:在设置键的过期时间时,同时创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。
  2. 惰性删除:放任键过期不管,每次从键空间取出键的时候,检查是否过期,如果过期的话那么就删除。
  3. 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。

其中1和3是主动删除,2是被动删除。

  1. 定时删除:该策略是对内存最友好的,定时器能够确保过期键尽可能快的被删除,并且释放过期键所占的内存。但是这个是对CPU时间最不友好的,因为删除过期键会占用一部分CPU时间,当CPU时间紧张的时候就可能对服务器性能造成影响。
  2. 惰性删除:这个策略和上面的策略相反,对内存最不友好,对CPU时间最友好。
  3. 定期删除:该策略是两者策略的一种折中,但是这个难点是如何确定删除操作执行的时长和频率。
9.6 Redis的过期键删除策略

​ Redis服务器实际上使用的是惰性删除+定期删除,这样可以合理使用CPU时间和避免浪费内存空间之间取得平衡。

  • 惰性删除策略的实现

    过期键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前会调用该函数对输入键进行检查。

    • 如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中的删除。
    • 如果输入键未过期,那么expireIfNeeded函数不做动作。

    该函数相当于一个过滤器,可以让真正的命令接触不到过期键。

  • 定期删除策略的实现

    过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作执行时,这个函数都会被调用。它在规定时间内,分多次遍历服务器的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。

9.7 AOF,RDB和复制功能对过期键的处理
  • 针对RDB文件

    • 生成RDB文件:一言以蔽之,已过期的键不会被保存到新创建的RDB文件中

    • 载入RDB文件:

      在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:

      • 如果服务器是主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入数据库,过期键被忽略。
      • 如果是从服务器模式运行,则无论是否过期,都会直接载入到数据库中。不过,**因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空。**因此不会对从服务器造成影响。
  • 针对AOF文件

    • AOF写入

      当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但还没有被删除,那么对AOF文件不会造成任何影响。

      当过期键被删除之后,AOF会在文件追加一条DEL命令,来显示记录该键被删除。

    • AOF重写

      当重写的时候,那么直接忽略已过期的键即可。

  • 复制模式

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

    • 主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键。

    • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样处理过期键。

    • 从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键。

9.8 数据库通知

​ 数据库通知这个功能可以让客户端通过订阅给定的频道或者模式,来获取数据库中的键的变化,以及数据库中国命令的执行情况。

​ // todo

9.9 重点回顾
  • Redis服务器的所有数据库都保存在redisServer.db数组中,而数据库的数量则由redisServer.dbnum属性保存。
  • 客户端通过修改目标数据库指针,让它指向redisServer.db数组中的不同元素来切换不同的数据库。
  • 数据库主要有dict和expires两个字典构成,其中dict字典负责保存键值对,而expires字典则负责保存键的过期时间。
  • 因为数据库由字典构成,所有对数据库的操作都是建立在字典操作的基础上。
  • 数据库的键总是一个字符串对象,而值则可以是任意一种Redis对象类型。
  • expires字典的键指向数据库的某一个键,而值则记录了该数据库键的过期时间,是一个以毫秒为单温的UNIX时间戳。
  • Redis使用惰性删除和定期删除两种策略删除过期键: 惰性删除只在碰到过期键才进行删除工作,定期删除是每隔一段时间主动查找并删除过期键。
  • 执行SAVE和BGSAVE命令所产生的RDB文件不会包含已经过期的键。
  • 执行BGREWRITEAOF命令所产生的重写AOF文件不会包含过期的键。
  • 当一个过期键被删除之后,服务器会追加一条DEL命令在AOF文件末尾,显式删除过期键。
  • 当***主服务器***删除一个过期键之后,它会向所有***从服务器***发送一条DEL命令,显式删除过期键。
  • 从服务器发现过期键也不会删除它,而是等待主服务器发来DEL命令,才会删除它。这样统一、中心化过期键删除策略保证主从服务器数据的一致性
  • 当Redis命令对数据库进行修改之后,服务器会根据配置向客户端发送数据库通知。

10. RDB持久化

10.0 基本介绍

为什么会提到持久化功能?

​ 这是因为Redis是内存数据库,它将自己的数据库状态储存在内存里面,如果不想办法将存储在内存中的数据库状态保存在磁盘中的话,一旦服务器进程退出,服务器中的数据库状态也会发生变化。

为了解决该问题,Redis提供了RDB持久化功能,这个功能可以将Redis在内存中数据库状态保存到磁盘里面,避免数据意外丢失。

RDB持久化功能所生成的RDB文件是一个经过压缩的二进制文件,RDB文件是保存在硬盘里面的,只要RDB文件还在,Redis服务器就可以用它来还原数据状态

10.1 RDB文件的创建和载入
  • 创建RDB文件

​ 有两个Redis命令可以用于生成RDB文件,一个是SAVE,一个是BGSAVE。二者区别是什么?

​ SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何请求。

​ BGSAVE命令会派生出一个子进程,然后有子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求。

(那么这个子进程处理完了如何通知父进程?)—— 靠信号

  • 载入RDB文件

    Redis没有载入RDB文件的命令。其载入工作是在服务器启动时自动执行的,服务器在启动检测到RDB文件存在,它就会自动载入RDB文件。

    Q: 那如果有多个RDB文件该怎么处理?

    此外,由于AOF文件的更新频率通常比RDB文件的更新频率高,所以:

    • 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。
    • 只有在AOF持久化处于关闭功能时,服务器才会使用RDB文件来还原数据库状态。

    服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入完成。

  • SAVE, BGSAVE命令

    ​ 当使用SAVE命令时,服务器阻塞了。因此一般来说SAVE命令不用。SAVE命令可以作为执行转储最新数据集的最后手段。

    ​ 当使用BGSAVE命令时,服务器不能使用SAVE命令,也不能使用BGSAVE。防止两个命令产生竞争条件。

    ​ 当使用BGSAVE命令时,服务器也不能用BGREWRITEAOF命令,这不是因为会产生竞争条件,而是二者都是创建子进程来执行对磁盘进行大量写入操作,这样的话浪费资源。

10.2 自动间隔性保存

​ 由于BGSAVE可以在不阻塞服务器进程的情况下执行,所以Redis允许用户通过设置服务器配置的Save选项,让服务器每隔一段时间自动执行一次BGSAVE命令。

​ 用户可以设置多个保存条件,只要其中一个满足,BGSAVE命令就会被执行。这个保存条件见下结构

struct redisServer {
    // ...
    
	// 记录了保存条件的数组
    // 数组中的每个元素都是一个saveparam结构,每个saveparam结构中都保存着一个save选项设置的保存条件。
    struct saveparam * saveparams;
    
    // ...
    // 修改计数器
    long long dirty;
    
    // 上一次执行保存的时间
    time_t lastsave;
}
saveparams[0]saveparams[1]saveparams[2]
seconds 900seconds 300seconds 60
changes 1changes 10changes 10000

​ (服务器状态中的保存条件)

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

  1. dirty计数器记录距离上次成功执行SAVE或者BGSAVE命令之后,服务器对数据库状态**(服务器中的所有数据库)**进行了多少次修改。
  2. lastsave属性是一个时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间。

针对这些保存条件,Redis服务器周期性操作函数会在默认情况下每隔100ms执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否满足,如果满足的话,就执行BGSAVE命令。

10.3 RDB文件结构

​ 一个完整的RDB文件如下表所示:(RDB文件是一个二进制文件)

结构项REDISdb_versiondatabasesEOFcheck_sum
长度(bytes)540-xxxx18
存储数据“REDIS”字符串整数如”0006“,代表第六版存储0到多个数据库,文件结构核心标志正文内容结束校验和

databases: 每个非空数据库在RDB文件中都可以保存为三个部分

SELECTDBdb_numberkey_value_pairs
常量,1byte,标识作用可能是1/2/5bytes(数据库号码)保存着该数据库中所有键值对

key_value_pairs: 这个部分保存了一个或多个键值对,如果键值对带有过期时间的话,那么也会保存进去。

如果是不带过期键的键值对的话,在RDB文件中由三个部分组成:

TYPEkeyvalue
1bytes, 记录value的类型字符串对象值对象

如果有过期键,那么由五个部分组成:

EXPIRETIME_MSmsTYPEkeyvalue
1byte,常量,标识作用8bytes,过期时间(以毫秒为单位的UNIX时间戳)1byte, 记录value的类型字符串对象值对象

针对value对象,会有一个编码。(这个编码是第八章中介绍过的)

编码大概意思就是根据Type值不同,选择不同的对象结构。对于字符串结构来说,如果长度较短(小于等于20字节),原样存储,如果长度较大,那么就压缩后存储。

这里有个细节,由于RDB是二进制文件,如果是INTSET整数集合编码的话,RDB文件保存这种对象的方法是,先将整数集合转换为字符串对象,然后将这个字符串对象保存到RDB文件里面

(ZIPLIST编码的列表,哈希表或者有序集合都是先转换成一个字符串对象,再将该字符串对象保存到RDB文件中。)

10.4 分析RDB文件

使用下面的命令对保存了的RDB文件进行分析

od -c temp.rdb (ASCII码分析)

0000000 R E D I S 0 0 0 9 372 \t r e d i s
0000020 - v e r 005 5 . 0 . 7 372 \n r e d i
0000040 s - b i t s 300 @ 372 005 c t i m e 302
0000060 > o D ` 372 \b u s e d - m e m 302 210
0000100 346 \a \0 372 \f a o f - p r e a m b l
0000120 e 300 \0 376 \0 373 001 \0 016 006 f r u i t s
0000140 001 " " \0 \0 \0 031 \0 \0 \0 003 \0 \0 005 a p
0000160 p l e \a 006 b a n a n a \b 006 c h e
0000200 r r y 377 377 226 312 026 300 W f t d
0000215

od -cx temp.rdb (十六进制分析)

0000000 R E D I S 0 0 0 9 372 \t r e d i s
0000020 - v e r 005 5 . 0 . 7 372 \n r e d i
0000040 s - b i t s 300 @ 372 005 c t i m e 302
0000060 > o D ` 372 \b u s e d - m e m 302 210
0000100 346 \a \0 372 \f a o f - p r e a m b l
0000120 e 300 \0 376 \0 373 001 \0 016 006 f r u i t s
0000140 001 " " \0 \0 \0 031 \0 \0 \0 003 \0 \0 005 a p
0000160 p l e \a 006 b a n a n a \b 006 c h e
0000200 r r y 377 377 226 312 026 300 W f t d
0000215

认真分析里面的东西。大概就明白了RDB文件如何存储。

10.5 重点回顾
  • RDB文件用于保存和还原Redis服务器所有数据库中的所有键值对数据。
  • SAVE命令由服务器进程直接执行保存操作,所以该命令会阻塞服务器。
  • BGSAVE命令由子进程执行保存操作,所以该命令不会阻塞服务器。
  • 服务器状态中会保存所有用save选项设置的保存条件,当任意一个保存条件被满足时,服务器会执行BGSAVE命令。
  • RDB文件是一个经过压缩的二进制文件,由多个部分组成。
  • 对于不同类型的键值对,RDB文件会使用不同的方式来保存它们。
10.6 一些问题
  • 当我设置几个键值对之后保存RDB文件之后,重启这个客户端,想要修改这几个保存的键值对,发现会出现一下错误。

MISCONF Redis is configured to save RDB snapshots, but is currently not able to persist on disk. Commands that may modify the data set are disabled. Please check Redis logs for details about the error.

Redis被配置为保存数据库快照,但它目前不能持久化到硬盘。用来修改集合数据的命令不能用。请查看Redis日志的详细错误信息。

网上的原因:

​ 强制关闭Redis快照导致不能持久化。本质上是内存不足导致的。因此需要进行相关的内存处理:

修改Redis config 修改快照备份的目录,即将快照重定向到其他目录 修改主机内存配置

方案1:将stop-writes-on-bgsave-error设置为no,这个方式是直接关闭保存持久化快照

127.0.0.1:6379> config set stop-writes-on-bgsave-error no

这个在生产中可能会出现一些问题,这个然而这个方法治标不治本,他只是让我们“忽略”他而已,使用之前需要确认bgsave失败的原因,比如当Redis用于缓存、会话的场景的时候,这么做是允许的

方案2: 将备份的rdb文件,重定向到目录

CONFIG SET dir /tmp/some/directory/other/than/var
CONFIG SET dbfilename temp.rdb

使用这个命令之后,需要确保bgsave_in_progress返回结果是0

方案3: 在内核运行时动态地修改内核的运行参数

echo 'vm.overcommit_memory = 1' >> /etc/sysctl.conf
sysctl vm.overcommit_memory=1

11. AOF持久化

11.0 基本介绍

​ RDB持久化是通过保存数据库中的键值对来记录数据库状态不同。AOF持久化是通过保存Redis服务器所执行写命令来记录数据库状态。

​ 服务器在启动的时候,可以通过载入和执行AOF文件中保存的命令来还原服务器关闭之前的数据库状态。

​ 本章将介绍AOF持久化功能,说明AOF文件的写入、保存、载入等操作的实现原理。然后再介绍AOF重写功能以及实现原理。

11.1 AOF持久化实现

​ AOF持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。

  • 命令追加

    当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾

    struct redisServer{
      	// ...
        
     	// AOF Buffer
        sds aof_buf;
        
        // ...
    };
    
  • 文件写入与同步

    ​ Redis的服务器进程就是一个事件循环(loop), 这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复。而时间事件则执行定时运行的函数。

    ​ 在文件事件中会有写命令,使得内容被追加到缓冲区中,在服务器每次结束一个时间循环之前,它都会调用flushAppendOnlyFile函数,考虑将缓冲区中的内容写入和保存到AOF文件中。

    flushAppendOnlyFile函数根据由服务器配置的appendfsync选项的值来决定,有三种行为。

    appendfsync选项值flushAppendOnlyFile函数的行为效率
    alwaysaof_buf缓冲区中所有内容写入并同步到AOF文件
    everysecaof_buf缓冲区中所有内容写入到AOF文件,如果上次同步时间距离现在超过1s,那么再次进行同步。同步操作是由一个线程专门负责执行的。适中
    noaof_buf缓冲区中所有内容写入到AOF文件,但并不对AOF文件进行同步,何时同步由操作系统来决定。

    这个选项值默认是everysec

11.2 AOF文件的载入与数据还原

​ 因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。

​ Redis读取AOF文件并还原数据库状态的详细步骤如下:

  1. 创建一个不带网络连接的伪客户端:因为Redis命令只能在客户端上下文中执行, 而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器直接使用一个为客户端来执行AOF文件中写命令的效果和真实客户端效果相同。
  2. 从AOF文件中分析并读取一条写命令
  3. 使用伪客户端执行被读出的写命令
  4. 一直执行2和3,知道AOF中命令全部处理完。
11.3 AOF重写

Q : 为什么要进行AOF重写

​ 因为AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件体积越来越大。这样会对AOF还原的效率产生影响,甚至会对服务器产生影响。

​ 为了解决AOF文件体积膨胀问题,Redis提供了AOF文件重写功能。通过该功能,Redis服务器创建一个新的AOF文件替代现在的AOF文件,两个文件的数据库状态相同,但是新AOF文件不会包含任何浪费空间的冗余命令

  • 实现AOF重写

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

    首先从数据库中读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令。

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

    注意: 在大部分情况下,大家都是直接用一条命令设置一个键的值,但是如果这个值的元素数量超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD 常量(一般这个常量为64),重写程序则会用多条命令来记录键的值。

  • AOF后台重写(BGREWRITEAOF命令)

    AOF重写程序需要进行大量写入操作,因此Redis决定将该程序放到子进程里执行,这样的话有两个优点

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

    Q:在子进程重写的时候,服务器有写命令执行怎么办?

    A: 这里提供了一个AOF重写缓冲区,这个缓冲区创建子进程之后开始使用,当Redis服务器执行完一个命令之后,它会同时将这个写命令发送给AOF缓冲 区和AOF重写缓冲区。‘

    这里保证在AOF重写的时候,服务器执行的所有写命令都被记录到AOF重写缓冲区。等子进程AOF重写之后,会给父进程发送一个信号,父进程收到信号,会调用一个信号处理函数,并执行一下工作:

    1. 将AOF重写缓冲区的所有内容写入到新的AOF文件中,这是新的AOF文件保存的数据库文件和服务器当前的数据库状态一致。
    2. 对新的AOF文件进行改名,原子覆盖现有的AOF文件。

    在这个过程中,只有信号处理函数会对父进程造成阻塞,其他部分都不会造成阻塞。

11.4 重点回顾
  • AOF文件通过保存所有修改数据的写命令请求来记录数据库状态
  • AOF文件中的所有命令都以Redis命令请求协议的格式保存
  • 命令请求会先保存到AOF缓冲区,再定期的写入并同步到AOF文件中
  • appendfsync选项的不同值对AOF持久化功能的安全性和Redis服务器的性能有很大影响
  • 服务器只要载入并重新执行保存唉AOF文件中的命令,就可以还原数据库本来的状态
  • AOF重写是产生一个效果相同但是体积更小的AOF文件
  • AOF重写和上一个AOF文件无关,是根据目前数据库状态来实现的。无需对上一个AOF文件进行任何的操作。
  • 在执行BGREWRITEAOF命令时,Redis会设置一个AOF重写缓冲区。重写完成之后,该缓冲区的内容会追加(APPEND)到新的AOF文件中。最后新的AOF文件会原子性的覆盖旧的AOF文件,以此完成AOF文件重写操作。

12. 事件

11.0 基本介绍

​ Redis是一个事件驱动程序,服务器需要处理以下两类事件:

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

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

  • 文件事件处理器使用的I/O多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接应答、读取、写入、关闭等操作时,与操作相关的文件事件就会产生,这时文件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器是以单线程方式运行,但通过使用I/O多路复用程序来监听多个套接字,文件事件既能实现高性能的网络通信模型,又可以很好地与Redis服务器中其他同样以单线程运行的模块对接,保证了Redis内部单线程设计的简单性。

  1. 文件事件处理器的构成

    文件事件处理器四个组成部分:套接字、I/O多路复用程序、文件事件分派器以及事件处理器。
    文件事件处理器

    • 套接字:文件事件是对套接字操作的抽象,每当一个套接字准备好执行操作时,就会产生一个文件事件。
    • I/O多路复用程序:负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。 在Redis服务器中,套接字的分派是有序,同步的。只有当上一个套接字处理完,I/O多路复用程序才会向文件分派器传送下一个套接字。
    • 文件分派器: 根据传送过来的套接字产生的事件类型,调用相应的事件处理器。
  2. I/O多路复用程序的实现

    Redis的I/O多路复用程序的所有功能都是包装常见的select,epoll,evport,kqueue这些I/O多路复用函数库来实现的。每个函数库在Redis源码对应一个文件。这些文件构成了Redis的I/O多路复用程序的底层实现,这些底层是可以互换的。程序会在编译时自动选择系统中性能最高的I/O多路复用函数库来作为Redis的多路复用底层实现。

  3. 事件类型

    • AE_READABLE:当套接字变得可读时(客户端对套接字执行write操作或者是close操作),或者有新的可应答套接字出现时(connect操作),套接字产生AE_READABLE事件。
    • AE_WRITABLE: 当套接字变得可写时(客户端对套接字执行read操作),套接字产生该事件。

    I/O多路复用程序允许服务器同时监听可读和可写事件。但是同时到来的时候,服务器将先读套接字,再写套接字。

  4. 文件处理器

    Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求,比如说:

    • 为了对连接服务器的各个客户端进行应答,服务器要为监听套接字关联连接应答处理器。(包装accept函数)

    • 为了接收客户端传来的命令请求,服务器要为客户端套接字关联命令请求处理器。(包装read函数)

    • 为了向客户端返回命令执行结果,服务器要为客户端套接字关联命令回复处理器。(包装write函数)

    • 当主服务器和从服务器进行复制时,主服务器都需要关联特别为复制功能编写的复制处理器

12.2 时间事件

​ Redis时间事件分为以下两类:

  • 定时事件:让一段程序在指定的时间之后执行一次。
  • 周期性事件:让一段程序每隔指定时间就执行一次。

​ 一次时间事件主要由三个属性组成:

  • id:服务器为时间事件创建全局唯一ID,ID是自增的。
  • when: 毫秒精度的Unix时间戳,记录了时间事件的到达时间。
  • timeProc: 时间事件处理器,一个函数。当时间时间到达时,服务器就会调用相应处理器来处理事件。

Q: 如何区别定时事件和周期性事件?

A: 事件处理器返回AE_NOMORE的整数值,那么这个事件为定时事件,如果返回事件整数值,那么为周期性事件。

如果为定时事件,那么事件在到达一次之后就会被删除。

如果为周期性时间,那么服务器根据事件处理器返回的值对时间事件的when属性进行更新,并继续执行。

​ 服务器将所有的时间事件都放在一个无序(指when属性的无序,id是逆序的)链表中。当时间事件执行器运行的时候,必须遍历整个时间事件,这样才能确保服务器中所有已到达的时间事件会被处理。

其伪代码如下:

def processTimeEvents():
    # 遍历服务器中的所有时间事件
    for time_event in all_time_event():
        # 检查事件是否已经到达
        if time_event.when <= unix_ts_now():
            # 事件已经到达
            # 执行时间事件处理器
            reval = time_event.timeProc()
            
            # 定时事件
            if reval == AE_NOMORE:
                # delete
                delete_time_event_for_server(time_event)
               
            # 周期事件: 根据reval更新when属性
            else:
                update_when(time_event, retval)
  • 时间事件应用实例:serverCron函数

    持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这些定期操作由serverCron函数执行,它的主要工作包括:

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

    serverCron函数每10s运行一次)

12.3 事件的调度和执行。

​ 当服务器中同时存在文件事件和时间事件两种事件类型的时候,先处理什么事件呢?

事件的调度和执行函数见下面伪代码:(这个也是服务器的主函数)

def aeProcessEvents():
    
    # 获取到达时间离当前时间最接近的时间事件
    time_event = aeSearchNearestTimer()
    
    # 计算最接近的时间事件还有多少毫秒
    remaind_ms = time_event.when - unix_ts_now()
    # 事件已到达,设置为0
    if remaind_ms < 0:
        remaind_ms = 0
        
    # 根据remaind_ms,创建相应的timeval结构    
    timeval = create_timeval_with_ms(remaind_ms)
    
    # 阻塞并等待文件事件产生,最大阻塞事件由传入的timeval结构决定
    # 如果remaind_ms = 0 那么直接返回不阻塞
    aeApiPoll(timeval)   # epoll_wait
    
    # 先处理所有已产生的文件事件
    processFileEvents()

    # 再处理所有已到达的时间事件
    processTimeEvents()

服务器运行流程

事件调度和执行的规则:

  • aeApiPoll最大阻塞事件由到达时间最接近的时间事件决定,这样该函数阻塞事件最短。
  • 文件事件随机出现,如果处理完该事件没有其它事件出现,那么会等待并文件事件。随着文件事件不断执行,时间文件不断逼近,服务器会开始处理时间事件。
  • 对文件事件和时间事件的处理都是同步、有序、原子地执行,服务器不会中途中断事件处理,也不会对事件进行抢占,他们只会尽可能的减少阻塞时间
  • 由于时间事件在文件事件之后处理,并且事件之间不会出现抢占,可能实际处理时间会比时间事件设置的时间稍晚一点
12.4 重点回顾
  • Redis是一个事件驱动时间,。服务器处理的事件分为时间事件和文件事件。
  • 文件事件处理器是基于Reactor模型
  • 文件事件是对套接字操作的抽象:套接字状态有三种**(可应答acceptable,可读readable,可写writable)**
  • 文件事件分为可读和可写事件两种。
  • 时间事件分为定时事件和周期性事件。
  • 服务器一般只执行一个时间事件serverCron函数,并且改为周期性事件。
  • 文件事件和时间事件是合作关系,服务器会轮流处理两种事件,不会发生抢占。
  • 时间事件实际处理时间会比设置的时间稍晚一点。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值