Redis面试题(一):常见的底层结构
欢迎关注我的公众号【意姆斯Talk】来聊聊Java面试,对线面试官系列持续更新中,因为图片不兼容,建议去原文地址观看。
预先知识
1:Redis的数据都是存在内存中的。
2:Redis是以键值对的形式存储数据,键只能是字符串对象,而值对应着五种常见的数据结构:string,list,hash,set,sorted-set。
3:Redis支持主从同步,哨兵模式,Redis集群来保证高可用。
4:Redis支持持久化技术,删除策略,Lua脚本,事务等功能。
图中的RedisObject,是五种数据类型对应的底层结构,
type是五种常见的数据类型。
encoding是常见的数据类型的底层实现,其中string的encoding分为int,embstr,raw,list的encoding分为zipList,linkedList,hash的encoding分为zipList,hashtable,set的encoding分为zipList,hashtable,sorted-set的encoding分为zipList,skipedList。
refcount是当前对象被引用的次数。
Lru是最近访问的时间,可用于Redis的淘汰策略。
ptr指向value值,字符串就是字符串格式,链表就是链表格式等。其实每个数据类型的底层都是嵌套了字符串对象。
typedef struct redisObject {// 总空间: 4 bit + 4 bit + 24 bit + 4 byte + 8 byte = 16 byte unsigned type:4; // 分别存储五种常用的数据类型,String,List,Set,Hash,Zset unsigned encoding:4; // 更细分,存储上面的编码方式 unsigned lru:LRU_BITS; // lru时间, 用于redis的淘汰机制的 int refcount; // 共享对象,被引用了多少次 void *ptr; // 指向sds地址,sds分多个结构体} robj;
String-字符串对象
String数据类型的底层分为:int,embstr,raw三种encoding。
set key value.
当value保存的是整数值,那么字符串对象会将整数值直接保存在redisObject中的ptr属性中,直接转换成long,并将encoding的编码设置为Int。
set user 001;redisObject{ type:string; encoding:int; ptr->001:}
sds{ //记录字节数组中未使用的空格数。 int free; //字符串长度 int len; //真正存储字符串地方:C语言是用字节数组标识字符串 char[] buff;}
当value保存的是字符串并且长度小于超过39字节,则采用embraw,若大于39字节,则用raw格式的encoding。但raw格式和embraw格式有不同点,是raw格式会调用两次内存分配函数来创建redisObject和ptr的结构,而embraw会调用一次内存分配函数同时创建redisObject和ptr的结构,如下图:
embstr:ptr和sds的内存地址是连续的,raw:ptr和sds不是连续的。
embstr和raw的区别:1:embstr编码的内存分配次数为1次,而raw需要两次内存分配。2:embstr释放空间只释放1次,raw释放2次。3:只要embstr的值被修改过,总会升级成raw格式。SDS在源码中也分为多种结构体,具体详情可以看源码熟悉以下结构体
sdshdr5 ,sdshdr8,sdshdr16,sdshdr32,sdshdr64
SDS字符串和C字符串的区别是什么?相同点 都是以'\0'结尾的字符数组。不同点
- SDS字符串获取字符串长度的时间复杂度为O(1),而C字符串是O(n)。
- SDS字符串可防止缓冲区溢出,用空间预分配和惰性空间释放技术来减少内存重分配次数,保证内存重分配最多是N次,而C字符串最少是N次。
- SDS可以存储二进制安全,文本的数据,C字符串只能存储文本数据。同时SDS是以len字段来判断字符串是否结尾,而不是用\0来判断。这也说明了SDS字符串可以有多个\0, 而C语言的\0只标识结尾。
List-列表对象list数据类型的底层分为:zipList,linkedList,新版本有quickList。
quickList是zipList和linkedList的结合。
zipList图
typedf struct ziplist<T>{ //zipList列表中占用的字节总数, ziplist占用大小. int32 zlbytes; //最后一个entry元素距离起始位置的偏移量,用于快速定位最后一个节点, 从而可以在尾部进行pop或push int32 zltail_offset; //元素个数, entries的数组大小 int16 zllength; //元素内容, T[] entries; //结束位, zipList中最后一个字节, 是一个结束标记,一般是225字节 int8 zlend;}ziplist
//entry的结构typede struct entry{ //前一个entry的长度, 这样就可以根据倒序遍历定位到前一个entry的位置, 因为知道了 int<var> prelen; // 保存了content的编码类型 int<var> encoding; // 元素内容 optional byte[] content;}entry
linkedList图
//链表typedef struct list { //头指针, 指向头一个节点 listNode *head; //尾指针, 指向尾部的一个节点 listNode *tail; //节点拷贝函数, 用于链表转移复制时,对节点value拷贝的一个实现 void *(*dup)(void *ptr); //释放节点函数,释放一个节点所占用的内存空间 void (*free)(void *ptr); //判断两个节点是否是相等的函数 int (*match)(void *ptr, void *key); //链表长度 unsigned long len;} list;
//链表中的节点的数据结构typedef struct listNode { //前指针 struct listNode *prev; //后指针 struct listNode *next; //当前值 void *value;} listNode;
zipList和linkedList的区别是什么?内存:zipList是连续的内存地址,entrys都是连续的,没有前后指针,因为指针也是占用字节的。而linkedList每一个节点的内存都是单独分配的,然后通过指针来建联。
zipList列表俗称压缩列表,顾名思义最大的特点是节约内存。当使用列表对象时,必须满足以下两个条件,反之则升级为linkedList:1:列表对象保存的所有字符串元素的长度都小于64字节。(有一个大于64字节都不行)2:列表对象中的元素个数要小于等于512个。(多一个都不行)。具体可以更具配置文件中设置。
hash-哈希对象
hash数据类型的底层为:zipList,hashtable其实这一块我当时有一个疑问,hash的encoding为zipList的时候是如何存储数据的?答:虽然是hash结构,但不能用惯性思维以为存储的就是hash结构!如果hash结构的encoing为zipList,则会将hash的key和value同时作为连续的两个entry存储。保证键在前,值在后,并且是连续的。
新增加的键值对会优先放在离表头近的地方。
hashtable的底层就是一个dict(字典),整个dict有两个hashTable,下图为没有rehash状态的字段字典,一个用来存储数据的,一个为空, 如图
//这是hashTable的结构. dict有两个hashTable,// 目的为了实现渐进式rehash,将旧表换成新表 typedef struct dictht { dictEntry **table; //存储数据的二维数组, unsigned long size; // hashtable容量 unsigned long sizemask; // size -1 unsigned long used; // hashtable 元素个数 , 已有的节点数量} dictht;
typedef struct dictEntry { void *key; union { void *val; uint64_t u64; int64_t s64; double d; } v; struct dictEntry *next;} dictEntry;
hash最重要的两个特征:扩容和解决冲突。扩容:分为扩容和缩容,两个操作都跟负载因子有关系
负载因子=已有节点数(used)/哈希表大小(size)。没有执行bgsave和bgrewriteaof命令时,当负载因子大于等于1时,则扩容。
当执行bgsave和ghrewriteaof命令时,负载因子大于等于5时,则扩容。
当负载因子小于等于0.1时,缩容。
扩容期间,写操作无法避免,提高负载因子的目的是为了尽量减少写操作,减少不必要的内存消耗。
渐进式rehash的步骤:
1:假设当前数据都在ht[0]上,在进行扩容时,先要给ht[1]分配空间,空间跟ht[0]中已存在的节点数成幂函数。
2:字典中的rehashIdx为0,标识hash开始。
3:只有在主动触发crud时,才进行rehash过程,每次对数据进行crud时,会将ht[0]在rehashIdx索引上的所有键值对都迁移到新ht[1]上,当迁移完成时,rehashIdx自增+1,触发curd,又会循环进行3操作。
4:迁移完成后,释放老ht[0],将ht[1]设置为主存储hashtable,同时rehashIdx设置为-1.
冲突:采用拉链法解决hash冲突,新来的节点采用的都是头插法。
当使用hash对象,必须满足一下条件,才会用zipList,反之则用hashTable1:哈希对象中的所有键值对的键值都要小于64字节。2:哈希对象中的键值对数量要小于等于512个。具体可以更具配置文件中设置。
zset-集合对象
set特点:跟插入元素的顺序没有关系,是无序的,并且元素不重复,底层数据类型存在两种:intset和hashtable。
intsetencoding对应三种编码格式,比如16位,32位,64位。length:集合中元素数量
contents:真正存储元素的地方,数组是按照从小到大有序排列的,并且不包含重复项。
//整数集合结构体typedef struct intset { uint32_t encoding; //编码格式,有如下三种格式,初始值默认为INTSET_ENC_INT16 uint32_t length; //集合元素数量 int8_t contents[]; //保存元素的数组,元素类型并不一定是ini8_t类型,柔性数组不占intset结构体大小,并且数组中的元素从小到大排列。} intset;
#define INTSET_ENC_INT16 (sizeof(int16_t)) //16位,2个字节,表示范围-32,768~32,767
#define INTSET_ENC_INT32 (sizeof(int32_t)) //32位,4个字节,表示范围-2,147,483,648~2,147,483,647
#define INTSET_ENC_INT64 (sizeof(int64_t)) //64位,8个字节,表示范围-9,223,372,036,854,775,808~9,223,372,036,854,775,807
intset存在升级过程,当存储的元素都是-32768~32767之间的元素时,encoding默认使用INT16存储,当插入40000这个元素时,会触发集合升级。
只要超过当前encoing的范围内就会升级,并且一旦升级是不会降级的
具体流程如下:1:根据新元素的类型,扩展intset整数集合的底层数组大小,并为新元素分配空间。2:将现有的encoding从INT16升级成INT32,同时给数组中的元素分配新空间,但要保证数组的有序性是不会发生变化的。3:最后新增的元素添加到对应的制定数组位置上。图解如下
假设现在redisObject的encoing位intSet,同时intSet整数集合的底层使用的是INT16位来存储整型数组元素,当新来的元素位40000时,会触发整型升级。
- 了解旧的存储格式
- 数组中的每个元素占用16位,同时数组的长度为4,固旧数组的存储格式总共占用16*4=64位。
- 确认新的存储格式
- 触发整型升级为INT32,固每个元素应该占用32位,新数组的存储格式总共应占用32*5=160位,
- 根据新的存储格式分配内存空间
- 旧数组的存储格式是占用64位的,新数组的存储格式占用160位。
- 从后到前,依此修改空间地址,先修改元素位4的,分配新空间
- 再为3分配新空间,同时移动空间地址。
- 再为2分配新空间,同时移动空间地址
- 再为1分配新空间,同时移动空间地址。
为什么整型集合升级时,是倒序分配空间的?
- 这样可以避免覆盖地址问题,从后新增空间,并不会影响原有的位置,同时encoding分为INT16,INT32,INT64的目的是为了解决空间,只有在需要升级的时,才升级。升级操作的时间复杂度为O(n)
- 在使用集合对象时,要保证存储的是整数即可。如果有字符串,则会使用hashtable结构,在查找元素的时候是通过二分查找查询元素。
sorted-set-有序集合对象简称z-set,底层的encoding分为两种:zipList和skipedList
zipList:满足以下两个条件,不满足则用跳表
1:[sore,value]键值对数量少于128个。2:每个元素的长度小于64字节。前期用zipList的好处是:
连续内存地址,可以省内存资源,但节点数量一旦多起来,会带来复制成本,降低性能,同时查找元素的时间复杂度为O(n),需要从头到尾遍历,查找效率低。
skipedList:由跳表跟hashtable一起组成层:即下图中的level高度,同级别的层指向下一个跳表节点同级别的层,比如L4就指向下一个链表结点的L4,中间是通过前进指针指向的,距离叫做跨度,如果跨度为0,则说明当前跳表结点的前进指针为null,跨度越大,说明距离越远。
后退指针BW:从表尾向表头访问结点,访问前一个链表结点,当后退指针为null,则说明从尾到头遍历结束,有点前驱结点的味道。
分值:所有节点的分值都是从小到大排序的,分值可以有相同的,相同则根据字典序进行排序。
成员:成员都是一个指向字符串对象的指针,必须是唯一的。
//sorted-set的结构体typedef struct zset { dict *dict; zskiplist *zsl;} zset;
typedef struct zskiplist { //header指向跳跃表的表头节点,tail指向跳跃表的表尾节点 struct zskiplistNode *header, *tail; //记录跳跃表的长度(表头节点不计算在内) unsigned long length; //记录跳表中最大的高度(表头结点不计算在内) int level;} zskiplist;
typedef struct zskipListNode{ //后退指针:当前节点的前驱结点 struct zskiplistNode *backward; //分值 double score; //成员对象 robj * obj; //层 struct zskiplistLevel{ //前进指针:当前层的后继节点 struct zskipListNode *forward; //跨度 int span; };}
跳表的CRUD的时间复杂度是O(logN),查询效率高,但占用空间比较大。
为什么Redis用跳表而不用平衡二叉树?
-
平衡树新增和删除, 可能会引起左旋和右旋, 而skipList只需要改变前后指针, 操作起来比较容易.
-
在平衡树找到指定的范围后, 还需要通过中序遍历继续寻找其他不超过这个值的节点, 但对于跳表而言, 到了指定的范围, 只需要遍历就可以拿到, 有点唯一索引和普通索引的味道, 但是平衡树和跳表的时间复杂度都为O(logN)。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构