第一章:数据结构与对象
一、简单动态字符串(SDS)
在Redis中默认字符串的表示使用了简单动态字符串(Simple Dynamic String),而没有使用C语言中的传统字符串(字面量,string literal)。字面量只用来表示一些无需修改的内容。而大部分没内容,如key和字符串value都使用SDS表示。
SDS的定义:
struct sdshdr{
// 记录buf数组中已经使用的字节数
// 等于SDS所保存的字符串长度
int len;
// 记录buf数组中剩余的空字节数
int free;
// 字节数组,用于保存字符串
char[] buf;
}
一个典型的SDS如下图所示:free的值为5表示还有5个字节可以使用,len的值为5表示当前字符串的长度为5,buf中存储着实际的字符串,需要注意的是SDS采用与C语言一样的规则在字符串尾部添加空字符\0
表示结束,该部分不算在len的计数中。
通过buf字节数组,SDS可以保存任何二进制数据,而不仅仅是字符串,所以他是二进制安全的。但是SDS同样兼容C的字符串,会自动分配n+1的空间,并在末尾加上空字符\0
表示结束。
在C语言中或许字符串长度需要遍历字符数组,要O(n)的时间复杂度。而SDS可以直接取len,只需要O(1)的时间复杂,len由SDS自身维护。
字符串扩容
当SDS API需要对SDS进行修改时,API会首先检查SDS的空间是否满足修改所需的要求,如果不满足SDS自动将空间扩展至修改所需要的大小,然后才会执行操作。同时SDS通过未使用空间(free记录的大小)实现了空间预分配和惰性空间释放两种优化策略。
-
空间预分配:在需要对SDS进行空间扩展时,程序不仅会为SDS分配修改所需要的必须空间,还会为SDS分配额外的未使用空间。这样做的好处是,当需要再次修改字符串时,不需要从新申请空间。
- 当len小于1M时,SDS会分配与len相同大小的未使用空间,即分配完成之后
len == free
。举个例子,当分配完成之后len=13
,则buf的大小为13+13+1 = 27
- 当len大于等于1M时,SDS会分配1M的未使用空间。举个例子,当分配完成之后
len=30M
,则buf的大小为30M + 10M = 40M
- 当len小于1M时,SDS会分配与len相同大小的未使用空间,即分配完成之后
-
惰性空间释放:当SDS的API要缩短保存的字符串时,程序不会立即回收多出来的字节,而是使用free保存起来,等待将来使用。只有当真正需要时才会使用SDS未使用的空间。
C字符串和SDS的区别总结:
二、链表
链表被广泛用于Redis的各种功能,比如列表键、发布与订阅、慢查询监视器等。
每个链表的节点都由一个ListNode表示,每个节点都有指向前置节点和后置节点的指针,以及一个指向具体存储内容的指针,定义如下:
typedef struct ListNode{
//前置节点
struct ListNode *prev;
//后置节点
struct ListNode *prev;
//存储内容
void *value
}
每个链表由一个list结构表示,这个结构带有一个表头节点,表尾节点以及表长度等信息。同时还包含复制,释放和对比等函数。
typedef struct List{
//表头节点
struct ListNode *head;
//表尾节点
struct ListNode *tail;
//长度
unsigned long len;
//节点复制函数
void *(*dup) (void *ptr);
//节点释放函数
void (*free) (void *ptr);
//节点值对比函数
int (*match) (void *ptr,void *key)
} list;
三、字典
1、哈希表
字典被用于Redis的各种功能,其中包括数据库和哈希表。字典的底层使用哈希表实现,Redis中哈希表的定义如下:
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表掩码大小,用于计算索引值
unsigned long sizemask;
//该哈希表已有节点数量
unsigned long used;
}
哈希表的节点由dictEntry表示,每个dictEntry都保存着一个键值对,其定义如下:
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
}
一个Redis中的哈希表如下图所示:
Redis的哈希表使用了Murmur2算法来计算哈希值,并使用拉链法来解决哈希冲突。
2、字典
Redis中的字典定义如下:
typedef struct dict{
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash索引
//当没有进行rehash时,其值为-1
int rehashidx;
}
其中dictType结构保存了一簇同于特定类型键值对的函数,Redis会为不同类型的字典设置不同的类型特定函数。
privdata属性了保存需要传给那些类型特定函数的可选参数。
ht属性包含了两个哈希表数组,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在ht[0]哈希表进行rehash时使用。
除了ht[1]之外,另一个与rehash相关的属性就是rehashidx,它记录了当前rehash的进度,如果当前没有进行rehash则他的值为-1。
下图展示了一个没有进行rehash的字典:
3、渐进式Rehash
为了让哈希表的负载因子(load factor)维持在一个合理的范围内,当哈希表保存的键值对数量过多或是过少时,程序需要对哈希表的大小进行相应的扩展和收缩。下面对Redis中渐进式Rehash的过程进行介绍:
- 为ht[1]分配空间,让哈希表同时持有ht[0]和ht[1]两个哈希表,具体ht[1]分配的大小为:
- 扩展:为第一个大于等于
ht[0].used * 2
的2^n
- 收缩:为第一个大于等于
ht[0].used
的2^n
- 扩展:为第一个大于等于
- 在字典中维持一个索引计数器变量rehashidx,并将它的值设为0,表示rehash正式开始。
- 在Rehash期间,除了对哈希表进行正常的操作之外,还会顺带将ht[0]在rehashidx索引上的所有键值对都迁移到ht[1],完成之后会将rehashidx+1。
- 在rehash过程中,对哈希表进行的删除,修改,更新操作会先在ht[0]查找对应的键值对进行操作,找不到的话才会在ht[1]进行操作。
- 在rehash过程中,对哈希表进行的添加操作都在ht[1]进行,保证ht[0]的键值对只减不增。
- 随着rehash的不断进行,最终ht[0]上所有的键值对都迁移到了ht[1]上,这时将rehashidx设为-1表示rehash完成。
渐进式的Rehash保证了Rehash可以与常规操作同时进行,从而避免了集中式的rehash带来的庞大的计算量和停顿。
四、跳跃表
在Redis内部,在两个地方用到了跳跃表(skiplist),一个是实现有序集合键(sorted set),另一个是在集群节点中用作内部数据结构。
Redis的跳表是由zskiplist和zskiplistNode两个结构组成,其中zskiplist保存了跳表的信息(比如表头节点,表尾节点和长度等),zskiplistNode则用于表示跳表节点。
skiplist的数据结构定义
#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25
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;复制代码
- 开头定义了两个常量,ZSKIPLIST_MAXLEVEL和ZSKIPLIST_P,分别对应skiplist的两个参数:一个是MaxLevel,一个是p。
- zskiplistNode定义了skiplist的节点结构。
- obj字段存放的是节点数据,它的类型是一个string robj。本来一个string robj可能存放的不是sds,而是long型,但zadd命令在将数据插入到skiplist里面之前先进行了解码,所以这里的obj字段里存储的一定是一个sds。有关robj的详情可以参见系列文章的第三篇:《Redis内部数据结构详解(3)——robj》。这样做的目的应该是为了方便在查找的时候对数据进行字典序的比较,而且,skiplist里的数据部分是数字的可能性也比较小。
- score字段是数据对应的分数。
- backward字段是指向链表前一个节点的指针(前向指针)。节点只有1个前向指针,所以只有第1层链表是一个双向链表。
- level[]存放指向各层链表后一个节点的指针(后向指针)。每层对应1个后向指针,用forward字段表示。另外,每个后向指针还对应了一个span值,它表示当前的指针跨越了多少个节点。span用于计算元素排名(rank),这正是前面我们提到的Redis对于skiplist所做的一个扩展。需要注意的是,level[]是一个柔性数组(flexible array member),因此它占用的内存不在zskiplistNode结构里面,而需要插入节点的时候单独为它分配。也正因为如此,skiplist的每个节点所包含的指针数目才是不固定的,我们前面分析过的结论——skiplist每个节点包含的指针数目平均为1/(1-p)——才能有意义。
- zskiplist定义了真正的skiplist结构,它包含:
- 头指针header和尾指针tail。
- 链表长度length,即链表包含的节点总数。注意,新创建的skiplist包含一个空的头指针,这个头指针不包含在length计数中。
- level表示skiplist的总层数,即所有节点层数的最大值。
一个可能的跳表如下图所示:
为了避免插入或者删除过程中指针的调整,跳表的每个节点的层高都是随机生成(遵循一定规则的随机),一个跳表生成过程演示:
同时Redis的跳表实现还具有下面几个特点:
- 跳表的第一层通过backward指针反向相连,也就是第一层为双向链表。
- 同时使用分值和对象进行排序:首先按照分值大小排序,如果分值相同则按照对象的字典序排序
五、整数集合
整数集合(intset)是Redis中集合(set)的实现方式之一,当一个集合中只包含整数元素,且这个集合的元素个数不多时,Redis就会将整数集合作为集合的底层实现,其结构定义如下:
typedef struct intset{
//编码方式
uint32_t encoding;
//元素数量
uint32_t length;
//保存元素的数组
uint8_t content[];
}
content数组是整数集合的底层实现,数组中各元素按值从小到大有序排列且唯一。
length表示整数集合长度,也就是content数组元素数量
encoding决定了content实际存储的真正类型(uint16_t, uint32_t, uint64_t),程序会根据新添加元素的类型,改变这个数组的类型。升级操作为整数集合带来了操作上的灵活性,并且尽可能节省了内存。
六、压缩列表
压缩列表作为列表和哈希表的底层实现之一,是一个为了节约内存而开发的顺序型数据结构。下图展示了一个压缩列表的示例:
其中表中的各种属性含义如下表所示:
每个列表节点(entry)可以保存一个字节数组或者一个整数值。主要三个属性构成:
- Previous_entry_length:以字节为单位,记录了压缩列表前一个节点的长度,可以用于反向遍历
- encoding:编码类型,记录entry中保存的数据长度
- content:具体的数据
如下图所示:
同时添加新元素或者删除时可能会引发连锁更新操作,但这种操作的几率不大。
六、对象
Redis没有直接使用上面介绍的这些数据结构直接存储对象,而是为这些数据结构创建了对象系统,这个系统包括字符串对象、列表对象、哈希对象、集合对象以及有序集合对象这五种类型的对象。同时Redis的对象系统还实现了基于引用计数的内存回收技术和对象共享技术。
Redis中的每一个对象都由一个redisObject结构表示,定义如下:
typedef struct redisObject{
//类型(外在类型)
unsigned type:4;
//编码 (底层数据结构类型)
unsigned encoding:4;
//指向底层实现数据结构的指针
void *ptr;
// ...
} robj;
对象的type属性记录了对象的外在类型,对于Redis存储的键值对来说,键总是字符串对象类型,而值可以是上面提到的五种类型。
对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。
每种类型的对象都至少使用了两种不同的编码,下表列出了每种类型的对象可以使用的编码。
通过encoding属性来设定对象所使用的的编码,而不是为特定类型的对象关联一种固定编码,极大的提高了Redis的灵活性和效率,因为Redis可以根据不同的使用场景为一个对象设置不同的编码。
1、字符串对象
字符串对象的编码可以是int,raw或者embstr。
- 如果一个字符串对象保存的是整数值,并且这个整数值可以使用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性中(将void* 转换为 long),并将编码属性设置为int
- 如果字符串对象保存的是一个字符串,并且这个字符串的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个值,并且并将编码属性设置为raw
- 如果字符串对象保存的是一个字符串,并且这个字符串的长度小于等于32字节,那么字符串对象将使用embstr编码格式来保存,embstr编码通过一次内存分配函数来分配一块连续的空间(raw类型需要两次),空间中依次包含redisObject和sdshdr两个结构。
当对int编码和embstr编码进行一定修改操作时,其编码会转换为raw类型。
2、列表对象
列表对象的编码可以是ziplist和linkedlist。
ziplist编码的列表对象使用压缩队列作为底层实现,每个压缩列表节点(entry)都保存了一个列表元素。下图展示了一个ziplist编码的列表对象。
linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(Node)都保存了一个字符串对象,而每个字符串对象都保存了列表元素,如下图所示。
当同时满足下面两种情况时列表会使用ziplist作为底层实现,否则会使用linkedlist
- 列表对象保存的所有字符串对象的长度都小于64字节
- 列表对象保存的元素数量小于512个
3、哈希对象
哈希对象的编码可以是ziplist或者hashtable。
ziplist作为哈希对象的编码时,键和值都用一个字符串对象表示,并且键和值对象连续存放,键在前,值在后,如下图所示:
当哈希对象同时满足下面的条件时,哈希对象使用ziplist编码,否则使用hashtable进行编码:
- 列表对象保存的所有键和值得字符串对象的长度都小于64字节
- 列表对象保存的键值对得数量小于512个
hashtable进行编码的哈希对象的如下图所示:
4、集合对象
集合对象的编码可以是intset或者hashtable。
intset编码的集合对象使用整数集合作为底层实现,集合包含的所有对象都被保存在整数集合里面。
同时hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每一个字符串对象包含一个集合元素,而字典的所有值指向null。
下图展示了分别使用intset或者hashtable编码的集合对象:
当集合对象同时满足下面两个条件时会使用intset进行编码,否则使用hashtable编码:
- 集合中保存的元素全部都是整数值
- 集合中元素个数小于等于512个
5、有序集合对象
有序集合的编码可以是ziplist或者skiplist。
ziplist编码的有序集合对象,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存节点的成员(member),紧挨着第二个节点存储元素的分值(score)。ziplist中的元素按照分值从小到大排序,分值较小的接近表头,分值较大的接近表尾,如下图所示:
skiplist编码的有序集合对象使用zset结构作为底层实现,zset同时包含一个字典和一个跳跃表,定义如下:
typedef struct zset{
//跳表
zskiplist * zsl;
//字典
dict *dict;
}
zset结构中的zsl跳跃表按照到分值从小到大保存了所有集合元素,其object属性保存元素的成员,而score属性保存了元素的分值。通过跳跃表,程序可以对有序集合进行范围查找操作,比如ZRANK,ZRANGE等。
zset结构的dict字典创建了成员到分值的映射,字典的键保存元素的成员(字符串对象),字典的值保存元素的分值(double类型)。通过字典,程序可以O(1)的复杂度查找成员的分值。
值得一提是,虽然zsl跳跃表和dict字典都保存了元素的成员和分值,但实际上zset使用指针来共享了相同元素的成员和分值。所以不会造成任何对象重复或者额外的内容。同时结合两个数据结构的特点实现了操作时间复杂度的优化。下图展示了一个zset对象:
当有序集合对象同时满足如下条件时使用ziplist进行存储,否则使用zset进行存储:
- 有序集合对象保存的所有元素成员对象的长度都小于64字节
- 有序集合对象保存的元素数量小于128个
6、优化
- 服务器在执行某些命令之前会首先检查对象的类型(type)和编码(encoding)来判断该命令能否对该对象执行
- Redis对象系统通过引用计数法实现了垃圾回收机制,当一个对象不再使用时就会被释放
- Redis会共享值为0到9999的字符串对象
- Redis的对象中有一个lru属性用于记录最后使用的时间,可以用于计算对象的空转时间从而实现了内存淘汰策略