1. 简单动态字符串(SDS)
redis是用C编写的内存数据库,但是存储的字串符不是C传统的用字符数组表示的形式,而是自己定义了一套名为简单动态字符串的抽象类型。
set msg "hello world"
对于上面的存储是简单的String类型,他们底层的数据结构将会是:
键(key)是一个字符串对象,对象底层保存着一个字符串"msg"的SDS;
值也是一个字符串对象,对象底层页保存着一个“hello world”的sds。
对于SDS本质就是C语言中的结构体,
/*
* 保存字符串对象的结构
*/
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 数据空间
char buf[];
};
- 用len来记录存储的字符串的长度
- 用free变量记录buf中还空余的空间(初次分配空间,分配大小一般是字符串长度,free是0,当对字符串修改时,比如添加字符,会发生扩容,会有剩余空间的出现)。
- buf是存储字符串的数组,使用的依然是
\0
作为结尾,但是字符串结束是根据len来计算的。
好处,相比较于C语言的字符串表示
- 获取字符串的长度(O(1)和O(n)):
C字符串使用N+1的字符数组来表示长度为N的字符串,所以获取一个字符串的长度,必须遍历整个字符串数组,记录下\0前的长度。
SDS内部维护了一个len的变量,直接记录了字符串的长度,复杂度为O(1).
-
杜绝缓存区溢出:
C字符串不记录字符串的长度,除了获取长度复杂度高外,还发生缓存区溢出。
具体原因通俗说是: C字符串是用字符数组存储的,如果程序中存在两个紧紧挨着的字符数组,
“aaa\0”和“bbb\0”,如果a串减少字符,则不会发生问题,但是如果此时a串继续添加“AA”,但是又忘了重新为a串分配足够的空间由于C字符数组是不记录长度的,则会将修改发生到b串身上,则就发生了字符溢出。
假设程序中有两个在内存中紧邻着的 字符串 s1 和 s2,其中s1 保存了字符串“redis”,二s2 则保存了字符串“MongoDb”:
如果我们现在将s1 的内容修改为redis cluster,但是又忘了重新为s1 分配足够的空间,这时候就会出现以下问题:
我们可以看到,原本s2 中的内容已经被S1的内容给占领了,s2 现在为 cluster,而不是“Mongodb”。
对于redis解决这个问题的方式就是通过free变量的添加。
free+len是一个SDS所能容纳的字符个数,当对buf进行修改时,redis会在执行拼接之前,检查free是否足够容纳。如果不够,会先去扩展SDS的空间,然后在进行拼接操作。
对于扩展SDS空间,下面分析:
- 减少修改字符串带来的内存分配次数
C语言字符串在进行字符串的扩充和收缩的时候,都会面临着内存空间的重新分配问题。对于C语言中字符串数组,如果发生拼接字符串,忘记申请分配空间,则会导致内存的溢出。在收缩的时候,由于没有记得字符串长度,当删减一部分字符后,那些删减的部分由于没有记录,就会发生内存泄漏。
对于redis采用预分配策略,刚开始初始化的时候分配的大小是字符串的大小,当发生修改时,会增加SDS的大小,通常大于所需要的空间大小,将多余的记录在free中,如果下次发生更改,则不需要再去申请内存空间了。
- 二进制安全
C 字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存想图片,音频,视频,压缩文件这样的二进制数据。
但是在Redis中,不是靠空字符来判断字符串的结束的,而是通过len这个属性。那么,即便是中间出现了空字符对于SDS来说,读取该字符仍然是可以的。
- 总结:
c语言存储字符串是通过定义char数组,用\0
表示字符串的结束,所以字符串中不能出现 \0,否则会认为字符串结束了,相比较于SDS,则可以实现 \0的存储。
对于SDS结构,通过预分配策略,可以有效的减少字符串修改带来的内存重新分配的次数,用free来记录剩余的大小。
2. 链表
链表是list的数据类型底层数据结构,是双向链表,key中存储了链表的头尾指针,可以获取到头尾节点。
链表的数据结构:
每个链表的节点使用listNode结构表示:
typedef struct listNode{
struct listNode *prev; //前驱节点
struct listNode * next; //后继节点
void * value; //节点值
}
多个链表节点组成的双端链表:
我们可以通过直接操作list 来操作链表会更加方便:
typedef struct list{
//表头节点
listNode * head;
//表尾节点
listNode * tail;
//链表长度
unsigned long len;
//节点值复制函数
void *(*dup) (void *ptr);
//节点值释放函数
void (*free) (void *ptr);
//节点值对比函数
int (*match)(void *ptr, void *key);
}
结构图如下:
从图中可以看到:
- 双端: 链表节点带有prev 和next 指针,获取某个节点的前置节点和后置节点的时间复杂度都是O(N)
- 无环: 头结点的prev和尾节点的next都是执行null,所以访问时指向null即可停止查找。
- 表头和表尾:因为链表带有head指针和tail 指针,程序获取链表头结点和尾节点的时间复杂度为O(1)
- 长度计数器:链表中存有记录链表长度的属性 len
3. 字典
dictht 是一个散列表结构,使用拉链法解决哈希冲突。字典中存储的是两个hash表还有trehashidx
,rehash的索引,作用是在rehash的时候可以渐进式的进行操作,避免数据量过大时,导致计算量大。hash表是真正的数据结构,它采用拉链法解决hash冲突,hash表中有位桶数组,数组中存储的元素类型是dictEntry
,采用头插法解决hash冲突。具体的数据结构:
3.1 哈希表(dictht)
Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值,应该是size-1的大小。
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}
一个空的字典的结构图如下:
我们可以看到,在结构中存有指向dictEntry 数组的指针,而我们用来存储数据的空间既是dictEntry
3.2 哈希表节点(dictEntry)
typeof struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}
//冲突的下一个节点
struct dictEntry *next;
}
在数据结构中,我们清楚key 是唯一的,但是我们存入里面的key 并不是直接的字符串,而是一个hash 值,通过hash 算法,将字符串转换成对应的hash 值,然后在dictEntry 中找到对应的位置。
这时候我们会发现一个问题,如果出现hash 值相同的情况怎么办?Redis 采用了链地址法:
当k1 和k0 的hash 值相同时,将k1中的next 指向k0 想成一个链表。
3.3 字典
个人理解字典的出现其实即使对dictht进行了一个简单封装,封装了两个哈希表,目的是为了进行rehash的时候方便,操作的本质还是对哈希表。
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privedata;
// 哈希表
dictht ht[2];
// rehash 索引
in trehashidx;
}
type属性和privedata属性是针对不同类型的键值对,为创建多态字典而设置的。
ht属性是一个dictht类型的数组,大小是2,所以存有两个哈希表。
普通状态下的字典:
使用的只有哈希表(dictht)只有有个,ht[0],ht[1]设置为空的哈希表,没有分配位桶数组,只有当需要扩容时,才会新创建一个dictht,存放在ht[1]中,并且位桶数组的大小是ht[1]的2倍。rehash下面分析。
3.4 解决hash冲突
jdk1.7的方式一样,忽略。
3.5 Rehash
随着对哈希表的不断操作,哈希表保存的键值对会逐渐的发生改变,为了让哈希表的负载因子维持在一个合理的范围之内,我们需要对哈希表的大小进行相应的扩展或者压缩,这时候,我们可以通过 rehash(重新散列)操作来完成。
当ht[0]对应的hash表使用达到扩容的阈值,则需要扩展,当前哈希表的状态:
为hash表分配空间:
哈希表空间分配的规则:
如果是扩展操作,那么ht[1]的位桶数组大小为第一个大小等于ht[0]的2的n次幂。
因此这里分配的大小是8
数据迁移:
在将ht[0]中的数据迁移到ht[1]中的时候,需要对节点进行重新结算,插入到ht[1]合适的位置。
当数据迁移完毕后,会将ht[0]释放,然后将ht[1]设置成ht[0],最后再将ht[1]分配一个空的哈希表。
3.6 渐进式 Rehash
在进行拓展或者压缩的时候,可以直接将所有的键值对rehash 到ht[1]中,这是因为数据量比较小。在实际开发过程中,这个rehash 操作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。
具体步骤:
- 为ht[1]分配空间,让字典同时又ht[0]和ht[1]两个哈希表。
- 在字典中维护一个索引计数器
rehashidx
,并将它设置为0,表示rehash的开始。 - 在rehash运行期间,每次对字典的执行操作时,程序除了执行指定的操作外,还会将ht[0]中的数据rehash到ht[1]中,并将rehashidx加1.
- 当ht[0]中的数据都迁移到 了ht[1]中,将rehashindex设置为-1,表示结束。
渐进式 rehash 通过记录 dict 的 rehashidx 完成,它从 0 开始,然后每执行一次 rehash 都会递增。例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],这一次会把 dict[0] 上 table[rehashidx] 的键值对 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。
采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。
4. 跳表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表是一种随机化的数据,跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美 ——查找、删除、添加等操作都可以在对数期望时间下完成,并且比起平衡树来说,跳跃表的实现要简单直观得多。
Redis 只在两个地方用到了跳跃表,一个是实现有序集合键,另外一个是在集群节点中用作内部数据结构。
跳表在redis中的数据结构定义的很复杂,根据以前的跳表结构学习,知道每个节点都有四个指针指向上下左右,跳表中元素是有序的,是基于链表实现了。在redis中定义了跳表的最高高度为32层。
完整的结构:
主要组成是有两部分,zskiplist和zskiplistNode组成。
zskiplist 数据结构:
typedef struct zskiplist {
//表头节点和表尾节点
structz skiplistNode *header,*tail;
//表中节点数量
unsigned long length;
//表中层数最大的节点的层数
int level;
}zskiplist;
从结构图中我们可以清晰的看到,header,tail分别指向跳跃表的头结点和尾节点。level 用于记录最大的层数,length 用于记录我们的节点数量。
zskiplistNode(节点) 数据结构:
typedef struct zskiplistNode{
//层
struct zskiplistLevel{
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
}
1、层:level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针。
2、前进指针:用于指向表尾方向的前进指针
3、跨度:用于记录两个节点之间的距离
4、后退指针:用于从表尾向表头方向访问节点
5、分值和成员:跳跃表中的所有节点都按分值从小到大排序。成员对象指向一个字符串,这个字符串对象保存着一个SDS值
总结:
- 跳表是zset数据类型的底层实现。
- 每个跳表的节点的高度在于1~32之间的随机数。
- 在同一个跳表中,多个节点,可以包含相同的分值,但每个节点的对象必须是唯一的。
- 节点按照分值的大小从大到小排序,如果分值相同,则按成员对象大小排序。
5. 整数集合(intSet)
只能使用元素数量不多时,redis就会使用整数集合intset作为集合的底层实现。
typedef struct intset{
//编码方式
uint32_t enconding;
// 集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}
我们观察一下一个完成的整数集合结构图:
1、encoding:用于定义整数集合的编码方式
2、length:用于记录整数集合中变量的数量
3、contents:用于保存元素的数组,虽然我们在数据结构图中看到,intset将数组定义为int8_t,但实际上数组保存的元素类型取决于encoding
整数集合的升级
在上述数据结构图中我们可以看到,intset 在默认情况下会帮我们设定整数集合中的编码方式,但是当我们存入的整数不符合整数集合中的编码格式时,就需要使用到Redis 中的升级策略来解决
Intset 中升级整数集合并添加新元素共分为三步进行:
1、根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
2、将底层数组现有的所有元素都转换成新的编码格式,重新分配空间
3、将新元素加入到底层数组中
比如,我们现在有如下的整数集合:
我们现在需要插入一个32位的整数,这显然与整数集合不符合,我们将进行编码格式的转换,并为新元素分配空间:
第二步,将原有数据他们的数据类型转换为与新数据相同的类型:(重新分配空间后的数据)
第三部,将新数据添加到数组中:
整数集合升级的好处
1、提升灵活性
2、节约内存
6. 压缩列表
压缩列表是列表键和哈希键的底层实现之一。当一个列表键只有少量列表项,并且每个列表项要么就是小整数,要么就是长度比较短的字符串,那么Redis 就会使用压缩列表来做列表键的底层实现。
一个压缩列表的组成如下:
1、zlbytes:用于记录整个压缩列表占用的内存字节数
2、zltail:记录要列表尾节点距离压缩列表的起始地址有多少字节
3、zllen:记录了压缩列表包含的节点数量。
4、entryX:要说列表包含的各个节点
5、zlend:用于标记压缩列表的末端
压缩列表是一种为了节约内存而开发的顺序型数据结构
压缩列表被用作列表键和哈希键的底层实现之一
压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值
添加新节点到压缩列表,可能会引发连锁更新操作。
下一篇是解析如何将底层数据结构和redis数据类型结合使用的。
数据类型与数据结构