《Redis设计与实现》阅读笔记1-数据结构与对象(字符串,链表,字典,跳跃表)

一 数据结构与对象

1 简单动态字符串(SDS)

Redis并未使用传统的C语言的字符串(以空字符结尾的字符数组),而是自己构建李忠简单动态字符串(simple dynamic string)(SDS),SDS不仅被用于保存数据库中字符串值,SDS还被用于缓冲区:AOF模块中的AOF缓冲区,客服端的输入缓冲区。

1.1 SDS的定义

struct sdshdr{
    //记录buf数组中已使用的字节的数量,等于SDS所保存字符串的长度
    int len;

    //记录buf数组中未使用字节的数量
    int free;

    //字节数组
    char buf[];
}

注:SDS遵循C字符串以空字符结尾的惯例,字符串末尾有一个空字符,但空字符不记录在len里面,并且为空字符额外分配1字节的空间,这样做的好处就是SDS可以直接重用部分C字符串函数库中的函数。

1.2 SDS与C字符串

C字符串采用的在结尾以空字符结尾的简单表示方法不能满足Redis在安全与效率及功能上的要求。

1.2.1 常数复杂度获取字符串长度
  • 传统的C字符串若要获取字符串的长度需要一次遍历,所以复杂度是O(N)
  • SDS由于len属性的存在,获取长度的复杂度为O(1),设置与更新SDS长度的工作由SDS的API在执行时自动完成,无需手动修改。
1.2.2 杜绝缓冲区溢出
  • 传统的C字符串会出现缓冲区溢出的现象,例如在使用strcat拼接字符串的时候,由于C字符串不会记录本身的长度,长度超长且未再次进行空间分配的情况下就会出现溢出的情况。
  • SDS的空间分配策略完全杜绝了缓冲区溢出的可能,当SDS的API对SDS进行修改时,会优先根据结构体中的属性来判断空间大小是否合适,若不够,会先进行拓展,再进行修改,所以不用担心溢出问题。
1.2.3 减少修改字符串带来的内存重分配次数
  • C字符串在增长和缩短一个字符串长度时总会对保存C字符串的数组进行一次内存重分配的操作:
    • 如果是增长字符串,就会拓展底层数组的空间大小,不然就会引起缓冲区溢出
    • 如果是缩短字符串,就会释放掉不用的那部分空间,不然就会引起内存泄漏

在一般的程序里面,如果修改字符串长度的情况不经常出现,那么每次修改都执行一次内存重分配是可以接受的,但Redis作为数据库,经常用于速度要求严格,数据被频繁修改的场合,如果每修改一次就进行一次内存重分配,就会对性能产生很大的影响。

  • 为了避免上述的缺陷,SDS解除了字符串长度和底层数组长度的关联,buf数组的长度不一定是len+1,里面可包含未使用的字节,未使用字节的数量就有free来记录。通过这些方法,SDS实现了空间预分配惰性空间释放两种优化策略。
    • 空间预分配
      当SDS字符串需要增长的时候,优先检查free剩余长度是否足够,若足够就直接增长,不够就进行空间拓展,进行空间扩展的时候,不仅会对其分配所需的空间,还会为SDS分配额外的未使用空间,此策略使得内存重分配次数从必定进行n次,到最多进行n次,分配策略如下。
      • 若字符串拓展后len的长度小于1MB,那么free的大小将于len大小一样,例如len从5byte长度的字符串需要拓展到13byte大小,此时free的剩余长度不足以支撑拓展,进行内存重分配,len的大小变成13byte,free大小也为13byte,buf数组的实际长度就为13byte+13byte+1byte。
      • 若字符串拓展后len的长度大于等于1MB,那么free的大小将于1MB,例如len从5MB长度的字符串需要拓展到10MB大小,此时free的剩余长度不足以支撑拓展,进行内存重分配,len的大小变成10MB,free大小就为1MB,buf数组的实际长度就为10MB+1MB+1byte。
    • 惰性空间释放
      • 当SDS字符串需要进行缩短操作时,并不会立即使用内存重分配来回收多余的字节,而且使用free属性将这些字节的数量记录下来,等待将来的使用。通过此操作避免了缩短字符串时进行的内存重分配,也优化了增长操作。
      • 当然SDS也提供相依的API,在需要时真正的释放SDS的未使用空间,来避免惰性空间释放策略带来的内存浪费问题。
1.2.4 二进制安全
  • 由于C字符串以ASCII编码,并以空字符结尾的特点,使得其只能存储文本数据,
    不能存储图片,音频,视频等二进制文件。
  • 而SDS保存文本或是二进制数据都是没有问题的,因为SDS使用len来判断结尾,而不是空字符,并且SDS的所有API都是二进制安全的,SDS的所有API都会以处理二进制的方式来处理SDS存放在buf数组中的数据。
1.2.5 兼容部分C字符串函数

由于SDS字符串保留了C字符串以空字符结尾的特点,所以SDS字符串可以兼容C字符串函数库的部分函数。

1.2.6 总结
C字符串 SDS
获取字符串长度复杂度O(N) 获取字符串长度复杂度O(1)
API不安全,可能出现缓冲区溢出 API安全,不会出现缓冲区溢出
修改字符串长度N次需要执行N次内存重分配 修改字符串长度N次最多执行N次内存重分配
只能保存文本数据 可以保存文本和二进制数据
可以使用<string.h>中所有函数 可以使用<string.h>中部分函数

2 链表

Redis使用的C语言未内置链表这样的结构,使用Redis自助构建的链表结构,链表在Redis中使用非常频繁,例如在一个列表键包含过多个元素,还在列表中包含的元素都是比较长的字符串时,Redis就会使用链表来作为列表键的底层实现。
并且除了链表键之外,发布与订阅,慢查询,监视器等功能也用到了链表。Redis服务器本身还使用链表来保存多个客户端的状态信息,构建客户端的输出缓冲区。

//单个节点的结构
typedef struct listNode{
    //前置节点
    struct listNode *prev;

    //后置节点
    struct listNode *next;

    //节点值
    void *value;
}listNode;

//使用list来持有链表
typedef struct list{
    //表头节点
    listNode *head;

    //表尾节点
    listNode *tail;

    //链表的节点数量
    unsigned long len;

    //节点值复制函数
    void *(*dup)(void *ptr);

    //节点值释放函数
    void *(*free)(void *ptr);

    //节点值对比函数
    void *(*match)(void *ptr,void *key);
}
  • dup函数用来复制链表节点的值;
  • free函数用于释放链表节点的值;
  • match函数用于对比链表节点的值与另一个输入的值是否相等;

Redis链表特征
- 双端:双向链表;
- 无环:链表不会成环;
- 带表头与表尾指针:head和tail;
- 带链表长度计数器:len属性;
- 多态:值value与三种方法dup,free,match使用void,可强转来保存不同类型的值。

具体的链表数据结构算法,可参考算法书。

3 字典

字典,又称为符号表,关联数组或映射,例如java中的map,是一种储存多个键值对的抽象数据结构。

例:

redis>HLEN website(integer) 10066
redis>HGETALL website
1)"Redis"
2)"Redis.io"
3)"MariaDB"
4)"MariaDB.org"
#...

//website键的底层实现就是一个字典,字典包含10066个键值对,例如"Redis"为键,"Redis.io"为值

3.1 字典的实现

Redis的字典使用哈希表作为底层实现,一个哈希表里面有多个哈希表节点,每个节点保存一组键值对。

3.1.1 哈希表
//哈希表结构
typedef struct dictht{
    //哈希表数组,存放键值对结构
    dictEntry **table;

    //哈希表大小
    unsigned long size;

    //哈希表大小掩码,用于计算索引值,总是为size-1
    unsigned long sizemask;

    //已有节点个数
    unsigned long used;

}dictht;
3.1.2 哈希表节点

//哈希表节点结构
typedef struct dictEntry{
    //键
    void *key

    //值,使用联合体使得值可以是多种类型,分别为指针,unsigned long int,long int类型。
    union{
        void *val
        uint64_t u64;
        int64_t s64;
    }v;

    //指向下一个节点的指针。这样就能将多个索引值相同的键连接在一起了。
    struct dictEntry *next;

}dictEntry;
3.1.2 字典
typedef struct dict{
    //类型特定函数
    dictType *type;

    //私有数据
    void *privdata;

    //哈希表
    dictht ht[2]

    //rehash索引,若rehash不在进行时,值为-1
    int rehashidx;

}dict;
  • type和privdata属性是针对不同类型的键值对,为创建多态字典而设置。

    • type属性是一个纸箱dictType结构的指针,每个dictType结构保存了一簇用于超重特定类型键值对的函数,Redis为不同的字典设置不同的类型特定函数。
    • privdata属性保存了需要传给那些类型特定函数的可选参数
  • ht属性为两个项的数组,每个项都是一个dictht哈希表,一般情况下只会用到ht[0],ht[1]只有在进行rehash时才会用到

  • trehashidx用来记录rehash的进度

typedef 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);

}

3.2 哈希算法

当需要新添加键值对进字典时,程序需要先计算键对应的哈希值,然后根据索引值将节点放到指定索引上

  • 使用字典里的哈希函数计算键的哈希值
    hash=dict->type->hashFunction(key);
  • 使用哈希表的sizemask和哈希值计算索引
    index=hash & dict->ht[x].sizemask;
  • 键值对放入dict->ht[x].table下对应索引的地方。

3.3 解决键冲突

当多个键值对最后计算出来的索引一样时,我们称此时发生了键冲突,此时我们利用哈希表节点中的next属性,形成链表将多个键值对放在一个哈希表节点中,而为了追求效率,插入时采用复杂度为O(1)的头插法。

3.4 rehash

随着操作的不断执行,哈希表保存的键值对会逐渐增多或减少,为了让哈希表的负担因子维持在一个合理的范围内,当哈希表保存的键值对数量太多或太少时,程序需要对哈希表的大小进行相应的扩展或收缩,这个操作就是rehash(重新散列)。

3.4.1 rehash的步骤
  • 为ht[1]哈希表重新分配空间,空间大小取决于要发生的操作
    • 如果是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*22^n,比如ht[0]的大小为62,那么因为2^8< 62*2 < 2^9,所以ht[1]的大小为2^9
    • 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于 ht[0].used2^n,比如ht[0]的大小为62,那么因为2^7< 62 < 2^8,所以ht[1]的大小为2^8
  • 将保存在ht[0]上的键值对全部rehash到ht[1]上(rehash指的是重新计算键值对的哈希值与索引值,然后再将键值对放到ht[1]上的指定位置)
  • ht[0]上的值全部rehash到ht[1]上以后(ht[0]变为空表),释放ht[0]的空间,将ht[1]设置为ht[0],并为ht[1]新创建一个空白空间为下一次rehash做准备。
3.4.2 哈希表的拓展与收缩

负载因子=哈希表保存节点个数 / 哈希表大小(load_factor = ht[0].used / ht[0].size)

  • 当个哈希表的负载因子小于0.1时,程序对哈希表执行收缩操作。
  • 服务器目前没有执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
  • 服务器正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

Redis之所以根据BGSAVE命令或者BGREWRITEAOF命令是否正在执行使用不同的负载因子来判定,是因为在执行这两种命令时,Redis会创建当前服务器进程的子进程,而大多数操作系统会使用写入时复制技术来优化子进程的使用效率,所以子进程存在期间,应尽量避免对哈希表进行拓展操作,最大限度节约内存。
写入时复制(Copy-on-write)是一个被使用在程序设计领域的最佳化策略。其基础的观念是,如果有多个呼叫者(callers)同时要求相同资源,他们会共同取得相同的指标指向相同的资源,直到某个呼叫者(caller)尝试修改资源时,系统才会真正复制一个副本(private copy)给该呼叫者,以避免被修改的资源被直接察觉到,这过程对其他的呼叫只都是通透的(transparently)。此作法主要的优点是如果呼叫者并没有修改该资源,就不会有副本(private copy)被建立。

3.5 渐进式rehash

相较与rehash,渐进式rehash是指在键值对从ht[0]中rehash到ht[1]中时,分多次进行,这样做的原因在于,若ht[0]中的键值对较少时直接全部rehash没有影响,但ht[0]中的键值对较多时,一次性全部rehash可能导致服务器在一段时间内停止服务。

步骤:

  • 为ht[1]分配空间,字典同时持有ht[0]和ht[1]两个哈希表
  • rehashidx置0,表示rehash开始工作
  • rehash期间,每次对字典进行增删改查的同时,还会顺带将ht[0]哈希表rehash到ht[1]上,rehash工作结束后rehashidx增加1
  • 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会rehash到ht[1]上,这是程序将rehashidx置-1,表示rehash完成

渐进式rehash采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典每次的增删改查上,避免了集中式rehash而带来的庞大计算量

渐进式rehash执行期间的哈希表操作
字典的删改查会先访问ht[0],再访问ht[1],新增的键值对会直接保存的ht[1]上。

4 跳跃表

跳跃表是一种有序数据结构,它通过在每个及诶单中维持多个指向其他节点的指针,从而达到快速访问其他节点的目的。跳跃表支持平均O(logN),最坏O(N)复杂度查找,大部分情况下,跳跃表的效率可以和平衡树相媲美,并且实现比平衡树更加简单。

在Redis中只有两个地方用到了跳跃表,一个是有序集合键,另一个是在集群节点中用作内部数据结构。

关于跳跃表的更多信息,推荐阅读请点击

4.1 跳跃表的实现

4.1.1 跳跃表节点

typedef struct zskiplistNode{
    //后退指针
    struct zskiplistNode *backward;

    //分值
    double score;

    //成员对象
    robj *obj

    //层
    struct zsliplistlevel{
        //前进指针
        struct zskiplistNode *forward;

        //跨度
        unsigned int span;
    }
}zskiplistNode

  • 跳跃表节点的level数组可以包含多个元素,每个元素都有一个前进指针指向其他跳跃表节点,程序可以通过这些指针来加快遍历速度,一般来说,层的数量越多,访问速度就越快。
    每次创建新跳跃表节点时,程序会根据幂次定律(越大的数出现概率越小)随机生成一个介于1-32之间的值作为level数组的大小,也就是层的高度
  • 前进指针
    每个层都有一个指向表尾方向的前进指针,用于从表头向表尾遍历节点。
  • 跨度
    用于记录两个节点间的距离
    • 两个节点间的间距跨度越大,说明他们相聚越远
    • 指向null的所有前进指针的跨度都是0
    • 跨度与遍历无关,遍历只需要前进指针,跨度用于计算节点的rank,初始节点以0开始算,那么到达某个节点经过的跨度和就是其rank值
  • 后退指针
    用于从表尾向表头前进,不像每个节点有多个前进指针,后退指针,每个节点只有一个。所以后退时只能一个节点一个节点的后退,不能类似前进指针那样跳跃移动。
  • 分值与成员
    分值用于节点的排序依据,成员为其保存的值。若分值相同时,按照成员的字典序大小来排序。
4.1.2 跳跃表

跳跃表由多个跳跃表节点组成

typedef struct zskiplist{
    //表头,表尾节点
    struct zskiplistNode *header,*tail;

    //节点数量
    unsigned long length;

    //层数最大节点的层数
    int level;

}zskiplist;

猜你喜欢

转载自blog.csdn.net/maniacxx/article/details/81949558