Redis的5种常见数据结构:字符串(String)、列表(List)、散列(Hash)、集合(Set)、有序集合(Sorted Set)。这些都是Redis对外暴露的数据结构,本文将介绍这些数据结构的底层数据结构的实现。
Redis底层数据结构有六种:
SDS是"simple dynamic string"的缩写。Redis中所有场景中出现的字符串,基本都是由SDS来实现的。
使用场景:
set msg "hello world"
中的key msg.set msg "hello world"
中的msg的值"hello wolrd"
RPUSH fruits "apple" "banana" "cherry"
中的"apple" "banana" "cherry"
SDS结构图:
// flags值定义(为了节约头部空间,在Redis3.2开始,增加flag字段。SDS由一种数据结构变成了5种数据结构,会根据SDS存储的内容长度来选择不同的结构,以达到节省内存的效果)
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
注:
每次增长或者缩短一个字符,程序都需要对保存这个字符串的数组进行一次内存重新分配操作。因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。
为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。
空间预分配用于优化SDS的字符串增长操作。当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。其中,额外分配的未使用空间数量由以下公式决定:
在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无需执行内存重分配。通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。
惰性空间释放用于优化SDS的字符串缩短操作。当SDS的API需要缩短SDS保存的字符串时,程序不会立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录下来,并等待将来使用。
通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能的增长操作提供了优化。
与此同时,SDS也提供了响应的API可以在有需要时,真正的释放SDS里面的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
列表在Redis中应用的非常广,列表的底层实现就是链表。此外,Redis的发布与订阅、慢查询、监视器等功能也用到了链表。
列表特点:
列表结构图:
列表的数据结构(adlist.h/listNode与adlist.h/list):
listNode:
list:
字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键都是唯一的,可以通过键查找与之关联的值,并对其修改或删除。
Redis的键值对存储就是用字典实现的,散列(Hash)的底层实现之一也是字典。
字典的结构图(与JDk中的HashMap结构很相似):
字典的数据结构(dict.h/dictht与dict.h/dict):
dict:
dictht:
dictEntry:
字典类型容量变化过程叫做rehash。需要满足一定的条件才能触发扩容机制:
渐进式hash的过程,简单来说类似数据库的迁移,读的时候先读ht[0],读不到读ht[1];写的时候只写ht[1];ht[0]数据慢慢地往ht[1]上搬。
当ht[0]的所有键值都迁至ht[1]之后,ht[0]变为空表,释放ht[0]。并将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,将rehashidx属性的值设为-1,表示rehash操作已完成。
具体步骤如下:
这里比较下Redis的渐进hash与JDk中HashMap的resize过程。如果对HashMap不了解,可以查看《详解并发下的HashMap以及JDK8的优化》。
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,可以保存类型为int16_t、int32_t、int64_t的整数值,并且保证集合中不会出现重复元素 整数集合是集合(Set)的底层实现之一,如果一个集合只包含整数值元素,且元素数量不多时,会使用整数集合作为底层实现
整数集合的结构图:
整数集合的数据结构(inset.h/inset):
intset:
当想要添加一个新元素到整数集合中时,并且新元素的类型比整数集合现有的所有元素的类型都要长,整数集合需要先进行升级,才能将新元素添加到整数集合里面。每次想整数集合中添加新元素都有可能会引起升级,每次升级都需要对底层数组已有的所有元素进行类型转换。
升级添加新元素:
整数集合的升级策略可以提升整数集合的灵活性,并尽可能的节约内存。另外,整数集合不支持降级,一旦升级,编码就会一直保持升级后的状态。
一个普通的单链表查询一个元素的时间复杂度为O(N),即便该单链表是有序的。使用跳跃表(SkipList)是来解决查找问题的,它是一种有序的数据结构,不属于平衡树结构,也不属于Hash结构,它通过在每个节点维持多个指向其他节点的指针,而达到快速访问节点的目的 跳跃表是有序集合(Sorted Set)的底层实现之一,如果有序集合包含的元素比较多,或者元素的成员是比较长的字符串时,Redis会使用跳跃表做有序集合的底层实现。
跳跃表其实可以把它理解为多层的链表,它有如下的性质:
有关跳跃表的讲解,可以查看《有关跳跃表的干货都在这里》
跳跃表的结构图:
压缩列表(ziplist)是为了节约内存而设计的,是由一系列特殊编码的连续内存块组成的顺序性(sequential)数据结构,一个压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者一个整数值。
压缩列表是列表(List)和散列(Hash)的底层实现之一,一个列表只包含少量列表项,并且每个列表项是小整数值或比较短的字符串,会使用压缩列表作为底层实现(在3.2版本之后是使用quicklist实现)。
压缩列表的结构图:
一个压缩列表可以包含多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩列表的数据结构:
每个压缩列表节点可以保存一个字节数字或者一个整数值。压缩列表节点的数据结构:
考虑到链表的附加空间相对太高,prev和next指针就要占去16个字节(64bit系统的指针是8个字节)。另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。因此Redis3.2版本开始对列表数据结构进行了改造,使用快速列表(quicklist)代替了压缩列表和列表。
快速列表的结构图:
快速列表的数据结构:
quicklistNode:
quickList:
quicklist默认的压缩深度是0,也就是不压缩。压缩的实际深度由配置参数list-compress-depth
决定。为了支持快速的push/pop操作,quicklist的首尾两个ziplist不压缩,此时深度就是1;如果深度为2,就表示quicklist的首尾第一个 ziplist以及首尾第二个ziplist都不压缩。
quicklist 内部默认单个ziplist长度为8k字节,超出了这个字节数,就会新起一个ziplist。ziplist的长度由配置参数list-max-ziplist-size
决定。
上面介绍了Redis的主要底层数据结构,包括简单动态字符串(SDS)、链表、字典、跳跃表、整数集合、压缩列表。但是Redis并没有直接使用这些数据结构来构建数据库,而是基于这些数据结构创建不同的编码,然后由不同条件下的不同编码来实现Redis的这些数据类型:字符串(String)、列表(List)、散列(Hash)、集合(Set)、有序集合(Sorted Set)。
接下来就介绍Redis五种数据结构对应的编码。
上面介绍了SDS,但这只是字符串对象的其中一种实现。字符串对象的编码可能有三种:int、raw、embstr。
浮点数如何保存:
Redis的字符串数据类型是支持保存浮点数,并且支持对浮点数进行加减操作,但是Redis在底层是把浮点数转换成字符串值,然后按照上述编码规则。对浮点数进行操作时,也是从字符串转换成浮点数进行计算,然后再转换成字符串进行保存的。
编码转换条件:
如果对一个int编码的字符串对象,修改它成非整数值,则对象就会使用raw编码。而Redis没有为embstr编码提供任何的修改操作,embstr编码的值是只读的,只要发生修改,立刻将编码转换成raw。
编码 | 使用条件 |
---|---|
int | 可以用long保存的整数 |
raw | 长度大于32的字符串 |
embstr | 字符串长度小于32字节(或者浮点数转换后满足) |
在 Redis 3.2 版本之前,列表对象底层由 压缩列表和双向链表配合实现,当元素数量较少的时候,使用压缩列表,当元素数量增多,就开始使用普通的双向链表保存数据。
但是这种实现方式不够好,双向链表中的每个节点,都需要保存前后指针,这个内存的使用量 对于Redis这个内存数据库来说极其不友好。
因此在 3.2 之后的版本,Redis新实现了一个数据结构,叫做快速列表(quicklist)。所有列表的底层实现都是这个数据结构了。它的底层实现基本上就是将 双向链表和压缩列表进行了结合,用双向的指针将压缩列表进行连接,这样不仅避免了压缩列表存储大量元素的性能压力,同时避免了双向链表连接指针占用空间过多的问题。
编码 | 使用条件 |
---|---|
quicklist | 无 |
集合对象的编码可以是intset或者hashtable。
当集合中的所有元素都是整数,且元素的数量不大于512个的时候,使用intset编码。
当元素不符合全部为整数值且元素个数小于512时,集合对象使用的编码方式为 hashtable。
编码 | 使用条件 |
---|---|
intset | 所有元素都是整数且元素个数小于 512 |
hashtable | 其他数据 |
有序集合对象的编码可以是ziplist以及skiplist。
当使用ziplist编码时,有序集合对象的实现数据结构为压缩列表。当条件变化,ziplist编码会转换成skiplist编码。
当使用skiplist编码的时候,内部使用zset 来实现数据的保存,zset的定义如下:
typedef struct zset{
zskiplist *zsl;
dict *dict;
}zset;
为什么需要同时使用跳跃表以及字典呢?
因此,将字典和跳跃表结合进行使用,可以在O(1)的时间复杂度下完成查询分值操作,而对一些范围操作使用跳跃表可以达到O(logn)的时间复杂度。
编码 | 使用条件 |
---|---|
ziplist | 元素数量少于128且所有元素成员的长度小于64字节 |
skiplist | 不满足上述条件的其他情况 |
散列对象的编码可以是ziplist或者hashtable.
ziplist编码下的哈希对象,使用了压缩列表作为底层实现数据结构,用两个连续的压缩列表节点来表示哈希对象中的一个键值对。实现方式类似于上面的有序集合的场景。
哈希结构本身在结构上和字典颇为相似,因此哈希对象中的每一个键值对都是字典中的一个键值对。
编码 | 使用条件 |
---|---|
ziplist | 键值对的键和值的长度都小于64字节,且键值对个数小于512 |
hastable | 不满足上述条件的其他情况 |
基础数据类型 | 可能的编码方式 |
---|---|
字符串 | int, raw, embstr |
列表 | 之前是 ziplist, linkedlist。3.2开始都是quicklist |
集合 | intset, hashtable |
有序集合 | ziplist, skiplist |
散列 | ziplist, hashtable |
参考文档: