Redis设计与实现
第一部分-数据结构与对象
1、简单动态字符串(SDS):
①SDS与普通C字符串的结构区别
-
Redis使用的字符串和传统C语言的字符串不同。C语言使用的是以空字符结尾的字符数组('c' 'h' 'a' 'r' '\0')而Redis使用的是SDS(simpie dynamic string),SDS除了以空字符结尾的字符串还包含两个属性:int len(字符串长度)和int free(字符数组未使用的长度)
-
书中提到'\0'和' ',其中'\0'表示空字符,' '为空格
②SDS的优点
-
len属性可以直接返回数组长度(C字符串需要O(n)时间复杂度的遍历)
-
SDS自带的API可以杜绝缓冲区溢出。如果两个C字符串s1和s2相邻,在对s1进行strcat(拼接)操作时,因为strcat函数默认s1拥有足够的空间会直接在s1后面拼接字符数据进而导致s2字符串被覆盖。但是SDS在进行拼接操作时sdscat函数会先检查SDS空间是否足够,不足则自动扩容后再修改。
-
减少修改字符串时带来的内存重新分配次数
-
C字符串是N+1长度的字符串(结尾空格),所以每次增加或者减少字符串是都要扩展或者释放空间。
-
当SDS的API对SDS进行修改
并需要额外分配空间时
,都会分配除了必要的额外的未使用空间
-
如果SDS的长度(len属性的值)小于1m,那么SDS额外分配的空间为扩展后长度即假设扩展后长度为13,则总数组长度为 13 + 13 + 1 = 27字节
-
如果大于1m则,SDS的len会变为30m,每次扩展1m的长度,即buf数组时间长度为30m + 1m + 1byte
-
-
-
惰性空间释放
-
在执行sdstrim删除字符中的某些字符时并不会立即释放空间,而是当做未使用的长度free在需要时重新使用,当然你也可以使用API手动释放掉这些未使用的内存。
-
-
二进制安全
-
C字符串以'\0'(空字符串结尾为标准)所以除了结尾中间不能有空字符串,不然会被认为是两个字符串所以他只能保存文本数据而不能保存图片、视频、音频、压缩文件等二进制文件,而SDS保存的是二进制数据,其API也是以二进制的方式来处理SDS存放在buf数据里的数据,这样SDS不仅可以保存文本还可以保存任意格式二进制数据(SDS在判断字符串结尾是以len属性为参考,而不是'\0')
-
-
可以使用部分C字符串函数
-
因为和C字符串相同,SDS也遵循以'\0'结尾,所以可以服用一部分C字符串的函数。
-
-
总结(引用书中图片)
编辑
编辑
2、链表
-
链表在redis中的使用非常广泛,因为C语言没有内置链表的数据结构,所以Redis构建了自己的链表(以下只是提到,具体应用后续章节会讲述)
-
用作列表键
-
发布与订阅
-
慢查询
-
监视器
-
保存多个客户端的状态信息
-
构建客户端的输出缓冲区
-
-
链表和链表节点的实现
-
构成
-
链表由多个listNode结构组成,每个listNode有三个属性,分别是前置节点prev,后置节点next和节点值value
-
多个listNode又由list结构来管理(操作)。list包含头结点head、尾节点tail、节点数量len三个属性和节点复制函数dup、节点释放函数free、节点值对比函数match三个函数。
-
dup:复制链表节点所保存的值
-
free:释放链表节点所保存的值
-
match:对比链表节点保存的值和另一个输入值是否相等
-
-
-
-
链表特性总结
-
双端:拥有前置节点和后置节点。
-
无环:头结点的prev和尾节点的next都是指向null
-
带长度计数器:有len属性,获取链表长度时间复杂度为O(1)
-
多态:链表节点使用 void* 指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点设置类型特定函数,所以链表可以用于保存各种不同类型的值。
-
-
总结
-
链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视 器等。
-
每个链表节点由一个listNode 结构来表示,每个节点都有一个指向前置节点和后 置节点的指针,所以Redis的链表实现是双端链表。
-
每个链表使用一个list结构来表示,这个结构带有表头节点指针、表尾节点指针, 以及链表长度等信息。
-
因为链表表头节点的前置节点和表尾节点的后置节点都指向 NULL,所以Redis 的链 表实现是无环链表。
-
通过为链表设置不同的类型特定函数,Redis的链表可以用于保存各种不同类型的值。
-
3、字典
-
概览
-
字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是 一种用于保存键值对(key-value pair)的抽象数据结构。C语言没有内置的字典数据结构,所以 Redis构建了自己的字典实现。
-
字典用于实现redis的数据库
-
除了用来表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。
-
-
字典的结构定义
-
table是一个数组,里面的每个元素都是一个指针,指向dictEntry结构,每个该结构都存储了一个键值对。
-
size记录了hash表的大小
-
sizeMask总是等于size - 1,作用是和hash值一起决定了一个键应该被放到table数组的哪个索引上(有点像java的hash算法?)
-
used属性记录目前已有节点(键值对)的数量
-
-
哈希表的节点dictEntry
-
key 属性保存着键值对中的键,而v属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64 t整数,又或者是一个int64_t 整数。
-
next 属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突(collision)的问题。
-
-
字典的结构
-
type属性是指针,指向一个dictType结构,里面有多个用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
privdata属性保存了需要传给那些类型特定函数的参数。
这两个属性配合创建了多态字典。
-
ht属性是一个长度固定为2的数组,存储了两个dictht哈希表,一般情况下四、只使用ht[0]这个哈希表,ht[1]这个哈希表只有在对ht[0]进行rehash的时候使用。
-
rehashidx记录了rehash目前的进度,如果目前没有在进行rehash,name他的值是-1。
-
-
哈希算法(Redis使用的MurmurHash2算法)
-
使用hashFunction(key)方法算出哈希值(假设为8)
-
再根据哈希值使用语句hash&dict ->ht[0].sizemask = 8 & 3 = 0(最后结果key的索引值为0 )
-
-
解决哈希冲突
-
当两个或两个以上数量的键被分配到了同一个索引上时我们称这些键发生了哈希冲突(collision)。
-
Redis 的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有 一个 next 指针,多个哈希表节点可以用next 指针构成一个单向链表,被分配到同一个索 引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。
-
如下图,当k2-v2加入字典时,和k1-v1发生了冲突,会将k2放在索引2的头部,排在前面,因为放在表头节点的时间复杂度为O(1)
-
-
rehash
-
为什么要rehash?
-
为了维持hash表的负载因子在一个合理的范围内,在哈希表保存的键值对过多或者过少时,需要对哈希表进行相应的扩容或者缩容
-
-
rehash步骤
-
为ht[1]分配空间(扩容操作空间是ht[0]的两倍,缩容则是1/2)
-
将ht[0]的键值对重新rehash到ht[1]上
-
释放ht[0],并将ht[1]重新置为ht[0]
-
将ht[1]分配一个空白hash表,为下一次rehash做准备
-
-
哈希表的扩容与收缩
-
扩展
-
以下条件任意一个被满足时进行扩展
1)服务器目前没有在执行 BGSAVE 命令或者BGREWRITEAOF命令,并且哈希表的负 载因子大于等于1。 2)服务器目前正在执行 BGSAVE 命令或者BGREWRITEAOF命令,并且哈希表的负载 因子大于等于5。
-
哈希表的负载因子公式:
负载因子 = 哈希表已保存节点数量/哈希表大小
load_factor = ht[0].used / ht[0].size
-
为什么在执行 BGSAVE 命令或者BGREWRITEAOF命令时,负载因子会变大?
因为redis在执行BGSAVE 命令或者BGREWRITEAOF命令时会创建当前服务器的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以服务器会提高达到扩展条件的负载因子,从而尽可能避免在子进程存在期间进行哈希表扩展操作。
可以避免不必要的内存写入操作,最大限度的节省内存
-
-
收缩
当哈希表的负载因子小于0.1时,哈希表进行收缩
-
-
-
渐进式rehash
上文提过,rehash分为四个步骤,然而这四个步骤并不是在一次性完成的,在将ht[0]的数据rehash到ht[1]上时,是渐进式的,这个步骤会分多次完成。
-
渐进式rehash的过程
-
在rehash开始时,往ht[1]分配对应的空间,在字典中维持一个索引计数器变了rehashidx,并设值为0.
-
在每次对hash表进行增、删、改、查时,哈希表会顺带将ht[0]中rehashidx索引上的值rehash到ht[1],当此次rehash完成后,rehashidx+1;
-
当所有索引上的数据全部rehash完成后,rehashidx重新置为-1,rehash完成。
-
因为在进行渐进式 rehash 的过程中,字典会同时使用 ht[0]和 ht[1]两个哈希表, 所以在渐进式 rehash 进行期间,字典的删除(delete)、查找(find)、更新(update)等操作 会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进 行查找,如果没找到的话,就会继续到ht[1] 里面进行查找,诸如此类。 另外,在渐进式 rehash 执行期间,新添加到字典的键值对一律会被保存到ht[1]里面, 而 ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不 增,并随着 rehash 操作的执行而最终变成空表。
-
-
为什么要使用渐进式rehash
如果键值对比较少,可以很快完成,但是如果键值对过多可能会导致服务器在一段时间内停止服务
-
-
重点回顾
-
字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键。
-
Redis 中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使 用,另一个仅在进行rehash时使用。
-
当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2 算法来计算键的哈希值。
-
哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
-
在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对 rehash 到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式 地完成的。
-
4、跳跃表
-
概述
-
跳跃表是一种有序的数据结构,他的每个节点有多个指向其他节点的指针,从而可以达到快速访问其他节点的目的。
跳跃表平均访问速度为O(logN),最坏O(N),并且可以顺序性操作来处理批量节点。
-
大部分情况下跳跃表的效率可以媲美平衡树,实现又比平衡树简单,所以大部分程序采用跳跃表代替平衡树(比如这里的redis)
-
Redis只有两个地方用到了跳跃表,一个是有序集合键一个是在集群中用作内部结构
-
-
跳跃表的实现
-
Redis 的跳跃表由 redis.h/zskiplistNode 和redis.h/zskiplist 两个结构定 义,其中zskiplistNode 结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
-
zskiplist
-
header:指向跳跃表的表头节点。
-
tail:指向跳跃表的表尾节点。
-
level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算 在内)。
-
length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计 算在内)。
-
-
zskiplistNode
-
层(level):图中的L1、L2、L2,分别表示一层、两层、三层,每一层都有两个属性,前进指针和跨度,前进指针指向表尾同层节点,跨度记录了前进指针所到节点和当前节点的距离。(level作用是遍历,一般层数越多遍历越快)
-
后退(backward)指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用(只指向前一个节点)。
-
分值(score):各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中, 节点按各自所保存的分值从小到大排列。
-
成员对象(obj):各个节点中的o1,o2和03是节点所保存的成员对象,他们指向一个保存着SDS的字符串对象
-
分值是一个浮点数(double),有可能相等,但是各个节点的成员对象必须唯一,分值相同时节点会按照成员对象在字典中的大小顺序排序。
-
注意表头节点和其他节点的构造是一样的:表头节点也有后退指针、分值和成员对象,不 过表头节点的这些属性都不会被用到,所以图中省略了这些部分,只显示了表头节点的各个层。
-
每次创建一个新的跳跃表节点时。程序都根据幂次定律(power low ,越大的数出现的概率越小)随机生成一个1到32直接的值作为level数组的大小,打个大小就是层的高度
-
-
-
遍历跳跃表
-
迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表 中的第二个节点。
-
在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。
-
在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点。
-
当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时 已经到达了跳跃表的表尾,于是结束这次遍历。
注意:跨度和跳跃表的遍历没有关系,主要是用来判断这个节点在跳跃表里的排位(第几个节点)使用的
-
-
重点回顾
-
跳跃表是有序集合的底层实现之一。
-
Redis 的跳跃表实现由 zskiplist 和 zskiplistNode 两个结构组成,其中 zskiplist 用于保存跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode 则用于表 示跳跃表节点。
-
每个跳跃表节点的层高都是1至32之间的随机数。
-
在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是 唯一的。
-
跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小 进行排序。
-
5、整数集合
-
整数集合的概念
-
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这 个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
-
-
整数集合的实现
-
整数集合(intset)是Redis 用于保存整数值的集合抽象数据结构,它可以保存类型为 int16_t(-32768—32767)、int32_t(-2147483648—2147483647)或者int64_t(-9223 372 036 854 775 808—9223 372 036 854 775 807)的整数值,并且保证集合中不会出现重复元素。
-
contents:是整数数组的底层实现,整数集合中每个元素都是contents数组的一个数组项(item),各个项从小到大排序,并且没有重复项。
-
length:记录了整数集合的长度也就是contents的长度
-
encoding:决定了contents数组里面的整数类型,虽然contens[]被声明为int8_t,但是并不是保存的int8_t类型的整数,主要看encoding属性的值。
例:下图保存的是encoding属性值为INTSET_ENC_INT16的整数集合
-
如果往int16_t数组里加入一个2147483648(int_64)的整数,根据整数集合的升级规则会把整个数组升级为int_64类型的
-
-
整数集和的升级
-
每次往集合里添加新元素并且新元素类型比现在的大时需要进行升级
-
升级时会先分配新的空间,下面以一个int_16类型含有1、2、3三个数的整数集合添加一个int_32的65535为例
-
原本集合为 3 * 16 = 48位,索引为0、1、2,分别对应0-15位,16-31位,32-47位。
-
添加元素后应先分配新的空间,把空间整体升级到128位(4*32)
-
然后根据索引,把3放到索引2(64-95位)的的位置,1和2同理。
-
最后把65535放到96-127位的位置。
-
最后把encoding的属性改成int_32,把length属性改为4
-
-
每次往整数集合里面添加元素时都可能导致升级,添加元素的时间复杂度为O(N)
因为导致升级的元素不是比现有元素大就是比现有元素小,所以升级后新元素不是在最开头就是在最末尾
-
-
升级的好处
-
提升灵活性
-
C语言是静态类型的语言,通常不会把类型不同的值放在同一个数据结构里面。有了升级机制我们可以随意把int_16、int_32或int_64类型的值放入整数集合里而不用担心出现类型错误
-
-
节约内存
-
如果想要一个数组同事可以保存int_16、int_32或int_64三种类型的值只需要将数组置为int_64类型的,但是只有当数组真正出现int_64类型的数值时才会真正用得上,而升级机制很好的在避免了对应的情况节省了内存。
-
-
-
降级
-
整数集合不支持降级,一但将数组升级后,就算删除了对应的元素,数组还是维持当前的类型,不会进行降级。
-
-
重点回顾
-
整数集合是集合键的底层实现之一。
-
整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
-
升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。
-
整数集合只支持升级操作,不支持降级操作。
-