zoukankan      html  css  js  c++  java
  • Redis 设计与实现-内部数据结构

    闲暇之余,通读了《Redis 设计与实现》,个人比较喜欢第一版,小记几笔,以便查阅,如果单纯为了使用,请移步:《命令查询手册》,共勉~

    简单动态字符串

    Redis中使用的并不是传统的C字符串,还是使用其特有的数据结构Sds(Simple Dynamic String,简单动态字符串)作为char*的替代品,因为传统字符串类型无法高效支持一些Redis常用操作,如:

    • 计算字符串长度,传统的字符串时间复杂度为O(N)
    • 对字符串进行N次追加,必定需要低字符串进行N次内存重分配(realloc)

    所以,Redis中的Sds做了类似于下面的定义:

    typedef char * sds;
    
    struct sdshdr {
    
        // buf 已占用长度
        int len;
    
        // buf 剩余可用长度
        int free;
    
        // 实际保存字符串数据的地方
        char buf[];
    };
    

    通过额外的字段记录,Sds的字符串长度的复杂度则变为了O(1),而buf则采用的是内存预分配的策略,比如当前分配了1KB的空间,当追加后的大小小于1KB,则不会引起内存的重新分配,若是大于1KB,则Redis会为他们额外分配1KB的空间,伪代码实现如下:

    def sdsMakeRoomFor(sdshdr, required_len):
    
        # 预分配空间足够,无须再进行空间分配
        if (sdshdr.free >= required_len):
            return sdshdr
    
        # 计算新字符串的总长度
        newlen = sdshdr.len + required_len
    
        # 如果新字符串的总长度小于 SDS_MAX_PREALLOC
        # 那么为字符串分配 2 倍于所需长度的空间
        # 否则就分配所需长度加上 SDS_MAX_PREALLOC 数量的空间
        if newlen < SDS_MAX_PREALLOC:
            newlen *= 2
        else:
            newlen += SDS_MAX_PREALLOC
    
        # 分配内存
        newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)
    
        # 更新 free 属性
        newsh.free = newlen - sdshdr.len
    
        # 返回
        return newsh
    

    链表

    链表作为一种常用的数据结构,在很多高级编程语言中均有内置,但由于Redis所使用的C语言并没有内置这种结构,所以Redis自己构建了链表的实现,链表在Redis中的应用非常广泛,比如列表、发布订阅,慢查询等等。

    链表节点定义伪代码:

    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列表中使用双端链表和压缩列表作为底层实现,因为双端链表占用的内存比压缩列表要多,所以当创建新的列表时,Redis会优先考虑压缩列表作为底层实现,在有需要的时候,才会从压缩列表转换到双端链表实现。该结构特性可总结如下:

    • 由于listNode带有prev和next指针,所以获取某个节点的前后节点的复杂度都是O(1)。
    • list保存了head和tail两个指针,所以对表头和表尾的复杂度都有O(1),所以list可以高效执行LPUSH、RPOP、RPOPLPUSH等命令。
    • list使用len来对节点进行技术,所以程序获取链表中节点数量的复杂度为O(1)。

    字典

    字典的结构想必大家并不陌生,也是Redis中应用广泛的结构之一,使用频率和Sds及双端链表不相上下,主要的用途有两个:

    1. 作为数据库键空间。
    2. 作为Hash类型键的底层实现之一。

    与双端链表一样,虽然字典作为一种常见的数据结构内置在很多高级编程语言里,但Redis里使用的C语言并没有内置这种结构,因此Redis自己构建了字典的实现,实现的方案有多种:

    • 最简单就是使用链表或数组,但只适用于元素个数不多的情况下。
    • 要兼顾高效和简单性,可以使用哈希表。
    • 如果追求更为稳定的性能特征,并希望高效的实现排序操作,则可使用更为复杂的平衡树。
      Redis选择高效和简单荐股的哈希表,作为字典的底层实现。
    /*
     * 字典
     *
     * 每个字典使用两个哈希表,用于实现渐进式 rehash
     */
    typedef struct dict {
    
        // 特定于类型的处理函数
        dictType *type;
    
        // 类型处理函数的私有数据
        void *privdata;
    
        // 哈希表(2 个)
        dictht ht[2];
    
        // 记录 rehash 进度的标志,值为 -1 表示 rehash 未进行
        int rehashidx;
    
        // 当前正在运作的安全迭代器数量
        int iterators;
    
    } dict;
    
    /*
     * 哈希表
     */
    typedef struct dictht {
    
        // 哈希表节点指针数组(俗称桶,bucket)
        dictEntry **table;
    
        // 指针数组的大小
        unsigned long size;
    
        // 指针数组的长度掩码,用于计算索引值
        unsigned long sizemask;
    
        // 哈希表现有的节点数量
        unsigned long used;
    
    } dictht;
    
    /*
     * 哈希表节点
     */
    typedef struct dictEntry {
    
        // 键
        void *key;
    
        // 值
        union {
            void *val;
            uint64_t u64;
            int64_t s64;
        } v;
    
        // 链往后继节点
        struct dictEntry *next;
    
    } dictEntry;
    

    因为压缩列表比字典更节省内存,所以在创建Hash键时,默认使用压缩列表作为底层实现,当有需要是,程序才会将底层实现从列表转换到字典。值得关注的是dict类型中使用了两个指针,分别指向两个哈希表,其中,0号哈希表(ht[0])是字典主要使用的哈希表,而1号哈希表(ht[1])则只有在程序对0号号系表进行rehash时才使用。Redis目前使用的哈希算法有两种:

    1. MurmurHash2 32bit算法:这种算法的分步率和速度都非常好:http://code.google.com/p/smhasher/
    2. 基于djb算法实现的一个大小写无关散列算法:http://www.cse.yorku.ca/~oz/hash.html

    尽管使用了哈希算法,但不同的两个键仍然可能拥有相同的哈希值,我们称之为碰撞,所以哈希表必须想办法对碰撞进行处理,字典哈希表所使用碰撞解决方法被称之为链地址法:就是使用链表将多个哈希值相同的节点串联在一起,从而解决冲突问题,如果哈希表的大小与节点数量保持在1:1时,哈希表性能最好,但是如果节点数量远大于哈希表的大小的话,那么哈希表就会退化成多个链表,那么性能就会明显下降。所以当字典的键值对不断增多的情况下,为了保持字典的性能,就需要对哈希表(ht[0])进行rehash操作,在不修改任何键值对的情况下,对哈希表进行扩容,尽量将比率维持在1:1左右。
    通过查看dictht的定义我们可以发现其定义了size(指针数组大小)和used(哈希表现有节点数量)两个属性,当他们之间的比率被定义为(ratio=used/size),当满足下列条件,rehash操作就会被激活:

    1. 自然rehash:ratio>=1且变量dict_can_resize==true;
    2. 强制rehash:ratio>dict_force_resize_ratio(在2.6版本默认为5)。

    Rehash 的执行过程

    1. 设置字典的rehashidx为0,标识rehash开始,创建一个比ht[0]->table更大的 ht[1]-->table,大小至少为ht[0]-->used的两倍;
    2. 将ht[0]->table中的所有键值迁移到ht[1]-->table;
    3. 将原有 ht[0]的数据清空,并将ht[1]替换为新的ht[0];

    也许你会有疑问,如果说在rehash的过程中,有新的值写入怎么办?如果直接阻塞,等rehash过程完成,这样是非常不友好的,所以Redis采用了渐进式(incremental)的rehash方式,主要由_dictRehashStep和dictRehashMilliseconds两个函数进行:

    • _dictRehashStep用于对数据库字典以及哈希键的字典被动rehash,每次执行_dictRehashStep,哈希表ht[0]-->table第一个不为空的索引上的所有节点就会全部迁移到ht[1]-->table,每一次执行添加、查找、删除操作,_dictRehashStep都会被执行一次,因为字典会保持哈希大小和节点的ratio在一个很小的范围内,所以每个索引上的节点数量不会很多,在执行操作的同时,对单个索引上的节点进行迁移,几乎不会对响应时间造成影响;
    • dictRehashMilliseconds则由Redis服务器常规任务程序(service cron job)执行,可以在指定的毫秒数内对数据库字典进行主动rehash,从而加速数据库字典的rehash过程;

    当然,为了保证rehash的顺利、正确执行,还需要采取一些特别的措施:

    • 在rehash未完成时,字典会同时使用两个哈希表,所以在这期间的查找、删除操作,除了在ht[0]上进行,还需要在ht[1]上进行;
    • 在执行添加操作时,新的节点会直接添加到ht[1]而不是ht[0],这样保证ht[0]的节点数量在整个rehash的过程中都只减不增。

    当然,如果因为大量的删除节点,导致了哈希表的可用节点数比已用节点数大很多的话,那么也可以通过rehash来收缩(shrink)字典,操作过程和上述过程类似,不过不同于扩展的是,字典的收缩是需要手动执行的,一般来说当字典的填充率小于10%,我们就可以对这个字典进行收缩操作了。

    跳跃表

    什么是跳跃表

    首先我们先谈谈单链表,比如一个链表L:1->2->3->4->5->6->7->8->9,如果我们想查找某个数据,就只能从头到尾遍历,时间复杂度为O(n),似乎有点难以接受,本着空间换时间的准则,大佬们为链表建立了索引L1:1->3->5->7->9,这样我们要查找6时,就现在L1中查找,当发现6在5到7之间时,在下降到L中进行查找,当加了一层索引后,我们就会发现,查找一个节点需要遍历的节点个数减少了,为了进一步提高效率,我们可以再加一级索引L2:1->5->7,这样效率就又会进一步提升,当有大量数据时,我们就可以通过这种多级索引的方式,使查找效率大大提升,这种多级索引的结构就是跳跃表。跳跃表的效率和平衡树媲美,在Redis主要用于实现有序数据类型,主要由以下几个部分构成:

    • 表头:负责维护跳跃表的节点指针;
    • 跳跃表节点:保存着元素值,以及多个层;
    • 层:保存着指向其他元素的指针。高层的指针越过的元素数量>=低层的指针,为了提高查找效率,程序总是从高层先开始访问,然后随着元素范围的缩小,慢慢降低层次;
    • 表尾:全部由NULL组成,标识跳跃表的末尾。

    avatar

    仅仅从文字上难以形象的说明跳跃表,还是直接上图来的形象,不过本人又是个惫懒货,就直接引用了原图,各位大佬还是移步原文去看吧,比我抄的好多啦。

    Redis中的跳跃表

    为了满足自身需要,Redis对跳跃表进行了修改:

    1. 允许重复的score值:多个不同的member的score值可以相同;
    2. 进行对比操作时,不进要检查score值,还要检查member,因为score重复时需要查member才行;
    3. 每个加点都有一个高度为1的后退指针,用于从表尾方向向表头方向迭代。
    //表示跳跃节点
    typedef struct zskiplist {
    
        // 头节点,尾节点
        struct zskiplistNode *header, *tail;
    
        // 节点数量
        unsigned long length;
    
        // 目前表内节点的最大层数(表头节点不计算在内)
        int level;
    
    } zskiplist;
    
    //保存跳跃节点的相关信息
    typedef struct zskiplistNode {
    
        // 成员对象:在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但多个节点保存的分值可以是相同的
        robj *obj;
    
        // 分值:在跳跃表中,节点按各自所保存的分值从小到大排序
        double score;
    
        // 后退指针:它指向位于当前节点的前一个节点,用于程序从表尾向表头遍历时使用
        struct zskiplistNode *backward;
    
        // 层
        struct zskiplistLevel {
    
            // 前进指针
            struct zskiplistNode *forward;
    
            // 这个层跨越的节点数量
            unsigned int span;
    
        } level[];
    
    } zskiplistNode;
    
  • 相关阅读:
    初学c课程笔记整理7-->>二维数组、字符串、指针
    初学c课程笔记整理6-->>数组
    ipad上自定义view的旋转适配
    苹果mac电脑中brew的安装使用及卸载详细教程
    Apple、Google、Microsoft的用户体验设计原则
    iOS图片拉伸技巧
    子网掩码解析【转载】
    Ios8,Xcode6下 设置Launch Image 启动图片
    ios8 设置单元格分割线无效
    简述UIScrollView的属性和用法
  • 原文地址:https://www.cnblogs.com/krockey/p/14328765.html
Copyright © 2011-2022 走看看