Redis的对象、简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表的存储机制
首先内存和硬盘的比较
内存直接由CPU控制,也就是CPU内部集成的内存控制器,所以说内存是直接与CPU对接,享受与CPU通信的最优带宽,然而硬盘则是通过桥接芯片(在主板上)与CPU相连,所以说速度比较慢。两者相比内存比硬盘到底有多快?通常的说法是:内存访问速度是纳秒级(10的-9次方),硬盘的访问速度是微秒级(10的-3次方)。找到一个稍微科学点的测试数据,如下图
1.顺序访问:这种情况下,内存访问速度仅仅是硬盘访问速度的6~7倍(358.2M / 53.2M = 6.7)
2.随机访问:这种情况下,内存访问速度就要比硬盘访问速度快上10万倍以上 (36.7M / 316 = 113,924)
Redis选择了高效的内存做为存储,大家有了解过Redis在内存在的结构是怎么样的吗?接下来将为大家解读其中的奥秘
Redis在内存中的结构划分
数据:作为数据库,数据是最主要的部分,这部分占用的内存会统计在used_memory中。 Redis使用键值对存储数据,其中的值(对象)包括5种类型,即字符串、哈希、列表、集合、有序集合。 这5种类型是Redis对外提供的,实际上,在Redis内部,每种类型可能有2种或更多的内部编码实现。此外,Redis在存储对象时,并不是直接将数据扔进内存,而是会对对象进行各种包装:如redisObject、SDS等。
进程本身运行需要的内存:Redis主进程本身运行肯定需要占用内存,如代码、常量池等等;除了主进程外,Redis创建的子进程运行也会占用内存,如Redis执行AOF、RDB重写时创建的子进程。
缓冲内存:缓冲内存包括客户端缓冲区、复制积压缓冲区、AOF缓冲区等。 其中,客户端缓冲存储客户端连接的输入输出缓冲;复制积压缓冲用于部分复制功能;AOF缓冲区用于在进行AOF重写时,保存最近的写入命令。
内存碎片:内存碎片是Redis在分配、回收物理内存过程中产生的。 例如,如果对数据的更改频繁,而且数据之间的大小相差很大,可能导致redis释放的空间在物理内存中并没有释放,但redis又无法有效利用,这就形成了内存碎片。
生产上我们经常使用 Redis Info 命令查看Redis服务器的信息和统计数值
在理想情况下, used_memory_rss 的值应该只比 used_memory 稍微高一点儿。当 rss > used ,且两者的值相差较大时,表示存在(内部或外部的)内存碎片。内存碎片的比率可以通过 mem_fragmentation_ratio 的值看出。当 used > rss 时,表示 Redis 的部分内存被操作系统换出到交换空间了,在这种情况下,操作可能会产生明显的延迟。当 Redis 释放内存时,分配器可能会,也可能不会,将内存返还给操作系统。如果 Redis 释放了内存,却没有将内存返还给操作系统,那么 used_memory 的值可能和操作系统显示的 Redis 内存占用并不一致。查看 used_memory_peak 的值可以验证这种情况是否发生。
mem_fragmentation_ratio < 1 表示Redis内存分配超出了物理内存,操作系统正在进行内存交换,内存交换会引起非常明显的响应延迟;
mem_fragmentation_ratio > 1 是合理的;
mem_fragmentation_ratio > 1.5 说明Redis消耗了实际需要物理内存的150%以上,其中50%是内存碎片率,可能是操作系统或Redis实例中内存管理变差的表现
了解了一些Redis基本常识以后,让我们真正的来看一下Redis的数据储存结构是怎么样的,Redis的数据存储中我们会涉及到内存分配器、简单的动态字符串(SDS)、5种数据类型及内部编码、redisObject等知识梳理。我们首先看下面一张图我们在存储数据时数据基本的数据模型。
dictEntry: 我们都知道Redis是Key-value数据库,因此每个键值都会有对应的一个dictEntry,里面存储着指向key和value的指针及next指向下一个dictEntry。
Key: Key并不是直接已字符串形式存储,而是存储在SDS结构中。
redisObject:Value既不是直接以字符串存储,也不是像Key一样直接存储在SDS中,而是存储在redisObject中。 实际上,不论Value是5种类型的哪一种,都是通过redisObject来存储的;而redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。 不过可以看出,字符串对象虽然经过了redisObject的包装,但仍然需要通过SDS存储。 实际上,redisObject除了type和ptr字段以外,还有其他字段图中没有给出,如用于指定对象内部编码的字段。redisObject对象非常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持。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: type字段表示对象的类型,占4个比特;目前包括REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)
encoding: encoding表示对象的内部编码,占4个比特。 对于Redis支持的每种类型,都有至少两种内部编码,例如对于字符串,有int、embstr、raw三种编码。 通过encoding属性,Redis可以根据不同的使用场景来为对象设置不同的编码,大大提高了Redis的灵活性和效率。 以列表对象为例,有压缩列表和双端链表两种编码方式。 如果列表中的元素较少,Redis倾向于使用压缩列表进行存储,因为压缩列表占用内存更少,而且比双端链表可以更快载入;当列表对象元素较多时,压缩列表就会转化为更适合存储大量元素的双端链表。
lru: lru记录的是对象最后一次被命令程序访问的时间
refcount: refcount与共享对象 refcount记录的是该对象被引用的次数,类型为整型
ptr: ptr指针指向具体数据的SDS、列表、hash、set、zset
SDS: SDS是简单动态字符串(Simple Dynamic String)的缩写,sds的结构如下:
struct sdshdr {
int len;
int free;
char buf[];
};
buf: 字节数组
len:buf已经使用的长度
free: 表示buf未使用的长度
以图说话
通过上图SDS的结构可以看出,buf数组的长度=free+len+1(其中1表示字符串结尾的空字符);所以,一个SDS结构占据的空间为:free所占长度+len所占长度+ buf数组的长度=4+4+free+len+1=free+len+9。那么SDS在C字符串的基础上加入了free和len字段,带来了很多好处:
此外,由于SDS中的buf仍然使用了C字符串(即以’\0’结尾),因此SDS可以使用C字符串库中的部分函数;但是需要注意的是,只有当SDS用来存储文本数据时才可以这样使用,在存储二进制数据时则不行(’\0’不一定是结尾)。
SDS的空间预分配:为减少修改字符串带来的内存重分配次数,sds采用了“一次管够”的策略:
注:所以稍微细心一点的小伙伴们有没有发现我上面的图中len为4,free为0是错误的。
SDS惰性删除机制:所谓惰性删除,即调整删除SDS中部分数据时,不会立刻执行内存重分配,而是会保留空出来内存,并更新内部free属性。以备将来有字符扩展需求,可以直接使用。是不是有点抽象我们用图表述一下
当我们删除XX后变为
双向链表(list)
Redis 的双向链表实现具有以下特性:
双向列表的代码结构每个节点都是一个listNode,拥有前驱节点,后继节点和值
typedef struct listNode {
struct listNode *prev; //前驱节点,如果是list的头结点,则prev指向NULL
struct listNode *next;//后继节点,如果是list尾部结点,则next指向NULL
void *value; //万能指针,能够存放任何信息
} listNode;
只要有多个节点就可以组成一个链表了,但是redis再在外面封装了一层,也就是使用adlist.h/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); //比较链表节点所保存的节点值和另一个输入的值是否相等
} list;
那上面数据的值为
压缩列表(ziplist)
参数说明:
zlbytes:记录整个压缩列表占用的内存字节数。
zltail:记录压缩列表表尾节点距离压缩列表起始地址有多少字节。
zllen:记录了压缩列表包含的节点数量。
entryN:压缩列表的节点,节点长度由节点保存的内容决定。
zlend:特殊值0xFF(十进制255),用于标记压缩列表的末端。
哈哈!上面的结构是不是有点类似数组,通过一段连续的内存空间来存储数据,但是它和数组还是不一样的。听到“压缩”两个字,直观的反应就是节省内存。之所以说这种存储结构节省内存,是相较于数组的存储思路而言的。我们知道,数组要求每个元素的大小相同,如果我们要存储不同长度的字符串,那我们就需要用最大长度的字符串大小作为元素的大小(假设是20个字节)。存储小于 20 个字节长度的字符串的时候,便会浪费部分存储空间
数组的优势占用一片连续的空间可以很好的利用CPU缓存访问数据。如果我们想要保留这种优势,又想节省存储空间我们可以对数组进行压缩
但是这样有一个问题,我们在遍历它的时候由于不知道每个元素的大小是多少,因此也就无法计算出下一个节点的具体位置。这个时候我们可以给每个节点增加一个lenght的属性。
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结枃。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值,如下图
zltail示例
entry结构示例
各值的具体含义
节点的 previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。 previous_entry_length属性的长度可以是1字节或者5字节。
节点的encoding属性记录了节点的content属性所保存数据的类型以及长度。
节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
上面的例子中的具体解释
整数集合(intset)
整数集合具有以下特点:
int16_t ,而如果集合中位长度最大的元素可以使用 32 位整数来保存的话,那么数组的类型就是 int32_t ,诸如此类。
科普一下嘻嘻Int8,Int16,Int32,int64,后面的数字就代表这个数据类型占据的空间。
Int8, 等于Byte, 占1个字节.
Int16, 等于short, 占2个字节. -32768 32767
Int32, 等于int, 占4个字节. -2147483648 2147483647
Int64, 等于long, 占8个字节. -9223372036854775808 9223372036854775807
这样, 看起来比short,int,long更加直观些!
C语言中写法为int8_t、int16_t、int32_t、int64_t
如上图,为一int16_t类型的整数集合,我们可以看到数组中存储了5个int16_t类型的整数,它们按照从小到大的顺序依次排列。这个时候我们思考一个问题。如果这个时候存入一个int32_t类型的整数会怎么样?内存溢出?
正如上面所提到的问题,每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面。升级整数集合并添加新元素主要分三步来进行。
有升级是是否存在降级的动作?
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。也就是说一旦我们向一个int16_t的整数集合内添加了一个int32_t的元素后,整数集合将升级到int32_t类型。即使后续的操作中我们删除了这个元素,整数集合还是会保持int32_t类型的状态。
整数集合升级的优点
因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。
例如,我们一般只使用int16_t类型的数组来保存int16_t类型的值,只使用int32_t类型的数组来保存int32_t类型的值,诸如此类。但是,因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活。
要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64t类型的数组作为整数集合的底层实现。不过这样一来,即使添加到整数集合里面的都是int16_t类型或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。
而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存。如果我们一直只向整数集合添加int16_t类型的值,那么整数集合的底层实现就会一直是int16_t类型的数组,只有在我们要将int32_t类型或者int64_t类型的值添加到集合时,程序才会对数组进行升级。
字典
字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对的抽象数据结构。 在字典中,一个键(key)可以和一个值(value)进行关联,字典中的每个键都是独一无二的。在C语言中,并没有这种数据结构,但是Redis 中构建了自己的字典实现。
举个简单的例子:
创建这样的键值对(“msg”,“OO XX”)在数据库中就是以字典的形式存储。
字典的定义
字典的结果定义
typedef struct dict {
dictType *type; // 类型特定函数
void *privedata; // 私有数据
dictht ht[2];// 哈希表
// rehash 索引
in trehashidx;
}
type 属性 和privdata 属性是针对不同类型的键值对,为创建多态字典而设置的。
ht 属性是一个包含两个项(两个哈希表)的数组
Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}
一个空的字典表如下:
我们可以看到,在结构中存有指向dictEntry 数组的指针,而我们用来存储数据的空间既是dictEntry
哈希表节点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 形成一个链表。
在插入后我们可以看到,dictEntry指向了k1,k1的next 指向了k0,从而完成了一次插入操作(这里选择表头插入是因为哈希表节点中没有记录链表尾节点位置)
什么是rehash? 其实有点类似HashMap的扩容
哈希表空间分配规则:
如果执行的是拓展操作,那么ht[1] 的大小为第一个大于等于ht[0] 的2的n次幂
如果执行的是收缩操作,那么ht[1] 的大小为第一个大于等于ht[0] 的2的n次幂
以图展示效果,图中每个节点都已经使用到了,这时候我们就需要对哈希表进行扩展。
按照上面的分配规则,此时ht[1]分配的空间为8,变为下图所示
将ht[0]中的数据转移到ht[1]中,在转移的过程中,需要对哈希表节点的数据重新进行哈希值计算,数据转移后的结果如下图
将ht[0]释放,然后将ht[1]设置成ht[0],最后为ht[1]分配一个空白哈希表,如下表所示
渐进式 rehash
上面我们说到,在进行拓展或者压缩的时候,可以直接将所有的键值对rehash 到ht[1]中,这是因为数据量比较小。在实际开发过程中,这个rehash 操作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。
渐进式rehash 的详细步骤:
1、为ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
2、在维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash 开始
3、在rehash 进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将ht[0]中的数据rehash 到ht[1]表中,并且将rehashidx加一
4、当ht[0]中所有数据转移到ht[1]中时,将rehashidx 设置成-1,表示rehash 结束
跳跃表
何为跳跃表?
如下图所示一个简单的跳表结构
上图展示了一个跳跃表示例, 位于图片最左边的是 zskiplist 结构, 该结构包含以下属性:
位于 zskiplist 结构右方的是四个 zskiplistNode 结构, 该结构包含以下属性:
注意表头节点和其他节点的构造是一样的: 表头节点也有后退指针、分值和成员对象, 不过表头节点的这些属性都不会被用到, 所以图中省略了这些部分, 只显示了表头节点的各个层
我们从简单跳表到多层的跳表查找数据实现
对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。如果我们想要提高其查找效率,可以考虑在链表上建索引的方式。每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引。
这个时候,我们假设要查找节点8,我们可以先在索引层遍历,当遍历到索引层中值为 7 的结点时,发现下一个节点是9,那么要查找的节点8肯定就在这两个节点之间。我们下降到链表层继续遍历就找到了8这个节点。原先我们在单链表中找到8这个节点要遍历8个节点,而现在有了一级索引后只需要遍历五个节点。
从这个例子里,我们看出,加来一层索引之后,查找一个结点需要遍的结点个数减少了,也就是说查找效率提高了,同理再加一级索引
从图中我们可以看出,查找效率又有提升。在例子中我们的数据很少,当有大量的数据时,我们可以增加多级索引,其查找效率可以得到明显提升。
回答刚刚提出的那个问题:从上面我们可以知道,跳跃表在链表的基础上增加了多级索引以提升查找的效率,但其是一个空间换时间的方案,必然会带来一个问题——索引是占内存的。原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势必然会被放大,而缺点则可以忽略。
层
前进指针:每个层都有一个指向表尾方向的前进指针(level[i].forward 属性), 用于从表头向表尾方向访问节点
跨度:层的跨度(level[i].span 属性)用于记录两个节点之间的距离;指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点; 跨度实际上是用来计算排位(rank)的: 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标节点在跳跃表中的排位
后退指针:节点的后退指针(backward 属性)用于从表尾向表头方向访问节点: 跟可以一次跳过多个节点的前进指针不同, 因为每个节点只有一个后退指针, 所以每次只能后退至前一个节点
Redis中为什么使用跳表不使用红黑树?
理论上来讲,查找、插入、删除以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度和跳表是一样的。
redis使用跳表而不是红黑树的原因:
Redis支持的数据结构
Redis所有的数据结构都是以唯一的key字符串作为名称,然后通过这个唯一的key值来获取相应的value数据。通过上面的讲解不同类型的数据结构的差异在于value的结构是不一样的。Redis可以存储键与5种不同数据结构类型之间的映射,这5种数据结构类型分别为:STRING、LIST、SET、HASH和ZSET(有序集合)
Set可以用intset或者字典实现
只有当数据全是整数值,而且数量少于512个时,才使用intset,intset是一个由整数组成的有序集合,可以进行二分查找。
不满足intset使用条件的情况下都使用字典(拉链法),使用字典时把value设置为null。
Zset
zset中的每个元素包含数据本身和一个对应的分数(score)。
经典例子:一个zset的key是"math",代表数学课的成绩,然后可以往这个key里插入很多数据。输入数据的时候,每次需要输入一个姓名和一个对应的成绩。那么这个姓名就是数据本身,成绩就是它的score。
zset的数据本身不允许重复,但是score允许重复。
zset底层实现原理:
数据库示例
在 Redis 里面,每个数据库都是一个字典,该字典的键和值都是我们之前提到的对象,其中:字典的键总是一个字符串对象,它储存了用户为键设置的键名;字典的值则可以是字符串对象、列表对象、散列对象、集合对象或者有序集合对象的其中一个;因为数据库就是字典,所以针对数据库的操作都是基于字典操作来 实现的。为了记录数据库键的过期时间,Redis 为每个数据库创建了另一个字典,专门使用这个字典来记录键的 过期时间,其中字典的键指向数据库键对象,也即是带有过期时间的那个键(数据库字典和储存过期时间的字典通过指针使用同一个键对象,不会㐀成任何资源浪费);键的值则是一个毫秒格式的 UNIX 时间戳,记录了键到期的时间。
详细结构
总结
在Redis的五大数据对象中,string对象是唯一个可以被其他四种数据对象作为内嵌对象的;
列表(list)、哈希(hash)、集合(set)、有序集合(zset)底层实现都用到了压缩列表结构,并且使用压缩列表结构的条件都是在元素个数比较少、字节长度较短的情况下;
四种数据对象使用压缩列表的优点:
(1)节约内存,减少内存开销,Redis是内存型数据库,所以一定情况下减少内存开销是非常有必要的。
(2)减少内存碎片,压缩列表的内存块是连续的,并分配内存的次数一次即可。
(3)压缩列表的新增、删除、查找操作的平均时间复杂度是O(N),在N再一定的范围内,这个时间几乎是可以忽略的,并且N的上限值是可以配置的。
(4)四种数据对象都有两种编码结构,灵活性增加。
五种类型的应用场景