Redis原理(一): 数据结构
动态字符串SDS
Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。
Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic tring),简称SDS 。
Redis是C语言实现的,不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题 ,其中SDS是一个结构体,源码如下:
SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为“hi”的SDS , 假如我们要给SDS追加一段字符串“Amy”,这里首先会申请新内存空间:
如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;
如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。
IntSet
IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征结构如下:
其中的encoding包含三种模式,表示存储的整数大小不同:
为了方便查找,Redis会将intset中所有的整数按照升序依次保存在contents数组中,其中contents 数组是指针。
统一格式是方便指针根据数组角标寻址,数组是连续的内存,根据指针找到起始位置,然后根据角标运算就可以获取元素所在的位置
IntSet升级
假设有一个intset,元素为15,10,20),采用的编码是INTSET_ENC_INT16,则每个整数占2字节 , 向该其中添加一个数字:50000,这个数字超出了int16 t的范围,intset会自动升级编码方式到合适的大小
-
升级编码为INTSET_ENC_INT32,每个整数占4字节,并按照新的编码方式及元素个数扩容数组
-
倒序依次将数组中的元素拷贝到扩容后的正确位置(倒叙防止覆盖)
-
将待添加的元素放入数组末尾
-
最后,将inset的encoding属性改为INTSET_ENC_INT32,将Length属性改为4
Intset可以看做是特殊的整数数组,具备一些特点:
- Redis会确保Intset中的元素唯一、有序
- 具备类型升级机制,可以节省内存空间
- 底层采用二分查找方式来查询
Dict
Redis是一个键值型(Key-Value Pair)的数据,而键与值的映射关系正是通过Dict来实现的。Dict由三部分组成,分别是:哈希表(DictHashTable) 、哈希节点(DictEntry) 、字典 (Dict)
Dict 字典
哈希表
哈希节点(DictEntry)
当向Dict添加键值对时,Redis首先根据kev计算出hash值(h),然后利用h & sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置
Dict的扩容
Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size),满足以下两种情况时会触发哈希表扩容:
-
哈希表的 LoadFactor>=1,并且服务器没有执行BGSAVE或者 BGREWRITEAOF 等后台进程
-
哈希表的 LoadFactor>5:
Dict除了扩容以外,每次删除元素时,也会对负载因子做检查,当LoadFactor<0.1 时,会做哈希表收缩
Dict的rehash
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。
Dict的rehash并不是一次性完成的。如果Dict中包含数百万的entry,要在一次rehash完成,极有可能导致主线程阻塞。因此Dict的rehash是分多次、渐进式的完成,因此称为渐进式rehash。流程如下
-
计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:
如果是扩容,则新size为第一个大于等于dict.ht[0].used +1的2n
如果是收缩,则新size为第一个大于等于dict.ht[o].used的2” (不得小于4)
-
按照新的realesize申请内存空间,创建dictht,并赋值给dict.ht[1]
-
设置dict.rehashidx =0,标示开始rehash
-
每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict,htr0l.tablelrehashidxl的entry链表rehash到dict.ht111,并且将rehashidx++。直至dict.htl0l的所有数据都rehash到dict.ht[1]
-
将dict.htl1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.hti0]的内存
-
将rehashidx赋值为-1,代表rehash结束
-
在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict,ht[0]和dict.ht[1]依次查找并执行。这样可以确保hti0)的数据只减不增,随着rehash最终为空
Dict的结构:
类似java的HashTable,底层是数组加链表来解决哈希冲突
Dict包含两个哈希表,ht[]平常用,ht[1]用来rehash
Dict的伸缩
当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容
当LoadFactor小于0.1时,Dict收缩
扩容大小为第一个大于等于used +1的2n
收缩大小为第一个大于等于used 的2n
Dict采用渐进式rehash,每次访问Dict时执行一次rehash
rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表
ZipList
zipList 是一种特殊的“双端链表”,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作,并且该操作的时间复杂度为 0(1)。
ZipListEntry
ZipList 中的Entrv并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构:
previous_entry_length + encoding + content
-
previous_entry_length: 前一节点的长度,占1个或5个字节。 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值 ,如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据 .
-
encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
-
contents:负责保存节点的数据,可以是字符串或整数
ZipList的连锁更新问题
假设有N个连续的、长度为250~253字节之间的entry,因此entry的previous entry length属性用1个字节即可表示,添加一个254字节的entry 会导致后续N个连续的entry 发生更新
ZipList特性
-
压缩列表的可以看做一种连续内存空间的"双向链表"
-
列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
-
如果列表数据过多,导致链表过长,可能影响查询性能
-
增或删较大数据时有可能发生连续更新问题
QuickList
Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList 。
为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项: list-max-ziplist-size来限制(其默认值为-2):
如果值为正,则代表ZipList的允许的entry个数的最大值
如果值为负,则代表zipList的最大内存大小,分5种情况
QuickList的特点
- 是一个节点为zipList的双端链表
- 节点采用ZipList,解决了传统链表的内存占用问题
- 控制了zipList大小,解决连续内存空间申请效率问题
- 中间节点可以压缩,进一步节省了内存
SkipList
skipList(跳表)首先是链表,但与传统链表相比有几点差异:
-
元素按照升序排列存储
-
节点可能包含多个指针,指针跨度不同
skipList的特点
- 跳跃表是一个双向链表,每个节点都包含score和ele值
- 节点按照score值排序,score值一样则按照ele字典排序
- 每个节点都可以包含多层指针,层数是1到32之间的随机数
- 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
- 增删改查效率与红黑树基本一致,实现却更简单
RedisObject
Redis中的任意数据类型的键和值都会被封装为一个Redisobject,也叫做Redis对象
Redis中会根据存储的数据类型不同,选择不同的编码方式,