Redis数据结构

系列导读:

Redis设计与实现(二)—对象

 

简单动态字符串 SDS

struct sdshdr {
    int len;    // buf数组已使用字节数
    int free;   // buf数组未使用字节数
    char buf[]; // SDS底层实现:字节数组,数组大小为 len + free + 1
};

空间分配策略

空间预分配

对一个SDS进行修改(空间扩展)时,程序不仅会为SDS分配修改所必需的空间,还会为SDS分配额外的未使用空间。

1)对SDS进行修改后,若SDS的长度小于1MB,即 len < 1MB,则分配和len同样大小的未使用空间,即 free = len;

2)对SDS进行修改后,若SDS的长度大于等于1MB,即 len >= 1MB,则分配1MB的未使用空间,即 free = 1MB。

通过空间预分配机制,Redis可以减少连续字符串增长所需的内存重分配操作。

惰性空间释放

当需要缩短SDS保存的字符串时,程序不会立即使用内存重分配回收字符串缩短后空出来的字节,而是使用free属性将这些字节的数量记录下来,以备将来使用(SDS也提供了相应的API以释放SDS的未使用空间,避免造成内存浪费)。

与C字符串的对比

1)SDS获取字符串长度的时间复杂度为O(1),C字符串获取字符串长度的时间复杂度为O(n)。

2)SDS扩容不会发生溢出,而C字符串在执行strcat(s,"test");时,如果没有给s分配足够的空间,会导致溢出。

3)通过使用二进制安全的SDS,Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据。

 

 

链表

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;
typedef struct list {
    listNode *header;
    listNode *tail;
    unsigned long len;

    void *(*dup)(void *ptr); // 节点值复制函数
    void (*free)(void *ptr); // 节点值释放函数
    int (*match)(void *ptr, void *key); // 节点值对比函数
} list;

多态:链表节点使用void *指针保存节点值,并通过list结构的dup、free、match三个属性为节点值设置类型特定函数,使得链表可用于保存各种不同类型的值。

 

 

字典

字典,又称为符号表、关联数组或映射,通常被用于保存键值对。 字典中的每个键都是唯一的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除某个键值对...

Redis的数据库使用字典作为底层实现,对数据库的增删改查操作均建立在对字典的操作上。

Redis的字典使用哈希表作为底层实现,一个哈希表可以包含多个哈希表节点,每个哈希表节点保存一个键值对。

哈希表

typedef struct dictht {
    dictEntry **table;  // 哈希表节点数组 dictEntry *table[];
    unsigned long size; // 哈希表节点数组大小
    unsigned long sizemask;  // 哈希表节点数组大小掩码,和哈希值一起决定一个键应该被放到table数组的哪个索引上
    unsigned long used; // 哈希表节点数组已有键值对的数量
} dictht;
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    struct dictEntry *next; // next将多个哈希值相同的键值对连接在一起形成链表,以此解决哈希冲突
} dictEntry;

字典

typedef struct dict {
    dictType *type; // 类型特定函数
    void *privdata; // 私有数据
    dictht ht[2];   // 哈希表,ht[1]只在rehash时使用
    int trehashidx; // rehash索引,若目前没有在进行rehash,trehashidx的值为-1
} dict;

每个dictType结构保存了一簇用于操作特定类型键值对的函数,如下所示:

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); // 值销毁函数
} dictType;

哈希算法

将一个新键值对添加到字典里时,程序要先根据键值对的键计算出哈希值和索引值,然后再根据索引值将包含新键值对的哈希表节点放到哈希表节点数组的指定索引上。

Redis计算哈希值和索引值的方法如下:

  • 使用字典设置的哈希函数计算哈希值     hash = dict->type->hashFunction(key);
  • 使用sizemask和哈希值计算索引值(根据情况不同,ht[x]可以为ht[0]或者ht[1])   index = hash & dict->ht[x].sizemask;

冲突解决

Redis中的字典使用链地址法解决键冲突。发生哈希冲突时,新节点会被添加到链表的表头位置。

rehash

随着操作的不断执行,哈希表保存的键值对会逐渐增多或减少。为了让哈希表的负载因子维持在一个合理的范围内,哈希表保存的键值对数量太多或太少时,需要对哈希表的大小进行相应的扩展或收缩(通过rehash操作完成)。

Redis对哈希表进行rehash的步骤如下:

1)为ht[1]分配空间,ht[1]的空间大小取决于要执行的操作以及ht[0]当前包含键值对的数量(即ht[0].used的值):

  • 如果执行的是扩展操作,则ht[1]的大小为第一个大于等于ht[0].used*2的2^n;
  • 如果执行的是收缩操作,则ht[1]的大小为第一个大于等于ht[0].used的2^n。

2)将保存在ht[0]中的所有键值对rehash到ht[1]中(重新计算键的哈希值和索引值,并将键值对放置到ht[1]的指定索引上)。

3)ht[0]包含的所有键值对迁移到了ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]上新建一个空白哈希表,为下一次rehash做准备。

rehash条件

当以下任意一个条件满足时,程序会自动对哈希表进行扩展(哈希表负载因子计算公式:load_factor = ht[0].used / ht[0].size):

1)服务器目前没有执行BGSAVE命令或BGREWRITEAOF命令,且哈希表负载因子大于等于1;

2)服务器目前正在执行BGSAVE命令或BGREWRITEAOF命令,且哈希表负载因子大于等于5。

哈希表负载因子小于0.1时,程序会自动对哈希表进行收缩操作。

渐进式rehash

rehash并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。这样做的原因在于,如果ht[0]中保存了大量的键值对,一次性将它们全部rehash到ht[1]中,庞大的计算量可能导致服务器在一段时间内停服。

在渐进式rehash期间,字典的查找、删除、更新等操作会在两个哈希表上进行,而新添加到字典中的键值对会保存到ht[1]中,ht[0]不再进行任何添加操作,这保证了ht[0]包含的键值对数量在rehash期间只减不增,并随着rehash操作的进行最终变为空表。

 

 

跳跃表

typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 头、尾节点
    unsigned long length; // 节点数量
    int level; // 表中层数最大的节点的层数
} zskiplist;
typedef struct zskiplistNode {
    struct zskiplistNode *backward; // 后退指针
    double score; // 分值
    robj *obj; // 成员对象,指向一个字符串对象
    struct zskiplistLevel { // 层
        struct zskiplistNode *forward; //前进指针
        unsigned int span; //跨度
    } level[];
} zskiplistNode;

1)每个跳跃表节点的层高(level数组大小)都是1至32之间的随机数。

2)跳跃表中的多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的。

3)跳跃表中的节点按照分值大小排序,分值相同时按照成员对象大小排序。

 

 

整数集合

typedef struct intset {
    uint32_t encoding; // 编码方式
    uint32_t length;   // 元素数量
    int8_t contents[]; // 整数集合底层实现:数组
} intset;

contents数组是整数集合的底层实现,整数集合中的每个元素都是contents数组的一个数组项,各个项在数组中按照值的大小从小到大排列(数组中不包含任何重复项)。

虽然将contents数组声明为int8_t类型,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值,即,encoding取值INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64分别表示contents为int16_t、int32_t、int64_t类型的数组。

升级

往整数集合中添加新元素时,若新元素类型比整数集合中已有元素的类型长时,需要对整数集合进行升级。

整数集合升级分为三步:

  • 根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间;
  • 将底层数组中现有的所有元素转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位置上,放置元素过程中,需要继续维持底层数组的有序性;
  • 将新元素添加到底层数组中。 

如,往uint32_t contents[]数组插入uint64_t的元素时,数组中原有的所有元素都会升级为uint64_t类型。

升级时,插入的元素必定大于或小于整数集合原有的所有元素。

升级的好处

  • 提升灵活性:通过自动升级底层数组,可以向整数集合添加int16_t、int32_t、int64_t类型的元素,而不必担心类型错误;
  • 节约内存:升级操作只在必要的时候进行,可有效的降低内存开销。

整数集合不支持降级!!!

 

 

压缩列表

压缩列表的基本结构如下图所示:

zlbytes zltail zllen entry1 entry2 ... entryN zlend
属性 类型 长度 用途
zlbytes uint32_t 4字节 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配或计算zlend的位置时使用
zltail uint32_t 4字节 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址
zllen uint16_t 2字节 记录了压缩列表包含的节点数量:当这个属性的值小于UINT16_MAX(65535)时,这个属性的值就是压缩列表包含节点的数量;当这个值等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算得出
entryX 列表节点 不定 压缩列表包含的各个节点,节点的长度由节点保存的内容决定
zlend uint8_t 1字节 特殊值0xFF,用于标记压缩列表的末端

压缩列表节点的构成

压缩列表节点entryX由previous_entry_length、encoding、content三部分组成,如下图所示。

entryX
previous_entry_length encoding content

previous_entry_length

节点的previous_entry_length属性以字节为单位,记录了压缩列表中某个节点前一节点的长度。根据前一个节点的长度,previous_entry_length属性的值可能为1字节或5字节。

  • 若前一个节点的长度小于254字节,则previous_entry_length的值为1字节(前一个节点的长度就保存在这个字节中);
  • 若前一个节点的长度大于等于254字节,则previous_entry_length的值为5字节(第一个字节固定为0xFE,之后的四字节则用于保存前一个节点的长度)。

由于节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的地址来计算前一个节点的位置。

encoding

节点的encoding属性记录了节点的content属性所保存数据的类型和长度:

  • 字节数组编码:1字节、2字节或5字节长,值的最高位为00、01或10,数组的长度由除去最高两位之后的其他位记录;
编码 编码长度 content属性保存的值
00bbbbbb 1字节 长度小于等于63字节的字节数组
01bbbbbb xxxxxxxx 2字节 长度小于等于16383的字节数组
10______ aaaaaaaa bbbbbbbb cccccccc dddddddd 5字节 长度小于等于4294967295的字节数组
  • 整数编码:1字节长,值的最高位为11,整数的类型和长度由除去最高两位之后的其他位记录。
编码 编码长度 content属性保存的值
11000000 1字节 int16_t 类型的整数
11010000 1字节 int32_t 类型的整数
11100000 1字节 int64_t 类型的整数
11110000 1字节 24位有符号整数
11111110 1字节 8位有符号整数
1111xxxx 1字节 使用这一编码的节点没有相应的content属性,因为编码本身的xxxx四个位已经保存了一个介于0和12之间的值,所以它无需content属性

content

节点content属性负责保存节点的值,节点值可以是一个字节数组或整数,值的类型和长度由压缩列表节点的encoding属性决定。

连锁更新

每个压缩列表节点的previous_entry_length属性都记录了前一个节点的长度。

  • 如果前一个字节的长度小于254,那么previous_entry_length需要用1字节来保存这个长度值。
  • 如果前一个字节的长度大于等于254,那么previous_entry_length需要用5字节来保存这个长度值。

现在假设这种情况:压缩列表有多个连续的长度介于250-253之间的节点e1-eN。因为每个节点的长度都小于254字节,所以这些节点的previous_entry_length属性都是1字节编码长度。此时如果将一个长度大于254的新节点设置为压缩列表的头节点,那e1的previous_entry_length长度将扩展为5字节,此时e1节点的长度超过了254字节,于是e2的previous_entry_length也超过了254...这样压缩列表中的所有节点就会连锁式的更新,并重新分配空间。

除了新增节点会引发连锁更新外,删除操作也可能导致连锁更新。假设压缩列表的第一个节点长度大于等于254,其余节点长度介于250-253之间,此时若删除第二个节点,就会导致之后的所有节点发生连锁更新。

连锁更新在最坏的情况下需要对压缩列表执行N次空间重分配操作,每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)。

虽然这很耗费时间,但是实际情况下这种发生的概率极低。而且,对很少一部分节点进行连锁更新并不会影响程序的性能。

 

你可能感兴趣的:(Redis)