Redis数据结构与对象
1 简单动态字符串
简单动态字符串(Simple Dynamic String,SDS)
底层定义:
struct sdshdr {
// 记录buf数组已使用的字节数量
int len;
// 记录buf数组未使用字节的数量
int free;
// 子节数组,用于保存字符串
char buf[];
}
使用SDS而不是原生的字符数组的优势:
- 能以常数复杂度获取字符串长度
- 杜绝缓冲区溢出
- 减少修改字符串时带来的内存重分配次数
空间预分配:当需要对SDS进行空间扩展时,程序不仅会为SDS分配修改所需要的空间,还会为SDS分配额外的未使用空间。其策略如下:
- 如果修改后SDS的长度(len属性的值)小于1MB,那么程序会分配和len属性同样大小的未使用空间,这时SDS的len属性值和free属性值相同。
- 如果修改后SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。
惰性空间释放:当SDS的API缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性讲这些子节数量纪录起来,等待将来使用。
二进制安全:SDS使用len属性的值而不是空字符\0
来判断字符串是否结束。
2 链表
由于C语言并没有内置链表这种数据结构,因此Redis构建了自己的链表实现。
链表节点的定义:
typedef struct listNode
{
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
链表定义:
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 表链包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
} list;
Redis的链表特性如下:
- 双向:链表节点带有prev和next指针
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL
- 带表头指针head和表尾指针tail
- 带链表计数器len属性
- 多态:使用
void*
指针保存节点值,通过dup、free、match三个属性为节点值设置类型特定函数。
3 字典
C语言也没有内置字典的实现,因此Redis构建了自己的字典实现。
哈希表定义:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用户计算索引值,总是等于size-1
unsigned long sizemask;
// 哈希表已有节点的数量
unsigned long used;
} dictht;
哈希表节点定义:
typedef struct dictEntry {
// 键
void *key;
// 值
union{
void *val;
unit64_tu64;
int64_ts64;
} v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
}
字典定义:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引
// 当rehash不在进行时,值为-1
int trehashidx;
}
-
type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
-
privdata属性则保存了需要传给那些类型特定函数的可选参数。
-
一般情况下字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
dictType定义:
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *provdata, const void *key);
// 复制值的函数
void *(*valDup)(void *provdata, const void *obj);
// 对比键的函数
void (*keyCompare)(void *provdata, const void *key1, const void *key2);
// 销毁键的函数
void (*keyDestructor)(void *provdata, void *key);
// 销毁值的函数
void (*valDestructor)(void *provdata, void *obj);
} dictType;
计算一个键的hash值:
// 使用字典设置的哈希函数,计算键key的哈希值
hash = dict -> type -> hashFunction(key);
// 使用哈希表的sizemask属性和哈希值,计算出索引值
index = hash & dict -> ht[x].sizemask; // ht[x]可以是ht[0]或者ht[1]
哈希冲突:Redis的哈希表使用拉链法来解决哈希冲突。
Rehash
rehash:为了让哈希表的负载因子(load factor)维持在一个合理的范围内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
负载因子计算公式:
load_factor = ht[0].used / ht[0].size
rehash过程如下:
- 为字典的ht[1]哈希表分配空间,这个哈希表空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(即ht[0].used属性的值)
- 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used * 2的 2 n 2^n 2n。
- 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的 2 n 2^n 2n。
- 将保存在ht[0]中的所有键值对rehash到ht[1]上:rehash指的是重新计算键到哈希值和索引值,然后将键值对放置在ht[1]哈希表的指定位置上。
- 当ht[0]包含的所有键值对都迁移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
哈希表的扩展和收缩条件:
-
扩展:只要满足以下任意一个条件,程序就会进行扩展操作
- 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
- 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
-
收缩:当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。
渐进式rehash
Redis进行rehash不是一次性、集中式地完成,因为这在数据量庞大的情况,会占用服务器大量资源,导致服务不可用。因此Redis采取的是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]中。其步骤如下:
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
- 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
- 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。
- 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash到ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。
优势:采用分治的思想,将rehash的计算成本分摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。
在渐进式rehash过程中,字典的删除、查找、更新等操作会在两个哈希表上进行。另外,新添加到字典的键值对会一律被保存到ht[1]里面,而ht[0]则不进行任何添加操作。
4 跳表
跳表(skiplist)是一种有序数据结构,通过在有序链表的基础上建立多级索引,从而达到平均O(logN)、最坏O(N)复杂度的节点查找。
下图展示了跳表的一个示例:
- header:指向跳表的表头节点
- tail:指向跳表的表尾节点。
- level:记录目前跳表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
- length:记录跳表的长度,也即是,跳表目前包含节点的数量(表头节点不计算在内)。
跳表节点定义:
typedef struct zskiplistNode {
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
//成员对象
robj *obj;
} zskiplistNode;
每创建一个新跳表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。
跳表定义:
typedef struct zskiplist {
// 表头节点和表尾节点
structz zskiplistNode *header, *tail;
// 表中节点数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
相比红黑树,跳表的优势:
- 支持范围操作
- 实现简单
5 整数集合
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
整数集合的每个元素都是contents数组的一个数组项(item),各个值在数组中按值的大小从小到大有序排序,并且数组中不包含重复项。虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,真正的类型是取决于encoding属性的值。
升级:将一个大于现有encoding编码的整数添加到整数集合中,会引起整数集合升级,其过程分三步进行。
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
降级:整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
6 压缩链表
压缩链表是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩链表节点entry的构成:
-
previous_entry_length
previous_entry_length以字节为单位,记录了压缩链表前一个节点的长度。previous_entry_length的长度可以是1字节或者5字节。
- 如果前一个节点长度小于254字节,那么previous_entry_length属性的长度为1字节:前一个节点的长度就保存在这一个字节里面。
- 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一个字节会被设置为0xFE,之后的四个字节则用于保存前一个节点的长度。
-
encoding
记录了节点的content属性所保存数据的类型以及长度:
- 一字节、两字节或者五字节,值的最高位00、01或者10的是字节数组编码
- 一字节长,值的最高位以11开头的是整数编码
-
content
负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
7 对象
Redis并没有直接使用前面介绍的数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含了字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每个对象都用到了至少一种数据结构。
使用对象的好处:
- 根据一个对象的类型来判断对象是否可以执行给定的命令。
- 针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。
对象定义:
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层数据结构的指针
void *ptr;
...
} robj;
对Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。
对象的编码:
编码常量 | 编码所对应的底层数据结构 |
---|---|
REDIS_ENCODING_INT | long类型的整数 |
REDIS_ENCODING_EMBSTR | embstr编码的简单动态字符串 |
REDIS_ENCODING_RAW | 简单动态字符串 |
REDIS_ENCODING_HT | 字典 |
REDIS_ENCODING_LINKEDLIST | 双端链表 |
REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_ENCODING_INTSET | 整数集合 |
REDIS_ENCODING_SKIPLIST | 跳表和字典 |
不同类型的对象使用的编码:
- 字符串
- REDIS_ENCODING_INT,使用整数值实现
- REDIS_ENCODING_EMBSTR,使用embstr编码实现的简单动态字符串实现
- REDIS_ENCODING_RAW,使用简单动态字符串实现
- List
- REDIS_ENCODING_ZIPLIST
- REDIS_ENCODING_LINKEDLIST
- Hash
- REDIS_ENCODING_ZIPLIST
- REDIS_ENCODING_HT
- Set
- REDIS_ENCODING_INTSET
- REDIS_ENCODING_HT
- ZSet
- REDIS_ENCODING_ZIPLIST
- REDIS_ENCODING_SKIPLIST
通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定编码,极大地提升了Redis的灵活性和效率,因为Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。
字符串对象
-
如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int。注意:long double类型表示的浮点数是使用字符串值来保存的。
-
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。
embstr编码和raw编码都是使用redisObject和sdshdr结构来表示字符串对象,但它们存在以下区别:
- embstr编码是专门用来保存短字符串的一种优化编码方式,通过调用一次内存分配函数分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构,如下图所示:
- raw编码会调用两次内存分配函数分别创建redisObject结构和sdshdr结构
- embstr编码的优势:内存分配和内存释放都会比raw编码少一次系统调用;数据保存在一块连续的内存里,能更好利用缓存带来的优势。
- embstr编码是专门用来保存短字符串的一种优化编码方式,通过调用一次内存分配函数分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构,如下图所示:
-
如果保存的不是整数,并且字符串值的字节大于32字节,那么使用raw编码保存。
编码转换:
- 对于int编码的字符串来说,如果执行一些命令后保存的不再是整数值,而是一个字符串值,那么字符串对象的编码会从int转换为raw。
- 由于Redis没有为embstr编写任何修改程序(只有int编码和raw编码的字符串有这些程序),所以embstr编码对象实际上是只读的。对一个embstr编码的字符串对象进行修改,会将编码先转换为raw编码,再执行修改命令。
列表对象
列表对象的编码可以是ziplist或linkedlist:
使用ziplist编码的列表对象
使用linkedlist编码的列表对象
编码转换:
当列表满足以下两个条件,列表对象使用的ziplist编码:
- 列表对象保存的所有字符串元素的长度都小于64字节
- 列表对象保存的元素数量小于等于512个
不满足以上两个条件的都是用linkedlist编码
哈希对象
哈希对象使用ziplist或hashtable。
使用ziplist编码的哈希对象
使用ziplist编码的哈希对象底层使用压缩列表存储数据,每当插入一个新的键值对,程序会先将保存键的压缩列表节点添加到压缩列表表尾,然后再将保存了值的压缩列表节点添加到压缩列表表尾。因此,保存了同一键值对的两个节点总是挨在一起,保存键的节点在前,保存值的节点在后。
使用hashtable编码的哈希对象
hashtable编码的哈希对象底层使用字典存储数据。
注:上面字典结构是简略的,具体的dict应该是这个样子。
编码转换
当且仅当哈希对象同时满足以下两个条件时,哈希对象使用ziplist编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都不超过64字节;
- 哈希对象保存的键值对数量不超过512个;
不能同时满足以上两个条件的都是用hashtable作为哈希对象的编码
集合对象
集合对象的编码可以是intset或hashtable
使用intset编码的集合对象
intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。
使用hashtable编码的集合对象
hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含一个集合元素,而字典的值全部被设置为NULL。
编码转换
当集合对象可以同时满足以下条件时,使用intset编码:
- 集合对象所保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过512个;
不能同时满足这两个条件的都是使用hashtable编码作为集合对象的编码。
注:第二个条件的上限值是可以在配置文件中修改的。
有序集合对象
有序集合的编码可以是ziplist或者skiplist。
使用ziplist编码的有序集合对象
ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),第二个节点则保存元素的分值(score)。
压缩列表内的集合元素按照分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。
使用skiplist编码
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳表。
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;
-
zset结构中的zsl跳表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳表节点的object属性保存了元素的成员,而跳表节点的score属性则保存了元素的分值。
-
zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。
有序集合的每个元素的成员都是字符串对象,而每个元素的分数都是一个double类型的浮点数。
虽然zset结构同时使用跳表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,因此不会浪费额外的内存。
Q:为什么skiplist编码的有序集合同时使用跳表和字典来实现数据的存储?
- 如果只使用字典来保存元素,由于字典是无序的,每次对有序集合进行范围操作都需要将字典的所有元素先按照分数进行排序,其 时间复杂度至少是O(NlogN)的。
- 如果只使用跳表来保存元素,虽然跳表能支持范围操作并且本身有序,但是跳表是按照分值排序的,查找有序集合中的一个元素成员是否存在时,需要遍历整个跳表,最坏时间复杂度为O(N)。
- 同时使用跳表和字典来实现有序集合,能够在O(1)的复杂度下快速查找元素成员的分值,通过这个分值去跳表查找该元素成员的位置,其时间复杂度最快能达到O(logN)。
编码的转换
当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:
- 有序集合保存的元素不超过128个;
- 有序集合保存的所有元素成员的长度都小于64字节;
不能满足以上两个条件的有序集合对象将使用skiplist编码。
注:以上两个条件的上限值是可以在配置文件中修改的。
命令类型检查
Redis的命令分为两种类型:
-
公共命令:可以对任何类型的键执行,比如DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令等。
-
类型特定命令:只能对特定类型的的键执行。
使用类型特定命令时需要对redisObject结构的type属性进行检查。
内存回收
由于C语言不具备自动回收内存功能,因此Redis在对象系统中使用一种引用计数技术实现内存自动回收机制。程序通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。
typedef struct redisObject {
// ...
// 引用计数
int refcount;
// ...
} robj;
对象的引用计数随着对象的使用状态而变化:
- 创建一个新对象时,引用计数值初始化为1;
- 当对象被一个新程序使用时,它的引用计数值加一;
- 当对象不再被一个程序使用时,它的引用计数值减一;
- 当对象的引用计数值为0时,对象所占有的内存会被释放。
修改对象引用计数的API:
- incrRefCount:将对象引用计数值加一;
- decrRefCount:将对象引用计数值减一,当对象引用计数值为0时,释放对象。
- resetRefCount:将对象引用计数值设置为0,但并不释放对象。
对象共享
为了节约内存,Redis支持多个键共享一个值对象:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数值加一。
Redis甚至在初始化服务器时,就创建了一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到0到9999的字符串对象时,服务器就会使用这些共享对象,而不是创建新对象。
不单单是字符串键可以使用共享对象,那些在数据结构中嵌套了字符串对象的对象,也可以使用这些共享对象。
需要注意的是,Redis处于时间成本的考虑,不共享包含字符串的对象。原因如下:
-
如果共享对象是保存字符串值的字符串对象,那么验证两个对象是否相等的时间复杂度为O(N);
-
如果共享对象时包含了多个值对象(对象的对象),比如列表对象或者哈希对象,那么验证两个对象是否相等的时间复杂度为 O ( N 2 ) O(N^2) O(N2)。
-
如果共享对象保存的是整数值的字符串对象,验证操作的时间复杂度只是O(1)。
因此虽然共享对象能够节约内存空间,但是要验证操作的复杂度过高时,会浪费CPU资源,得不偿失。
对象空转时间
redisObject结构的最后一个属性:lru属性,该属性记录的是对象最后一次被命令程序访问的时间。
typedef struct redisObject {
// ...
unsigned lru:22;
// ...
} robj;
通过命令OBJECT IDLETIME
命令可以打印出给定键的空转时间,这个空转时间是通过当前时间戳减去键的值对象的lru时间计算得出。