Redis对象与底层数据结构详解

给我介绍字符串对象吧

答: 字符串对象是redis最常使用的操作对象,他的大小最多不能超过512M。他的内部编码为

  1. 可以保存用long类型存储的整数类型。
  2. 长度小于44字节(Redis3.2之前为39字节),用embstr存储。
  3. 大于44字节(Redis3.2之前为39字节),则用raw存储。

参见下面操作示例

# 整数用int
127.0.0.1:6379> set k 123
OK
127.0.0.1:6379> object encoding k
"int"

# 小于44字节用embstr
127.0.0.1:6379> set k2 hello
OK
127.0.0.1:6379> object encoding k2
"embstr"

# 大于则用raw
127.0.0.1:6379> set k3 ksjdladjalkdjkledjkdjakldjalkjdkaldjkldjlkdjalkdmlkajdlkajdwaldmkaldjalkdjalkj
OK
127.0.0.1:6379> object encoding k3
"raw"
127.0.0.1:6379>

字符串对象底层布局可不可以给我说说呢?

答: 我们针对字符串类型的三种变化一个个说过去吧:

int内存布局图解

可以看到数据类型以及数据指针都在同一个结构体上。

在这里插入图片描述

embstr内存布局图解

到这里的ptr指针所指向的字符串类型并不是c语言常见的字符串类型,而是一种sds字符串,关于sds字符串类型,后文会进行详细介绍。

在这里插入图片描述

raw类型内存图解

可以看到raw类型是一个独立的数据块,一个指针直接指向一个全新的结构体。

在这里插入图片描述

整数集IntSet这个是什么东西,能不能给我具体说说呢?

答: 从源码了解intset内存结构,当一个集合仅仅存储整数的时候,set底层就会用intset来存储数据。他的源码如下所示

typedef struct intset {
    uint32_t encoding;// 决定intset中每一个元素存放的格式,可以是INTSET_ENC_INT16, INTSET_ENC_INT32, INTSET_ENC_INT64
    uint32_t length;//集合的长度
    int8_t contents[]; //真正存放元素的数组,虽然类型声明为int8_t ,但是具体存储的元素还是由encoding决定的
} intset;

内存布局图

如下所示,可以看到intset,就是一段带有encodinglength的数组。

在这里插入图片描述

补充:整数集合的升级

当我们的整数集合的元素都是int16时,突然插入一个int32的整数,他的升级过程大抵如下:

1. 扩展底层的数组空间,将新元素存放到数组中
2. 将原有元素存放到新空间的正确位置,保持有序性质不变
3. length+1

那个什么sds字符串能不能给我说说这是啥?为什么redis要用这种内部编码实现字符串类型呢?

答: redis字符串并不是使用C的字符串数组,即\0结束的字符数组,而是使用更加灵活安全的SDS(simple dynamic string)

从源码的角度认识sds

sds头文件如下,可以看到sds结构有5、8、16、32、64几种情况。结构体中都有len、alloc、flags、buf几个字段。
他们分别的含义为:

	1. len:当前字符串长度
	2. alloc:分配的字符串大小uint8_t代表1字节、uint16_t代表2字节,以此类推
	3. flag:占用1字节,用低3位表示头部类型,高5位未被使用
	4. buf[]:用于保存字符串中的每一个元素

sds.h源码

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[];
};

为什么要使用sds

  1. 因为len字段的设计,sds获取字符串长度的时间为O(1),相比于C语言通过遍历计算长度更加高效。
  2. 因为len的缘故,进行字符串拼接时可以预先检查字符串空间大小在进行操作,避免缓冲区溢出。
  3. 因为sds空间预分配惰性释放策略,使得字符串修改不必像C语言原生字符串那样每次都需要重新分配内存空间。
  4. C语言原生字符串是以空格作为结束符,一旦遇到图片文件的字符串很可能出现错误。而sds都是以二进制的方式存储避免了这一问题,做到了二进制安全

空间预分配思想

例如我们执行以下命令。此时key的data作所占用的内存空间为11

127.0.0.1:6379> set key "hello world"
OK
127.0.0.1:6379>

然后我们在使用了如下的命令此时data占用空间就变为18,根据预分配思想,他们的内存空间就会变为18+18(预分配空间大小)+1(\0结束符)=37,如下图

127.0.0.1:6379> set key "hello world again!"
OK

在这里插入图片描述

小结

在这里插入图片描述

raw 和 embstr 有什么区别呢?

答: 由上图我们可以看出embstr内部空间的dataredisObject是连在一起的,这也意味他们的空间是连续分配的。所以我们若要修改embstrdata值就需要为其重新分配一个内存空间。所以redis就索性将embstr设置为只读。
再看看raw类型dataredisObject是分开的,这意味着我们要常见一个raw类型对象需要进行两次内存空间分配(先创建一个redisObject,然后在创建一个data)。

那些情况下字符串类型会进行编码转换?

答:

  1. long类型的整数被修改为字符串时,自动会转为row
# k为整数
127.0.0.1:6379> set k 123
OK
127.0.0.1:6379> object encoding k
"int"

# 修改后变为raw
127.0.0.1:6379> APPEND k hey
(integer) 6
127.0.0.1:6379> object encoding k
"raw"
  1. 因为embstr是只读的,当他被修改时,也会自动转为raw
# embstr被修改后也变为raw
127.0.0.1:6379> set k2 hello
OK
127.0.0.1:6379> object encoding k2
"embstr"
127.0.0.1:6379> APPEND k2 world
(integer) 10
127.0.0.1:6379> get k2
"helloworld"
127.0.0.1:6379> object encoding k2
"raw"


列表对象了解过嘛?

答:

  1. 他是简单的字符串列表。
  2. 支持从左边插入节点或者从右边节点插入。
  3. 他的底层是链表结构。
  4. 在早期版本的列表底层用的是ziplist或者linked,但是现在统一用quicklist

那个ziplist是什么?

答:

  1. ziplist是一个空间连续的双向链表。
  2. 存储整数时是采用二进制的方式存储,而不是以字符串的方式存储。
  3. 可以使用O(1)进行头插入和尾插入,每次插入都需要对空间进行重新分配。

从源码角度理解ziplist结构

我们摘取ziplist.c的关键注释,可以看到ziplist.c的有以下几个字段,他们分别的含义是

1. zlbytes:大小为uint32_t(4字节),记录ziplist所占用的字节数
2. zltail:大小为uint32_t,记录ziplist的尾节点的偏移量,以实现快速pop等操作
3. zllen:大小为uint16_t用于记录entry的数量,最多记录2^16次方即可65535个,超过这个数这个值就为65535,具体数量还需要自行遍历计算了。
4. entry:ziplist中每个元素的结构体
5. zlend:ziplist的结束符,为全F
 * The general layout of the ziplist is as follows:
 *
 * <zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>

在这里插入图片描述

从源码角度理解ziplist的entry

从源码中我们可知,entry的结构分为两种情况:

  1. entry中存放的是非整数时:

     1. `prevlen`:当前entry前一个元素大小。
     2. `encoding`:表示当前entry和长度,注意encoding高2位为11时就代表当前数据类型为int
     3. `entry-data`:entry的具体值。
    

在这里插入图片描述

  1. 当entry为整数int时,就只有encodingprelen两个字段,其中encoding代表字符串长度和值,prelen代表前一个entry的大小

在这里插入图片描述

为什么ziplist能够做到节约内存

与常规list相比,ziplist每个元素并非定长,而是根据情况设置不同的encoding为每个entry设置不同的空间

ziplist的缺点

  1. 因为元素非定常,所以遍历的时候需要参考prelen来定位下一个元素的位置。
  2. 假如我们修改ziplist的第一个元素,使其encoding从1为字节变为5字节,那么后续所有的entry都需要同步修改重新分配内存空间,尽管这个时间复杂度是O(1),但是在大数据情况下也很可能导致性能问题。

列表类型的内存结构了解嘛?

quicklist的内存布局如下图所示,可以看到quicklist就是一个挂着ziplist的双向链表,具体笔者会在后文中详尽阐述。

在这里插入图片描述

哈希对象呢?能不能也给我说说哈希对象的底层数据结构

答:

  1. 哈希对象的键为字符串类型,值是一个键值对集合
  2. 内部编码可以是ziplist,也可以是hashtable
  3. 当列表元素小于64字节,且元素个数小于512个的时候,哈希对象底层使用的就是ziplist,反之就是hashtable
# 刷新redis数据表
127.0.0.1:6379> FLUSHDB
OK
# 创建一个哈希对象
127.0.0.1:6379> hset user:1 name xiaomig

# 小于64字节,为ziplist
(integer) 1
127.0.0.1:6379> object encoding user:1
"ziplist"

# 反之就变成hashtable
127.0.0.1:6379> hset user:1 name 
xiaomighdakjshdjadhjdhjkdhjdhkjhdjdksahdkjahdkjahdkjsahdjadhkjhdkjadhakjshdkjahdkjadhkjqhdjhkasdhqlkjdhjkadhahdkjadhad
(integer) 0
127.0.0.1:6379> object encoding user:1
"hashtable"

给我说说哈希的内部编码图吧

答: 我们先说说它在ziplist情况下的数据结构吧:

哈希表处理ziplist时的内存

当哈希对象是用ziplist的时候,他的内存布局如下图所示

在这里插入图片描述

若我们执行以下命令

hset profile name "Tom"
hset profile age 25
hset profile career "Programmer"

则ziplist的存储结构就如下图所示

在这里插入图片描述

再说说hashtable

hashtable的内存布局如下图所示,可以看到数据指针ptr指向一个dict示例,这个示例中的一个table指向一个哈希表数组,数组中每个entry就是记录着一个个键值对。

在这里插入图片描述

好,那你能不能说说既然ziplist实现hashtable为什么我们还需要用hashtable呢?

  1. ziplist是为了节约内存而诞生的,在少量键值对以及对与对象修改操作不多的情况下,使用ziplist最合适不过。
  2. 假如键值对数量过多,且经常设置键值对修改的操作,因为ziplist是一个数组,修改一个数据空间很可能造成"牵一发动全身"的情况,使用hashtbale更加合适

哈希对象的编码类型转换是什么情况下会发生呢?

答: 上文提到

1. 哈希对象值小于64字节
2. 列表元素个数小于512时

哈希对象使用的是ziplist

但这也是一成不变的,我们可以通过redis配置文件的hash-max-ziplist-entrieshash-max-ziplist-value来修改这个阈值


hash-max-ziplist-entries 512
hash-max-ziplist-value 64

不错,再给我说一下集合对象吧

答: 集合对象就是set:

  1. 集合对象是个字符串集合(就算是整数也会当作字符串进行存储)
  2. 集合对象是无序的
  3. 集合对象存储结构有intsetdict(只用到dict的key,这样就可以做到去重,这一点和Java的hashmap有点像)

能不能给我说一下集合对象的内存布局

答:

intset的内部编码

当集合对象内部存储的都是整数的时候,他的内部编码就是intset,如下图所示,ptr指针指向一个数组。

在这里插入图片描述

例如我们执行以下命令在number这个key中添加几个整数

127.0.0.1:6379> SADD number 1 3 5
(integer) 3
127.0.0.1:6379> object encoding number
"intset"
127.0.0.1:6379>

dict的内部编码

若有元素不为整数或者元素数量超过512的时候,内部存储结构就会转为dict,只不过这个dict只用到了key,value都用null。

在这里插入图片描述

例如我们执行以下命令

SADD Dfruits "apple" "banana" "cherry"

集合对象什么时候会发现内部编码转换呢?

答: 上文提到内部编码转换的一个条件为集合对象元素个数超过512,其实这个参数我们可以通过调整redis配置参数set-max-intset-entries文件来改变

set-max-intset-entries 512

有序集合对象了解过嘛?

答:

  1. 与集合相比,有序集合同样是元素唯一,但会通过score进行排序,注意score的值是可以重复的哦。
  2. 元素小于64字节且个数小于128的时候,内部编码为ziplist反之就是dict+skiplist

有序集合内存结构图呢?长什么样?

答: 先来说说压缩列表的形式吧:

ziplist

redis设计者为了节约内存,在小数据情况下仍然使用ziplist来作为内部编码,因为ziplist是有序的,所以插入元素时,只需遍历一遍就知道新元素的插入位置了。

在这里插入图片描述

dict与skiplist

当有序集合内部编码转为dict与skiplist时,存储结构就如下图所示。
实际上有序集合使用dict就能够做到唯一了,为什么还需要跳表呢?原因如下:

  1. 使用dict保证了查询时间复杂度为O(1)且元素唯一,但是在有序集合进行范围查找时,查询效率极差。
  2. 若单纯使用跳表,范围查询效率高了,但是唯一查询时间复杂度却变为O(n)。
  3. 所以为了平衡这两点,redis的设计将两个数据结构结合起来使用,实现"空间换时间"

在这里插入图片描述

有序集合内部编码转换时机我们要怎么调整呢?

答: 我们可以通过redis配置文件的zset-max-ziplist-entrieszset-max-ziplist-value来修改这个阈值,从而决定它的转换实际。


zset-max-ziplist-entries 128
zset-max-ziplist-value 64

快表是什么?能不能给我详细介绍一下呢?

答:

  1. quicklist是一个双向链表
  2. quicklist每一个节点都是由ziplist组成

从源码角度quicklist内存结构

快表的结构体如下,读者可以参考笔者注释进行阅读。

// 表示quicklist每一个节点的结构体
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl; //该指针就只想ziplist实例
    ......
} quicklistNode;



//quicklist 结构体 
typedef struct quicklist {
    quicklistNode *head;//指向头节点
    quicklistNode *tail;//指向为节点
    unsigned long count;      //ziplist所有entry数量总和
    unsigned long len;         //当前quicklist的节点数量
......
} quicklist;



quicklist内存布局图

可以看到整体上是一个双向链表,而每个链表节点都挂着一个ziplist。

在这里插入图片描述

quicklist的参数详解

  1. quicklist.fill
    这个是为负数时决定ziplist的大小:

     1. -1  不超过4kb
     2. -2 不超过8kb
     3. -3 不超过16kb
     4. -4 不超过32kb
     5. -5 不超过64kb
    

若为正数则表示ziplist的entry数目

  1. quicklist.compress

  2. 0表示不压缩

  3. 1表示头尾节点不压缩,其余都压缩

  4. 2 表示头两个节点不压缩,尾两个节点不压缩,其他都压缩

  5. 以此类推, 最大值为2^16

  6. quicklistNode.encoding
    代表quicklistNode是否被压缩过

    1. 代表未压缩
    2. 表示压缩过
    
  7. quicklistNode.container
    决定链表节点的类型是什么,redis目前都是为2,代表ziplist

  8. quicklistNode.recompress
    表示quicklistNode是否被解压过,1表示解压缩,且下一次需要被压缩

quicklist解决了什么问题

ziplist通过连续内存空间,解决了原生list使用指针管理导致过度耗费内存的问题,但也出现了每次修改内容都需要重新分配内存的问题。quicklist在此基础上使用双向链表挂ziplist的折中的解决了这一问题。

你刚刚还说了字典/哈希表-Dict,能不能也给我详细说说呢?

答: 我们可以将哈希表理解为java中的hashmap,从源码的角度来看看这个数据结构的特点,第一段源码就是哈希对象的整体结构,通过table这个二级指针指向一个entry的数组

typedef struct dictht{
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值
    //总是等于 size-1
    unsigned long sizemask;
    //该哈希表已有节点的数量
    unsigned long used;
 
}dictht

entry的源码如下,可以看到哈希表的每一个entry都有一个key,而value是一个共用体,他的值可以是一个对象,也可以是一个8字节的uint64_t(typedef unsigned long long),或者是int64_t (typedef signed long long)

typedef struct dictEntry{
     //键
     void *key;
     //值
     union{
          void *val;
          uint64_t u64;
          int64_t s64;
     }v;
 
     //指向下一个哈希表节点,形成链表
     struct dictEntry *next;
}dictEntry

内部编码图解

将上面的源码转换成图片就是这样子,可以看出redis中的哈希对象解决冲突的方式是链地址法

在这里插入图片描述

哈希对象的扩容和缩容

当保存的键值对过多或者过少时就会触发扩容和缩容,扩容和缩容的大小就是基于原来的大小乘2或者除2。整个过程会在迁徙完毕后间原来的内存空间释放掉。

触发扩容的条件为

在介绍扩容条件前,我们需要知道redis的哈希对象的负载因子计算公式:

	负载因子=哈希表中元素个数/哈希表大小

以下便是redis哈希对象触发扩容的条件

	1. 服务器没有执行BGSAVE 命令或者 BGREWRITEAOF,而负载因子大于1
	2. 服务器执行BGSAVE 命令或者 BGREWRITEAOF,而负载因子大于5

渐进式rehash

为了避免redis哈希对象扩容或者缩容造成redis阻塞,所以redis提出的渐进式rehash,即表的扩容和缩容迁徙过程是并非一次性完成的。
所以为了保证redis正常的运行,查找数据时,被迁移哈希表查不到数据时,就到新的哈希表中查找数据,而增加和删除操作也一律在新的哈希表中进行。

跳表(ZskipList)是什么?(重点)

答:

  1. 跳跃表使用场景只有一个,即zset内部编码转换时被用到
  2. 跳跃表查询、修改、增加等操作性能和平衡树相当,但是实现相对简单一些
  3. 跳跃表缺点就创建多级索引时会耗费大量空间,是空间换时间的典型

redis跳跃表的设计

使用链表查询12,需要7次

在这里插入图片描述

当我们创建多级索引之后,查询次数大大减小了

在这里插入图片描述

源码+图解了解跳跃表

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;//sds的元素值
    double score;//score值
    struct zskiplistNode *backward; //值向前一个节点
    struct zskiplistLevel {
        struct zskiplistNode *forward; //指向比自己高的后继节点,这个节点与自己同一个level,这个level是什么后文会介绍
        unsigned int span; //与后继节点的距离
    } level[];
} zskiplistNode;

//zskiplist 结构体 
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;//指向头节点和尾节点的指针
    unsigned long length; //表中节点数量
    int level; //记录目前跳跃表内,层数最大的那个节点的层数
} zskiplist;

将上面的代码转换为图片如下图所示,我们大抵归纳特点如下:

  1. header指向头节点,tail指向尾节点,因为表中level最大值为3所以level记录为3。
  2. 因为表中只有10个值,所以length为10。
  3. 跳表创建多级索引,L1的forward指向L1级的forward,L2的backward指向L2的backward,由图中我们还可以看出L0之间的span(L1级别的zskiplistLevel )为1。L2到L2之间的距离需要前进9步才能到达,所以span为9。

在这里插入图片描述

为什么redis有序集合底层编码不用平衡树或者哈希表

  1. 跳表实现相比于后者简单。
  2. 跳表新增、删除元素无需要为了平衡而去进行树翻转等操作。
  3. 跳表相对哈希和平衡树进行范围效率效率更高,因为数据结构是有序的。无需像平衡树那样找到最小节点后在进行中序遍历等操作。

关于Redis对象其他相关面试题

Redis 常用的数据结构有哪些?

答:

常用:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)
不常用:HyperLogLogs(基数统计)、Bitmap (位存储)、Geospatial (地理位置)

String 还是 Hash 存储对象数据更好呢?

答:

若对象只读,且字段较少,考虑节约内存的话字符串更好(同等对象string消耗约为hash的一半)。
若字段较多,或者需要对对象字段进行频繁修改操作的话,使用hash更合适(避免大量序列化和反序列化操作)

使用 Redis 实现一个排行榜怎么做?

答:

用有序集合 ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)

使用 Set 实现抽奖系统需要用到什么命令?

答:

1. spop key count:用于一次抽取一个或者多个用户时(每个用户只有一次抽奖机会)
2. SRANDMEMBER key count : 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。

使用 Bitmap 统计活跃用户怎么做?

答:

bitmap有个bit op的操作,用and可以查看用户连续在线天数,用or查看活跃用户总数

使用 HyperLogLog 统计页面 UV 怎么做?

答: 用下面的指令进行统计计算

PFADD  、PFCOUNT 

参考文献

Redis进阶 - 数据结构:redis对象与编码(底层结构)对应关系详解

Redis进阶 - 数据结构:底层数据结构详解

Redis常见面试题总结(上)

zskiplist

C中int8_t、int16_t、int32_t、int64_t、uint8_t、size_t、ssize_t区别

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

shark-chili

您的鼓励将是我创作的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值