Redis数据库中的每个键值对都是由对象组成,键总是一个字符串对象(string object),而值可以由字符串对象、列表对象(list object)、哈希对象(hash object)、集合对象(set object)和有序集合对象(sorted set object)这五种。下面分别介绍每一种。

一、简单动态字符串

1、定义

从Redis3.2开始,sds就有了5种类型,5种类型分别存放不同大小的字符串。其定义在sds.h文件中华,定义如下

typedef char *sds;

 //sdshdr5在源码中没有用到
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 低三位为type, 高五位为字符串长度 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 已经使用长度 */
    uint8_t alloc; /* 分配的字符串长度,除头部和结尾的空字符 */
    unsigned char flags; /* 低三位为type, 高五位为字符串长度 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; 
    uint16_t alloc;
    unsigned char flags; 
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; 
    uint32_t alloc; 
    unsigned char flags;
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len;
    uint64_t alloc;
    unsigned char flags;
    char buf[];
};

2、SDS和C语言字符串的区别和改进

2.1 获取字符串长度的复杂度是常数级的

C字符串获取长度的时候需要遍历整个字符串,此操作的复杂度为O(N)。而SDS记录了字符串的长度len。在设置和更新字符串的时候,自动更新此长度。

2.2 避免了缓冲区溢出

C字符串在拼接的时候,要保证拼接后的字符串已分配足够的长度,不然会造成缓冲区溢出。而SDS在修改字符串时先根据alloc判断空间是否满足修改所需,避免了缓冲区溢出。如果空间不够,SDS会先扩展空间。

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

C字符串每次修改和删除都会进行一次内存重新分配。SDS通过空间预分配、惰性空间释放等措施减少内存分配。因为内存分配是非常耗时的过程。

2.4 二进制安全

C字符串必须符合某种编码,并且中间不能有空格,所以不能保存图片、音频等二进制的数据。Redis的buf数组是用来保存二进制数据的,所以没有这些限制。SDS是通过len来判断字符串是否结束,而不是通过空格。

二、链表

1、定义

链表在实际开发中的使用非常广泛,在Redis中也是,比如列表键、发布与订阅、慢查询、监视器,以及服务器本身。链表的定义在adlist.h中,定义如下:

//链表节点定义
typedef struct listNode {
    //前节点
    struct listNode *prev;
    //后节点
    struct listNode *next;
    //节点值
    void *value;
} listNode;
//链表迭代器
typedef struct listIter {
    //链表下一节点
    listNode *next;
    //迭代方向
    int direction;
} listIter;
//双端链表
typedef struct list {
    //链表头节点
    listNode *head;
    //链表尾节点
    listNode *tail;
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值比较函数
    int (*match)(void *ptr, void *key);
    //链表长度
    unsigned long len;
} list;

2、Redis链表特性

双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

三、字典

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。
字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上的。除了用来表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

1、定义

Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。字典的定义在dict.h文件中,定义如下:

1.1、哈希表节点

//哈希表节点
typedef struct dictEntry {
    //哈希键
    void *key;
    //哈希值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    //指向下一个哈希表节点
    struct dictEntry *next;
} dictEntry;

key属性保存着键值对中的键,而v属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突(collision)的问题。

1.2 类型特定函数

//类型特定函数
typedef struct dictType {
    //计算哈希值函数
    uint64_t (*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);
} dictType;

每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。

1.3 哈希表

//哈希表结构
typedef struct dictht {
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表掩码,总是等于size-1,用于计算索引值
    unsigned long sizemask;
    //哈希表已有节点的数量
    unsigned long used;
} dictht;

1.4 字典

//Redis中的字典定义
typedef struct dict {
    //类型特定函数,是dictType类型
    dictType *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2];
    //rehash索引,当rehash不在进行时,值为-1
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    //当前迭代器的数量
    unsigned long iterators; /* number of iterators currently running */
} dict;

type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的:
type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。

而privdata属性则保存了需要传给那些类型特定函数的可选参数。

1.5 字典的迭代器

 //如果迭代器的数量设置为1是安全的,意味着可以调用dictAdd, dictFind等方法。否则
 //是不安全的迭代器,只能调用dictNext()方法
typedef struct dictIterator {
    dict *d;
    long index;
    int table, safe;
    dictEntry *entry, *nextEntry;
    //滥用fingerprint是非安全的迭代
    long long fingerprint;
} dictIterator;

哈希算法

在Redis 5.0中,已经将哈希算法替换为SipHash,他的提出是为了解决安全问题:hash flooding。通过让输出随机化,SipHash 能够有效减缓 hash flooding 攻击。Rust、Python和Ruby等都使用了该算法,而Java的HashMap使用红黑树。感兴趣的读者可以具体去了解SipHash算法。

冲突解决

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

rehash

为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
Redis对字典的哈希表执行rehash的步骤如下:

  1. 为字典的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]哈希表的指定位置上。
  3. 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

渐进式rehash

rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。如果一次性rehash,庞大的计算量可能会导致服务器在一段时间内停止服务。
以下是哈希表渐进式rehash的详细步骤:

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
  2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
  3. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。
  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。

四、跳跃表

定义

Redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息。Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。跳跃表的定义在文件redis.h中,定义如下:

//跳表节点定义
typedef struct zskiplistNode {
    //成员对象
    robj *obj;
    //节点分值,通过此分值排序
    double score;
    //后退指针,指向当前节点的前节点,用于从后向前遍历
    struct zskiplistNode *backward;
    //节点层级信息
    struct zskiplistLevel {
        //前进指针
        struct zskiplistNode *forward;
        //跨度
        unsigned int span;
    } level[];
} zskiplistNode;
//跳跃表定义
typedef struct zskiplist {
    //头指针和尾指针,分别指向跳跃表的头节点和尾节点
    struct zskiplistNode *header, *tail;
    //跳跃表长度,即节点数
    unsigned long length;
    //记录层数最大节点的层数
    int level;
} zskiplist;

五、整数集合

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

定义

整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。整数集合的定义在文件intset.h中,定义如下:

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //长度
    uint32_t length;
    //保存整数元素的数组
    int8_t contents[];
} intset;

contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。contents数组的真正类型取决于encoding属性的值:

编码属性升级

将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级。升级整数集合并添加新元素共分为三步进行:
1)根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
2)将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
3)将新元素添加到底层数组里面。

六、压缩列表

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

七、对象

未完待续......

参考书籍:Redis设计与实现


zhzhd
16 声望5 粉丝