• redis设计与实现


    目录

      第2章:简单动态字符串

        2.1  SDS定义

        2.2  SDS与C字符串的区别

      第3章:链表

        3.1链表和表节点的实现

      第4章:字典

        4.1 字典的实现

        4.2 哈希算法

        4.3 键冲突解决:类似于hashmap(个人理解)

        4.4 rehash(重新散列)

        4.5 渐进式rehash

      第5章:跳跃表

        5.1 跳跃表实现

        6.2  升级

      第6章:整数集合

        6.1  整数集合的实现

      第7章:压缩列表

      第8章:对象

      第9章:数据库

        9.1 服务器中的数据

        9.2 切换数据库

        9.3 数据库键空间

        9.4 设置键的生存和过期时间

        9.5 过期键删除策略

        9.6 redis的过期键删除策略(惰性删除 + 定期删除)

        9.7 AOF、RDB 和复制功能对过期键的处理

      第10章:RDB持久化

      第11章:AOF持久化

      第15章:复制

        15.1&2 旧版复制功能的实现和缺陷

        15.3&4 新版复制的实现、部分重同步的实现

        15.5 PSYNC命令的实现

        15.7 心跳检测

      第16章:Sentinel

    第2章:简单动态字符串

    2.1  SDS定义

    /*
     * 保存字符串对象的结构
     */
    struct sdshdr {
        int len; // buf 中已占用空间的长度  
        int free; // buf 中剩余可用空间的长度
        char buf[];  // 数据空间
    };

     

     2.2  SDS与C字符串的区别

    第3章:链表

    3.1链表和表节点的实现

    节点结构

    /*
     * 双端链表节点
     */
    typedef struct listNode { 
        struct listNode *prev;  // 前置节点   
        struct listNode *next; // 后置节点
        void *value; // 节点的值
    } listNode;

    链表结构

    /*
     * 双端链表结构
     */
    typedef struct list {   
        listNode *head;  // 表头节点
        listNode *tail;  // 表尾节点
    unsigned long len; // 链表所包含的节点数量 void *(*dup)(void *ptr); // 节点值复制函数 void (*free)(void *ptr); // 节点值释放函数 int (*match)(void *ptr, void *key); // 节点值对比函数 } list;

    redis的链表特性如下:
      1)双向:每个listNode节点带有prev和next指针,可以找到前一个节点和后一个节点,具有双向性。
      2)无环:list链表的head节点的prev和tail节点的next指针都是指向null。
      3)带表头指针和尾指针:即上述的head和tail,获取头指针和尾指针的时间复杂度O(1)。
      4)带链表长度计数器;即list的len属性,记录节点个数,因此获取节点个数的时间复杂度O(1)。
      5)多态:链表使用void*指针来保存节点的值,可以通过list的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存不同类型的值。

    第4章:字典

    字典在redis中的应用相当广泛,比如redis的数据库就是使用字典作为底层实现的,对数据库的增删改查操作也是构建在对字典的操作之上的。

    4.1 字典的实现

    4.1.1  哈希表

    /*
     * 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
     */
    typedef struct dictht   
        dictEntry **table; // 哈希表数组
        unsigned long size;  // 哈希表大小;也是table数组大小
        unsigned long sizemask; // 哈希表大小掩码,用于计算索引值;总是等于 size - 1
        unsigned long used; // 该哈希表已有节点的数量
    } dictht;

    4.1.2  哈希表节点

    /*
     * 哈希表节点
     */
    typedef struct dictEntry {
        void *key;   //
        union {      //
            void *val;
            uint64_t u64;
            int64_t s64;
        } v;
        struct dictEntry *next;  // 指向下个哈希表节点,形成链表;指向另一个哈希表节点的指针,该指针将多个哈希值相同的键值对连接在一起,避免因为哈希值相同导致的冲突。
    } dictEntry;

     

    4.1.3  字典

    /*
     * 字典
     */
    typedef struct dict {
        dictType *type; // 类型特定函数(不懂)
        void *privdata; // 私有数据(不懂)
        dictht ht[2]; // 哈希表,其中h{0}为平时使用的,h[1]为rehash使用的;
        int rehashidx; // rehash 索引;当 rehash 不在进行时,值为 -1(rehashing not in progress if rehashidx == -1)
        int iterators; // // 目前正在运行的安全迭代器的数量;(number of iterators currently running)(不懂)
    } dict;

     4.2 哈希算法

    要将新的键值对加到字典,程序要先对键进行哈希算法,算出哈希值和索引值,再根据索引值,把包含新键值对的哈希表节点放到哈希表数组指定的索引上。redis实现哈希的代码是:
      hash =dict->type->hashFunction(key);
      index = hash& dict->ht[x].sizemask;
    算出来的结果中,index的值是多少,则key会落在table里面的第index个位置(第一个位置index是0)。
    其中,redis的hashFunction,采用的是murmurhash2算法:即使加入的键是有规律的,算法仍能给出一种很好的随机分布性,并且算法的计算速度也非常快。

    4.3 键冲突解决:类似于hashmap(个人理解)

    1,redis的哈希表使用链地址法(seprate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引的多个节点可以用这些单向链表连接起来,这就解决了键冲突的问题。

    2,为了速度上面的考虑,程序总是将新节点添加到链表的表头位置,复杂度为0(1),排在其它已有节点的前面。

    4.4 rehash(重新散列)

    4.5 渐进式rehash

    第5章:跳跃表

    跳跃表支持评价O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作批量处理节点

    在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树来得更简单,所以有不少程序都是用跳跃表来代替平衡树。

    5.1 跳跃表实现

    上图最左边的就是zskiplist结构,该结构包含以下属性:
    1 header:指向跳跃表的表头表头节点。
    2 tail:指向跳跃表的表尾节点。
    3 level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
    4 length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。
    
    位于zskiplist结构右方的四个zskiplistNode结构,该结构包含一下属性:
    1 层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的举例。在上图中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
    2 后退指针:节点中用BW(backward)字样标记节点的后退指针,他指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
    3 分值(score):各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排量。
    4 成员对象(obj):各个节点中o1、o2和o3是节点所保存的成员对象。
    注意:表头节点和其他节点的构造是一样的;表头节点也有后退指针、分值和成员对象,不过表头节点的这些属性不会被用到,所以图中省略了。

     ***跳跃表节点:

    /* ZSETs use a specialized version of Skiplists */
    /*
     * 跳跃表节点
     */
    typedef struct zskiplistNode {
        robj *obj; // 成员对象
        double score;    // 分值
        struct zskiplistNode *backward; // 后退指针
        //
        struct zskiplistLevel {
            struct zskiplistNode *forward; // 前进指针
            unsigned int span; // 跨度
        } level[];
    } zskiplistNode;

    1)层

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

     

    2)前进指针

    每个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点。下图用虚线表示出了程序从表头向表尾方向,遍历跳跃表中所有节点的路径:
    
           a 迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点。
           b 在第二个节点时,程序沿着第二层的前进指针移动到表中第三个节点。
           c 在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点。
           d 当程序再次沿着第四个节点的前进指针移动式,他碰到一个null,程序知道这时已经到达了跳跃表的表尾,于是结束这次遍历。

    3)跨度

    层的跨度用于记录两个节点之间的距离:
    a 两个节点之间的跨度越大,他们相距得就越远。
    b 指向null的所有前进指针的跨度都为0,因为他们没有连向任何节点。
           初看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前进指针就可以完成了,宽度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
           举个例子,下图用虚线标记了在跳跃表中查找分值为3.0、成员对象为o3的节点时,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为3,所以目标节点在跳跃表中的排位为3。

     再举个例子,下图用虚线标记了在跳跃表中查找分值为2.0、成员对象为o2的节点时,沿途经历的层:在查找节点的过程中,程序经过了两个跨度为1的节点,因此可以计算出,目标节点在跳跃表中的排位为2。

    4)后退指针

    节点的后退指针用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。

    5)分值和成员

    节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按照分值从小到大来排序。
    节点的成员对象是一个指针,他指向一个字符串对象,而字符串对象则保存着一个SDS值。
    在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点按照成员对象在字典中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。
    举个例子,在下图所示的跳跃表中,三个跳跃表节点都保存了相同的分值10086.0,但保存成员对象o1的节点却排在保存成员对象o2和o3的节点之前,由顺序可知,三个对象在字典中的排序哦o1<=o2<=o3。

    ***跳跃表

    /*
    * 通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点的数量等信息。
    * header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头及诶点和表尾节点的复杂度为O(1)。
    * 通过使用length属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度。
    * level属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内。
    */ typedef struct zskiplist { struct zskiplistNode *header, *tail; // 表头节点和表尾节点 unsigned long length; // 表中节点的数量 int level; // 表中层数最大的节点的层数 } zskiplist;

    第6章:整数集合

    6.1  整数集合的实现

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

     6.2  升级

    每当我们要将一个新元素添加到一个整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面;

    因为每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的元素进行类型转换,所以向整数集合添加新元素的时间复杂度为o(N)。

    升级的好处:提升灵活性;节约内存。

    整数集合只支持升级操作,不支持降级操作

    第7章:压缩列表

    1)压缩列表是一种为节约内存而开发的顺序型数据结构;

    2)压缩列表被用作列表建和哈希键的实现之一;

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

    4)添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现的几率并不高;

    第8章:对象

     redis 并没有使用这些数据结构来实现键值对数据库,而是基于这些数据结构来创建了一个对象系统,这些系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种我们前面所介绍的数据结构。

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

                                                                            -- redis对象数据结构

    1)redis_encoding_embstr 编码比 redis_encoding_raw 编码更叼;

    2)可以用long,double类型表示的浮点数在redis中也是作为字符串值来保存的。如果我们要保存一个浮点数到字符串对象里面,那么程序会先将这个浮点数转换成字符串值,然后再保存转换所得的字符串值。要用的时候,再取出这个字符串,再转换成浮点型;

    第9章:数据库

    9.1 服务器中的数据库 

    redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中,db数组中的每一项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库

    struct redisServer { 
        redisDb *db; // 一个数组,保存着服务器中的所有数据库
        int dbnum; // 服务器的数据库数量,在初始化服务器时,程序会根据服务器状态的dbnum属性来决定应该创建多少个数据库,默认是16
        struct saveparam *saveparams; // 记录了保存条件的数组 它包括time_t seconds int changes秒数和修改次数 
        long long dirty; // 修改计数器
        time_t lastsave; // 上一次保存的时间
        sds aof_buf; // AOF缓冲区
        ...
    }
     
    typedef struct redisClient { 
        redisDb *db; // 客户端正在使用的数据库
        ...
    }
     
    typedef struct redisDb { 
        dict *dict; // 数据库键空间,保存着数据库中的所有键值对 key永远是字符串对象,value可以是五大类型
        dict *expires // 过期字典,保存着键的过期时间;key为指向上面那个dict的key,value为过期时间的毫秒时间戳
        ...
    }

    9.2 切换数据库

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

    9.3 数据库键空间 

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

    9.4 设置键的生存和过期时间

    9.5 过期键删除策略

    • 定时删除
      • 含义:在设置key的过期时间的同时,为该key创建一个定时器,让定时器在key的过期时间来临时,对key进行删除
      • 优点:保证内存被尽快释放
      • 缺点:
        • 若过期key很多,删除这些key会占用很多的CPU时间,在CPU时间紧张的情况下,CPU不能把所有的时间用来做要紧的事儿,还需要去花时间删除这些key
        • 定时器的创建耗时,若为每一个设置过期时间的key创建一个定时器(将会有大量的定时器产生),性能影响严重
        • 没人用
    • 惰性删除
      • 含义:key过期的时候不删除,每次从数据库获取key的时候去检查是否过期,若过期,则删除,返回null。
      • 优点:删除操作只发生在从数据库取出key的时候发生,而且只删除当前key,所以对CPU时间的占用是比较少的,而且此时的删除是已经到了非做不可的地步(如果此时还不删除的话,我们就会获取到了已经过期的key了)
      • 缺点:若大量的key在超出超时时间后,很久一段时间内,都没有被获取过,那么可能发生内存泄露(无用的垃圾占用了大量的内存)
    • 定期删除
      • 含义:每隔一段时间执行一次删除过期key操作
      • 优点:
        • 通过限制删除操作的时长和频率,来减少删除操作对CPU时间的占用--处理"定时删除"的缺点
        • 定期删除过期key--处理"惰性删除"的缺点
      • 缺点
        • 在内存友好方面,不如"定时删除"
        • 在CPU时间友好方面,不如"惰性删除"
      • 难点
        • 合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除)(这个要根据服务器运行情况来定了)

    9.6 redis的过期键删除策略(惰性删除 + 定期删除)

     

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

    1)函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键;

    2)全局变量current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理,比如说,如果当前activeExpireCycle函数在遍历10号数据库返回了,那么下次activeExpireCycle函数执行时,将从11号数据库开始查找并删除过期键;

    3)随着activeExpireCycle函数的不断执行,服务器中所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作;

    9.7 AOF、RDB 和复制功能对过期键的处理

    生成RDB文件:在执行SAVE命令或者BGSAVE命令创建一个新的RDB命令时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中;

    载入RDB文件:

      以主服务器的模式运行:在载入RDB文件时,程序会对文件中的键进行检查,未过期的键会被载入数据库中,而过期键则会被忽略;

           以从服务器的模式运行:在载入RDB文件时,程序会对文件中的键进行检查,不论键是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据就会被清空;

    AOF文件写入:当过期键被惰性删除或定期删除后,程序会向AOF追加(append)一条DEL命令,来显示记录该键已被删除;

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

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

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

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

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

    第10章:RDB持久化

    save命令:会阻塞redis服务器进程,知道RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求;
    bgsave命令:会派生出一个子进程,然后由子进程负责创建RDB文件,服务器不能处理任何命令请求;

     注意:SAVE/BGSAVE/BGREWRITEOF 这三个命令不能同时执行,因为避免产生竞争条件,或者性能方面考虑

    因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:
    1)如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态;
    2)只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态;

    载入RDB文件的实际工作有rdb.c/rdbLoad函数完成,这个函数和rdbSave函数之间的关系可以用图10-5表示:

    第11章:AOF持久化

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

    AOF重写:不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。(一条命令就搞定,替代之前的多条命令)

    第15章:复制

    15.1&2 旧版复制功能的实现和缺陷

    Redis的复制功能分为同步(sync)和命令传播(command propagate)两个操作:
    同步操作用于将服务器的数据库状态更新至主服务器当前的数据库状态;
    命令传播操作则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态;

     15.1.1 旧版复制/同步

    15.1.2 命令传播

    Redis中,从服务器对主服务器的复制可以分为以下两种情况:
    初次复制:从服务器以前没有复制过任何主服务器,或者从服务器当前要复制的主服务器和上一次复制的主服务器不同;
    断线后复制:处于命令传播阶段的主从服务器因为网络原因而中断了复制,但从服务器通过自动重新连接连上了主服务器,并继续复制主服务器;

    在主从服务器断线期间,主服务器执行的写命令可能会有成百上千个之多,而不仅仅是两三个写名ing。但总的来说,主从服务器断开的时间越短,主服务器在断线期间执行的写命令就越少,而执行少量写命令所铲山的数据量通常比整个数据库的数据量要少得多,在这种情况下,为了让从服务器补足一小部分缺失的数据,却要让主服务器重新执行一次SYNC,这种做法无疑是非常低效的。

    15.3&4 新版复制的实现、部分重同步的实现

    为了解决旧版复制功能在处理断线重复机制情况时的低效问题,Redis从2.8版本开始,使用PSYNC命令代替SYNC命令来执行复制时的同步操作:
    完整同步(full resynchronization):用于处理初次复制的情况,和SYNC命令的执行步骤基本一样,他们都是通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步;
    部分同步(partial resynchronization):用于处理断线后重复机制,当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据更新至主服务器当前所处的状态;

    PSYNC功能由一下三个部分构成:
    1)主服务器的复制偏移量(replication offset)和从服务器的复制偏移量
    2)主服务器的复制积压缓冲区(replication backlog)
    3)服务器的运行ID(run ID)

     15.5 PSYNC命令的实现

    15.7 心跳检测

    在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:REPLCONF ACK <replication_off>,其中 replication_offset 是从服务器当前的复制偏移量。
    发送REPLCONF ACK 命令对于主从服务器有三个作用:
    1)检测主从服务器的网络连接状态
    2)辅助实现min-slaves选项
    3)检测命令丢失:主从服务器的复制偏移量是否一致

    其中,redis的min-slaves-to-write 和 min-slaves-max-lag 两个选项可以防止主服务器在不安全的情况下执行写命令。例如:

    min-slaves-to-write 3

    min-slaves-max-lag 10

    在从服务器的数量少于3个,或者三个从服务器是延迟(lag)值都大于或等于10秒时,主服务器将拒绝执行写命令。

    第16章:Sentinel

    THE END!

  • 相关阅读:
    next_permutation函数(全排列)
    滚动数组
    多重背包问题
    ubuntu12.04硬盘安装
    UBUNTU12.04下安装配置体验gnome3
    给Ubuntu安装KDE桌面 [转]
    Ubuntu 13.04 用户安装 gnome 3.8 桌面
    ubuntu下安装wine
    ubuntu 安装输入法(fcitx)
    js 对url进行编码和解码的三种方式
  • 原文地址:https://www.cnblogs.com/ericguoxiaofeng/p/10962158.html
Copyright © 2020-2023  润新知