《Redis设计与实现》笔记

redis并没有直接使用数据结构来实现键值对数据库,而是将数据结构封装成对象来使用,对象包含字符串对象,列表对象,哈希对象,集合对象和有序集合对象这五种类型的对象,数据结构包含链表,字典(底层由hash实现),跳跃表,整数集合,压缩列表。接下来将逐一介绍这些对象和数据结构。

对象的类型和编码

redis中每一个键对值都包含两个对象,一个对象为键对象(一般为字符串对象),一个对象为值对象。每个对象都由一个redisObject结构表示。

typedef struct redisObject {

    // 类型
    unsigned type:4;

    // 编码
    unsigned encoding:4;

    // 对象最后一次被访问的时间
    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */

    // 引用计数
    int refcount;

    // 指向底层实现数据结构的指针
    void *ptr;

} robj;

type记录了对象的类型包含字符串对象,列表对象,哈希对象,集合对象和有序集合。

ptr指针指向对象的底层实现数据结构,这些数据结构由对象的encoding属性决定。

encoding属性记录了对象所使用的编码。

encoding属性的值如下

redis塞1个对象 redis 对象_redis


每种对象使用的编码并不是唯一的,一种对象可以使用多种编码,如下图所示

redis塞1个对象 redis 对象_字符串_02

字符串对象

字符串对象的编码可以是int、raw或embstr。

如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示那么这个字符串编码为int。

如果如果一个字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于39字符,那么这个字符串编码为embstr。

如果一个字符串对象保存的是一个字符串值,并且这个字符串值的长度大于39字符,那么这个字符串编码为raw。

redis并没有为embstr编写任何相应的修改程序,只有int和raw有相应的修改程序,所以embstr对象实际上是只读的。我们对embstr编码的字符串进行任何修改命令时,程序都会将编码从embstr转换为raw。

redis塞1个对象 redis 对象_链表_03

简单动态字符串

redis并没有使用传统c语言的字符串而是自己构建了一种简单动态字符字符串(简称SDS)。一个SDS结构中包含len(记录buf数组已使用的字节数量),free(记录buf数组未使用的字节数量),buf[]数组(用于保存字符串)。这样设计的好处有很多,传统c字符串获取字符串长度的复杂度为O(N),而SDS获取字符串长度的复杂度为O(1)。c字符串容易造成缓冲区溢出,而SDS的空间分配则不会造成缓冲区溢出,当要对SDS进行修改时,API会对SDS进行检查,查看SDS空间是否满足修改的需求,当不满足时,API会对SDS进行空间扩张然后再进行修改。

1.SDS空间预分配

空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间(即free属性)。
SDS的额外分配的未使用空间如何分配呢?
如果SDS修改后,SDS的长度小于1mb,那么分配和len属性同样大小的未使用空间。如果修改后的长度大于等于1mb,那么未使用的空间大小为1mb。
在拓展SDS空间之前,API需要查看SDS未使用空间大小是否足够,如果足够的话,API会直接使用未使用空间,无需执行内存重分配。
使用该策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。

2.惰性空间释放

惰性空间释放用于优化需要缩短SDS的字符串操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配释放缩短后多余的字符空间,而是将这些空间用free属性记录起来并等待使用。
通过惰性空间释放减少了缩短字符串带来的内存重分配操作,并为将来可能有的增长操作提供了优化。
SDS还是二进制安全的,可以保存像图片、音频、视频、压缩文件这样的二进制数据,而c字符串不行。

列表对象

列表对象的编码是ziplist或linkedlist。

当列表对象保存的所有字符串元素的长度都小于64字节以及元素数量小于512个时,使用ziplist否则使用linkedlist。

redis塞1个对象 redis 对象_redis_04

ziplist压缩列表

使用ziplist的列表对象示例

redis塞1个对象 redis 对象_redis塞1个对象_05

1.压缩列表的构成

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

redis塞1个对象 redis 对象_redis_06

2.压缩列表节点的构成

redis塞1个对象 redis 对象_redis塞1个对象_07

1.previous_entry_length

previous_entry_length记录了压缩列表中前一个节点的长度,previous_entry_length属性的长度可以为1字节或5字节。
当为1字节时,代表前一节点的长度小于254字节,前一节点的长度就保存在这一字节当中。
当为5字节时,代表前一节点的长度大于254字节,并且属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度。
因为previous_entry_length记录了前一节点的长度,所以可以通过运算,根据当前节点的起始地址运算出前一节点的起始地址。压缩列表的从表尾向表头遍历操作就是使用这一原理实现的。

2.encoding

节点的encoding属性记录了节点的content属性所保存数据结构的类型以及长度

3.content

节点的content属性记录了节点的值,值的类型和长度由encoding属性决定

linkedlist链表

typedef struct listNode {
    struct listNode *prev; //前驱节点,如果是list的头结点,则prev指向NULL
    struct listNode *next;//后继节点,如果是list尾部结点,则next指向NULL
    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

redis塞1个对象 redis 对象_链表_08

哈希对象

哈希对象的编码可以是ziplist或hashtable。

当列表对象保存的所有键值对的字符串长度都小于64字节以及元素数量小于512个时,使用ziplist否则使用hashtable。对于使用ziplist编码的列表对象来说,当上诉两个条件不满足时,会自动转换为hashtable编码。

redis塞1个对象 redis 对象_redis塞1个对象_09


hash架构

redis塞1个对象 redis 对象_字符串_10


dict:type属性和private属性是针对不同类型的键值对,为创建多态字典而设置的:type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数;private属性保存了需要传给那些类型特定函数的可选参数。ht属性包含两个哈希表,一般情况下用ht[0],而ht[1]只会在ht[0]进行rehash时使用。rehashid:记录当前rehash进度,当不在rehash时,值为-1。

dictht:sizemask用于计算索引值,总是等于size-1。

哈希表的拓展和收缩:当一下任意一个条件被满足时,程序会对哈希表自动拓展:

1)服务器没有在执行BGSAVE或BGREWRITEAOF,并且哈希表的负载因子大于等于1。

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

load_factor=ht[0].used/ht[0].size

当负载因子小于0.1时,自动收缩

rehash步骤

1)为字典的ht[1]分配空间,如果是拓展操作,ht[1]的大小为第一个大于等于ht[0].used*2的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[1]设置为ht[0],并在ht[1]新建一个空白哈希表。

为避免rehash对服务器性能的影响,服务器并不是一次性的将ht[0]全部rehash到ht[1],而是分多次的渐进式的rehash。

集合对象

集合对象的编码可以是intset或hashtable

当集合对象保存的所有元素值为整数并且元素数量不超过512个时,对象使用intset编码,否则使用hashtable。

当使用hashtable时,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为null。

redis塞1个对象 redis 对象_字符串_11


intset整数集合

intset可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素,集合中成员按照从小到大排列。

当一个比现有元素的类型都要大的新类型添加入集合中时,我们要将集合进行升级。升级步骤如下:

1)根据新元素的类型,拓展集合底层数组的空间大小,并为新元素分配空间。

2)将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位置上,而且在放置的过程中,要维持底层数组有序性质不变。

3)将新元素放置在正确的位置上。

有序集合对象

有序集合的编码可以是ziplist或skiplist

当有序集合保存的元素数量少于128个并且所有元素成员的长度小于64字节时使用ziplist编码否则使用skiplist。

redis塞1个对象 redis 对象_redis_12


ziplist

当使用ziplist作为底层实现时,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素则保存元素的分值。压缩列表内的集合元素按分值从小到大排序。

skiplist

当使用skiplist作为底层实现时,一个zset结构同时包含一个字典和一个跳跃表,为什么这么设计呢 ?如果只使用字典,那么集合将无序存储,如果只使用跳跃表,根据成员查找分值复杂度从O(1)上升为O(logN)。

跳跃表按分值大小保存了所有集合元素,每个跳跃表节点保存了元素成员和元素分值;字典的键保存了元素成员,字典值保存了元素的分值。

跳跃表的实现

skiplist结构如下

redis塞1个对象 redis 对象_链表_13


层:节点中的层用L1、L2、L3等表示,每个层中有两个属性:前进指针和跨度,前进指针用于访问位于表尾方向的其他节点,跨度记录了前进指针所指向节点和当前节点的距离。每次创建一个新的跳跃表节点时,程序根据幂次定律,随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的高度。

后退指针(BW):指向当前指针的前一个节点,用于程序从表尾向表头遍历。