redis五大数据结构
- String
- Hash
- set
- List
- Zset
- 总结
String
String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M.
当我们在redis客户端输入set hello word 的时候
其在redis当中的存储如下图所示:
下面我们来解释一下这个dictEntry、SDS、redisObject在redis当中代表的含义.下面我们解释一下:
首先我们解释一下这个dictEntry,我们都知道redis一个kv结构的nosql数据库。dictEntry就是这个哈希的节点里面包含了key和val。在我们上面的例子当中那么这个key就是这个hello 但是请注意这个val并不是这个world这一个字符串。
在redis当中有5大基本数据类型,redis给这5大基本数据类型抽象了一个数据结构叫做redisObject。
而这个SDS指的是这个简单动态字符串,下面我们通过阅读源码来看一下这个SDS简单动态字符串是什么样子的
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
如下图所示:
为什么Redis需要重新设计一个SDS这样的结构出来了?而不直接使用C语义当中的字符串?
c语言不想c++,java一样有这个String类只能是靠自己的char[]来实现,字符串在 C 语言中的存储方式,想要获取 「Redis」的长度,需要从头开始遍历,直到遇到 ‘\0’ 为止。所以,Redis 没有直接使用 C 语言传统的字符串标识,而是自己构建了一种名为简单动态字符串SDS的抽象类型,并将 SDS 作为 Redis 的默认字符串。
下面说一下博客对Redis为什么需要设计一个SDS动态字符串的理解:
- SDS 获取字符串长度的时间复杂度是 O(1),有时候我们需要使用strlen去获取字符串的长度,如果用c语言里面的字符串我们需要变量一遍才能将长度给求出来这样效率很低。
- Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。
- Redis当中的字符串是二进制安全的不会因为\0而中断,并且Redis不仅可以保存文本数据,还可以保存二进制数据。
下面我们谈一下string底层的三种编码方式。
当我们在redis客户端使用这个set k1 v1 时在底层会发生什么了?首先会调用到这个函数当中来:
void setCommand(client *c) {
robj *expire = NULL;
int unit = UNIT_SECONDS;
int flags = OBJ_NO_FLAGS;
if (parseExtendedStringArgumentsOrReply(c,&flags,&unit,&expire,COMMAND_SET) != C_OK) {
return;
}
c->argv[2] = tryObjectEncoding(c->argv[2]);//尝试对字符串进行编码决定采用哪种编码
setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL);
}
我们发现他会尝试对他进行编码下面我们在命令行来验证:
字符串对象的内部编码(encoding)有 3 种 :
- int :当存储的字符串是数字并将能用long型存储时采用int
- embstr:嵌入型字符串当字符串长度小于44个字节时采用embstr
- raw:当字符串长度大于44个字节时采用raw.
首先是这个int
当字符串键值的内容可以用一个64位有符号整形来表示时,Redis会将键值转化为long型来进行存储,此时即对应 OBJ_ENCODING_INT 编码类型。内部的内存结构表示如下:
对于redisObject源码如下所示:
struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
};
Redis 启动时会预先建立 10000 个分别存储 0~9999 的 redisObject 变量作为共享对象,这就意味着如果 set字符串的键值在 0~10000 之间的话,则可以 直接指向共享对象 而不需要再建立新对象,此时键值不占空间!减少内存分配。
下面我们看一下redis当中的伪代码来看看到底是不是这样的:
其中这个OBJ_SHARED_INTEGERS这个宏的值是这个10000:
下面我们看看这个embstr这个嵌入式字符串,为什么我们叫他嵌入式字符串了
下面我们重点看一下这个createEmbeddedStringObject这个方法
我们重点关注这个红色标注的箭头这两段代码:
robj *createEmbeddedStringObject(const char *ptr, size_t len) {
robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr8)+len+1);
struct sdshdr8 *sh = (void*)(o+1);
o->type = OBJ_STRING;
o->encoding = OBJ_ENCODING_EMBSTR;
o->ptr = sh+1;
o->refcount = 1;
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
} else {
o->lru = LRU_CLOCK();
}
sh->len = len;
sh->alloc = len;
sh->flags = SDS_TYPE_8;
if (ptr == SDS_NOINIT)
sh->buf[len] = '\0';
else if (ptr) {
memcpy(sh->buf,ptr,len);
sh->buf[len] = '\0';
} else {
memset(sh->buf,0,len+1);
}
return o;
}
我们发现他开辟空间时,将这个redisObject和sds的空间一起开辟了减少内存碎片,其中这一句代码 o->ptr = sh+1;直接让redisObject当中的ptr指针指向了这个对象。用一张图来看应该是这样的:
总结一下其优点:
- embstr编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次
- 释放 embstr编码的字符串对象同样只需要调用一次内存释放函数;
- 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用 CPU 缓存提升性能。
但是嵌入式字符串也有缺点:
- 如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis没有为embstr编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象执行任何修改命令(例如append)时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。
最后看一下这个raw这种编码格式
当字符串的长度大于等于这个44个字节时采用这个raw这中编码格式
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
return createEmbeddedStringObject(ptr,len);
else
return createRawStringObject(ptr,len);
}
字符串的键值为长度大于44的超长字符串时,Redis 则会将键值的内部编码方式改为OBJ_ENCODING_RAW格式,这与OBJ_ENCODING_EMBSTR编码方式的不同之处在于,此时动态字符串sds的内存与其依赖的redisObject的内存不再连续了
Hash
Hash 是一个键值对(key - value)集合,其中 value 的形式入:value=[{field1,value1},…{fieldN,valueN}]。Hash 特别适合用于存储对象。
Hash 与 String 对象的区别如下图所示:
在Redis6.0 时候Hash是的底层物理编码是ziplist和hashtable来实现的。由于博主已经装了最新版本的redis,在这里博主直接使用docker安装redis6.0并使用config get hash*来进行查看
上面这个图是通过命令去获取这个redis当中的redis.conf的配置信息,下面解释一下这两个是什么意思:
- 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
- 如果上面的条件不满足那么就会采用这个hash作为Hash结构的底层数据结构。
下面我们将这两个数值修改的小一些来进行验证,使用config set 命令来设置。
然后我们插入元素:
然后我们在插入一个比较长的元素:
我们发现这个物理编码里面变成了这个hashtable.下面我们重点说一下这个ziplist(压缩链表)
ziplist为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组并且是一个经过特殊编码的双向链表,它不存储指向前一个链表节点prev和指向下一个链表节点的指针next而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,节约内存,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面
如果用一张图来描述他的话应该是这样的:
下面我们看看这个entry长啥样:
typedef struct zlentry {
unsigned int prevrawlensize; /*存储上一个节点长度的数值所需要的字节数*/
unsigned int prevrawlen; /* 上一个节点的长度 */
unsigned int lensize; /* 当前节点长度的数值所需要的字节数*/
unsigned int len; /* 当前节点的长度 */
unsigned int headersize; /* 当前节点的头部大小,值 = prevrawlensize + lensize. */
unsigned char encoding; /* 编码方式,ZIP_STR_* 或 ZIP_INT_* */
unsigned char *p; /* 指向节点内容的指针. */
} zlentry;
- zlbytes:记录了压缩列表占用的内存字节数,在对压缩列表进行内存重分配,或者计算zlend的位置时使用。它本身占了4个字节。
- zltail:记录了尾节点(entry)至起始节点(entry)的偏移量。通过这个偏移量,可以快速确定最后一个entry节点的地址。
- zllen:记录了entry节点的数量。当zllen的值小于65535时,这个值就表示节点的数量。当zllen的值大于65535时,节点的真实数量需要遍历整个压缩列表才能得出。
- entry:压缩列表中所包含的每个节点。每个节点的长度根据该节点的内容来决定。
- zlend:特殊值0XFF,标记了压缩列表的末端。表示该压缩列表到此为止。
在这里需要注意的是:
前节点:(前节点占用的内存字节数)表示前1个zlentry的长度,privious_entry_length有两种取值情况:1字节或5字节。取值1字节时,表示上一个entry的长度小于254字节。虽然1字节的值能表示的数值范围是0到255,但是压缩列表中zlend的取值默认是255,因此,就默认用255表示整个压缩列表的结束,其他表示长度的地方就不能再用255这个值了。所以,当上一个entry长度小于254字节时,prev_len取值为1字节,否则,就取值为5字节。记录长度的好处:占用内存小,1或者5个字节。
总结:
在这里说明一下这个entry:
虽然定义了这个结构体,但是根本就没有使用zlentry结构来作为压缩列表中用来存储数据节点中的结构,因为,这个结构存小整数或短字符串实在是太浪费空间了。这个结构总共在32位机占用了28个字节(32位机),在64位机占用了32个字节。这不符合压缩列表的设计目的:提高内存的利用率。因此,在redis中,并没有定义结构体来进行操作,而是定义了一些宏,压缩列表的节点真正的结构如下图所示:
zplist这样设计的好处是什么了?链表在内存中,一般是不连续的,遍历相对比较慢,而ziplist可以很好的解决这个问题。如果知道了当前的起始地址,因为entry是连续的,entry后一定是另一个entry,想知道下一个entry的地址,只要将当前的起始地址加上当前entry总长度。如果还想遍历下一个entry,只要继续同样的操作。
既然有了双向循环链表为啥还要有这个ziplist了?
- 普通的双向链表会有两个指针,在存储数据很小的情况下,我们存储的实际数据的大小可能还没有指针占用的内存大,得不偿失。ziplist 是一个特殊的双向链表没有维护双向指针:previous next;而是存储上一个 entry的长度和当前entry的长度,通过长度推算下一个元素在什么地方。牺牲读取的性能,获得高效的存储空间,因为(简短字符串的情况)存储指针比存储entry长度更费内存。这是典型的“时间换空间”
- 链表在内存中一般是不连续的,遍历相对比较慢而ziplist可以很好的解决这个问题,普通数组的遍历是根据数组里存储的数据类型找到下一个元素的(例如int类型的数组访问下一个元素时每次只需要移动一个sizeof(int)就行),但是ziplist的每个节点的长度是可以不一样的,而我们面对不同长度的节点又不可能直接sizeof(entry),所以ziplist只好将一些必要的偏移量信息记录在了每一个节点里,使之能跳到上一个节点或下一个节点。
- 头节点里有头节点里同时还有一个参数 len,和string类型提到的 SDS 类似,这里是用来记录链表长度的。因此获取链表长度时不用再遍历整个链表,直接拿到len值就可以了,这个时间复杂度是 O(1)
既然有优点那么有没有缺点了?
压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患
第一步:现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:
因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值,一切OK,O(∩_∩)O哈哈~
第二步:这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为entry1的前置节点,如下图:
这下就完蛋了压缩链表需要记录上一个节点的长度,现在头插了一个大于254的节点长度那么prelen就需要用5个字节来存储。
entry1节点原本的长度在250~253之间,因为刚才的扩展空间,此时entry1节点的长度就大于等于254,因此原本entry2节点保存entry1节点的 prevlen属性也必须从1字节扩展至5字节大小。entry1节点影响entry2节点,entry2节点影响entry3节点…一直持续到结尾。这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」。
在redis7当中ziplist已经被废用次采用这个listpack(紧凑链表)来进行这个存储。同样的天上飞的理念总要有落地的实现。
注意可能有老铁会说这不是还有ziplist的吗?这是redis为了兼容性将ziplist保留了并没有删除但是实际上底层仍然采用的是listpack.下面我们通过实验来看看到底是不是这样的
同样的当节点的个数或者key、val的长度不符合条件就会改为hashtable.
和ziplist 列表项类似,listpack 列表项也包含了元数据信息和数据本身。不过,为了避免ziplist引起的连锁更新问题,listpack 中的每个列表项,不再像ziplist列表项那样保存其前一个列表项的长度。而是保存当前节点的长度
- 定义该元素的编码类型,会对不同长度的整数和字符串进行编码。
- 实际存放的数据。
- encoding + data 的总长度,len 代表当前节点的回朔起始地址长度的偏移量。
不难看出listpack 没有记录前一个节点长度,只记录当前节点的长度,从而避免了压缩列表的连锁更新问题。
set
Redis用intset或hashtable存储set。如果元素都是整数类型,就用intset存储。如果不是整数类型,就用hashtable(数组+链表的存来储结构)。key就是元素的值,value为null。
同样的我们可以来验证一下:
下面我们来验证一下:
下面我们看看我们加入一个不是数字的类型看看:
这个非常的简单就不再说明:
List
在Redis3.0之前,list采用的底层数据结构是ziplist压缩列表+linkedList双向链表
然后在高版本的Redis中底层数据结构是quicklist(替换了ziplist+linkedList),而quicklist也用到了ziplist结论:quicklist就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表
quicklist 实际上是 zipList 和 linkedList 的混合体,它将 linkedList按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。
在redis7当中ziplist被这个listpack所代替只是将quickListNode当中的节点改为了这个listpack而不在使用ziplist,在这里就不在画图了。
Zset
在redis6当中,当有序集合中包含的元素数量超过服务器属性 server.zset_max_ziplist_entries 的值(默认值为 128 ),或者有序集合中新添加元素的 member 的长度大于服务器属性server.zset_max_ziplist_value 的值(默认值为 64 )时,redis会使用跳跃表作为有序集合的底层实现,否则会使用ziplist作为有序集合的底层实现。
下面我们来进行这个实验:
下面我们插入几条数据来进行这个实验
这是这个redis7当中的配置,在redis6当中使用的是这个ziplist。在这里就不作验证了,老铁可以自行下来研究。
对于这个跳表有兴趣的可以参考一下我的博客在这里将这个链接给出:
跳表博客
总结
在redis6当中这个数据类型的编码主要是
而在redis7当中的物理编码对应是
对于数据结构的时间复杂度为: