当前位置: 首页 > news >正文

Redis——底层数据结构

SDS(simple dynamic string):

优点:
  1. O1时间获取长度(char *需要ON)
  2. 快速计算剩余空间(alloc-len),拼接时根据所需空间自动扩容避免缓存区溢出(char *容易溢出)
  3. 使用字节数组存储二进制数据,可存储图片、视频等数据,且不会因为特殊字符“\0”意外中断字符串(char *默认末尾存储“\0”,可能导致意外中断)
  4. flags:分为sdshdr5、sdshdr8、sdshdr16、sdshdr32和sdshdr64,用于定义len和alloc的类型,统一为uint16_t(对应sdshdr16)或uint32_t,灵活保存不同大小的字符串,节省内存空间。同时使用了编译优化,取消字节对齐式的内存分配(浪费多余内存),使用紧凑方式的内存分配

双向链表:

原双向链表结构:
typedef struct listNode {//前置节点struct listNode *prev;//后置节点struct listNode *next;//节点的值void *value;
} listNode;
二次包装:
typedef struct list {//链表头节点listNode *head;//链表尾节点listNode *tail;//节点值复制函数void *(*dup)(void *ptr);//节点值释放函数void (*free)(void *ptr);//节点值比较函数int (*match)(void *ptr, void *key);//链表节点数量unsigned long len;
} list;
优点:
  1. 双向链表,获取某个节点的前后节点都只需要O1时间
  2. 增加头尾指针,快速定位表头表尾
  3. 保存了链表长度,O1时间获取链表大小
  4. 函数和值都使用void*指针,可以指向任意类型数据,因此链表节点可以保存任意不同类型的值
缺陷:

内存不连续,无法利用CPU缓存,每个节点都是一个结构体,内存开销较大。

哈希表:

概述:

Redis采用了链式哈希(拉链法)来解决哈希冲突,这与Java中的hashMap是相似的

哈希表的底层其实是数组,通过计算对象的哈希值对数组长度取余来确定索引位置,可以在O1的时间获取数据

哈希表结构:
typedef struct dictht {//哈希表数组dictEntry **table;//哈希表大小unsigned long size;  //哈希表大小掩码,用于计算索引值unsigned long sizemask;//该哈希表已有的节点数量unsigned long used;
} dictht;
dictEntry是哈希数组,数组的每个元素是指向一个哈希表节点(dictEntry)的指针
哈希表节点结构:
typedef struct dictEntry {//键值对中的键void *key;//键值对中的值union {void *val;uint64_t u64;int64_t s64;double d;} v;//指向下一个哈希表节点,形成链表struct dictEntry *next;
} dictEntry;
特色:

dictEntry结构中的值实际上是一个联合体,可以存储浮点数,无符号或有符合整数,然后是对应值的地址。这样做的好处是可以节省内存空间,因为简单的数据类型可以直接存储值而无需再存放地址

rehash(本质上类似于数组的自动扩容):

拉链法解决哈希冲突有局限性,链表过长时查询性能就会下降。实际Redis使用哈希表时,定义了一个dict结构体,并在里面定义了两个哈希表

typedef struct dict {…//两个Hash表,交替使用,用于rehash操作dictht ht[2]; …
} dict;

正常服务时,插入数据都会写入到哈希表1中,哈希表2此时没有分配空间

随着数据增多,触发rehash操作
  • 给哈希表2分配空间,一般是哈希表1的两倍大小
  • 哈希表1数据迁移到表2
  • 释放表1空间,并将哈希表2设为表1,并创建新的空白表2,为下次rehash做准备

迁移过程中,若表1的数据量非常大,可能会涉及大量的数据拷贝,进而导致Redis阻塞。

因此Redis采用了渐进式rehash
  • 给表2分配空间
  • rehash期间,每次哈希表元素进行查找或更新时,Redis除了进行这些操作外,还会顺序将表1中该索引位置上的所有key-value迁移到表2上。
  • 随着客户端发起的操作请求数量变多,最终某个时间点表1的节点会全部迁移到表2中,变成空表。
  • 且rehash期间查找操作会先到表1查找再到表2查找。另外,新增操作直接在哈希表2中进行,保证表1的键值对数量只会减少
rehash的触发条件:

  • 当负载因子大于等于1,且没有进行bgsave或bgrewiteaof命令时(也就是没有执行RDB快照或AOF重写时),就会进行rehash操作
  • 当负载因子大于等于5时,说明哈希冲突已经非常严重了,无论有没有再进行RDB快照或AOF重写,都会强制进行rehash操作

整数集合

整数集合是set对象的底层实现,若一个set对象只包含整数且数量不大时,就会使用整数集合。

结构设计:

本质上是一块连续内存空间,定义如下:

typedef struct intset {uint32_t encoding;  // 编码类型(INTSET_ENC_INT16/32/64)uint32_t length;    // 元素数量int8_t contents[];  // 实际类型由 encoding 决定
} intset;

保存元素的容器是类型为int8_t的contents数组,但实际上,int8_t只是占位符,数组保存的值的类型是encoding的属性值,不同类型的contents数组大小也不同。

  • 若encoding属性值是INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组
  • 若encoding属性值是INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组
  • 若encoding属性值是INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组
升级操作:

添加到整数集合的新元素类型比现有元素类型都要长时,整数集合会进行升级:

新的类型扩展contents数组的空间大小

将新元素加入到整数集合里

升级过程中维持集合的有序性

特点:

动态升级可按需存储数据,节省内存

不支持降级操作,会一直保持升级后的状态

跳表:

只有Zset对象底层用到了跳表,支持平均OlogN的节点查找

zset有两个数据结构:跳表和哈希表既能进行高效范围查询,又能进行高效单点查询

typedef struct zset {dict *dict;zskiplist *zsl;
} zset;
结构设计:

在链表基础上改造为多层的有序链表

层级为3的跳表示例:

例如我们要查找节点4的数据,使用链表需要遍历四次,但使用跳表只需要遍历两次,查找过程就是在不同层级上根据元素的权重进行遍历直到定位数据

跳表节点结构:
typedef struct zskiplistNode {//Zset 对象的元素值sds ele;//元素权重值double score;//后向指针struct zskiplistNode *backward;//节点的level数组,保存每层上的前向指针和跨度struct zskiplistLevel {struct zskiplistNode *forward;unsigned long span;} level[];
} zskiplistNode;
跳表底层结构:
typedef struct zskiplist {//跳表的头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点struct zskiplistNode *header, *tail;//跳表长度,便于在O(1)时间复杂度获取跳表节点的数量unsigned long length;//跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量int level;
} zskiplist;
查询过程:
  • 如果当前节点的权重小于要查找的权重,访问该层下一个节点
  • 如果当前节点的权重等于要查找的权重,比较对应的SDS类型数据,若小于则访问该层下一个节点 
  • 上面两个条件都不满足,或下一个节点为空则沿着当前节点的下一层指针继续查找
查询过程示例:

如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的:

  • 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;
  • 但是该层的下一个节点是空节点( leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];
  • 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];
  • 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束
跳表节点层数设置:

跳表相邻节点数量最理想的比例是2:1,查找复杂度可以降低到OlogN

维持手段:

  • 采用新增或删除节点来维持比例会带来额外开销,所以Redis使用概率生成来决定每个节点的层数
  • 跳表创建节点时,会生成0到1的随机数,若随机数小于0.25,则该节点层数增加一层,然后继续生成随机数,直到结果大于0.25结束
  • 层数越高,概率越低,层高大小限制为64,创建跳表时,会根据层高限制创建所有头节点
为什么使用跳表而不用平衡树?
  • 内存占用上:跳表使用的内存更少。平衡树每个节点包含两个指针,而跳表平均只有1.33个指针(概率为25%的情况下)
  • 范围查找时:跳表操作更简单。平衡树找到指定范围的小值后,还需要中序遍历寻找其他不超过大值的节点。而跳表只需要找到最小值后对第1层链表进行遍历即可。
  • 算法实现难度上:跳表实现更加简单。平衡树的插入删除操作可能引发子树的调整。而跳表只需要修改相邻节点的指针,与链表类似

压缩列表:

结构设计:

连续内存块组成的顺序数组结构,类似于数组。Redis7.0后被完全废除

时间复杂度为On

四个额外字段

  • zlbytes:记录压缩列表占用的内存字节数
  • zltail:记录压缩列表尾部节点的偏移量
  • zllen:记录压缩列表包含的节点数量
  • zlend:位于表尾,标记结束点,固定值0xFF(255)​

每个键或值都作为一个独立的 entry 节点存储,节点结构包含:

  • prevlen:记录前一个节点的长度(用于逆向遍历)。
  • encoding:标识当前节点的数据类型字符串或整数及长度
  • content:存储实际的键或值数据 

插入数据时,根据数据结构和类型进行不同空间大小的分配,节省内存

连锁更新:

新增或修改元素时,若空间不足,压缩列表占用的内存空间就需要重新分配插入元素较大时,可能会导致后续元素的prevlen占用空间全部发生变化,从而引起连锁更新(后面元素的prevlen的记录量本来为1字节,但新元素大小超过了254字节,需要用5字节来存储,而当前元素扩容后,后续的元素也要相应扩容,直到所有元素扩容完成)。

缺陷:

虽然压缩列表紧凑型的内存布局能节省内存开销,但元素过大会导致内存重新分配甚至连锁更新影响压缩列表的访问性能,所以压缩列表只适合保存节点数量不多的场景。

后续引入的quicklist类型和listpack类型就是在保持节省内存的优势的同时解决压缩列表的连锁更新的问题

quicklist:

3.0前,List对象底层是双向链表或压缩链表。后来底层改为quicklist实现。quicklist实质上就是双向链表+压缩链表的组合:quicklist是一个双向链表,节点存储的是压缩列表。

解决连锁更新的办法:

quicklist通过控制每个节点中压缩列表的大小或者元素个数来规避连锁更新的问题。因为压缩列表元素越少越小,连锁更新带来的影响就越小。

结构设计:
typedef struct quicklist {//quicklist的链表头quicklistNode *head;      //quicklist的链表头//quicklist的链表尾quicklistNode *tail; //所有压缩列表中的总元素个数unsigned long count;//quicklistNodes的个数unsigned long len;       ...
} quicklist;
节点结构设计:
typedef struct quicklistNode {//前一个quicklistNodestruct quicklistNode *prev;     //前一个quicklistNode//下一个quicklistNodestruct quicklistNode *next;     //后一个quicklistNode//quicklistNode指向的压缩列表unsigned char *zl;              //压缩列表的的字节大小unsigned int sz;                //压缩列表的元素个数unsigned int count : 16;        //ziplist中的元素个数 ....
} quicklistNode;

添加元素时,会先检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到该压缩列表,否则才新增节点和压缩列表

本质上连锁更新是没有解决的,只是通过控制压缩列表的大小来尽量减少连锁更新的性能损耗

listpack:

采用了很多压缩列表的优秀设计,例如还是用连续空间来紧凑存储数据,并且为节省内存开销,listpack节点采用不同的编码方式保存不同大小的数据

结构设计:

节点结构:

  • backlen:encoding+data的总长度
  • encoding:该元素的编码类型,对不同长度的整数和字符串进行编码
  • data:实际存放的数据

可以发现,listpack移除了prevlen字段,只记录当前节点的长度,当我们向listpack加入新元素时,不会影响其他节点的内存空间,从而避免了压缩列表的连锁更新问题。

相关文章:

  • MySQL 第四讲---基础篇 数据类型
  • SRS流媒体服务器(5)源码分析之RTMP握手
  • 关于 TCP 端口 445 的用途以及如何在 Windows 10 或 11 上禁用它
  • 课设:基于swin_transformer的植物中草药分类识别系统(包含数据集+UI界面+系统代码)
  • 基于51单片机和8X8点阵屏、矩阵按键的记忆类小游戏
  • Windows系统功能管控指南 | 一键隐藏关机键/禁用任务管理器
  • 二层交换机、三层交换机与路由器三者的详细对比
  • 一文讲透面向对象编程OOP特点及应用场景
  • 高压单端探头共模干扰问题分析及应对措施
  • java -jar命令运行 jar包时如何运行外部依赖jar包
  • 物联网中的WiFi模式解析:AP、STA与混合模式
  • 电平匹配电路
  • Flink运维要点
  • Python字符串常用内置函数详解
  • 车道线检测----Lane-ATT
  • 在vue3中使用Cesium的保姆教程
  • C# NX二次开发-实体离散成点
  • 5G-A和未来6G技术下的操作系统与移动设备变革:云端化与轻量化的发展趋势
  • Qwen3技术报告
  • 【Opencv】canny边缘检测提取中心坐标
  • 东部沿海大省浙江,为何盯上内河航运?
  • 尹锡悦宣布退出国民力量党
  • 中国恒大披露清盘进展:要求债权人提交债权证明表
  • 音乐节困于流量
  • 陕西宁强县委书记李宽任汉中市副市长
  • 第十二届警博会在京开幕:12个国家和地区835家企业参展