预先知识
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{
//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 prelen;
// 保存了content的编码类型
int 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,反之则用hashTable
1:哈希对象中的所有键值对的键值都要小于64字节。
2:哈希对象中的键值对数量要小于等于512个。
具体可以更具配置文件中设置。
zset-集合对象
set特点:跟插入元素的顺序没有关系,是无序的,并且元素不重复,
底层数据类型存在两种:intset和hashtable。
intset
encoding对应三种编码格式,比如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,固旧数组的存储格式总共占用164=64位。
确认新的存储格式
触发整型升级为INT32,固每个元素应该占用32位,新数组的存储格式总共应占用325=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)。