1. 前言
在平时的面试中,可能经常会有童鞋被问到,redis的5种数据类型(String、List、Hash、Set、SortedSet)的底层数据结构是怎么实现的,可能有很多童鞋会抱怨,我又不是redis开发者,会在不同的业务场景中使用这五种不同的数据类型就行了,为啥还要去理解它的底层数据结构呀;其实当了解完这些数据结构的底层实现之后,你或许也可以想到redis为啥如此快的原因
2. 五种常用的数据类型
在redis中,5种常用的数据类型以及常用的场景如下:
- String:缓存,计数器,分布式锁等
- List:链表,队列,微博关主人时间轴
- Set:去重,赞,共同好友
- Hash:用户信息,hash表
- Zset:排行榜功能,访问量排行榜,点击量排行榜
2.1 与六种底层数据结构的关系
- String :存储数字的话,采用int类型编码,非数字,采用rwa编码.
- List : 当元素字符串的长度小于64字节而且元素个数小于512时,采用zipList;否则采用likedList;该值在redis的配置文件redis.conf的配置为:
- list-max-ziplist-entries 512
- list-max-ziplist-value 64
- hash: 底层数据结构可用使用zipList或者hashTab,当hash对象的键与值的长度都小于64字节时而且键值对的个数小于512个,采用zipList,其它情况,采用hashTa
- set:Set对象的编码可以是 intset 或 hashta表,当保存的元素都是整形数字,而且元素个数小于配置范围的时候,则使用intset,否则使用hash表。
- SortSet:底层数据结构可使用zipList或者skiplList,当元素个数小于128而且成员的长度小于64字节的时候,则使用zipList,否则使用skipList。该值在redis.con配置为:
- zset-max-ziplist-entries 128
- zset-max-ziplist-value 64
3. 六种底层数据结构的实现
3.1 SDS - 简单动态字符串
redis并没有直接使用C的字符串(以空字符结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string)的抽象类型SDS用作Redis的默认字符串表示。除了用来保存数据库中的字符串,SDS还用做缓冲区,如AOF缓冲区,客户端输入的缓冲区。 SDS的定义如下:
struct sdshdr{
// 记录buf 数组已经所使用的字节数量,既SDS的字符长度
int len;
// buf未使用字节数量
int free;
// 字节数组,保存字符串
char buf[];
}
- len表示字符串的长度
- free表示字符数组,未使用的长度
- buf 字节数组,保存字符串
3.1.1 空间预分配
空间预分配,用于优化redis对字符串的增长操作,当SDS的API对一个SDS需要修改,并且需要对SDS空间进行扩容时,redis不仅会对SDS分配锁必须的空间,而且还会为SDS分配额外的未使用空间;具体分配规则如下:
- 当SDS修改之后的长度小于1MB的时候,那么redis将会为SDS分配一个与len一样的未使用空间,既当前free与len大小一样,当前数组buf的长度为:len*2+1;额外的一个字节为保存空字符串。
- 当SDS修改之后的长度大于等于1MB的时候,那么redis将会为SDS分配1MB的未使用空间,既当然free大小为1MB,当前数组长度为:len+1M+1。
3.1.2 惰性空间释放
当对 SDS 进行缩短操作时,程序并不会回收多余的内存空间,而是使用 free 字段将这些字节数量记录下来不释放,后面如果需要 append 操作,则直接使用 free 中未使用的空间,减少了内存的分配
3.1.3 总结
- 通过使用SDS,reids获取字符串的长度所需的时间复杂度可以从o(n)降到o(1),因为C需要遍历整个字符数组,遇到空格结束才能统计出来字符串的长度。即使我们对非常长的字符串反复使用STRLEN命令,也不会造成系统性能瓶颈,因为STRLEN命令的复杂度为o(1)。
- 空间预分配, 操作字符串时候,避免了缓冲区溢出,SDS在操作字符串的时候,会预先检查SDS的空间是否足够,如果不够的话,sdscat会先扩展SDS空间,然后再执行拼接操作,避免C在修改字符串长度时,重复执行内存分配。
3.2 zipList - 压缩列表
zipList是由由连续内存块组成的顺序型数据结构,zipList有多个entry节点,每个entry节点可以存放整数或者字符串,其对应的C语言结构体和图示如下:
struct ziplist<T> {
int32 zlbytes; // 整个压缩列表占用字节数
int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
int16 zllength; // 元素个数
T[] entries; // 元素内容列表,挨个挨个紧凑存储
int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
3.3 LinkedList-双向列表
链表提供了高效的节点重排能力,以及顺序访问方式,并且可以通过删除节点来灵活调整链表长度;链表和链表节点的实现如下:
// 链表节点
typedef struct listNode {
// 前置节点
struct listNode *pre;
// 后置节点
struct listNode *next;
// 节点值
void *value;
}
// 链表结构
typedef struct list{
// 头节点
listNode *head;
// 尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点复制函数,复制链表所保存的值
void (*dup) (void *ptr);
// 节点释放函数,ß
void (*free) (void *ptr);
// 对比函数
void (*match) (void *ptr,void *key);
}
3.1 双向列表特性总结
- 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的复杂度都是 O(1)。
- 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点。
- 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表尾节点的复杂度为 O(1)。
- 带链表长度计数器:程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,程序获取链表中节点数量的复杂度为 O(1)。
3.4 skipList 跳跃表
跳跃表是一种有序的数据结构,通过在每个节点维护多个指针,从而达到快速访问的目的,在redis内部,跳跃表只有两种场景用到:
- 实现有序集合sordSet,当有序集合元素较多或者成员变量是较长的字符串时。
- 集群内部节点的数据结构。
Redis跳跃由zskiplistNode和zskiplist两个结构定义,其中zskiplistNode为跳跃表节点,zskipList则保存跳跃表相关节点信息,如节点数量,表头表尾指针等。
3.4.1 跳跃表节点
跳跃表节点定义如下:
typedef struct zskiplistNode{
// 层
struct zskiplistLevel{
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
}level[];
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *robj;
}zskiplistNode;
- 层:层是redis内部快速访问其它跳跃表节点的一种手段,层的数量越多,访问其它节点的速度就越快;在创建跳跃表的节点时,会根据幂次定律随机生成1-32之间的值作为层数组的大小,这个大小成为层的高度。
- 前进指针:指向表尾方向的指针,通过遍历前进指针,可以从当前节点访问到表尾。图中虚线表示从表头向表尾方向遍历所有节点路径
- 跨度:只要用来计算两个节点之间的距离,连个节点的跨度大越大,则表示节点相聚越远。
- 排位:在查找节点的过程中,将沿途访问过的所有层累积起来,得到的结果。
- 后退指针:用于从表尾向表头方向访问,与前进指正不同的是,后退指针只有一个。
- 分值:分值是一个double类型的浮点数,跳跃表中的所有节点都按照分值大小排序。
- 成员对象:它指向一个字符串对象SDS。
3.4.2 跳跃表
对于跳跃表,redis是通过zskiplist这个数据结构来表示的,其内部不仅持有跳跃表节点,还持有跳跃表节点的表头节点和表尾节点,跳跃表节点的数量。如下,为zskiplist的结构定义:
typedef struct zskiplist{
// 表头节点和表尾节点
struct zskiplist *header,*tail;
// 表节点个数
unsigned long length;
// 表节点最大层数
int level;
}zskiplist;
- header和tail分表指向跳跃表的头节点和尾节点,通过这两个指针,定位跳跃表的头节点和尾节点的时间复杂度为o(1).
- length记录跳跃表节点数量,redis可以在o(1)的时间复杂度返回跳跃表长度。
3.4.3 跳跃表的遍历
- 头节点:头节点和跳跃表节点结构一样,但是分值和后退指针,成员遍历都不会被用到,所以图中没有画出来
- 跳跃表节点:在该图最右边四个,跳跃表节点主要有如下几个属性:层(L1,L2,...,Ln)以及层内部的跨度与前进指针,后退指针,分支score,成员变量mem
- 跳跃表:图中最左边的一个,分表有四个核心组成:头指针,尾指针,跳跃表长度,跳跃表层数
- 表头到表尾的遍历过程:
- 迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层(L4)的前进指针移动到表中的第二个节点
- 在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。
- 在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点
- 当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表的表尾,结束这次遍历。
3.5 整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现;其结构如下:
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
- length:记录了整数集合包含的元素数量,也即是contents数组的长度。
- contents:表示整数集合,集合里的数据按照从小到大排序,并且数组里的数据不包含重复项。
- encoding : 整形数据的数据编码,也可理解为数组每项所占的字节大小,取值为如下3种:
- INTSET_ENC_INT16:数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)
- INTSET_ENC_INT32:数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647
- INTSET_ENC_INT64:数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)
3.5.1 整数集合升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面,升级过程大致分为三步:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将新元素添加到底层数组里面
3.5.1 总结
- 整数集合是集合键的底层实现之一
- 整数集合的底层实现是数组,这个数组以有序无重复的方式保存集合元素,同时,在集合添加新元素的时候,会改变数组的数据类型,称之为整数集合的升级
- 整数集合只支持升级操作,不支持降级操作
3.6 hash表
Redis字典所使用的hash表如下所示:
hash表有一个table属性数组,数组中的每个元素类型都是一个dictEntry,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小,也即是table数组的大小,而used属性则记录了哈希表目前已有节点(键值对)的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面,属性Java的童鞋,可联想对比一下Java中的hashMap,是否也是类似的设计
3.6.1 字典
字典的核心组成主要有两个:ht数组,rehashIndex,至于其它的核心组成,如类型特定函数这些,不在本文阐述范围,感兴趣的可自行去理解.
- ht数组,ht是一个固定大小为2的数据,数组的每一个元素分别为一个hash表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
- rehashidx:它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。
3.6.2 字典定位元素过程
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面
- 计算hash值:根据字典类型,选择不同的hash函数,计算ket的hash值。
- 定位索引:index= hash & dict->ht[x].sizeMask,ht[x]可以是ht[0]或者ht[1]
- 解决hash冲突:采用拉链法,注意的是,因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置。
3.6.3 rehash
扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:
- 为字典中的ht[1]哈希表分配空间,分配的空间大小取决于要执行的操作和当前ht[0]的空间大小
- 如果要执行扩容操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n,既ht[1]分配的小大于等于当前ht[0]当前表节点两倍的最小2次幂
- 如果要执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2n
- 将保存在ht[0]中的所有键值对rehash到ht[1]上面,rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
- 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备
3.6.3.1 rehash 触发条件
- 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
- 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
- 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
注:负责因子计算公式:load_factory = ht[0].userd /ht[0].size
3.6.4 渐进式rehash
渐进式rehash主要是解决rehash过程中,将ht[0]数据拷贝到ht[1]时,如果有上亿数据,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。因此redis为了避免rehash过程对redis服务器性能造成影响,不是一次性将ht[0]数据拷贝到ht[1]中,而是分多次,渐进式的将数据拷贝到ht[1]中。
渐进式rehash步骤如下:
- 将字典中的变量rehashidx设置为0,表示该字典rehash工作正式开始,此时该字典同时持有ht[0]和ht[1]
- 当每次对字典进行增,删,改,更新操作时,除了会执行相应的动作之外,同时还会执行rehash过程,将对应ht[0]元素转移到ht[1]中,转移结束之后,将rehashinx值加一
- 随着操作的不断进行,ht[0]最终持有的元素将为0,此时,渐进式hash结束,将rehashinx的值设置为-1,表示rehash操作完成
注:在渐进式rehash过程中,因为字典持有两个哈希表,所以对字典的操作增,删,改,查都是基于两个hash表进行的,但如果在渐进式hash中,如果有新增操作,会直接放在ht[1]中,这样就能保证ht[0]的元素只减不增,最终会变成一个空的hash表