字符串是Redis最常见的一种数据结构,因为Redis中保存的key是字符串,value往往也是字符串或者字符串的集合。
虽然Redis是由C语言编写的,但Redis并没有直接使用C语言中的字符串,因为C语言的字符串存在很多的问题:
Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。
其中SDS是一种结构体(相当于Java中的类),源码如下图
SDS具备动态扩容的能力,例如一个内容为“hi”的SDS:
如果需要再SDS后追加一段新的字符串“xxx”,首先会申请新内存空间(分为两种情况):
所以,SDS的优点:
IntSet是Redis中Set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征(适用于数据量不是很大的情况)。结构如下图:
其中的encoding包含三种模式,表示存储的整数大小不同
为了方便查找,Redis会将intset中所有的整数按照升序依次保存在如下图的contents数组中
数组中每个数字都在int16_t(16字节)的范围内,因此采用的编码方式为INTSET_ENC_INT16,每部分占用的字节大小为:
上图黑色方框中的公式为寻址公式
现在假设一个IntSet元素为{5,15,20},此时采用的编码是INTSET_ENC_INT16,则每个整数占两个字节
此时向其中添加一个数字为50000,这个数字已经超出int16_t的范围,IntSet会自动升级编码方式到合适的大小。
升级的过程为:
IntSet可以看做是特殊的整数数组,具备一些特点:
① Redis会确保IntSet中的元素唯一、有序。
② 具备类型升级机制,可以节省内存空间。
③ 底层采用二分查找方式来查询(查询速度更快)。
Redis是一个键值型 (Key-Value Pair) 的数据库,可以根据键来实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。
Dict是由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)。
当需要向Dict添加键值对时,Redis首先会根据key1计算出hash值(h),然后利用h & sizemark来计算元素应该存储到数组中的哪个索引位置。
当添加的键值对的节点值相同时,则会形成一个链表,采用头插法的形式放入数据,所以新元素始终在前面(如下图)。
dict结构如图所示
Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size),满足以下两种情况会触发哈希扩容:
Dict在每次删除元素的时候,也会对负载因子进行检查,当LoadFactor < 0.1时,会做哈希表收缩。
无论是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,该过程称为rehash,具体实现过程如下:
当然,Dict的rehash并不是一次性完成的。如果Dict中包含百万数量级以上的entry,要在一次rehash完成,极有可能会导致主线程阻塞。因此Dict的rehash是分多次、渐进式的完成,因此称为渐进式rehash。流程如下(只有步骤4、6、7不同):
ZipList是一种特殊的“双端链表”,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作,并且该操作的时间复杂度为O(1)。
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4字节 | 记录整个压缩列表占用的内存字节数 |
zltail | uint32_t | 4字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。 |
zllen | uint16_t | 2字节 | 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的 真实数量需要遍历整个压缩列表才能计算得出。 |
entry | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend | uint8_t | 1字节 | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
ZipList中的Entry并不像普通链表那种记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了如下结构
ZipListEntry中的encoding编码分为字符串和整数两种:
编码 | 编码长度 | 字符串大小 |
---|---|---|
|00pppppp| | 1 bytes | <= 63 bytes |
|01pppppp|qqqqqqqq| | 2 bytes | <= 16383 bytes |
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| | 5 bytes | <= 4294967295 bytes |
例如,保存字符串“ab”和“bc”
编码 | 编码长度 | 整数类型 |
---|---|---|
11000000 | 1 | int16_t(2 bytes) |
11010000 | 1 | int32_t(4 bytes) |
11100000 | 1 | int64_t(8 bytes) |
11110000 | 1 | 24位有符整数(3 bytes) |
11111110 | 1 | 8位有符整数(1 bytes) |
1111xxxx | 1 | 直接在xxxx位置保存数值,范围从0001~1101,减1后结果为实际值 |
例如,保存整数值“2”和“5”
ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:
如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:
ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。
ZipList的特性:
①压缩列表的可以看做一种连续内存空间的"双向链表"(并没有使用指针,只是意义上的双向链表)。
②列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低。
③如果列表数据过多,导致链表过长,可能影响查询性能。
④增或删较大数据时有可能发生连续更新问题。
问题一:ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低该怎么办?
问题二:但是需要存储大量数据,超出了ZipList最佳的上限该怎么办?
问题三:数据拆分后比较分散,不方便管理和查找,这些多个ZipList应该如何建立联系?
为了避免QuickList中的每个ZipList中的entry过多,Redis提供了一个配置项:list-max-ziplist-size
来限制。
-1
:每个ZipList的内存占用不能超过4kb。-2
:每个ZipList的内存占用不能超过8kb。(默认值)-3
:每个ZipList的内存占用不能超过16kb。-4
:每个ZipList的内存占用不能超过32kb。-5
:每个ZipList的内存占用不能超过64kb。除了控制ZipList的大小,QuickList还可以对节点的ZipList做压缩。通过配置项list-compress-depth来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。这个参数是控制首尾不压缩的节点个数:
以下是QuickList和QUickListNode的结构源码
typedef struct quicklist {
// 头节点指针
quicklistNode *head;
// 尾节点指针
quicklistNode *tail;
// 所有ziplist的entry的数量
unsigned long count;
// ziplists总数量
unsigned long len;
// ziplist的entry上限,默认值 -2
int fill : QL_FILL_BITS;
// 首尾不压缩的节点数量
unsigned int compress : QL_COMP_BITS;
// 内存重分配时的书签数量及数组,一般用不到
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
typedef struct quicklistNode {
// 前一个节点指针
struct quicklistNode *prev;
// 下一个节点指针
struct quicklistNode *next;
// 当前节点的ZipList指针
unsigned char *zl;
// 当前节点的ZipList的字节大小
unsigned int sz;
// 当前节点的ZipList的entry个数
unsigned int count : 16;
// 编码方式:1,ZipList; 2,lzf压缩模式
unsigned int encoding : 2;
// 数据容器类型(预留):1,其它;2,ZipList
unsigned int container : 2;
// 是否被解压缩。1:则说明被解压了,将来要重新压缩
unsigned int recompress : 1;
unsigned int attempted_compress : 1; //测试用
unsigned int extra : 10; /*预留字段*/
} quicklistNode;
结构图如下
SkipList(跳表),单一链表有几点差异:
源码:
// t_zset.c
typedef struct zskiplist {
// 头尾节点指针
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 最大的索引层级,默认是1
int level;
} zskiplist;
// t_zset.c
typedef struct zskiplistNode {
sds ele; // 节点存储的值
double score;// 节点分数,排序、查找用
struct zskiplistNode *backward; // 前一个节点指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 下一个节点指针
unsigned long span; // 索引跨度
} level[]; // 多级索引数组
} zskiplistNode;
内存结构图:
SkipList的特点:
Redis中的任意数据类型的键和值都会被封装成为一个RedisObject,也称为Redis对象。
Redis根据存储的数据类型不同,选择不同的编码方式。
编号 | 编码方式 | 说明 |
---|---|---|
0 | OBJ_ENCODING_RAW | raw编码动态字符串 |
1 | OBJ_ENCODING_INT | long类型的整数的字符串 |
2 | OBJ_ENCODING_HT | hash表(字典dict) |
3 | OBJ_ENCODING_ZIPMAP | 已废弃 |
4 | OBJ_ENCODING_LINKEDLIST | 双端链表 |
5 | OBJ_ENCODING_ZIPLIST | 压缩列表 |
6 | OBJ_ENCODING_INTSET | 整数集合 |
7 | OBJ_ENCODING_SKIPLIST | 跳表 |
8 | OBJ_ENCODING_EMBSTR | embstr的动态字符串 |
9 | OBJ_ENCODING_QUICKLIST | 快速列表 |
10 | OBJ_ENCODING_STREAM | Stream流 |
每个数据类型的使用的编码方式
数据类型 | 编码方式 |
---|---|
OBJ_STRING | int、embstr、raw |
OBJ_LIST | LinkedList和ZipList(3.2以前)、QuickList(3.2以后) |
OBJ_SET | intset、HT |
OBJ_ZSET | ZipList、HT、SkipList |
OBJ_HASH | ZipList、HT |
Redis中包含的数据类型一共有五种:String、List、Set、ZSet(SortedSet)、Hash。
String是Redis最常见的数据存储类型,其基本编码方式是RAW,基于简单动态字符串(SDS)实现,存储上限为512MB。
如果存储的SDS的长度小于44字节,则会采用EMBSTR编码,此时object head 与SDS是一段连续的空间,申请内存时只需要调用一次内存分配函数,效率更高。
如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了。
Redis的List类型可以从首、尾操作列表中的元素。哪一种数据结构能满足这个特征呢?
Redis的List结构类似于一个双端链表/
Set是Redis中的单列集合,满足下列特点:
Set是Redis中的集合,不一定确保元素有序,可以满足元素唯一、查询效率要求极高。
源码
ZSet即SortedSet,其中每一个元素都需要指定一个score值和member值(必须唯一)。ZSet可以根据score值排序,根据member查询分数。
因此,ZSet底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求:
源码
所以ZSet使用的是SkipList和HT两种数据结构结合的方式来进行实现,功能非常强大,性能非常好(下图为其内存结构图)。
但通过上图可以发现,ZSet也存在一些问题,如:内存消耗过大(两种数据结构一起使用,大量使用链表结构),面临同种数据重复存储的风险。
所以针对上述情况,ZSet还有第二种存储方式:
即当元素数量不多时,HT和SkipList的优势不明显,并且内存消耗很大。因此ZSet会采用ZipList结构来节省内存。但使用ZipList需要同时满足两个前提:
ziplist本身没有排序功能,而且没有键值对的概念,因此需要ZSet通过业务逻辑编码实现:
内存结构图:
Hash结构与Redis中的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字节)。
源码: