Redis能高效的处理,除了在内存中进行操作,还有一个原因就是它的数据结构和类型。
数据结构
RedisObject
Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象。
从Redis的使用者的角度来看,⼀个Redis节点包含多个database(非cluster模式下默认是16个,cluster模式下只能是1个),而一个database维护了key space 到 value space的映射关系。其中key是string类型,value可以是任意类型:如string、List、Set、Zset、Map。
⽽从Redis内部实现的⾓度来看,database内的这个映射关系是用⼀个dict来维护的。dict的key固定用⼀种数据结构来表达就够了,这就是动态字符串sds。而value则比较复杂,为了在同⼀个dict内能够存储不同类型的value,这就需要⼀个通⽤的数据结构,这个通用的数据结构就是robj,全名是redisObject。
SDS
Redis中保存的Key是字符串,通常情况下Value也是字符串或者字符串集合,因此字符串使用的很频繁。Redis是c实现的,为了提高效率,并没有采用c的字符串结构,c的字符串所存在的问题:
- 获取字符串长度需要运算
- 不可修改
- 非二进制安全(c的字符串以'\0'结尾)
为了优化以上问题,Redis提出了SDS,之所以叫做SDS(动态字符串)是因为它具备动态扩容的功能。
如上图SDS的数据结构,可以知道:
- 通过len获取字符串长度的时间复杂度变成O(1)
- 不需要用 “\0” 字符来标识字符串结尾了,而是有个专门的 len 成员变量来记录长度,所以可存储包含 “\0” 的数据。
- 通过alloc - len 可以在进行修改操作的时候判断是否扩容
IntSet
IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。
Intset可以看做是特殊的整数数组,具备一些特点:
-
Redis会确保Intset中的元素唯一、有序
-
具备类型升级机制,可以节省内存空间
-
底层采用二分查找方式来查询
Dict
Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。
Dict由三部分组成:字典,哈希表、哈希节点
当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。
Dict的扩容
Dict中的哈希表采用的是数组+链表的方式实现,在数据量过大,链表长度会变长查询效率降低,因此需要进行扩容,每次插入新值都会检查负载因子(used/size),以下两种情况会进行扩容:
- 哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
- 哈希表的 LoadFactor > 5 ;
哈希表的初始化大小为4,扩容大小为第一个大于等于used+1的2^n
Dict的rehash
从上图可以看到dict中定义了两个哈希表,
在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:
- 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大一倍(两倍的意思);
- 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
- 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。
上面这过程看起来没啥问题,但是第二步在数据量大的时候,迁移会涉及大量的数据拷贝,可能会对 Redis 造成阻塞,无法服务其他请求。因此提出了渐进式hash。
渐进式hash
渐进式hash也就是将一次大迁移分成多个小迁移。
渐进式hash过程:
- 给「哈希表 2」 分配空间;
- 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上;
- 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。
在渐进式hash的过程中,数据是分配在两个哈希表中的,因此操作要在两个表上进行,比如,查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。
另外,新增操作只会在表2新增,保证了表1的数据一直减少,知道变成空表rehash结束。
ZipList
ZipList 是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。
ZipList的Entry不像其他链表节点那样,存储上下节点指针,因为记录两个指针占用16字节,它采用的是下面的结构:
例如,我们要保存字符串:“ab”和 “bc”
- previous_entry_length:前一节点的长度,占1个或5个字节(条件在下面)。
-
encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节。
-
contents:负责保存节点的数据,可以是字符串或整数
上面这种结构也还是存在一些问题的,比如连锁更新:
ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:
- 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
- 如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:
这里多添加了一个254字节的数据进来,原来后续节点的previous_entry_length都为1字节,现在都需要变成5字节,发生了连锁更新。
QuickList
介绍QuickList之前可以先看一下ZipList的缺点和解决办法:
ZipList虽然节省内存,但是需要分配连续的内存空间,在内存占用多的情况下怎么办?
- 为了解决这个问题,需要限制ZipList的长度和Entry的大小。
我们要存储大量数据,超出了ZipList最佳的上限该怎么办?
- 我们可以创建多个ZipList来分片存储数据,提高查询效率。
数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?
- QuickList就是用来解决这个问题的
QuickList它是一个双端链表,只不过链表中的每个节点都是一个ZipList。
为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。 如果值为正,则代表ZipList的允许的entry个数的最大值 如果值为负,则代表ZipList的最大内存大小,这里就不在一一介绍了。
SkipList
Redis只有Zset底层实现用到了调表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。
跳表是在链表的基础上改进过来的,实现了一种多层的有序列表。下面是一个层级为3的调表:
图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:
- L0 层级共有 5 个节点,分别是节点1、2、3、4、5;
- L1 层级共有 3 个节点,分别是节点 2、3、5;
- L2 层级只有 1 个节点,也就是节点 3 。
假如我们需要查找节点4的数据,使用链表需要查找4次,而使用跳表,从L2层直接定位到节点3,只需在向后查找一次。
节点根据score属性来进行排序
数据类型
数据类型是在数据结构基础上进行了封装,redis直接操作的是数据类型。
String
String是Redis中最常见的数据存储类型:
其基本编码方式是RAW,基于简单动态字符串(SDS)实现,存储上限为512mb。
String总共有三种编码方式,如下所示:
List
Redis的List类型可以从首、尾操作列表中的元素:
那有下面三种结构可以满足:
LinkedList:普通链表,可以从双端访问,内存占用较高,内存碎片较多
ZipList:压缩列表,可以从双端访问,内存占用低,存储上限低
QuickList:LinkedList + ZipList,可以从双端访问,内存占用较低,包含多个ZipList,存储上限高
在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码。
在3.2版本之后,Redis统一采用QuickList来实现List:
Set
Set是Redis中的单列集合,满足以下特点:
- 不保证有序
- 保证唯一
- 可以进行差集、并集、交集
可以看出,Set对查询元素的效率要求非常高,思考一下,什么样的数据结构可以满足? HashTable,也就是Redis中的Dict,不过Dict是双列集合(可以存键、值对)
Set是Redis中的集合,不一定确保元素有序,可以满足元素唯一、查询效率要求极高。 为了查询效率和唯一性,set采用HT编码(Dict)。Dict中的key用来存储元素,value统一为null。
ZSet
ZSet也就是SortedSet,其中每一个元素都需要指定一个score值和member值:
- 可以根据score值排序
- member必须唯一
- 可以根据member查询socre
因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。前面的哪种编码结构可以满足?
-
SkipList:可以排序,并且可以同时存储score和ele值(member)
-
HT(Dict):可以键值存储,并且可以根据key找value
当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件:
-
元素数量小于zset_max_ziplist_entries,默认值128
-
每个元素都小于zset_max_ziplist_value字节,默认值64
ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:
-
ZipList是连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
-
score越小越接近队首,score越大越接近队尾,按照score值升序排列
Hash
Hash结构与Redis中的Zset非常类似:
-
都是键值存储
-
都需求根据键获取值
-
键必须唯一
hash结构如下:
zset集合如下:
因此,Hash底层采用的编码与Zset也基本一致,只需要把排序有关的SkipList去掉即可:
Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value
当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:
-
ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
-
ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)