Redis
1.Redis的简单介绍
Redis 诞生于 2009 年,全称是 Remote Dictionary Server , 远程词典服务器, 是一个基于内存的键值型 NoSQL 数据库。Redis是一个key-value的数据库
特征:
- 键值( key-value ) 型, value 支持多种不同数据结构, 功能丰富
- 单线程, 每个命令具备原子性
- 低延迟, 速度快(基于内存、 I O 多路复用、 良好的编码) 。
- 支持数据持久化
- 支持主从集群、 分片集群
- 支持多语言客户端
2.常用命令
2.1 KEYS
1 |
|
比如 keys *
表示查找所有的key,keys a*
表示查找所有以a开头的key
2.2 DEL
1 |
|
比如key age
表示删除名为age的key,返回删除的key的个数
2.3 EXISTS
1 |
|
2.4 EXPIRE & TTL
1 |
|
1 |
|
2.5 SET
1 |
|
设置键值对
3.Redis中的数据结构与对象
3.1 简单动态字符串(SDS)
简单动态字符串(SDS,simple dynamic string):Redis的默认字符串表示,包含字符串值的键值对在底层都是由SDS实现的。
一个SDS结构由三部分组成:
- free 记录buf数组中未使用字节的数量
- len 记录buf数组中已使用字节的数量
- buf 字节数组,用于保存字符串,为一个char类型的数组
SDS遵循C字符串以空字符(\0
)结尾的习惯,这个空间不算在SDS的len属性中。redis会为空字符分配额外的1字节空间,这个空字符对SDS的使用者来说是完全透明的。
SDS相较于C字符串的好处
O(1)复杂度获取字符串长度。
在C语言中普通的字符串如果字符数组的有效长度是N,那实际长度是N+1,最后一个字符是空字符\0,表示字符串的结束。并没有额外记录数组的长度,所以当每次获取字符串长度时,都需要遍历整个字符串,整个遍历操作的时间复杂度是O(N)。而SDS字符串中,使用len属性实时记录了字符串的长度,所以可以直接获取,时间复杂度是O(1)。
有效防止缓冲区内存溢出
如果没有为字符串分配足够的空间,当进行字符串扩展拼接时,多出来的部分就会溢出,可能会覆盖这个内存地址中的其他内容,造成内存溢出。而SDS的API进行字符串扩展拼接时,会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,继续执行,所以这样也不会内存溢出。
减少修改字符串时带来的内存分配次数
由于内存重分配涉及复杂的算法,并且可能需要执行系统调用,比较耗时,所以尽量减少内存分配的次数。在C字符串中,每次拼接/裁剪字符串都需要重新进行内存的分配。在SDS中,有一个记录空闲空间的字段free,通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。
空间预分配
当SDS的API对一个SDS进行修改,并需要对SDS进行空间扩展时,程序会为SDS分配足够的空间,以及一些额外的空间,为下次扩展做准备(这个策略可以减少连续执行字符串增长操作所需的内存重分配次数)。
额外分配的未使用空间的大小:
- 对SDS修改后,它的长度(len)小于1MB,那程序会分配和len一样大的空间,保存到free字段中。
- 对SDS修改后,它的长度(len)大于1MB,那么程序会分配1MB的未使用空间(free)
惰性空间释放
用于优化SDS的字符串缩短操作,当需要缩短字符串时,并不直接释放要删除的空间,而是使用free记录,这样避免了缩短字符串时所需的内存重分配操作,并为将来有可能的增长操作提供了优化。
修改字符串长度N次最多需要执行N次内存重分配。
二进制安全
C字符串中的字符必须符合某种编码(如ASCII),并且只有字符串末尾可以是空字符\0,所以C中只能保存文本数据,而不能保存图片、音频等二进制数据。而SDS使用len记录字符串的结尾,byte字节数组记录真实的数据(存放的是二进制数据,而不是一位一位的字符)
兼容部分C字符串
因为SDS同样在字符串末尾加上\0,所以对于C中的部分函数,SDS也可以使用。
3.2 链表
C语言中没有这种数据结构,因此Redis构建了自己的链表实现。由 list
结构和 listNode
节点组成的链表。
ListNode的结构:
1 |
|
list的结构:
1 |
|
双端节点,无环(头结点的prev和尾结点的next都指向NULL),head指向头结点,tail指向尾结点,len记录链表节点长度,还有三个函数使链表节点可以保存不同类型的值。
dup、free和match成员则是用于实现多态链表所需的类型特定函数
- dup函数用于复制 链表节点所保存的值
- free函数用于释放 链表节点所保存的值
- match函数则用于对比链表节点所保存的值和另一个输入值是否相等
3.3 字典
字典,又称为符号表、关联数组、映射,是一种用于保存键值对的抽象数据结构。
Redis的数据库使用字典来作为底层实现,对数据库的增删改查操作也是构建在对字典的操作之上的。而字典使用哈希表作为底层实现。
3.3.1 Redis中字典的结构
1 |
|
(1) type
一个指向dictType结构的指针,每个dictType结构保存了一些用于操作特定类型键值对的函数。
1 |
|
(2) privdata
保存了需要传给类型特定函数的可选参数。
(3) dictht ht[2]
字典中保存两个哈希表,哈希表的结构如下:
1 |
|
table table为一个dictEntry类型的一维数组,数组中的每个元素都是一个指向dictEntry结构的指针,每个dictEntry中保存了一个键值对。
dictEntry的结构为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14//每个dictEntry节点
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一个哈希节点,形成链表
struct dictEntry *next;
} dictEntry;key属性保存键值对中的键,v属性保存键值对中的值,它的类型可以是这几种。
next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,来解决键冲突,这是链地址法
size 哈希表大小
sizemask 哈希表掩码,大小为size-1,用于计算索引值(哈希值&sizemark)
used 哈希表中已有的节点数
(4) rehashidx
rehash索引,没有进行rehash时,值为-1
3.3.2 哈希算法
当要将一个新的键值对添加到字典里时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表的指定桶上。
步骤:
- 计算哈希值
hash=dict->type->hashFunction(key);
在这个函数里面,Redis使用的是MurmurHash2算法来计算键的哈希值。 - 计算索引值(桶下标)
index=hash & dict->ht[0].sizemark;
当有两个或两个以上的键被分配到了哈希表数组的同一个索引上面,则这些键发生了哈希冲突(collision)。Redis的哈希表使用链地址法来解决键冲突,单向链表,新的节点被插入到链表的头部(这是因为链表没有指向链表尾结点的指针,所以为了提高速度,每次就将新节点插入表头位置)
3.3.3 渐进式rehash(扩容或缩容)
哈希表的负载因子=哈希表已保存节点数量/哈希表大小
扩容/缩容的目标容量的规定:
即要为ht[1]分配的空间大小。
- 扩容,ht[1]的大小为第一个大于等于(ht[0].used*2)的2的n次方,比如ht[0]中有4个节点,则ht[0].used×2=8,则第一个大于等于8,并且需要是2的n次方的数就是8,所以容量就是8。
- 缩容,ht[1]的大小为第一个大于等于ht[0].used的2的n次方,比如ht[0]中有4个节点,则ht[0].used=4,则第一个大于等于4,并且是2的n次方的数是4,所以ht[1]的容量是4。
以下情况,程序会自动对哈希表进行rehash操作:
- 服务器目前没有在执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子>=1 扩容
- 服务器目前正在执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子>=5 扩容
- 哈希表的负载因子<0.1 缩容
步骤:
- 为ht[1]分配空间(指定容量),此时字典同时拥有两个哈希表:ht[1]和ht[0]
- 在字典中维护一个索引计数器变量rehashidx,初值为0,表示从ht[0]的数组的0位置开始。
- 在rehash期间,用户依旧可以对redis数据库中的数据进行增删改查更新等操作,数据库在响应用户请求的同时,会将ht[0]哈希表的rehashidx索引上的所有键值对rehash到ht[1],这里需要重新计算索引下标。当这次的rehash完成后,rehashidx的值加1,然后继续执行后面的桶。
- 当ht[0]上的所有键值对都被rehash到了ht[1]后,rehashidx的值被设为-1,表示rehash完成。
- 现在ht[0]是空表, 释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个哈希表,为下次rehash做准备。
渐进式rehash
rehash不是一次性完成的,而是渐进式完成的。用户操作(增删改查更新)和节点迁移是并发执行的。
字典会同时使用ht[0]和ht[1],对字典的删改查会在这两个哈希表上进行,比如要在字典中查找一个键,程序会现在ht[0]中查找,如果没有,就到ht[1]中查找。对于新添加到字典的键值对,都会被直接保存到ht[1]中,ht[0]中不再进行任何的添加操作。
3.4 跳跃表(skiplist)
跳跃表是一个有序并联链表,它以随机化数据结构为基础,通过在每个节点中维持多个指向其他不同的节点的的指针,来达到快速访问的目的。
Redis在实现有序集合键
和在集群节点
中用作内部数据结构。
Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,或者有序集合中元素的成员是比较长的字符串时,redis就会使用跳跃表来作为有序集合键的底层实现。
——-《redis的设计与实现》
1、结构
相同颜色的是同一层。
Redis的跳跃表是由zskiplistNode
和zskiplist
两个结构定义,zskiplisNode
是一个跳跃表节点,跳跃表由许多个跳跃表节点构成,然后有一个zskiplist
来保存跳跃表节点的相关信息,就类似于虚拟头结点dummyNode,有指向链表的头结点和尾结点的指针,以及其他信息。
1 |
|
zskiplistNode结构:
1 |
|
level[]数组
是一个结构体数组,结构体有两个成员,forward和span。数组的每个元素代表这个节点所在的层,level[0]是第一层,level[1]是第二层,以此类推。
数组的大小:每次创建一个新跳跃表节点时,程序都根据幂次定律随机生成一个1-32之间的值作为level数组的大小,这个大小就是节点的层高。
前进指针forward
指向后面的某个节点,用于快速访问节点(可以跳着访问)
跨度
用于记录两个节点之间的距离,这两个节点,一个是当前节点,一个是forward指针指向的节点,跨度的值等于两节点中间相隔的节点个数+1。forward指针指向NULL,则跨度为0。跨度是用来计算当前节点在跳跃表中的排位的。第一个节点(头节点后的节点)的排名为1,第三个节点的排位为2….
后退指针backward
后退指针没有跨度,所以只能挨个访问。访问方法是先通过tail找到尾结点,然后访问后退指针指向的节点,一个一个倒着访问。
- 分值和成员
跳跃表中的所有结点都按照分值从小到大来排序,double类型的浮点数。
节点的成员对象obj
是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值,即简单动态字符串
关于排序问题:在同一个跳跃表中,各个结点保存的成员对象必须是唯一的,但分值可以相同。如果两个节点的分值不同,则按照分值从小到大排序,如果分值相等,则按照成员对象在字典序中的大小进行排序,小的排在前面。
zskiplist
多个跳跃表节点组成了一个跳跃表list,用zskiplist来维护这个表
1 |
|
2、跳表的核心操作
N为跳跃表的长度,即节点个数,不包括头结点。
- 插入一个数据;平均O(logN)
- 删除一个数据;平均O(logN)
- 查找一个数据;平均O(logN)
- 按照区间查找数据(比如查找值在[100, 356]之间的数据);平均O(logN)
新加入的节点,层数是随机的,假如随机生成的层数是2,则level0、level1三层有这个节点。
如果要实现这个效果,我们要分两步去做:
- 找到新节点在每一层的上一个节点(即:对于level0,应该先找到节点9;对于level1,应该先找到节点8)
- 将新节点插入到每一层的上一个节点和下一个节点之间
redis的跳跃表在插入节点时,会随机生成节点的层数,通过控制每一层的概率,控制每一层的节点个数,也就是保证第一层的节点个数,之后逐层增加。生成n+1的概率是生成n的概率的4倍。我觉得这是为了避免最差情况下退化为链表的结构,所以尽可能的要多生成几层。
引用大佬文章:https://djqueue.blog.csdn.net/article/details/105476382
3、相关问题
为什么Redis用跳跃表而不用链表?
储存数据时,数组容量有限且有序数组增加元素时效率比较低,所以存储数据时常选用链表。但是因为查询链表中某个节点的时间复杂度是O(N),无论链表中的元素是否有序。
但是使用红黑树,二叉搜索树,B树,B+树,实现他们又过于复杂。所以使用跳表,利用空间换时间的方式,提高查询效率。
为什么Redis用跳跃表而不用B+树?
从内存占用、对范围查找的支持和实现难易程度这三方面总结的原因
MySQL是从磁盘中读取数据。所以MySQL要解决的问题是尽可能的减少磁盘IO的次数,在MySQL中,叶子节点存储数据,即数据库中的每条记录,非叶子结点存储索引,每个节点可以存储多条记录,MySQL将节点大小设置为磁盘页的大小,所以每次读取磁盘页就会读取整个叶子节点,为的是最大限度的降低磁盘IO次数,B+树只需要查到最后一层的某些页面,然后逐个遍历(记录与记录之间是单向链表的形式,页面和页面之间是双向链表的形式)。
b+树一个节点可以存多个keyvalue结构,其他很多数据结构,这也保证了B+树的稳定性。
而Redis是从内存中读取数据,不涉及IO,从内存读取比从磁盘读取快得多,所以不必那么麻烦,就使用跳跃表。
为什么Redis使用单线程
redis本身是基于内存的,所以redis的性能瓶颈更多的是在于内存和网络带宽,而不是CPU。而单线程的实现更加简单和经济,指令串行,不用维护额外的锁机制,资源竞争,避免了不必要的上下文切换和竞争条件,减少了CPU的消耗。即Redis是基于内存的操作,没有I/O操作,所以单线程执行效率更高。
单线程下串行更快的原因:
对于单CPU来说,线程的串行比并行更快,因为只有一个CPU,只能单线程,此时的并行相当于先执行A一段时间,再执行B一段时间,再执行A,再执行B,….,在切换线程时,会导致上下文切换,CPU需要保存切换线程的信息,这个操作也很耗时,所以对于单线程 串行比较快。
对于I/O操作,并发执行效率更高
因为I/O操作主要有以下两个过程
- 等待I/O准备就绪
- 真正操作I/O资源
等待I/O准备就绪这个阶段,CPU是空闲的,这时便可以去执行其他任务,这样也就提高了CPU的利用率
3.5 整数集合(intset)
整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会只用整数集合作为集合键的底层实现。
整数集合可以保存的整数类型是int16_t、int32_t、int64_t,集合中元素从小到大有序排列,并且没有重复的元素。
命令:SADD numbers 1 3 5 7 //创建整数集合
smumbers numbers //查看整数集合
1、结构
1 |
|
encoding
集合采用的编码方式,可以是INSERT_ENC_INT16(int16_t)、INSERT_ENC_INT32(int32_t)、INSERT_ENC_INT64(int64_t)
length
集合的长度,即数组contents的长度
contents[]
数组的真正类型取决于encoding的值,而不是int8_t
2、升级
当我们要将一个新元素添加到整数集合中,且新元素的类型比整数集合现有的所有元素类型都长,整数集合需要先进行升级,然后再添加这个新的元素。
比如现在整数集合的类型是int16_t,但是要添加的元素的长度类型是int_32t,所以需要先将其升级为int_32t类型。
当向一个包含三个 int16_t
类型的值**(每个元素占16位)**的整数集合添加一个 int32_t
类型的值时, 整数集合的扩展和转换过程如下。(注意! 在转换过程中底层数组的有序性不变)
初始数组状态:
这时,需要将65535添加到整数集合里面,因为int16_t能够表示的范围为(-32768~32767),无法容纳该数字,所以需要升级。
1、扩展content的分配的内存空间,由3x16 扩展为 4x32
2、按照元素索引由高到低的顺序将16位的元素移动至新的位置(32位)。最后添加新插入的元素。
最后,改变intset中encoding和length的值
- encoding由INTSET_ENC_INT16改为INTSET_ENC_INT32
- lentg由3改为4
最终结果:
升级后新元素的位置
因为新元素的长度大于数组中所有其他元素的长度,所以新元素的值要么大于所有现有元素,要么就小于所有现有元素。
- 若为最小值,放在数组开头
- 若为最大值,放在数组末尾
升级好处:
- 提高灵活性。整数集合可以通过自动升级底层数组来适应新元素,即你可以存放多种类型大小的整数值。
- 节约内存。自适应,只有在有大元素需要添加时才会升级为大类型数组。
整数集合不支持降级操作,一旦升级,就会一直保持这个状态,直到下次升级。
3.6 压缩列表
压缩列表(ziplist)是列表键(list)和哈希键(hash)的底层实现之一。前提是一个列表键只包含少量的列表项,并且每个列表项要么是小的整数值,要么是长度比较短的字符串;或哈希键中只包含少量键值对,并且每个键值对的键和值要么是小整数值,要么是长度比较短的字符串,才会使用压缩列表来实现列表键。
否则,压缩列表的查询效率会很低。那时就会选择跳表或其他方法来实现列表键和哈希键了。
ziplist使用紧凑的连续内存块顺序存储数据,在list或者hash结构中,未使用listNode(24字节)和dictEntry(24字节)结构体来存储元素项(这些内存占用存储的并不是数据,而是一些定义和指针),因此会节省内存。
1、结构
zlbytes unit32_t 4字节,用来记录整个压缩列表占用的内存字节数。
zltail unit32_t 4字节,尾结点到列表起始节点的距离(偏移量),一个指向压缩列表起始地址的指针p,则
p + zltail
就是表尾结点的地址。zlen unit16_t 2字节,记录压缩列表中的节点数量,小于65535(2的16次方)时是真实节点的数量,大于等于时,则需要遍历整个列表才可以得到总的节点数量。
entryX 列表节点,每个压缩列表节点可以保存一个整数值或者一个字节数组。
previous_entry_length
单位是字节,记录压缩列表中前一个节点的长度。
如果前一个节点长度小于254(2的8次方)字节,那该属性的长度为1字节(8bit),里面保存前一个节点 长度。
如果前一个节点长度大于等于254字节,则该属性的长度为5字节,属性的第一个字节为0xFE(254),后面4字节用来保存前一个字节的长度。
压缩列表的访问是从表尾到表头的顺序,因为每个节点中记录了前一个节点的长度,所以当指针指p向当前结点时,则前一个节点的起始地址为
p - previous_entry_length
,encoding
1.记录节点的content属性保存的数据的类型(字节数组还是整数值)和长度。
最高位为00 or 01 or 10,则content保存的是字节数组,除去这两位,剩下的位表示数组的长度。
2.最高位是11,则content保存的是整数值,去除这两位,剩余的位数决定整数的类型。
content
保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
zlend unit8_t 1字节,永远等于0xFF,标记压缩列表的末尾
2、连锁更新
连锁更新就是指添加一个节点导致所有节点的previous_entry_length
都要更新的现象。
一个压缩列表的所有节点的长度在250~253之间,previous_entry_length
都是1字节,如果现在添加一个长度大于253字节的节点到表头,那后面的节点的previous_entry_length
长度变成5字节,则这个节点的长度超过254字节,则它后面的节点也需要修改,引发连锁更新。
添加新节点到压缩列表或者从压缩列表中删除节点,可能会引发连锁更新操作,不过这种操作出现的几率不高。
不过由于这种情况很少见,而且假如出现了,只要更新的节点个数不多,也不会产生什么太大的影响。