Redis数据结构

Redis数据结构

动态字符串(SDS)

字符串是Redis最常见的一种数据结构,因为Redis中保存的key是字符串,value往往也是字符串或者字符串的集合。

虽然Redis是由C语言编写的,但Redis并没有直接使用C语言中的字符串,因为C语言的字符串存在很多的问题:

image-20230601130536600

  • 获取字符串长度时需要进行运算(或是遍历,得到数组长度要减一才是字符串长度,时间复杂度为O(n))。
  • 非二进制安全的(因为C语言字符串本质是字符串数组,末尾由’\0’作为字符串的结尾,所以C语言的字符串中不允许’\0’这样的特殊字符出现,否则字符串会出现漏读风险)。
  • 不可修改(声明好的字符串放入常量池以后,不能做拼接运算,需要重新开辟一块更大的空间来拼接新的字符串)。

Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS

其中SDS是一种结构体(相当于Java中的类),源码如下图

image-20230601132157417

SDS具备动态扩容的能力,例如一个内容为“hi”的SDS:

image-20230601133012040

如果需要再SDS后追加一段新的字符串“xxx”,首先会申请新内存空间(分为两种情况):

  • 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1。
  • 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1(内存预分配)。

所以,SDS的优点:

  1. 获取字符串长度的时间复杂度为O(1)。
  2. 支持动态扩容。
  3. 减少内存分配机制。
  4. 二进制安全。

IntSet

IntSet是Redis中Set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征(适用于数据量不是很大的情况)。结构如下图:

image-20230601140115564

其中的encoding包含三种模式,表示存储的整数大小不同

image-20230601140333443

为了方便查找,Redis会将intset中所有的整数按照升序依次保存在如下图的contents数组中

image-20230601140758164

数组中每个数字都在int16_t(16字节)的范围内,因此采用的编码方式为INTSET_ENC_INT16,每部分占用的字节大小为:

  • encoding:4字节
  • length:4字节
  • conrents:6字节(2字节 * 3)

image-20230601141340438

上图黑色方框中的公式为寻址公式

自动升级

现在假设一个IntSet元素为{5,15,20},此时采用的编码是INTSET_ENC_INT16,则每个整数占两个字节

image-20230601142145794

此时向其中添加一个数字为50000,这个数字已经超出int16_t的范围,IntSet会自动升级编码方式到合适的大小。

升级的过程为:

  1. 升级编码为INTSET_ENC_INT32,每个整数占4个字节,并按照新的编码方式及元素个数扩容数组。
  2. 倒序依次将数组中的元素拷贝到扩容后的正确位置。
  3. 将待添加的元素放入数组的末尾。
  4. 将IntSet的encoding属性给为INTSET_ENC_INT32,并将length属性改为4。

image-20230601142355472

image-20230601142634502

IntSet可以看做是特殊的整数数组,具备一些特点:

① Redis会确保IntSet中的元素唯一、有序。

② 具备类型升级机制,可以节省内存空间。

③ 底层采用二分查找方式来查询(查询速度更快)。


Dict

Redis是一个键值型 (Key-Value Pair) 的数据库,可以根据键来实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。

Dict是由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)。

image-20230601144901163

当需要向Dict添加键值对时,Redis首先会根据key1计算出hash值(h),然后利用h & sizemark来计算元素应该存储到数组中的哪个索引位置。

当添加的键值对的节点值相同时,则会形成一个链表,采用头插法的形式放入数据,所以新元素始终在前面(如下图)。

image-20230601150554938

image-20230601150808660

dict结构如图所示

image-20230601151156736

Dict的扩容

Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。

Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size),满足以下两种情况会触发哈希扩容

  • 哈希表的LoadFactor>=1时,并且服务器没有执行BGSAVE或者BGREWRITEAOF等后台进程。
  • 哈希表的LoadFactor>5时。

image-20230601152252834

Dict的收缩

Dict在每次删除元素的时候,也会对负载因子进行检查,当LoadFactor < 0.1时,会做哈希表收缩。

image-20230601152812221

Dict的rehash

无论是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,该过程称为rehash,具体实现过程如下:

image-20230601154600314

  1. 计算新hash表的realSize,值取决于当前要做的是什么操作(扩容还是收缩):
    • 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1 的2 n ^n n
    • 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2 n ^n n(不得小于4)
  2. 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]。

image-20230601163924520

  1. 设置dict.rehashidx=0,标示开始rehash。

image-20230601164041241

  1. 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]。

image-20230601164146084

  1. 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存。

image-20230601164259341

当然,Dict的rehash并不是一次性完成的。如果Dict中包含百万数量级以上的entry,要在一次rehash完成,极有可能会导致主线程阻塞。因此Dict的rehash是分多次、渐进式的完成,因此称为渐进式rehash。流程如下(只有步骤4、6、7不同):

  1. 计算新hash表的realSize,值取决于当前要做的是什么操作(扩容还是收缩):
    • 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1 的2 n ^n n
    • 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2 n ^n n(不得小于4)
  2. 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]。
  3. 设置dict.rehashidx=0,标示开始rehash。
  4. 每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[i],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]
  5. 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存。
  6. 将rehashidx赋值为-1,代表rehash结束
  7. 在rehash过程中,新增操作,则直接写入ht[1],查询、修改、删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只增不减,随着rehash最终为空

ZipList

ZipList是一种特殊的“双端链表”,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作,并且该操作的时间复杂度为O(1)。

image-20230601164859345

属性类型长度用途
zlbytesuint32_t4字节记录整个压缩列表占用的内存字节数
zltailuint32_t4字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。
zllenuint16_t2字节记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的 真实数量需要遍历整个压缩列表才能计算得出。
entry列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlenduint8_t1字节特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

ZipListEntry

ZipList中的Entry并不像普通链表那种记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了如下结构

image-20230601165802846

  • uprevious_entry_length:前一节点的长度,占1个或5个字节。
    • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值。
    • 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据。
  • encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节。
  • contents:负责保存节点的数据,可以是字符串或整数。

Encoding

ZipListEntry中的encoding编码分为字符串和整数两种:

  • 字符串:如果encoding是以“00”、“01”或者“10”开头,则证明content是字符串。
编码编码长度字符串大小
|00pppppp|1 bytes<= 63 bytes
|01pppppp|qqqqqqqq|2 bytes<= 16383 bytes
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt|5 bytes<= 4294967295 bytes

例如,保存字符串“ab”和“bc”

image-20230601172011764

  • 整数:如果encoding是以“11”开始,则证明content是整数,且encoding固定只占用1个字节。
编码编码长度整数类型
110000001int16_t(2 bytes)
110100001int32_t(4 bytes)
111000001int64_t(8 bytes)
11110000124位有符整数(3 bytes)
1111111018位有符整数(1 bytes)
1111xxxx1直接在xxxx位置保存数值,范围从0001~1101,减1后结果为实际值

例如,保存整数值“2”和“5”

image-20230601173209177


连锁更新问题

ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:

  • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值

  • 如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据

现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:

image-20230601173954633

ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。


特性

ZipList的特性:

①压缩列表的可以看做一种连续内存空间的"双向链表"(并没有使用指针,只是意义上的双向链表)。

②列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低。

③如果列表数据过多,导致链表过长,可能影响查询性能。

④增或删较大数据时有可能发生连续更新问题。


QuickList

问题引入

问题一:ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低该怎么办?

  • 为了缓解这个问题,必须限制ZipList的长度和entry的大小。

问题二:但是需要存储大量数据,超出了ZipList最佳的上限该怎么办?

  • 可以创建多个ZipList来分片存储数据。

问题三:数据拆分后比较分散,不方便管理和查找,这些多个ZipList应该如何建立联系?

  • 可以使用Redis在3.2版本引入的新的数据结构QuickList,它是一个双端链表,链表的每一个节点都是一个ZipList。

为了避免QuickList中的每个ZipList中的entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。

  • 如果值为正,则代表ZipList的允许的entry个数的最大值。
  • 如果值为负,则代表ZipList的最大内存大小分为下面五种情况:
    1. -1:每个ZipList的内存占用不能超过4kb。
    2. -2:每个ZipList的内存占用不能超过8kb。(默认值)
    3. -3:每个ZipList的内存占用不能超过16kb。
    4. -4:每个ZipList的内存占用不能超过32kb。
    5. -5:每个ZipList的内存占用不能超过64kb。

除了控制ZipList的大小,QuickList还可以对节点的ZipList做压缩。通过配置项list-compress-depth来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。这个参数是控制首尾不压缩的节点个数:

  • 0:特殊值,代表不压缩。
  • 1:标示QuickList的首尾各有1个节点不压缩,中间节点压缩。
  • 2:标示QuickList的首尾各有2个节点不压缩,中间节点压缩。

以下是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;

结构图如下

image-20230601185720214


SkipList

SkipList(跳表),单一链表有几点差异:

  • 元素按照升序排列存储。
  • 节点肯能包含多个指针,指针跨度不同。

image-20230601190445669

源码:

// 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;

内存结构图:

image-20230601191359979

特点

SkipList的特点:

  • 跳跃表是一个双向链表,每个节点都包含score和ele值。
  • 节点按照score值排序,score值一样则按照ele字典排序。
  • 每个节点都可以包含多层指针,层数是1到32之间的随机数。
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大。
  • 增删改查效率与红黑树基本一致,实现却更简单。

RedisObject

Redis中的任意数据类型的键和值都会被封装成为一个RedisObject,也称为Redis对象。

image-20230601192611121

编码方式

Redis根据存储的数据类型不同,选择不同的编码方式。

编号编码方式说明
0OBJ_ENCODING_RAWraw编码动态字符串
1OBJ_ENCODING_INTlong类型的整数的字符串
2OBJ_ENCODING_HThash表(字典dict)
3OBJ_ENCODING_ZIPMAP已废弃
4OBJ_ENCODING_LINKEDLIST双端链表
5OBJ_ENCODING_ZIPLIST压缩列表
6OBJ_ENCODING_INTSET整数集合
7OBJ_ENCODING_SKIPLIST跳表
8OBJ_ENCODING_EMBSTRembstr的动态字符串
9OBJ_ENCODING_QUICKLIST快速列表
10OBJ_ENCODING_STREAMStream流

每个数据类型的使用的编码方式

数据类型编码方式
OBJ_STRINGint、embstr、raw
OBJ_LISTLinkedList和ZipList(3.2以前)、QuickList(3.2以后)
OBJ_SETintset、HT
OBJ_ZSETZipList、HT、SkipList
OBJ_HASHZipList、HT

五种数据类型

Redis中包含的数据类型一共有五种:String、List、Set、ZSet(SortedSet)、Hash。

String

String是Redis最常见的数据存储类型,其基本编码方式是RAW,基于简单动态字符串(SDS)实现,存储上限为512MB。

image-20230602091307493

如果存储的SDS的长度小于44字节,则会采用EMBSTR编码,此时object head 与SDS是一段连续的空间,申请内存时只需要调用一次内存分配函数,效率更高。

image-20230602091414907

如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了。

image-20230602092029980


List

Redis的List类型可以从首、尾操作列表中的元素。哪一种数据结构能满足这个特征呢?

  • LinkedList:普通链表,可以双端访问,但内存占用较高,内存碎片较多。
  • ZipList:压缩列表,可以从双端访问,内存占用低,存储上限也低。
  • QuickList:LinkedList + ZipList,可以从双端访问,内存占用较低,包含多个ZipList,存储上限高。

Redis的List结构类似于一个双端链表/

  • 在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码。
  • 在3.2版本之后,Redis统一采用QuickList来实现List。

image-20230602094235915


Set

Set是Redis中的单列集合,满足下列特点:

  1. 元素之间不保证有序性。
  2. 保证元素唯一性(用作判断元素是否存在)。
  3. 求交集、并集、差集。

Set是Redis中的集合,不一定确保元素有序,可以满足元素唯一、查询效率要求极高。

  • 为了查询效率和唯一性,set采用HT编码(Dict)。Dict中的key用来存储元素,value统一为null。

image-20230602101006367

  • 当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,Set会采用IntSet编码,以节省内存。

image-20230602100915306

源码

image-20230602100558948

image-20230602100431424


ZSet

ZSet即SortedSet,其中每一个元素都需要指定一个score值和member值(必须唯一)。ZSet可以根据score值排序,根据member查询分数。

因此,ZSet底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求:

  • SkipList:可以排序,并且可以同时存储score和ele值(member)。
  • HT(Dict):可以键值存储,并且可以根据key找value。

源码

image-20230602110539767

所以ZSet使用的是SkipList和HT两种数据结构结合的方式来进行实现,功能非常强大,性能非常好(下图为其内存结构图)。

image-20230602105211324

但通过上图可以发现,ZSet也存在一些问题,如:内存消耗过大(两种数据结构一起使用,大量使用链表结构),面临同种数据重复存储的风险。

所以针对上述情况,ZSet还有第二种存储方式:

即当元素数量不多时,HT和SkipList的优势不明显,并且内存消耗很大。因此ZSet会采用ZipList结构来节省内存。但使用ZipList需要同时满足两个前提:

  1. 元素数量小于zset_max_ziplist_entries(默认值为128)。
  2. 每个元素都小于zset_max_ziplist_value字节(默认值为64)。

ziplist本身没有排序功能,而且没有键值对的概念,因此需要ZSet通过业务逻辑编码实现:

  • ZipList是连续内存,因此score和element是紧挨在一起的两个entry,element在前,score在后。
  • score越小越接近队首,score越大越接近队尾,按照score值升序排列。

image-20230602113643811

image-20230602113657026

内存结构图:

image-20230602113738058


Hash

Hash结构与Redis中的Zset非常类似:

  • 都是键值存储
  • 都需求根据键获取值
  • 键必须唯一

区别如下:

  • ZSet的键是member,值是score;Hash的键和值都是任意值
  • ZSet要根据score排序;Hash则无需排序

因此,Hash底层采用的编码与ZSet也基本一致,只需要将排序有关的SkipList去掉即可:

image-20230602114804665

  • Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value

  • image-20230602115354341

  • 当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:

image-20230602115408577

① ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)。

② ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)。

源码:

image-20230602143904631

image-20230602143915896

image-20230602144949556

image-20230602145019358

image-20230602143923484

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值