我们常用的Redis数据类型有5种,分别是:
String
List
Set
ZSet / SortedSet
Hash
还有一些高级数据类型,比如Bitmap、HyperLogLog、GEO等,其底层都是基于上述5种基本数据类型。因此在Redis的源码中,其实只有5种数据类型。
Redis是C-S架构
Redis 使用 dictht 结构体表示哈希表,不过,在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了两个哈希表(ht[2])。
Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash。
之所以定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表了。
可以看到,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。
要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展。
接下来,看看 Redis 是如何实现的 rehash 的。
在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。
随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:
这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。
渐进式 rehash 步骤如下:
这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。
在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。
比如,查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。
另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表。
介绍了 rehash 那么多,还没说什么时情况下会触发 rehash 操作呢?
rehash 的触发条件跟 负载因子(load factor)有关系。
负载因子可以通过下面这个公式计算:
触发 rehash 操作的条件,主要有三个:
- 我们都知道Redis中保存的Key是字符串,Value往往是字符串或者字符串的集合,可见字符串是Redis中最常用的一种数据结构。
Redis String类型的底层的数据结构实现主要是SDS(简单动态字符串),不过Redis并没有直接使用C语言中的字符串,因为SDS相比C语言的原生字符串:
- SDS不仅可以保存文本数据,还可以保存图片、音视频等这样的二进制数据,而C语言的原生字符串只能保存文本数据。
- SDS获取字符串长度的时间复杂度是O(1),因为C语言的字符串并不记录自身长度,所以获取长度的时间复杂度为O(n),而SDS结构里用len属性记录了字符串长度,所以时间复杂度为O(1)。
- 不会发生缓冲区溢出 - SDS之所以叫动态字符串,是因为它具备动态扩容的能力:Redis的SDS API是安全的,拼接字符串不会造成缓冲区溢出,因为SDS在拼接字符串之前会检查SDS空间是否满足要求,如果空间不够会自动扩容,会申请新的内存空间,所以不会导致缓冲区溢出的问题。
所以Redis构建了一种全新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。
- 但是如果一个String类型的Value的值是数字,那么Redis内部会把它转成long类型来存储,从而减少内存的使用。
List类型的底层数据结构是由双向链表或ZipList压缩列表实现的:
- 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用ZipList压缩列表作为 List 类型的底层数据结构;
- 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 QuickList 实现了,替代了双向链表和压缩列表。
Hash结构与Redis中的Zset非常类似:
都是键值存储
都需求根据键获取值
键必须唯一
区别如下:
zset的键是member,值是score;hash的键和值都是任意值
zset要根据score排序;hash则无需排序
hash结构如下:
Hash类型的底层数据结构是由ZipList压缩列表或哈希表实现的:
- 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用ZipList压缩列表作为 Hash 类型的底层数据结构;
- 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
在 Redis 7.0 中,ZipList压缩列表数据结构已经废弃了,交由 listpack 紧凑列表数据结构来实现了。
Set是Redis中的单列集合,满足下列特点:
不保证有序性
保证元素唯一
求交集、并集、差集
Set 类型的底层数据结构是由哈希表或整数集合IntSet实现的:
- 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用IntSet整数集合作为 Set 类型的底层数据结构;
- 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
ZSet也就是SortedSet,其中每一个元素都需要指定一个score值和member值:
可以根据score值排序后
member必须唯一
可以根据member查询分数
- 因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。
Zset / SortedSet有序集合类型的底层数据结构是由ZipList压缩列表或SkipList跳表实现的:
- 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表ZipList作为 Zset 类型的底层数据结构,来代替HashTable和SkipList;
- 如果有序集合的元素不满足上面的条件,Redis 会使用SkipList跳表作为 Zset 类型的底层数据结构;
由于ZipList压缩列表存在连锁更新问题,因此在 Redis 7.0 中压缩列表数据结构已经废弃了,交由 Listpack紧凑列表 数据结构来实现了。
先来看看「链表节点」结构的样子:
typedef struct listNode {
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
} listNode;
有前置节点和后置节点,可以看的出,这个是一个双向链表:
因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。
- 压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,从而有效的利用CPU缓存,节省内存开销。
但是,压缩列表的缺陷也是有的:
- 不能保存过多的元素,否则查询效率就会降低,但它可以从双端访问,如果我们要查找定位第一个元素和最后一个元素,复杂度是 O(1) - 压缩列表可以在任意一端进行压入 / 弹出操作,而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。;
- 新增或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配,当新插入或删除的元素较大时,甚至可能引发「连锁更新」的问题,导致每个元素的空间都要重新分配,这会直接影响到压缩列表的访问性能。
因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。
压缩列表结构设计:
- 压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。
- 压缩列表紧凑型的内存布局能节省内存开销。
- Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。
面试题:Redis的SortedSet底层的数据结构是怎样的?
- SortedSet是有序集合,底层的存储的每个数据都包含element和score两个值,score是得分,element则是字符串值,SortedSet会根据每个element的score值排序,形成有序集合。
它支持的操作有很多,比如:
- 根据element查询score值
- 按照score值升序或降序查询element
- zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。
- Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。
- Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表 => 要实现对score值排序,并且查询效率还高,就需要有一种高效的有序数据结构,SortedSet是基于跳表实现的;
- 而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引 => 要实现根据element查询对应的score值,就必须实现element与score之间的键值映射,SortedSet底层是基于HashTable哈希表来实现的。
可能很多人会奇怪,为什么我开头说 Zset 对象的底层数据结构是「压缩列表」或者「跳表」,而没有说哈希表呢?
- Zset 对象在使用跳表作为数据结构的时候,是使用由「哈希表+跳表」组成的 struct zset,但是我们讨论的时候,都会说跳表是 Zset 对象的底层数据结构,而不会提及哈希表,是因为 struct zset 中的哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。
因为SortedSet底层需要用到两种数据结构,对内存占用比较高。因此Redis底层会对SortedSet中的元素大小做判断。如果元素大小小于128且每个元素都小于64字节,SortedSet底层会采用ZipList,也就是压缩列表来代替HashTable和SkipList
不过,
ZipList
存在连锁更新问题,因此而在Redis7.0版本以后,ZipList
又被替换为Listpack(紧凑列表)。接下来,详细的说下跳表。
- 传统链表在查找元素的时候,因为需要逐一查找(因为链表它的指针跨度是1,也就是说每一个节点它的指针永远指向的是下一个节点,所以需要逐一查找,依次遍历),所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。
- 所以要想提升查询的效率,我们就必须提升指针的跨度,也就是说我能从1号节点直接往后跳着找,那我们的查询性能就可以大大提升了,这就是我们跳表名称的由来了!
Redis源码中zset,也就是SortedSet的结构体如下:
typedef struct zset {
dict *dict; // dict,底层就是HashTable
zskiplist *zsl; // 跳表
} zset;
SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。
- 跳表是一个双向链表,每个节点都包含score和ele值
- 节点则按照score值排序,score值一样则按照ele字典排序
- 元素按照升序排列存储
- 节点可能包含多个指针,且指针跨度不同 => 每个节点都可以包含多级指针,层数是1到32 / 64之间的随机数,层级越高,跨度越大,内部包含了跨度不同的多级指针,可以让我们跳跃查找链表中间的元素,效率非常高。
其结构如图:可以看到不同的节点它的底层是不一样的
图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:
在我们的跳表里面,它最多允许32级指针~!
跳表的结构体如下:
typedef struct zskiplist {
// 头尾节点指针
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 最大的索引层级,默认是1
int level;
} zskiplist;
可以看到SkipList主要属性是header和tail,也就是头尾指针,因此它是支持双向遍历的。
跳表结构里包含了:
跳表中节点的结构体如下:
typedef struct zskiplistNode {
sds ele; // 节点存储的字符串值或ZSet对象的元素值
double score;// 元素权重值或节点分数,排序、查找用
struct zskiplistNode *backward; // 前一个节点指针 => 后向指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 下一个节点指针 => 前向指针
unsigned long span; // 索引跨度,用来记录两个节点之间的距离
} level[]; // 多级索引数组
} zskiplistNode;
Zset 对象要同时保存「元素」和「元素的权重」,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量,每个节点中都包含ele和score两个属性,其中score是得分,也就是节点排序的依据,ele则是节点存储的字符串数据指针。
每个跳表节点都有一个后向指针(struct zskiplistNode *backward),指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。
跳表是一个带有层级关系的链表,跳表就相当于给链表做了多层索引。
- 如果采用新增节点或者删除节点时,来调整跳表节点以维持比例的方法的话,会带来额外的开销。
跳表的写入是依赖随机函数计算层数的:
- 具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数,从而尽量保证整个跳跃表的高度不会过高。。
- 这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。
- 如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点。
- ZSKIPLIST_MAXLEVEL 定义的是最高的层数,Redis 7.0 定义为 32,Redis 5.0 定义为 64,Redis 3.0 定义为 32。
从Redis的使用者的角度来看,一个Redis节点包含多个DataBase(非Cluster模式下默认是16个,Cluster模式下只能是1个)
redisObject的源码如下:
可以看到整个redisObject结构体并不包含真实的数据,仅仅是对象头信息,内存占用的大小为16个字节,然后指针ptr指向的才是真实数据存储的内存地址,所以RedisObject的内存开销是很大的。
我们知道Redis是Key-Value的数据库,这个映射关系的Key是String类型,而Value可以是多种数据类型,比如String、List、Hash、Set、ZSet等,我们可以看到,Key的类型固定是String字符串,而Value可能有多种数据类型,而从Redis内部实现的角度来看,DataBase内的这个映射关系是用一个Dict字典来维护的,Dict的Key固定用一种数据结构来表达就够了,这就是动态字符串SDS,而Value则比较复杂,为了在同一个Dict内能够存储不同类型的Value,这就需要一个通用的数据结构,这个通用的数据结构就是robj,全名是redisObject。