为什么说embstr效率高于raw embstr和raw区别_为什么说embstr效率高于raw


预先知识

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位,新数组的存储格式总共应占用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)。