SDS动态字符串详解
我们知道在redis中字符串是使用非常广泛的,并且redis是基于C语言编写的,但是C语言中的字符串,是通过‘\0’来区分结束的位置的。这样并不安全,所以在redis中使用结构体定义了SDS动态字符串
其中 len 代表了字符串的长度,这样就不会读取字符串结束标识,而是直接读取指定长度的字符串,从而保证二进制安全
之所以叫做动态字符串就是因为,这个结构具备扩容的能力,假设一个内容为'hi’的字符串,我想要在其后面加上‘,Amy’这四个字节的数据,那么如何实现的呢?
这里首先会申请新内存空间:
如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;
如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。
很显然这里没有超过1M,所以数组长度会扩容至新空间的两倍+1 也就是 2 * 6 + 1 = 13
最后总结一下:
intset详解
intset是redis中set集合的一种实现,我们知道set集合是有序去并且没有重复元素的,所以和上面说到的sds类似,redis底层也是基于C语言中的结构体来实现的,源码如下
其中编码方式直接决定了数组中每个元素的大小,如果是16位,那么数组中每个元素的大小就是两个字节。那么这个时候就有一个问题了:为什么要要采用统一的编码方式呢?其实这主要是是为了方便确定元素在数组中给的位置,这样就可以很容易找到元素的起始下标。
可能还会有细心的人注意到为什么整数的数组只有1个字节的大小?那是因为这个数组保存的并不是真正的数据,而是指向这些数据的指针,所以是一个保存指针的数组
这里也衍生了一个问题,那就是如果保存的数字大于encoding规定的长度那又该如何呢?这里intset会对整个数组进行一个整体的扩容,具体的过程如下:
1、将编码进行调整,例如如果存储一个数据50000,那么16位就得扩容为32位
2、将数据倒序进行拷贝,如果正序拷贝会将原有的数据覆盖,所以采用倒序拷贝
3、将待添加的数据放到数组的末尾
4、将encoding和len的值进行重新计算
源码参考如下:
值得注意的是如果插入的元素是最小的,那么就会将其插入到intset中的首位,如果是最大的那么就会插入到尾部,但是如果是基于二者之间的呢?这时候我们注意到有一个函数是intsetSearch,这个函数就是用来确定位置的,是基于二分查找来确定在数组中的位置。
总结一下:
Intset可以看做是特殊的整数数组,具备一些特点:
-
Redis会确保Intset中的元素唯一、有序
-
具备类型升级机制,可以节省内存空间
-
底层采用二分查找方式来查询
DICT详解
我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。 Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)
下面是对应的源码
当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。
dict的数据结构可也参考下面的源码
具体对应的数据结构如图所示
其中ht[1]表示rehash使用的哈希表,其实状态的rehashidx为-1表示未进行,当需要对哈希表进行扩容的时候就会使用rehash重新计算数据在哈希表中的下标。
Dict扩容和收缩
Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下两种情况时会触发哈希表扩容: 哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程; 哈希表的 LoadFactor > 5 ;
Dict在删除元素的时候也会去检验检查负载因子,当负载因子小于0.1的时候就会触发哈希表的收缩机制
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。过程是这样的:
-
计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:
-
如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
-
如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
-
-
按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
-
设置dict.rehashidx = 0,标示开始rehash
-
将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]
-
将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
-
将rehashidx赋值为-1,代表rehash结束
-
在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空
扩容后的结构如图所示
总结一下:
Dict的结构:
-
类似java的HashTable,底层是数组加链表来解决哈希冲突
-
Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash
Dict的伸缩:
-
当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容
-
当LoadFactor小于0.1时,Dict收缩
-
扩容大小为第一个大于等于used + 1的2^n
-
收缩大小为第一个大于等于used 的2^n
-
Dict采用渐进式rehash,每次访问Dict时执行一次rehash
-
rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表
ZipList
ZipList 是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。实际上并不是双端链表,但是ZipList的结构能实现双向链表的功能,如图所示
每个属性的用途如下:
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数 |
zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。 |
zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entry | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend | uint8_t | 1 字节 | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
最关键的就是其中的列表节点十分巧妙, 可以实现双向链表的功能,结构如下图:
-
previous_entry_length:前一节点的长度,占1个或5个字节。
-
如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
-
如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
-
-
encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
-
contents:负责保存节点的数据,可以是字符串或整数
如果是第一个元素那么previous_entry_length就是0,encoding值就保存content的数据类型,这样就可以计算这个entry的值,就可以实现双向链表的正序遍历和倒序遍历,但是值得一提的是:ZipList无法实现随机遍历,只能从头开始或者从后开始进行遍历。
QuickList
思考一下这样的一个问题,使用ZipList一般存储的数据不宜过大,防止bigkey的问题出现,但是那些占用很大的数据应该如何去存储呢?
那我用多个ZipList来存储不就好了!没错,QuickList就是这样的一个数据结构,采用双向链表的方式存储,其中每个节点都是一个ZipList。结构如图所示
为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。 如果值为正,则代表ZipList的允许的entry个数的最大值 如果值为负,则代表ZipList的最大内存大小,分5种情况,其默认值为 -2:
-
-1:每个ZipList的内存占用不能超过4kb
-
-2:每个ZipList的内存占用不能超过8kb
-
-3:每个ZipList的内存占用不能超过16kb
-
-4:每个ZipList的内存占用不能超过32kb
-
-5:每个ZipList的内存占用不能超过64kb
以下是QuickList的和QuickListNode的结构源码:
我们可以使用一段流程图来描述一下这个结构
总结一下:
QuickList的特点:
-
是一个节点为ZipList的双端链表
-
节点采用ZipList,解决了传统链表的内存占用问题
-
控制了ZipList大小,解决连续内存空间申请效率问题
-
中间节点可以压缩,进一步节省了内存
SkipList
SkipList(跳表)首先是链表,但与传统链表相比有几点差异: 元素按照升序排列存储 节点可能包含多个指针,指针跨度不同。结构图如图所示:
然后来看一下源码中是如何定义这样的一个结构的
可以看到skiplist这个结构就是一个头尾指针然后节点数量和最大索引的层级,也不算复杂。
对应存储节点的指针就稍微复杂一点了,主要包括这些部分:
1、存储节点的值
2、用作排序的分数
3、指向下一个节点和前一个节点的指针
4、多级索引数组。
这里为什么要用一个数组去存储多级指针呢?还是 上面的结构图,我们可以看到节点1有四级指针,但是节点3只有二级指针,所以不同的节点的指针的数量是不同的,所以用一个数组来存储,并且每个节点起码都有一个指向下一个节点的指针,所有数组中至少有一个元素。注意一下,指针最多有32级,不能超过这个数量了。
总结一下
SkipList的特点:
-
跳跃表是一个双向链表,每个节点都包含score和ele值
-
节点按照score值排序,score值一样则按照ele字典排序
-
每个节点都可以包含多层指针,层数是1到32之间的随机数
-
不同层指针到下一个节点的跨度不同,层级越高,跨度越大
-
增删改查效率与红黑树基本一致,实现却更简单
RedisObject
上面这些所有的数据类型在redis中都会封装成一个RedisObject,也就是redis对象,例如一个10个sds字符串,那么在存储的时候就是10个RedisObject,但是如果我们使用一个ZipList进行存储的话,那么就是一个RedisObject,这样就节省了很多空间。
下面是对应的源码:
Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型:
编号 | 编码方式 | 说明 |
---|---|---|
0 | OBJ_ENCODING_RAW | raw编码动态字符串 |
1 | OBJ_ENCODING_INT | long类型的整数的字符串 |
2 | OBJ_ENCODING_HT | hash表(字典dict) |
3 | OBJ_ENCODING_ZIPMAP | 已废弃 |
4 | OBJ_ENCODING_LINKEDLIST | 双端链表 |
5 | OBJ_ENCODING_ZIPLIST | 压缩列表 |
6 | OBJ_ENCODING_INTSET | 整数集合 |
7 | OBJ_ENCODING_SKIPLIST | 跳表 |
8 | OBJ_ENCODING_EMBSTR | embstr的动态字符串 |
9 | OBJ_ENCODING_QUICKLIST | 快速列表 |
10 | OBJ_ENCODING_STREAM | Stream流 |
Redis中会根据存储的数据类型不同,选择不同的编码方式。每种数据类型的使用的编码方式如下:
数据类型 | 编码方式 |
---|---|
OBJ_STRING | int、embstr、raw |
OBJ_LIST | LinkedList和ZipList(3.2以前)、QuickList(3.2以后) |
OBJ_SET | intset、HT |
OBJ_ZSET | ZipList、HT、SkipList |
OBJ_HASH | ZipList、HT |
有了这些前置的知识,下篇文章就来解析一下redis中常见的数据类型String,list,set,zset和hash