一、存储
1、内存
既然要把数据放到内存中,就需要提供索引数据的方式,常见的索引实现技术有:Hash表,B+树,字典树等。MySQL中的索引是通过B+树实现的。而Redis作为KV内存数据库,其是采用哈希表来实现索引的。
为了实现键值快速访问,Redis使用了个哈希表来存储所有的键值对。
在内存中的布局如下:
哈希桶中存储entry元素,entry元素包含了key和value指针,指向实际的key和value内容。
key指向的是字符串,value指向的是实际的各种redis数据结构。
这种结构下,只要哈希冲突不多,那么寻找kv键值对的效率就是很高的,而redis与Memcached相比,最重要的亮点就是value中提供的各种数据结构。这些数据结构的实现决定了执行各种操作命令获取数据的性能。
我们接下来看看这些数据结构。
1.1、REDIS中的各种数据结构
redis主要的数据类型有:String,List,Hash,Set,SortedSet,也称为对象,而这些数据类型,底层是基于特定的数据结构来实现的。
常用数据结构:Array、LinkedList、Stack、Queue、PQ、IPQ、BST、BBST、AVL、HashTable、并查集、树状数组、后缀数组。
而Redis中,基于存储效率和访问效率的考虑,使用到了一些其他的数据结构。我们首先来看看Redis中常见的这些数据结构,然后在看看这些数据类型是由什么数据结构组成的。
1.1.1、SDS
我们知道,String类内部定义了常量数组进行存储字符串,是不可以修改的,每次对字符串操作都会另外分配一个新的常量数组空间。
而Redis中的字符串是动态的。字符串是Redis中最为常见的存储类型,底层采用简单动态字符串实现(SDS[1], simple dynamic string),是可以修改的字符串,类似于Java中的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。
Redis中的键值对中的键、值里面的字符串、缓冲区、AOF缓冲区、等都是用的SDS。
下面是SDS数据结构的一个图示:
亮点:
- 获取字符串长度时间复杂度为O(1),而C语言则需要遍历整个数组才能得到长度;
- 采用空间预分配避免反复对字节数组进程扩容,如上图的SDS,还有2个字节的空闲空间,如果只是追加一个字符,就不用扩容了。避免了类似C语言要手动重新分配内存的情况下,忘记了进行分配而导致的缓冲区溢出或者内存泄露问题;
- 惰性空间释放:当要缩短字符串长度的时候,程序不会立刻释放内存,而是通过free属性将这些需要释放的字节数量记录下来,等待将来重复使用;
- 二进制安全:所有的SDS API都会以处理二进制的方式来处理SDS的buf数组数据,这样就避免了C字符串读取到空字符就返回,导致读取不完整字符串的问题。二进制安全的SDS,是的Redis可以保存任意格式的二进制数据,而不仅仅是文本数据。
-
如果是C语言,字符串存入了一种使用空字符(\0,不是指空格)来分隔单词的特殊数据格式,存储如下内容:
-
使用C字符串函数读取到的结果会丢失空字符后面的内容,得到:itzhai,丢失了com。
-
SDS使用len属性的值来判断字符串是否结束,而不是空字符。
-
- 兼容部分C字符串函数:为了兼容部分C字符串函数库,SDS字符串遵循C字符串以空字符结尾的惯例。
空间预分配规则
- 字符串大小小于1M的时候,每次扩容加倍;
- 超过1M,扩容只会多扩容1M的空间,上限是512M。
1.1.2、链表 LINKEDLIST
Redis中List[3]使用的是双向链表存储的。
如下图:
List特点:
- 双向链表;
- 无环;
- 带head和tail指针;
- 带链表长度;
- 多态,链表节点可存储不同类型的值;
List提供了以下常用操作参考:命令列表:https://redis.io/commands/?group=list
通过LPUSH、LPOP、RPUSH、RPOP操作,可以把List当成队列或者栈来使用:
1.1.3、HASH字典
Redis的字典使用哈希表作为底层实现。该数据结构有点复杂,我直接画一个完整的结构图,如下:
如上图,字典结构重点属性:
- type和privdata主要是为创建多态字典而设置的;
- ht[1]: 包含两个哈希表,正常情况下,只使用ht[0],当需要进行rehash的时候,会使用到ht[1];
- rehashidx: 记录rehash目前的进度,如果没有在rehash,则值为-1;
而哈希表又有如下属性:
- 哈希表数组,里面的元素作为哈希表的哈希桶,该数组每个元素都会指向一个dictEntry结构指针,dictEntry结构保存具体的键值对;
- 数组大小,记录了哈希表数组的大小;
- 哈希掩码,主要用于计算哈希索引值,这个属性和哈希值决定一个键在哈希表数组中的位置;
- 已有节点个数,记录哈希表目前已有节点的数量。
dictEntry结构如图中代码所示:
- key保存键值对的键的指针;
- v保存着键值对的值,值可以是一个指针,或者是unit64_t整数,或者是int64_t整数。
1.1.3.1、hash和hash冲突
作为哈希表,最重要的就是哈希函数,计算新的数据应该存入哪个哈希桶中。
具有良好统一的哈希函数的时候,才能真正的实现花费恒定时间操作哈希表。由于哈希算法计算的数据是无限的,而计算结果是有限的,因此最终会出现哈希冲突。常用的两种解决哈希冲突的方式是链地址法和开放定址法。而Redis中使用的是链地址法。
1.1.3.2、字典是如何进行rehash的?
随着哈希冲突的不断加剧,hash查找的效率也就变慢了,为了避免这种情况的出现,我们要让哈希表的负载因子维持在一个合理地范围之内。
当哈希冲突增加的时候,就需要执行rehash操作了。
rehash操作,也就是指增加哈希表中的哈希桶数量,让超负载哈希桶中的entry元素重新分散到更多的桶里面,从而减少单个桶中的元素数量,减少哈希冲突,从而提高查找效率。
rehash的流程
Redis的rehash是一个渐进的rehash的过程。为什么要这样做呢?
如果需要rehash的字典非常大,有几百上千万个键值对,那么执行rehash就要很长的时间了,这个期间有客户端需要写入新的元素,就会被卡住了,因为Redis执行命令是单线程的,最终将导致Redis服务器在这段时间不能正常提供服务,后果还是比较严重的。
这种这种情况,我们可以采用分而治之的思想,把rehash的过程分小步一步一步来处理,每一步迁移少量键值对,并在对字典的操作流程中做一些兼容处理,确保rehash流程对客户端无感知,这就是我们所说的渐进式rehash。
大致流程如下:
这样,从第0个哈希桶开始,每次执行命令的时候,都rehash下一个哈希桶中的entry,并且新增的元素直接往ht[1]添加。于是ht[0]的元素逐渐减少,最终全部转移到了ht[1]中,实现了哈希表的平滑rehash,最小程度的降低对Redis服务的影响。
1.1.4、跳跃表 SKIPLIST
Redis中的跳跃表是一种有序数据结构,通过在每个节点维持指向其他节点的指针,从而实现快速访问节点的目的。
Redis中的有序集合就是使用跳跃表来实现的。
为了简化跳跃表模型,我们先来看看,假设所有跳跃表节点的层级都是1的情况,如下图:
这个时候,跳跃表相当于一个双向链表,查找元素的复杂度为最差的O(N),即需要遍历所有的节点。
为了提高遍历效率,让跳跃表真正的跳起来,现在我们尝试在各个节点添加更多的Level,让程序可以沿着这些Level在各个节点之间来回跳跃,如下图,我们现在要查找score=9的节点,查找流程如下图红线所示:
这种查找类似于二分查找,首先从最高层L3进行查找,发现L3的下一个节点score=10,比目标节点大,于是下降到L2继续比较,发现L2的下一个节点为5,比目标节点小,于是继续找下一个节点,为10,比目标节点大,于是在score为5的节点中继续下降到L1,查找下一个节点,刚好为9,至此,我们就找到了目标节点,查找的时间复杂度为O(log N)。
如果每一层节点数是下面一层节点个数的一半,那就是最理想的类,跟二分查找一样。但是这样每次插入新元素,都会打乱上下两层链表节点个数2:1的比例关系,如果要维持这种关系,就必须对插入节点的后面所有节点进行重新调整。为了避免这种问题,跳跃表不做这个严格的上下级比例约束,而是每个节点随机出一个Level层数。
跳跃表节点如何生成level数组? 每次创建新的跳跃表节点的时候,都会根据幂次定律,随机生成一个介于1~32之间的数组大小,这个大小就是level数组元素个数。
插入节点操作只需要修改插入节点前后的指针就可以了,降低了插入的复杂度。
1.1.5、整数集合 INTSET
在Redis中,当一个集合只包含整数值,并且集合元素不多的时候,会使用整数集合保存整数集,集合中的数据不会出现重复,集合元素从小到大有序排列,可以保存int16_t,int32_t,int64_t的整数值。
下面是整数集合的数据结构:
在内存中,整数集合是在一块连续的内存空间中的,如下图所示:
- contents数组按从小到大的顺序保存集合的元素;
- encoding编码,可选编码:
- INTSET_ENC_INT16(From −32,768 to 32,767)
- INTSET_ENC_INT32(From −2,147,483,648 to 2,147,483,647)
- INTSET_ENC_INT64(From −9,223,372,036,854,775,808 to 9,223,372,036,854,775,807)
整数集合是如何升级的? 当往整数集合添加的元素比当前所有元素类型都要长的时候,需要先对集合进行升级,确保集合可以容纳这个新的元素。
升级方向:INTSET_ENC_INT16 --> INTSET_ENC_INT32 --> INTSET_ENC_INT64 注意,一旦升级之后,就不可以降回去了。
下面是一个升级的过程说明:
原本整数数组存的都是INTSET_ENC_INT16编码的元素,接下来往里面插入一个元素 32768,刚好超出了原来编码的范围,于是需要对整数集合进行升级。于是对intset进行内存扩容,扩容后也是通过一块连续空间存储的,这有可能带来一次数据拷贝。
如果要插入的元素在中间,说明不用进行升级,这个时候会使用二分查找算法找到插入的位置,然后扩容插入元素,重新调整元素位置。
intset特点
- 由于intset是从小到大排列的,所以可以进行二分查找,查找性能比ziplist的遍历查找性能高;
- intset只能存储整数,并且由于是内存紧凑的存储模式,没有携带len信息,所以每个元素必须统一编码;
- intset存储可能变成字典存储,条件:
- 添加了一个超过64bit的有符号数字之后;
- 添加的集合元素个数超过了set-max-intset-entries配置的值(默认512);
1.1.6、压缩列表 ZIPLIST
压缩列表是为了节省Redis内存,提高存储效率而开发的数据结构,是列表键和哈希键的底层实现之一,当hash中的数据项不多,并且hash中的value长度不大的时候,就会采用压缩列表存储hash。
为什么要这样设计呢?是因为ziplist不擅长存储大量数据:
- 数据量大了,每次插入或者修改引发的realloc操作可能会造成内存拷贝,加大系统开销;
- ziplist数据量大了,由于是遍历查找,查找性能会变得很低。
以下是压缩列表的数据结构:
尾节点地址 = 压缩列表起始指针地址 + zltail(偏移量)
我们再来看看压缩列表节点的结构:
xxxx部分表示存储的长度内容,或者是0~12整数。
压缩列表的content可以保存一个字节数组或者一个整数值。
如上图,ziplist通过特殊的编码约定,通过使用尽可能少的空间,很巧妙的存储了编码和长度信息,并且通过entry中的属性,可以在ziplist中进行双向遍历,效果相当于双向链表,但是占用更少的内存。
整数编码 1111xxxx为什么能存储2^4-1个整数? 由于11110000,11111110分别跟24位有符号整数和8位有符号整数冲突了,所以只能存储 00011101范围,代表113数值,但是数值应该从0开始计数,所以分别代表 0~12。
什么是ziplist连锁更新问题? 假设我们有一个ziplist,所有节点都刚好是253字节大小,突然往中间插入了一个254字节以上大小的节点,会发生什么事情呢?
如下图,由于新的节点插入之后,新节点的下一个节点的previous_entry_length为了记录新节点的大小,就必须扩大4个字节了。然后又会触发后续节点的previous_entry_length扩大4个字节,一直这样传递下去。所以这个过程也成为连锁更新。
最坏情况进行N次空间重新分配,每次重新分配最坏复杂度O(N)。触发连锁更新的最坏复杂度为O(N^2)。
但是实际情况,出现连续多个节点长度结语250~253之间的情况还是比较少的,所以实际被连续更新的节点数量不会很多,平均时间复杂度为O(N)。
由于ziplist的数据是存储在一块连续的空间中,并不擅长做修改操作,数据发生改动则会触发realloc并且触发连锁更新,可能导致内存拷贝,从而降低操作性能。
1.1.7、QUICKLIST
linkedlist由于需要存储prev和next指针,消耗内存空间比较多,另外每个节点的内存是单独分配的,会加剧内存的碎片化,影响内存管理的效率。
于是,在Redis 3.2之后的版本,重新引入了一个新的数据结构:quicklist,用来代替ziplist和linkedlist。
为何需要quicklist? 我们得先来说说ziplist和linkedlist的优缺点:
- linkedlist优点:插入复杂度很低;
- linkedlist缺点:有prev和next指针,内存开销比较大;
- ziplist优点:数据存储在一段连续的内存空间中,存储效率高;
- ziplist缺点:修改复杂度高,可能会触发连锁更新。
为了整合linkedlist和ziplist的优点,于是引入了quicklist。quicklist底层是基于ziplist和linkedlist来实现的。为了进一步节省空间,Redis还会对quicklist中的ziplist使用LZF算法进行压缩。
quicklist结构
如上图所示,quicklist结构重点属性:
- head:指向链表表头;
- tail:指向链表表尾;
- count:所有quicklistNode中的ziplist的所有entry节点数;
- len:链表的长度,即quicklistNode的个数。
quicklistNode重点属性:
- prev:指向链表前一个节点的指针;
- next:指向链表后一个节点的指针;
- zl:数据指针,如果当前节点数据没有被压缩,那么指向一个ziplist结构;否则,指向一个quicklistLZF结构;
- sz:不管有没有被压缩,sz都表示zl指向的ziplist占用的空间大小;
- count:ziplist里面包含的entry个数;
- encoding:ziplist是否被压缩,1没有被压缩,2被压缩了;
- container:预留字段,固定为2,表示使用ziplist作为数据容器;
- recompress:之前是否已经压缩过此节点?当我们使用类似index这样的命令查看已经压缩的节点数据的时候,需要暂时解压数据,通过这个属性标记后边需要把数据重新压缩。
quicklist配置项 list-max-ziplist-size:指定quicklist中每个ziplist最大的entry元素个数,负数表示:
- -5:指定快速列表的每个ziplist不能超过64 KB;
- -4:指定快速列表的每个ziplist不能超过32 KB;
- -3:指定快速列表的每个ziplist不能超过16 KB;
- -2:指定快速列表的每个ziplist不能超过8 KB。这是默认值;
- -1:指定快速列表的每个ziplist不能超过4 KB。
list-compress-depth:指定列表中两端未压缩的条目数。有效值:0到65535。
- 0:不压缩列表中的节点。这是默认值;
- 1到65535:不压缩列表两端的指定节点数,但是压缩所有中间节点。
可以看出,表头和表尾总是不会被压缩,以便在两端进行快速存取。
而实际上,Redis顶层是以对象(数据类型)的形式提供数据的操作API的,而对象的底层实现,则是用到了以上的数据结构。
接下来我们继续看看Redis中的对象。
1.2、对象
对象可以理解为Redis的数据类型,数据类型底层可以使用不同的数据结构来实现。
我们先来看看对象的基本格式:
常见的有5种数据类型,底层使用10种数据结构实现,随着Redis版本的升级,数据类型和底层的数据结构也会增加。
而这5种数据类型,根据不同场景来选择不同的编码格式,如下所示:
String(字符串)
底层实现有三种
- 1.REDIS_ENCODING_INT:使用整数值实现的字符串对象
- 2.REDIS_ENCODING_EMBSTR:使用embstr编码的简单动态字符串实现的字符串对象
- 3.REDIS_ENCODING_RAW:使用简单字符串实现的字符串对象
三种底层的编码转换
-
- REDIS_ENCODING_INT:整数,存储字符串长度小于21且能够转化为整数的字符串
- 2.embstr编码方式和raw编码方式在3.0版本之前是以小于等于39字节为分界的,而在3.2版本之后,则变成了44字节为分界
List列表
底层实现是三种
- 1.zipList:压缩列表
- 2.linkedList:底层采用双端链表
- 3.quicklist:快速列表,quickList是一个ziplist组成的双向链表。每个节点使用ziplist来保存数据。本质上来说,quicklist里面保存着一个一个小的ziplist。
编码的转换
- 同时满足一下两个条件 使用zipList
- 1.列表对象保存的所有元素的长度都小于64字节
- 2.列表元素数量小于512个
- 不满足上面两个条件使用LinkedList编码
- 以上两个上限可以修改分别是list-max-ziplist-value,list-max-ziplist-entries
注意:考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。
- Redis3.2版本开始对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。
Hash哈希
底层实现是两种
- 1.zipList:压缩列表
- 2.Hashtable 底层实现字典
编码的转换 同时满足一下两个条件:使用zipList
- 1.哈希对象保存的所有键值对键和值的字符串的长度都小于64字节
- 2.哈希对象键值对数量小于512个
- 不满足使用hashtable编码
- 以上两个上限可以修改分别是hash-max-ziplist-value,hash-max-ziplist-entries
set集合对象
底层实现是两种
- 1.intset压缩列表
- 2.Hashtable 底层实现字典
编码的转换 同时满足一下两个条件 使用intset
- 1.集合对象保存的所有元素都是整数
- 2.集合元素数量不超过512个
- 不满足使用hashtable编码
- 第二个条件可以修改分别是set-max-intset-entries
zset有序集合
底层实现是两种
- 1.zipList:压缩列表
- 2.skipList:跳跃表
编码的转换 同时满足一下两个条件 使用zipList
- 1.有序集合保存的元素字符串的长度都小于64字节
- 2.有序集合保存的元素数量小于128个
- 不满足使用skipList编码
- 以上两个上限可以修改分别是zset-max-ziplist-value,zset-max-ziplist-entries
Redis对象的其他特性:
- 对象共享:多个键都需要保存同一个字面量的字符串对象,那么多个键将共享同一个字符串对象,其中对象的refcount记录了该对象被引用的次数;
- 内存回收:Redis实现了基于引用计数的内存回收机制,当对象的refcount变为0的时候,就表示对象可以被回收了;
- 空转时长:通过OBJECT IDLETIME命令,可以获取键的空转时长,该时长为当前时间 - 对象lru时间计算得到,lru记录了对象最后一次被命令访问的时间。
接下来我们逐个讲解。
1.2.1、REDIS_STRING
REDIS_ENCODING_INT
如果一个字符串对象保存的是整数,并且可以用long类型表示,那么ptr会从void * 变为long类型,并且设置字符串编码为REDIS_ENCODING_INT。
127.0.0.1:6379> set itzhai 10000
OK
127.0.0.1:6379> OBJECT ENCODING itzhai
"int"
REDIS_ENCODING_EMBSTR
如果存储的是字符串,并且值长度小于等于44个字节,那么将使用embstr编码的SDS来保存这个字符串值。
raw编码的SDS会调用两次内存分配函数来分别创建redisObject和sdshdr结构,而embstr编码只需要调用一次内存分配函数就可以了,redisObject和sdshdr保存在一块连续的空间中,如下图:
127.0.0.1:6379> set name "itzhai"
OK
127.0.0.1:6379> OBJECT ENCODING name
"embstr"
REDIS_ENCODING_RAW
如果存储的是字符串值,并且值长度大于44字节,那么将使用SDS来保存这个字符串值,编码为raw:
27.0.0.1:6379> set raw_string "abcdefghijklmnopqrstuvwxyzabcdefghijklumnopqr"
OK
127.0.0.1:6379> STRLEN raw_string
(integer) 45
127.0.0.1:6379> OBJECT ENCODING raw_string
"raw"
127.0.0.1:6379> set raw_string "abcdefghijklmnopqrstuvwxyzabcdefghijklumnopq"
OK
127.0.0.1:6379> STRLEN raw_string
(integer) 44
127.0.0.1:6379> OBJECT ENCODING raw_string
"embstr"
注意:不同版本的Redis,Raw和embstr的分界字节数会有所调整,本节指令运行于 Redis 6.2.1
STRING是如何进行编码转换的? 浮点数会以REDIS_ENCODING_EMBSTR编码的格式存储到Redis中:
127.0.0.1:6379> OBJECT ENCODING test
"embstr"
127.0.0.1:6379> INCRBYFLOAT test 2.0
"3.28000000000000025"
127.0.0.1:6379> OBJECT ENCODING test
"embstr"
long类型的数字,存储之后,为REDIS_ENCODING_INT编码,追加字符串之后,为REDIS_ENCODING_RAW编码:
127.0.0.1:6379> set test 12345
OK
127.0.0.1:6379> OBJECT ENCODING test
"int"
127.0.0.1:6379> APPEND test " ..."
(integer) 9
127.0.0.1:6379> OBJECT ENCODING test
"raw"
REDIS_ENCODING_EMBSTR 类型的数据,操作之后,变为REDIS_ENCODING_RAW编码:
127.0.0.1:6379> OBJECT ENCODING test
"embstr"
127.0.0.1:6379> APPEND test "c"
(integer) 3
127.0.0.1:6379> OBJECT ENCODING test
"raw"
总结一下:
EMBSTR编码的字符串不管追加多少字符,不管有没有到达45字节大小,都会转为RAW编码,因为EMBSTR编码字符串没有提供修改的API,相当于是只读的,所以修改的时候,总是会先转为RAW类型再进行处理。
1.2.2、REDIS_LIST
Redis 3.2版本开始引入了quicklist,LIST底层采用的数据结构发生了变化。
Redis 3.2之前的版本 列表对象底层可以是ziplist或者linkedlist数据结构。
使用哪一种数据结构:
REDIS_ENCODING_ZIPLIST
ziplist结构的列表对象如下图所示:
REDIS_ENCODING_LINKEDLIST
linkedlist结构的列表对象如下图所示
linkedlist为双向列表,每个列表的value是一个字符串对象,在Redis中,字符串对象是唯一一种会被其他类型的对接嵌套的对象。
Redis 3.2之后的版本
而Redis 3.2之后的版本,底层采用了quicklist数据结构进行存储。
1.2.3、REDIS_HASH
哈希对象的编码可以使ziplist或者hashtable数据结构。
使用哪一种数据结构:
REDIS_ENCODING_ZIPLIST
我们执行以下命令:
127.0.0.1:6379> HSET info site itzhai.com
(integer) 1
127.0.0.1:6379> HSET info author arthinking
(integer) 1
得到如下ziplist结构的哈希对象:
REDIS_ENCODING_HT
hashtable结构的哈希对象如下图所示:
其中,字典的每个键和值都是一个字符串对象。
1.2.4、REDIS_SET
集合对象的编码可以是intset或者hashtable数据结构。
使用哪一种数据结构:
REDIS_ENCODING_INTSET
执行以下命令:
127.0.0.1:6379> SADD ids 1 3 2
(integer) 3
127.0.0.1:6379> OBJECT ENCODING ids
"intset"
则会得到一个intset结构的集合对象,如下图:
REDIS_ENCODING_HT 执行以下命令:
127.0.0.1:6379> SADD site_info "itzhai.com" "arthinking" "Java架构杂谈"
(integer) 3
127.0.0.1:6379> OBJECT ENCODING site_info
"hashtable"
则会得到一个hashtable类型的集合对象,hashtable的每个键都是一个字符串对象,每个字符串对象包含一个集合元素,hashtable的值全部被置为NULL,如下图:
1.2.5、REDIS_ZSET
有序集合可以使用ziplist或者skiplist编码。
使用哪一种编码:
REDIS_ENCODING_ZIPLIST
执行以下命令:
127.0.0.1:6379> ZADD weight 1.0 "Java架构杂谈" 2.0 "arthinking" 3.0 "itzhai.com"
(integer) 3
127.0.0.1:6379> OBJECT ENCODING weight
"ziplist"
127.0.0.1:6379> ZRANGE weight 1 2
1) "arthinking"
2) "itzhai.com"
则会得到一个ziplist编码的zset,如下图:
REDIS_ENCODING_SKIPLIST
执行以下命令:
127.0.0.1:6379> ZADD weight 1 "itzhai.com" 2 "aaaaaaaaaabbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffgggggg"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING weight
"skiplist"
则会得到一个skiplist编码的zset,skiplist编码的zset底层同时包含了一个字典和跳跃表:
typedef struct zset {
zskiplist *zsl;
dict * dict;
} zset;
如下图所示:
其中:
- 跳跃表按照分值从小到大保存了所有的集合元素,一个跳跃表节点对应一个集合元素,object属性保存元素成员,score属性保存元素的分值,ZRANK,ZRANGE,ZCARD,ZCOUNT,ZREVRANGE等命令基于跳跃表来查找的;
- 字典维护了一个从成员到分值的映射,通过该结构查找给定成员的分值(ZSCORE),复杂度为O(1);
- 实际上,字典和跳跃表会共享元素成员和分值,所以不会造成额外的内存浪费。
1.2.6、REDIS_MODULE
从Redis 4.0开始,支持可扩展的Module,用户可以根据需求自己扩展Redis的相关功能,并且可以将自定义模块作为插件附加到Redis中。这极大的丰富了Redis的功能。
关于Modules的相关教程:Redis Modules: an introduction to the API
Redis第三方自定义模块列表(按照GitHub stars数排序):Redis Modules
1.2.7、REDIS_STREAM
这是Redis 5.0引入的一个新的数据类型。为什么需要引入这个数据类型呢,我们可以查阅一下:RPC11.md | Redis Change Proposals:
This proposal originates from an user hint: During the morning (CEST) of May 20th 2016 I was spending some time in the #redis channel on IRC. At some point Timothy Downs, nickname forkfork wrote the following messages: <forkfork> the module I'm planning on doing is to add a transaction log style data type - meaning that a very large number of subscribers can do something like pub sub without a lot of redis memory growth <forkfork> subscribers keeping their position in a message queue rather than having redis maintain where each consumer is up to and duplicating messages per subscriber
这使得Redis作者去思考Apache Kafka提供的类似功能。同时,它有点类似于Redis LIST和Pub / Sub,不过有所差异。
STREAM工作原理
如上图:生产者通过XADD API生产消息,存入Stream中;通过XGROUP相关API管理分组,消费者通过XREADGROUP命令从消费分组消费消息,同一个消费分组的消息只会分配各其中的一个消费者进行消费,不同消费分组的消息互不影响(可以重复消费相同的消息)。
Stream中存储的数据本质是一个抽象日志,包含:
- 每条日志消息都是一个结构化、可扩展的键值对;
- 每条消息都有一个唯一标识ID,标识中记录了消息的时间戳,单调递增;
- 日志存储在内存中,支持持久化;
- 日志可以根据需要自动清理历史记录。
Stream相关的操作API
- 添加日志消息:
- XADD:这是将数据添加到Stream的唯一命令,每个条目都会有一个唯一的ID:
# * 表示让Redis自动生成消息的ID
127.0.0.1:6379> XADD articles * title redis author arthinking
# 自动生成的ID
1621773988308-0
127.0.0.1:6379> XADD articles * title mysql author Java架构杂谈
1621774078728-0
- 读取日志消息:
- XRANGE key start end [COUNT count]:按照ID范围读取日志消息;
- XREVRANGE key end start [COUNT count]:以反向的顺序返回日志消息;
- XREAD:按照ID顺序读取日志消息,可以从多个流中读取,并且可以以阻塞的方式调用:
# 第一个客户端执行以下命令,$ 表示获取下一条消息,进入阻塞等待
127.0.0.1:6379> XREAD BLOCK 10000 STREAMS articles $
# 第二个客户端执行以下命令:
127.0.0.1:6379> XADD articles * title Java author arthinking
1621774841555-0
# 第一个客户端退出阻塞状态,并输出以下内容
articles
1621774841555-0
title
Java
author
arthinking
-
删除日志消息:
- XDEL key ID [ID ...]:从Stream中删除日志消息; *XTRIM key MAXLEN|MINID [=|~] threshold [LIMIT count]:XTRIM将Stream裁剪为指定数量的项目,如有需要,将驱逐旧的项目(ID较小的项目);
-
消息消费:
- XGROUP:用于管理消费分组:
# 给articles流创建一个group,$表示使用articles流最新的消息ID作为初始ID,后续group内的Consumer从初始ID开始消费
127.0.0.1:6379> XGROUP CREATE articles group1 $
# 指定消费组的消费初始ID
127.0.0.1:6379> XGROUP SETID articles group1 1621773988308-0
OK
# 删除指定的消费分组
127.0.0.1:6379> XGROUP DESTROY articles group1
1
-
- XREADGROUP:XREADROUP是对XREAD的封装,支持消费组:
# articles流的group1分组中的消费者consumer-01读取消息,> 表示读取没有返回过给别的consumer的最新消息
127.0.0.1:6379> XREADGROUP GROUP group1 consumer-01 COUNT 1 STREAMS articles >
articles
1621774078728-0
title
mysql
author
Java架构杂谈
-
- XPENDING key group [start end count] [consumer]:检查待处理消息列表,即每个消费组内消费者已读取,但是尚未得到确认的消息;
- XACK:Acknowledging messages,用于确保客户端正确消费了消息之后,才提供下一个消息给到客户端,避免消息没处理掉。执行了该命令后,消息将从消费组的待处理消息列表中移除。如果不需要ACK机制,可以在XREADGROUP中指定NOACK:
127.0.0.1:6379> XACK articles group1 1621776474608-0
1
-
- XCLAIM:如果某一个客户端挂了,可以使用此命令,让其他Consumer主动接管它的pending msg:
# 1621776677265-0 消息闲置至少10秒并且没有原始消费者或其他消费者进行推进(确认或者认领它)时,将所有权分配给消费者consumer-02
XCLAIM articles group1 consumer-02 10000 1621776677265-0
- 运行信息:
- XINFO:用于检索关于流和关联的消费者组的不同的信息;
- XLEN:给出流中的条目数。
Stream与其他数据类型的区别
特性 | Stream | List, Pub/Sub, Zset |
---|---|---|
查找元素复杂度 | O(long(N)) | List: O(N) |
偏移量 | 支持,每个消息有一个ID | List:不支持,如果某个项目被逐出,则无法找到最新的项目 |
数据持久化 | 支持,Streams持久化道AOF和RDB文件中 | Pub/Sub:不支持 |
消费分组 | 支持 | Pub/Sub:不支持 |
ACK | 支持 | Pub/Sub:不支持 |
性能 | 不受客户端数量影响 | Pub/Sub:受客户端数量影响 |
数据逐出 | 流通过阻塞以驱逐太旧的数据,并使用Radix Tree和listpack来提高内存效率 | Zset消耗更多内存,因为它不支持插入相同项目,阻止或逐出数据 |
随机删除元素 | 不支持 | Zset:支持 |
Redis Stream vs Kafka Apache Kafka是Redis Streams的知名替代品,Streams的某些功能更收到Kafka的启发。Kafka运行所需的配套比较昂贵,对于小型、廉价的应用程序,Streams是更好的选择。
1.3、REDIS高级功能
基于基础的数据类型,Redis扩展了一些高级功能,如下图所示:
数据类型 | 数据类型 |
---|---|
Bitmap | REDIS_STRING |
HyperLogLog | REDIS_STRING |
Bloom Filter | REDIS_STRING,Bitmap实现 |
Geospatial | REDIS_ZSETZ |
1.3.1、BITMAP
Bitmap,位图算法,核心思想是用比特数组,将具体的数据映射到比特数组的某个位置,通过0和1记录其状态,0表示不存在,1表示存在。通过使用BitMap,可以将极大域的布尔信息存储到(相对)小的空间中,同时保持良好的性能。
由于一条数据只占用1个bit,所以在大数据的查询,去重等场景中,有比较高的空间利用率。
注意:BitMap数组的高低位顺序和字符字节的位顺序是相反的。
由于位图没有自己的数据结构,BitMap底层使用的是REDIS_STRING进行存储的。而STRING的存储上限是512M(2^32 -1),如下,最大上限为4294967295:
127.0.0.1:30001> setbit user_status 4294967295 1
0
127.0.0.1:30001> memory usage user_status
536887352
127.0.0.1:30001> setbit user_status 4294967296 1
ERR bit offset is not an integer or out of range
如果要存储的值大于2^32 -1,那么就必须通过一个的数据切分算法,把数据存储到多个bitmap中了。
Redis中BitMap相关API
- SETBIT key offset value:设置偏移量offset的值,value只能为0或者1,O(1);
- GETBIT key offset:获取偏移量的值,O(1);
- BITCOUNT key start end:获取指定范围内值为1的个数,注意:start和end以字节为单位,而非bit,O(N);
- BITOP [operations] [result] [key1] [key2] [key...]:BitMap间的运算,O(N)
- operations:位移操作符
- AND 与运算 &
- OR 或运算 |
- XOR 异或 ^
- NOT 取反 ~
- result:计算的结果,存储在该key中
- key:参与运算的key
- 注意:如果操作的bitmap在不同的集群节点中,则会提示如下错误:CROSSSLOT Keys in request don't hash to the same slot,可以使用HashTag把要对比的bitmap存储到同一个节点中;
- operations:位移操作符
- BITPOS [key] [value]:返回key中第一次出现指定value的位置
如下例子,两个bitmap AND操作结果:
127.0.0.1:30001> SETBIT {user_info}1 1001 1
0
127.0.0.1:30001> SETBIT {user_info}2 1001 0
1
127.0.0.1:30001> BITOP AND {user_info}3 {user_info}1 {user_info}2
126
127.0.0.1:30001> GETBIT {user_info}3 1001
0
性能与存储评估 关于BitMap的空间大小 BitMap空间大小是一个影响性能的主要因素,因为对其主要的各种操作复杂度是O(N),也就意味着,越大的BitMap,执行运算操作时间越久。
Redis的BitMap对空间的利用率是很低的,我们可以做个实验:
127.0.0.1:30002> SETBIT sign_status 100000001 1
1
127.0.0.1:30002> memory usage sign_status
12501048
可以看到,我们只是往BitMap里面设置了一位,就给BitMap分配了12501048的空间大小。
这是由于Redis的BitMap的空间分配策略导致的。由于底层是用的Redis字符串存储的,所以扩容机制跟字符串一致。执行SETBIT命令,当空间不足的时候,就会进行扩容,以确保可以在offset处保留一个bit。
所以我们一开始给100000001偏移量进行设置,就会立刻申请一个足够大的空间,这个申请过程中,会短时间阻塞命令的执行。
为了避免使用较大的BitMap,Redis文档建议将较大的BitMap拆分为多个较小的BitMap,处理慢速的BITOP建议在从节点上执行。提前拆分,这样可以了更好的应对未来潜在的数据增长。
关于BitMap的存储空间优化 从上面的分析可知,直接就设置很大的offset,会导致数据分布式很稀疏,产生很多连续的0。针对这种情况,我们可以采用RLE(行程编码,Run Length Encoding, 又称游程编码、行程长度编码、变动长度编码)编码对存储空间进行优化,不过Redis中是没有做相关存储优化的。
大致的思想是:表达同样的一串数字 000000,没有编码的时候是这样说的 :零零零零零零,编码之后可以这样说:6个零,是不是简单很多呢?
给如下的BitMap:
000000110000000000100001000000
可以优化存储为:
0,6,2,10,1,4,1,6
表示第一位是0,连续有6个0,接下来是2个1,10个0,1个1,4个0,1个1,6个0。这个例子只是大致的实现思路。
guava中的EWAHCompressedBitmap是一种压缩的BitMap的实现。EWAH是完全基于RLE进行压缩的,很好的解决了存储空间的浪费问题。
1.3.2、BLOOM FILTER
考虑一个这样的场景,我们在网站注册账号,输入用户名,需要立刻检测用户名是否已经注册过,如果已注册的用户数量巨大,有什么高效的方法验证用户名是否已经存在呢?
我们可能会有以下的解法:
- 线性查找,复杂度O(n)这是最低效的方式;
- 二分查找,复杂度O(log2n),比线性查找好很多,但是仍绕需要多个查找步骤;
- HashTable查找,快速,占用内存多。
而如果使用Boolean Filter,则可以做到既节省资源,执行效率又高。
布隆过滤器是一种节省空间的概率数据结构,用于测试元素是否为集合的成员,底层是使用BitMap进行存储的。
例如,检查用户名是否存在,其中集合是所有已注册用户名的列表。
它本质上是概率性的,这意味着可能会有一些误报:它可能表明给定的用户名已被使用,但实际上未被使用。
布隆过滤器的有趣特性
- 与HashTable不同,固定大小的布隆过滤器可以表示具有任意数量元素的集合;
- 添加元素永远不会失败。但是,随着元素的添加,误判率会越来越高。如果要降低误报结果的可能性,则必须使用更多数量的哈希函数和更大的位数组,这会增加延迟;
- 无法从过滤器中删除元素,因为如果我们通过清除k个散列函数生成的索引处的位来删除单个元素,则可能会导致其他几个元素的删除;
- 重点:判定不在的数据一定不存在,存在的数据不一定存在。
Bloom Filter如何工作 我们需要k个哈希函数来计算给定输入的哈希值,当我们要在过滤器中添加项目时,设置k个索引h1(x), h2(x), ...hk(x)的位,其中使用哈希函数计算索引。
如下图,假设k为3,我们在Bloom Filter中标识"itzhai.com"存在,则需要分别通过三个哈希函数计算得到三个偏移量,分别给这三个偏移量中的值设置为1:
当我们需要检索判断"itzhai.com"是否存在的时候,则同样的使用者三个哈希函数计算得到三个偏移量,判断三个偏移量所处的位置的值是否都为1,如果为1,则表示"itzhai.com"是存在的。
Bloom Filter中的误判
假设我们继续往上面的Bloom Filter中记录一个新的元素“Java架构杂谈”,这个时候,我们发现h3函数计算出来的偏移量跟上一个元素相同,这个时候就产生了哈希冲突:
这就会导致,即使偏移量为12的这个值为1,我们也不知道究竟是“Java架构杂谈”这个元素设置进去的,还是"itzhai.com"这个元素设置进去的,这就是误判产生的原因。
在Bloom Filter中,我们为了空间效率而牺牲了精度。
如何减少误判 Bloom Filter的精度与BitMap数组的大小以及Hash函数的个数相关,他们之间的计算公式如下:
p = pow(1−exp(−k/(m/n)),k)
其中:
- m:BitMap的位数
- k:哈希函数的个数
- n:Bloom Filter中存储的元素个数
为了更方便的合理估算您的数组大小和哈希函数的个数,您可以使用Thomas Hurst提供的这个工具来进行测试:Bloom Filter Calculator。
Redis中的Bloom Filter Redis在4.0版本开始支持自定义模块,可以将自定义模块作为插件附加到Redis中,官网推荐了一个RedisBloom[8]作为Redis布隆过滤器的Module。可以通过以下几行代码,从Github获取RedisBloom,并将其编译到rebloom.so文件中:
$ git clone --recursive git@github.com:RedisBloom/RedisBloom.git
$ cd RedisBloom
$ make
$ redis-server --loadmodule /path/to/rebloom.so
这样,Redis中就支持Bloom Filter过滤器数据类型了:
BF.ADD visitors arthinking
BF.EXISTS visitors arthinking
除了引入这个模块,还有以下方式可以引入Bloom Filter:
- pyreBloom(Python + Redis + Bloom Filter = pyreBloom);
- lua脚本来实现;
- 直接通过Redis的BitMap API来封装实现。
Bloom Filter在业界的应用
- Medium使用Bloom Filter通过过滤用户已看到的帖子来向用户推荐帖子;
- Quora在Feed后端中实现了一个共享的Bloom Filter,以过滤掉人们以前看过的故事;
- Google Chrome浏览器曾经使用Bloom Filter来识别恶意网址;
- Google BigTable,Apache HBase和Apache Cassandra以及Postgresql使用Bloom Filter来减少对不存在的行或列的磁盘查找。
1.3.3、HYPERLOGLOG
HyperLogLog是从LogLog算法演变而来的概率算法,用于在不用存储大集合所有元素的情况下,统计大集合里面的基数。
基数:本节该术语用于描述具有重复元素的数据流中不同元素的个数。而在多集合理论中,该术语指的是多集合的重复元素之和。
HyperLogLog算法能够使用1.5KB的内存来估计超过10^9个元素的基数,并且控制标准误差在2%。这比位图或者Set集合节省太多的资源了 。
HyperLogLog算法原理 接下来我们使用一种通俗的方式来讲讲HyperLogLog算法的原理,不做深入推理,好让大家都能大致了解。
我们先从一个事情说起:你正在举办一个画展,现在给你一份工作,在入口处记下每一个访客,并统计出有多少个不重复的访客,允许小的误差,你会如何完成任务呢?
你可以把所有的用户电话号码都记下来,然后每次都做一次全量的对比,统计,得到基数,但这需要耗费大量的工作,也没法做到实时的统计,有没有更好的方法呢?
我们可以跟踪每个访客的手机号的后6位,看看记录到的后六位的所有号码的最长前导0序列。
例如:
- 123456,则最长前导0序列为0
- 001234,则最长前导0序列为2
随着你记录的人数越多,得到越长的前导0序列的概率就越大。
在这个案例中,平均每10个元素就会出现一次0序列,我们可以这样推断:
假设L是您在所有数字中找到的最长前导0序列,那么估计的唯一访客接近10ᴸ。
当然了,为了获得更加均匀分布的二进制输出,我们可以对号码进行哈希处理。最后在引入一个修正系数φ用于修正引入的可预测偏差,最终我能可以得到公式:
2ᴸ/ φ
这就是Flajolet-Martin算法(Philippe Flajolet和G. Nigel Martin发明的算法)。
但是假如我们第一个元素就碰到了很长的前导0序列,那么就会影响我们的预测的准确度了。
为此,我们可以将哈希函数得到的结果拆到不同的存储区中,使用哈希值的前几位作为存储区的索引,使用剩余内容计算最长的前导0序列。根据这种思路我们就得到了LogLog算法。
为了得到更准确的预测结果,我们可以使用调和平均值取代几何平均值来平均从LogLog得到的结果,这就是HyperLogLog的基本思想。
更加详细专业的解读,如果觉得维基百科的太学术了,不好理解,可以进一步阅读我从网上找的几篇比较好理解的讲解:
- Redis 中 HyperLogLog 讲解
- HyperLogLog: A Simple but Powerful Algorithm for Data Scientists
- Redis 中 HyperLogLog 的使用场景
Redis中的HyperLogLog Redis在2.8.9版本中添加了HyperLogLog结构。在Redis中,每个HyperLogLog只需要花费12KB的内存,就可以计算接近2^64个不同元素的基数。
Redis中提供了以下命令来操作HyperLogLog:
- PFADD key element [element ...]:向HyperLogLog添加元素
- PFCOUNT key [key ...]:获取HyperLogLog的基数
- PFMERGE destkey sourcekey [sourcekey ...]:将多个HyperLogLog合并为一个,合并后的HyperLogLog基数接近于所有被合并的HyperLogLog的并集基数。
以下是使用例子:
# 存储第一个HyperLogLog
127.0.0.1:6379> PFADD visitors:01 arthinking Jim itzhai
1
# 获取第一个HyperLogLog的基数
127.0.0.1:6379> PFCOUNT visitors:01
3
# 存储第二个HyperLogLog
127.0.0.1:6379> PFADD visitors:02 arthinking itzhai Jay
1
# 获取第二个HyperLogLog的基数
127.0.0.1:6379> PFCOUNT visitors:02
3
# 合并两个HyperLogLog
127.0.0.1:6379> PFMERGE result visitors:01 visitors:02
OK
# 获取合并后的基数
127.0.0.1:6379> PFCOUNT result
4
# 获取HyperLogLog中存储的内容
127.0.0.1:6379> GET result
"HYLL\x01\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00Lo\x80JH\x80GD\x84_;\x80B\xc1"
1.3.4、GEOSPATIAL
现在的App,很多都会利用用户的地理位置,做一些实用的功能,比如,查找附近的人,查找附近的车,查找附近的餐厅等。
Redis 3.2开始提供的一种高级功能:Geospatial(地理空间),基于GeoHash和有序集合实现的地理位置相关的功能。
如果叫你实现一个这样的功能,你会如何实现呢?
用户的地理位置,即地理坐标系统中的一个坐标,我们的问题可以转化为:在坐标系统的某个坐标上,查找附近的坐标。
而Redis中的Geo是基于已有的数据结构实现的,已有的数据结构中,能够实现数值对比的就只有ZSET了,但是坐标是有两个值组成的,怎么做比较呢?
核心思想:将二维的坐标转换为一维的数据,就可以通过ZSET,B树等数据结构进行对比查找附近的坐标了。
我们可以基于GeoHash编码,把坐标转换为一个具体的可比较的值,再基于ZSET去存储获取。
GeoHash编码 GeoHash编码的大致原理:将地球理解为一个二维平面,将平面递归分解为子块,每个子块在一定的经纬度范围有相同的编码。
总结来说就是利用二分法分区间,再给区间编码,随着区块切分的越来越细,编码长度也不断追加,变得更精确。
为了能够直观的对GeoHash编码有个认识,我们来拿我们的贝吉塔行星来分析,如下图,我们把行星按照地理位置系统展开,得到如下图所示的坐标系统:
我们可以给坐标系统上面的区块进行分块标识,规则如下:
- 把纬度二等分:左边用0表示,右边用1表示;
- 把经度二等分:左边用0表示,右边用1表示。
偶数位放经度,奇数位放维度,划分后可以得到下图:
其中,箭头为数值递增方向。上图可以映射为一维空间上的连续编码: 00 01 10 11,相邻的编码一般距离比较接近。
我们进一步递归划分,划分的左边或者下边用0表示,右边或者上边用1表示,可以得到下图:
GeoHash Base32编码 最后,使用以下32个字符对以上区块的经纬度编码进行base 32编码,最终得到区块的GeoHash Base32编码。
同一个区块内,编码是相同的。可以发现:
- 编码长度越长,那么编码代表的区块就越精确;
- 字符串相似编码所代表的区块距离相近,但有特殊情况;
根据Geohash可知,当GeoHash Base32编码长度为8的时候,距离精度在19米左右。
关于区块距离的误差
基于GeoHash产生的空间填充曲线最大缺点是突变性:有些编码相邻但是距离却相差很远,如下图:
因此,为了避免曲突变造成的影响,在查找附近的坐标的时候,首先筛选出GeoHash编码相近的点,然后进行实际的距离计算。
Redis中的Geo Redis中的Geo正是基于GeoHasn编码,把地理坐标的编码值存入ZSET中,进行查找的。
下面我们演示一下相关API的用法:
# 添加国内的6个旅游地点
127.0.0.1:6379> GEOADD destinations 100.26764 25.60648 大理
1
127.0.0.1:6379> GEOADD destinations 99.74317 27.84254 香格里拉
1
127.0.0.1:6379> GEOADD destinations 100.29829 29.03704 稻城
1
127.0.0.1:6379> GEOADD destinations 119.73572 49.21336 呼伦贝尔
1
127.0.0.1:6379> GEOADD destinations 117.37836 49.59655 满洲里
1
127.0.0.1:6379> GEOADD destinations 116.23128 40.22077 北京
1
# 查找坐标 116.23128 40.22077 1500公里范围内的旅游地点
127.0.0.1:6379> GEORADIUS destinations 116.23128 40.22077 1500 km ASC COUNT 10
北京
呼伦贝尔
满洲里
# 查找坐标 116.23128 40.22077 2000公里范围内的旅游地点
127.0.0.1:6379> GEORADIUS destinations 116.23128 40.22077 2000 km ASC COUNT 10
北京
呼伦贝尔
满洲里
稻城
# 查找坐标 116.23128 40.22077 3000公里范围内的旅游地点
127.0.0.1:6379> GEORADIUS destinations 116.23128 40.22077 3000 km ASC COUNT 10
北京
呼伦贝尔
满洲里
稻城
香格里拉
大理
正是因为命令执行完全是在内存中处理的,所以redis执行速度非常快,但是为了数据的持久化,我们就不能离开磁盘了,因为一旦断点,内存的数据就都丢失了。
下面再来讲讲Redis是怎么通过磁盘来提供数据持久化,和宕机后的数据恢复的。
二、磁盘
Redis是一个内存的键值对数据库,但是要是服务进程挂了,如何恢复数据呢?这个时候我们就要来讲讲Redis的持久化了。
Redis的持久化有两种方式:RDB和AOF。
2.1、RDB
RDB是Redis持久化存储内存中的数据的文件格式。RDB,即Redis Database的简写。也称为内存快照。
2.1.1、如何创建RDB文件?
触发生成RDB文件命令是SAVE和BGSAVE。从命名的命名就可以知道,BGSAVE是在后台运行的:
- SAVE:执行SAVE命令创建RDB文件的过程中,会阻塞Redis服务进程,此时服务器不能处理任何命令;
- BGSAVE:BGSAVE会派生出一个子进程来创建RDB文件,Redis父进程可以继续处理命令请求。
BGSAVE执行流程
BGSAVE执行流程如下:
在发起BGSAVE命令之后,Redis会fork出一个子进程用于执行生成RDB文件。fork的时候采用的是写时复制(Copy-on-write)技术。不会立刻复制所有的内存,只是复制了页表,保证了fork执行足够快。如上图,Redis父进程和执行BGSAVE的子进程的页表都指向了相同的内存,也就是说,内存并没有立刻复制。
然后子进程开始执行生成RDB。
在生成RDB过程中,如果父进程有执行新的操作命令,那么会复制需要操作的键值对,父子进程之间的内存开始分离:
如上图,父进程执行命令修改了一些键值对的时候,该部分键值对实际上会复制一份进行修改,修改完成之后,父进程中的该内存数据的指针会指向被复制的的内存。而子进程继续指向原来的数据,原来的数据内容是不会被修改的。
在生成RDB文件过程中,父进程中对数据的修改操作不会被持久化。
执行BGSAVE会消耗很多内存吗? 由上面描述可知,BGSAVE并不会立刻复制内存数据,而是采用了写时复制技术,所以并不会立刻消耗很多内存。
但是如果Redis实例写比读的执行频率高很多,那么势必会导致执行BGSAVE过程中大量复制内存数据,并且消耗大量CPU资源,如果内存不足,并且机器开启了Swap机制,把部分数据从内存swap到磁盘上,那么性能就会进一步下降了。
服务端什么时候会触发BGSAVE?
Redis怎么知道什么时候应该创建RDB文件呢?我们得来看看redisServer中的几个关键属性了,如下图:
- dirty计数器:记录距离上一次成功执行SAVE或者BGSAVE命令之后,服务器数据库进行了多少次修改操作(添加、修改、删除了多少个元素);
- lastsave时间:一个Unix时间戳,记录服务器上一次成功执行SAVE或者BGSAVE命令的时间;
- saveparams配置:触发BGSAVE命令的条件配置信息,如果没有手动设置,那么服务器默认设置如上图所示:
- 服务器在900秒内对数据库进行了至少1次修改,那么执行BGSAVE命令;
- 服务器在300秒内对数据库进行了至少10次修改,那么执行BGSAVE命令;
- 服务器在60秒内对数据库进行了至少10000次修改,那么只需BGSAVE命令。
Redis默认的每隔100毫秒会执行一次serverCron函数,检查并执行BGSAVE命令,大致的处理流程如下图所示:
2.1.2、如何从RDB文件恢复?
Redis只会在启动的时候尝试加载RDB文件,但不是一定会加载RDB文件的,关键处理流程如下图:
服务器在载入RDB文件期间,一直处于阻塞状态。
2.1.3、RDB文件结构是怎样的?
我们用一张图来大致了解下RDB文件的结构,如下图所示:
具体格式以及格式说明参考上图以及图中的描述。
而具体的value,根据不同的编码有不同的格式,都是按照约定的格式,紧凑的存储起来。
2.2、AOF
从上一节的内容可知,RDB是把整个内存的数据按照约定的格式,输出成一个文件存储到磁盘中的。
而AOF(Append Only File)则有所不同,是保存了Redis执行过的命令。
AOF,即Append Only File的简写。
我们先来看看,执行命令过程中是如何生成AOF日志的:
如上图,是Redis执行命令过程中,产生AOF日志的一个过程:
- 执行完命令之后,相关数据立刻写入内存;
- 追加命令到AOF缓冲区(对应redisServer中的aof_buf属性),该缓冲区用于把AOF日志追写回到磁盘的AOF文件中,有三种不同的写回策略,由appendfsync参数控制:
- Always:同步写回,每个写命令执行完毕之后,立刻将AOF缓冲区的内容写入到AOF文件缓冲区,并写回磁盘;
- Everysec:每秒写回,每个写命令执行完后,将AOF缓冲区所有内容写入到AOF文件缓冲区,如果AOF文件上次同步时间距离现在超过了一秒,那么将再次执行AOF文件同步,将AOF文件写回磁盘;
- No:操作系统控制写回,每个写命令执行完毕之后,将AOF缓冲区的内容写入到AOF文件缓冲区,具体写回磁盘的时间,由操作系统的机制来决定。
2.2.1、AOF文件格式
AOF文件格式如上图最右边所示:
- *3:表示当前命令有三部分;
- $3:每个部分以$ + 数字打头,数字表示这部分有多少字节;
- 字符串:该部分具体的命令内容。
2.2.2、应该用哪种AOF写回策略?
可以看到appendfsync是实现数据持久化的关键技术了,哪种写回策略最好呢?
这里,我们通过一个表格来对比下:
写回策略 | 写回时机 | 优点 | 缺点 |
---|---|---|---|
Always | 同步写回 | 数据基本不丢失 | 每次写数据都要同步,性能较差 |
Everysec | 每秒写回 | 性能与可靠性的平衡 | 宕机将丢失一秒的数据 |
No | 操作系统控制写回 | 性能好 | 宕机将丢失上一次同步以来的数据 |
2.2.3、如何通过AOF实现数据还原?
为了实现数据还原,需要把AOF日志中的所有命令执行一遍,而Redis命令只能在客户端上下文中执行,所以会先创建一个不带网络套接字的伪客户端进行执行命令,大致流程如下:
2.2.4、AOF文件太大了,影响载入速度怎么办?
如果AOF文件太大,需要执行的命令就很多,载入速度回变慢,为了避免这种问题,Redis中提供了AOF重写机制,把原来的多个命令压缩成一个,从而减小AOF文件的大小和AOF文件中的命令数量,加快AOF文件的载入速度。
Redis中触发AOF重写的是bgrewriteaof命令。
要注意的是,AOF重写并不是读取原来的AOF文件的内容进行重写,而是根据系统键值对的最新状态,生成对应的写入命令。
重写效果 比如执行了以下命令:
RPUSH list "a
RPUSH list "b" "c"
RPOP list
HMSET map "site" "itzhai.com"
HMSET map "author" "arthinking"
HMSET map "wechat" "Java架构杂谈"
那么,理想的情况,执行AOF重写之后,生成的AOF文件的内容会变为如下所示:
RPUSH list "a" "b"
HMSET map "site" "itzhai.com" "author" "arthinking" "wechat" "Java架构杂谈"
最终,每个键,都压缩成了一个命令。
如果集合中的元素太多了,如何生成命令? 为了避免命令太长,导致客户端输入缓冲区溢出,重写生成命令的时候,会检查元素个数,如果超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD(64),那么将拆分为多条命令。
AOF重写运行原理
重写运行原理如下图所示:
- 1、触发bgrewriteaof命令;
- 2、fork子进程,采用写时复制,复制页表;
- 如果此时父进程还需要执行操作命令,则会拷贝内存数据并修改,同时追加命令到AOF缓冲区和AOF重写缓冲区中;
- 3、根据内存数据生成AOF文件;
- 4、生成完成之后,向父进程发送信号;
- 5、父进程执行信号处理函数,这一步会把AOF重写缓冲区中的数据追加到新生成的AOF文件中,最终替换掉旧的AOF文件。
AOF重写涉及到哪些关键设计点?
- 不停服:所谓的不停服,指的是父进程可以继续执行命令;
- 双写:因为重写不一定会成功,所以在重写过程中执行的操作命令,需要同时写到AOF缓冲区和AOF重写缓冲区中。这样一来:
- 即使重写失败了,也可以继续使用AOF缓冲区,把数据回写到AOF文件;
- 如果重写成功了,那么就把AOF重写缓冲区的数据追加到新的AOF文件即可;
- 内存优化:这里采用的是写时复制技术,保证fork效率,以及尽可能少的占用过多的内存。
2.3、有没有那种快速恢复、开销小、数据不丢失的持久化方案?
Redis 4.0开始提供了RDB+AOF的混合持久化方式,结合了两者的优点。对应的配置项开关为:aof-use-rdb-preamble。
开启了混合持久化之后,serverCron定时任务以及BGREWRITEAOF命令会触发生成RDB文件,在两次生成RDB文件之间执行的操作命令,使用AOF日志记录下来。
最终生成的RDB和AOF都存储在一个文件中。
通过这种方案,基本保证了数据的不丢失,但是在分布式主从集群系统中,一旦发生了故障导致主从切换或者脑裂问题,就不可避免的导致主从数据不一致,可能导致数据丢失。有没有修复主从数据不一致问题的方法决定了数据会不会丢失,很可惜,Redis不管怎么做,都是有可能丢失消息的,我们在分布式章节会详细介绍这类问题。
2.4、RDB、AOF仍然不够快?
虽然RDB文件生成,或者AOF重写都用上了写时复制技术,但是如果内存中的数据实在太多了,那也是会造成Redis实例阻塞的。
有没有更好的方案呢?有,可以实现的思路:让内存中的数据不能保存太多,内存只存储热点数据,对于冷数据,可以写入到SSD硬盘中,再把未写入SSD硬盘的数据通过某种方式记录下来,可以参考MySQL的binlog,这样不用RDB或者AOF,就实现了数据的持久化。
比如,360开源的Pika。
不过该方案也是有缺点的,如果要频繁的从SSD中加载数据,那么查询的性能就会低很多。另外SSD硬盘的使用寿命也和擦写次数有关,频繁的改写,SSD硬盘成本也是一个问题。
这种方案适合需要大容量存储、访问延迟没有严格要求低业务场景中使用。