给我介绍字符串对象吧
答: 字符串对象是redis
最常使用的操作对象,他的大小最多不能超过512M
。他的内部编码为
- 可以保存用
long
类型存储的整数类型。 - 长度小于
44
字节(Redis3.2之前为39字节)
,用embstr
存储。 - 大于
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
,就是一段带有encoding
和length
的数组。
补充:整数集合的升级
当我们的整数集合的元素都是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
- 因为len字段的设计,sds获取字符串长度的时间为
O(1)
,相比于C语言通过遍历计算长度更加高效。 - 因为len的缘故,进行字符串拼接时可以预先检查字符串空间大小在进行操作,避免缓冲区溢出。
- 因为sds
空间预分配
和惰性释放
策略,使得字符串修改不必像C语言原生字符串那样每次都需要重新分配内存空间。 - 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
内部空间的data
和redisObject
是连在一起的,这也意味他们的空间是连续分配的。所以我们若要修改embstr
的data
值就需要为其重新分配一个内存空间。所以redis
就索性将embstr
设置为只读。
再看看raw
类型data
和redisObject
是分开的,这意味着我们要常见一个raw
类型对象需要进行两次内存空间分配(先创建一个redisObject
,然后在创建一个data
)。
那些情况下字符串类型会进行编码转换?
答:
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"
- 因为
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"
列表对象了解过嘛?
答:
- 他是简单的字符串列表。
- 支持从左边插入节点或者从右边节点插入。
- 他的底层是链表结构。
- 在早期版本的列表底层用的是
ziplist
或者linked
,但是现在统一用quicklist
。
那个ziplist是什么?
答:
ziplist
是一个空间连续的双向链表。- 存储整数时是采用二进制的方式存储,而不是以字符串的方式存储。
- 可以使用
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的结构分为两种情况:
-
当
entry
中存放的是非整数时:1. `prevlen`:当前entry前一个元素大小。 2. `encoding`:表示当前entry和长度,注意encoding高2位为11时就代表当前数据类型为int 3. `entry-data`:entry的具体值。
- 当entry为整数int时,就只有
encoding
和prelen
两个字段,其中encoding
代表字符串长度和值,prelen
代表前一个entry
的大小
为什么ziplist能够做到节约内存
与常规list相比,ziplist
每个元素并非定长,而是根据情况设置不同的encoding
为每个entry
设置不同的空间
ziplist的缺点
- 因为元素非定常,所以遍历的时候需要参考
prelen
来定位下一个元素的位置。 - 假如我们修改
ziplist
的第一个元素,使其encoding
从1为字节变为5字节,那么后续所有的entry都需要同步修改重新分配内存空间,尽管这个时间复杂度是O(1)
,但是在大数据情况下也很可能导致性能问题。
列表类型的内存结构了解嘛?
quicklist
的内存布局如下图所示,可以看到quicklist
就是一个挂着ziplist
的双向链表,具体笔者会在后文中详尽阐述。
哈希对象呢?能不能也给我说说哈希对象的底层数据结构
答:
- 哈希对象的键为字符串类型,值是一个键值对集合
- 内部编码可以是
ziplist
,也可以是hashtable
- 当列表元素小于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呢?
ziplist
是为了节约内存而诞生的,在少量键值对以及对与对象修改操作不多的情况下,使用ziplist
最合适不过。- 假如键值对数量过多,且经常设置键值对修改的操作,因为ziplist是一个数组,修改一个数据空间很可能造成
"牵一发动全身"
的情况,使用hashtbale
更加合适
哈希对象的编码类型转换是什么情况下会发生呢?
答: 上文提到
1. 哈希对象值小于64字节
2. 列表元素个数小于512时
哈希对象使用的是ziplist
但这也是一成不变的,我们可以通过redis
配置文件的hash-max-ziplist-entries
和hash-max-ziplist-value
来修改这个阈值
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
不错,再给我说一下集合对象吧
答: 集合对象就是set:
- 集合对象是个字符串集合
(就算是整数也会当作字符串进行存储)
- 集合对象是无序的
- 集合对象存储结构有
intset
和dict
(只用到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
有序集合对象了解过嘛?
答:
- 与集合相比,有序集合同样是元素唯一,但会通过
score
进行排序,注意score的值是可以重复的哦。 - 元素小于64字节且个数小于128的时候,内部编码为
ziplist
反之就是dict+skiplist
有序集合内存结构图呢?长什么样?
答: 先来说说压缩列表的形式吧:
ziplist
redis
设计者为了节约内存,在小数据情况下仍然使用ziplist
来作为内部编码,因为ziplist
是有序的,所以插入元素时,只需遍历一遍就知道新元素的插入位置了。
dict与skiplist
当有序集合内部编码转为dict与skiplist
时,存储结构就如下图所示。
实际上有序集合使用dict
就能够做到唯一了,为什么还需要跳表呢?原因如下:
- 使用
dict
保证了查询时间复杂度为O(1)
且元素唯一,但是在有序集合进行范围查找时,查询效率极差。 - 若单纯使用跳表,范围查询效率高了,但是唯一查询时间复杂度却变为O(n)。
- 所以为了平衡这两点,
redis
的设计将两个数据结构结合起来使用,实现"空间换时间"
有序集合内部编码转换时机我们要怎么调整呢?
答: 我们可以通过redis配置文件的zset-max-ziplist-entries
和zset-max-ziplist-value
来修改这个阈值,从而决定它的转换实际。
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
快表是什么?能不能给我详细介绍一下呢?
答:
- quicklist是一个双向链表
- 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的参数详解
-
quicklist.fill
这个是为负数时决定ziplist的大小:1. -1 不超过4kb 2. -2 不超过8kb 3. -3 不超过16kb 4. -4 不超过32kb 5. -5 不超过64kb
若为正数则表示ziplist的entry数目
-
quicklist.compress
-
0表示不压缩
-
1表示头尾节点不压缩,其余都压缩
-
2 表示头两个节点不压缩,尾两个节点不压缩,其他都压缩
-
以此类推, 最大值为
2^16
-
quicklistNode.encoding
代表quicklistNode是否被压缩过1. 代表未压缩 2. 表示压缩过
-
quicklistNode.container
决定链表节点的类型是什么,redis目前都是为2,代表ziplist
-
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)是什么?(重点)
答:
- 跳跃表使用场景只有一个,即
zset
内部编码转换时被用到 - 跳跃表查询、修改、增加等操作性能和平衡树相当,但是实现相对简单一些
- 跳跃表缺点就创建多级索引时会耗费大量空间,是空间换时间的典型
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;
将上面的代码转换为图片如下图所示,我们大抵归纳特点如下:
- 由
header
指向头节点,tail指向尾节点,因为表中level最大值为3所以level记录为3。 - 因为表中只有10个值,所以length为10。
- 跳表创建多级索引,L1的forward指向L1级的forward,L2的backward指向L2的backward,由图中我们还可以看出L0之间的span(L1级别的zskiplistLevel )为1。L2到L2之间的距离需要前进9步才能到达,所以span为9。
为什么redis有序集合底层编码不用平衡树或者哈希表
- 跳表实现相比于后者简单。
- 跳表新增、删除元素无需要为了平衡而去进行树翻转等操作。
- 跳表相对哈希和平衡树进行范围效率效率更高,因为数据结构是有序的。无需像平衡树那样找到最小节点后在进行中序遍历等操作。
关于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