一、存储

1、内存

既然要把数据放到内存中,就需要提供索引数据的方式,常见的索引实现技术有:Hash表,B+树,字典树等。MySQL中的索引是通过B+树实现的。而Redis作为KV内存数据库,其是采用哈希表来实现索引的。

为了实现键值快速访问,Redis使用了个哈希表来存储所有的键值对。

在内存中的布局如下: image.png

哈希桶中存储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类内部定义了常量数组进行存储字符串,是不可以修改的,每次对字符串操作都会另外分配一个新的常量数组空间。 image.png

而Redis中的字符串是动态的。字符串是Redis中最为常见的存储类型,底层采用简单动态字符串实现(SDS[1], simple dynamic string),是可以修改的字符串,类似于Java中的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。

Redis中的键值对中的键、值里面的字符串、缓冲区、AOF缓冲区、等都是用的SDS。

下面是SDS数据结构的一个图示: image.png

亮点

  • 获取字符串长度时间复杂度为O(1),而C语言则需要遍历整个数组才能得到长度;
  • 采用空间预分配避免反复对字节数组进程扩容,如上图的SDS,还有2个字节的空闲空间,如果只是追加一个字符,就不用扩容了。避免了类似C语言要手动重新分配内存的情况下,忘记了进行分配而导致的缓冲区溢出或者内存泄露问题;
  • 惰性空间释放:当要缩短字符串长度的时候,程序不会立刻释放内存,而是通过free属性将这些需要释放的字节数量记录下来,等待将来重复使用;
  • 二进制安全:所有的SDS API都会以处理二进制的方式来处理SDS的buf数组数据,这样就避免了C字符串读取到空字符就返回,导致读取不完整字符串的问题。二进制安全的SDS,是的Redis可以保存任意格式的二进制数据,而不仅仅是文本数据。
    • 如果是C语言,字符串存入了一种使用空字符(\0,不是指空格)来分隔单词的特殊数据格式,存储如下内容: image.png

    • 使用C字符串函数读取到的结果会丢失空字符后面的内容,得到:itzhai,丢失了com。

    • SDS使用len属性的值来判断字符串是否结束,而不是空字符。

  • 兼容部分C字符串函数:为了兼容部分C字符串函数库,SDS字符串遵循C字符串以空字符结尾的惯例。

空间预分配规则

  • 字符串大小小于1M的时候,每次扩容加倍;
  • 超过1M,扩容只会多扩容1M的空间,上限是512M。

1.1.2、链表 LINKEDLIST

Redis中List[3]使用的是双向链表存储的。

如下图: image.png

List特点:

  • 双向链表;
  • 无环;
  • 带head和tail指针;
  • 带链表长度;
  • 多态,链表节点可存储不同类型的值;

List提供了以下常用操作参考:命令列表:https://redis.io/commands/?group=list

通过LPUSH、LPOP、RPUSH、RPOP操作,可以把List当成队列或者栈来使用: image.png

1.1.3、HASH字典

Redis的字典使用哈希表作为底层实现。该数据结构有点复杂,我直接画一个完整的结构图,如下: image.png

如上图,字典结构重点属性:

  • 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。

大致流程如下: image.png

这样,从第0个哈希桶开始,每次执行命令的时候,都rehash下一个哈希桶中的entry,并且新增的元素直接往ht[1]添加。于是ht[0]的元素逐渐减少,最终全部转移到了ht[1]中,实现了哈希表的平滑rehash,最小程度的降低对Redis服务的影响。

1.1.4、跳跃表 SKIPLIST

Redis中的跳跃表是一种有序数据结构,通过在每个节点维持指向其他节点的指针,从而实现快速访问节点的目的。

Redis中的有序集合就是使用跳跃表来实现的。

为了简化跳跃表模型,我们先来看看,假设所有跳跃表节点的层级都是1的情况,如下图: image.png

这个时候,跳跃表相当于一个双向链表,查找元素的复杂度为最差的O(N),即需要遍历所有的节点。

为了提高遍历效率,让跳跃表真正的跳起来,现在我们尝试在各个节点添加更多的Level,让程序可以沿着这些Level在各个节点之间来回跳跃,如下图,我们现在要查找score=9的节点,查找流程如下图红线所示: image.png

这种查找类似于二分查找,首先从最高层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的整数值。

下面是整数集合的数据结构: image.png

在内存中,整数集合是在一块连续的内存空间中的,如下图所示: image.png

  • 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进行内存扩容,扩容后也是通过一块连续空间存储的,这有可能带来一次数据拷贝。 image.png

如果要插入的元素在中间,说明不用进行升级,这个时候会使用二分查找算法找到插入的位置,然后扩容插入元素,重新调整元素位置。

intset特点

  • 由于intset是从小到大排列的,所以可以进行二分查找,查找性能比ziplist的遍历查找性能高;
  • intset只能存储整数,并且由于是内存紧凑的存储模式,没有携带len信息,所以每个元素必须统一编码;
  • intset存储可能变成字典存储,条件:
    • 添加了一个超过64bit的有符号数字之后;
    • 添加的集合元素个数超过了set-max-intset-entries配置的值(默认512);

1.1.6、压缩列表 ZIPLIST

压缩列表是为了节省Redis内存,提高存储效率而开发的数据结构,是列表键和哈希键的底层实现之一,当hash中的数据项不多,并且hash中的value长度不大的时候,就会采用压缩列表存储hash。

为什么要这样设计呢?是因为ziplist不擅长存储大量数据:

  • 数据量大了,每次插入或者修改引发的realloc操作可能会造成内存拷贝,加大系统开销;
  • ziplist数据量大了,由于是遍历查找,查找性能会变得很低。

以下是压缩列表的数据结构: image.png

尾节点地址 = 压缩列表起始指针地址 + zltail(偏移量)

我们再来看看压缩列表节点的结构: image.png

xxxx部分表示存储的长度内容,或者是0~12整数。

压缩列表的content可以保存一个字节数组或者一个整数值。

如上图,ziplist通过特殊的编码约定,通过使用尽可能少的空间,很巧妙的存储了编码和长度信息,并且通过entry中的属性,可以在ziplist中进行双向遍历,效果相当于双向链表,但是占用更少的内存。

整数编码 1111xxxx为什么能存储2^4-1个整数? 由于11110000,11111110分别跟24位有符号整数和8位有符号整数冲突了,所以只能存储 00011101范围,代表113数值,但是数值应该从0开始计数,所以分别代表 0~12。

什么是ziplist连锁更新问题? 假设我们有一个ziplist,所有节点都刚好是253字节大小,突然往中间插入了一个254字节以上大小的节点,会发生什么事情呢?

image.png

如下图,由于新的节点插入之后,新节点的下一个节点的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结构 image.png

如上图所示,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的数据类型,数据类型底层可以使用不同的数据结构来实现。

我们先来看看对象的基本格式: image.png

常见的有5种数据类型,底层使用10种数据结构实现,随着Redis版本的升级,数据类型和底层的数据结构也会增加。

而这5种数据类型,根据不同场景来选择不同的编码格式,如下所示:

String(字符串)

底层实现有三种

  • 1.REDIS_ENCODING_INT:使用整数值实现的字符串对象
  • 2.REDIS_ENCODING_EMBSTR:使用embstr编码的简单动态字符串实现的字符串对象
  • 3.REDIS_ENCODING_RAW:使用简单字符串实现的字符串对象

三种底层的编码转换

    1. 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。 image.png

编码的转换

  • 同时满足一下两个条件 使用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

image.png

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"

image.png

REDIS_ENCODING_EMBSTR

如果存储的是字符串,并且值长度小于等于44个字节,那么将使用embstr编码的SDS来保存这个字符串值。

raw编码的SDS会调用两次内存分配函数来分别创建redisObject和sdshdr结构,而embstr编码只需要调用一次内存分配函数就可以了,redisObject和sdshdr保存在一块连续的空间中,如下图: image.png

127.0.0.1:6379> set name "itzhai"
OK
127.0.0.1:6379> OBJECT ENCODING name
"embstr"
REDIS_ENCODING_RAW

如果存储的是字符串值,并且值长度大于44字节,那么将使用SDS来保存这个字符串值,编码为raw: image.png

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"

总结一下: image.png

EMBSTR编码的字符串不管追加多少字符,不管有没有到达45字节大小,都会转为RAW编码,因为EMBSTR编码字符串没有提供修改的API,相当于是只读的,所以修改的时候,总是会先转为RAW类型再进行处理。

1.2.2、REDIS_LIST

Redis 3.2版本开始引入了quicklist,LIST底层采用的数据结构发生了变化。

Redis 3.2之前的版本 列表对象底层可以是ziplist或者linkedlist数据结构。

使用哪一种数据结构: image.png

REDIS_ENCODING_ZIPLIST

ziplist结构的列表对象如下图所示: image.png

REDIS_ENCODING_LINKEDLIST

linkedlist结构的列表对象如下图所示 image.png

linkedlist为双向列表,每个列表的value是一个字符串对象,在Redis中,字符串对象是唯一一种会被其他类型的对接嵌套的对象。

Redis 3.2之后的版本 而Redis 3.2之后的版本,底层采用了quicklist数据结构进行存储。 image.png

1.2.3、REDIS_HASH

哈希对象的编码可以使ziplist或者hashtable数据结构。

使用哪一种数据结构: image.png

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结构的哈希对象: image.png

REDIS_ENCODING_HT

hashtable结构的哈希对象如下图所示: image.png

其中,字典的每个键和值都是一个字符串对象。

1.2.4、REDIS_SET

集合对象的编码可以是intset或者hashtable数据结构。

使用哪一种数据结构: image.png

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结构的集合对象,如下图: image.png

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,如下图: image.png

1.2.5、REDIS_ZSET

有序集合可以使用ziplist或者skiplist编码。

使用哪一种编码: image.png

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,如下图: image.png

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;

如下图所示: image.png

其中:

  • 跳跃表按照分值从小到大保存了所有的集合元素,一个跳跃表节点对应一个集合元素,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工作原理

image.png

如上图:生产者通过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,所以在大数据的查询,去重等场景中,有比较高的空间利用率。 image.png

注意: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存储到同一个节点中;
  • 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:

image.png

当我们需要检索判断"itzhai.com"是否存在的时候,则同样的使用者三个哈希函数计算得到三个偏移量,判断三个偏移量所处的位置的值是否都为1,如果为1,则表示"itzhai.com"是存在的。

Bloom Filter中的误判 假设我们继续往上面的Bloom Filter中记录一个新的元素“Java架构杂谈”,这个时候,我们发现h3函数计算出来的偏移量跟上一个元素相同,这个时候就产生了哈希冲突: image.png

这就会导致,即使偏移量为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算法的原理,不做深入推理,好让大家都能大致了解。

我们先从一个事情说起:你正在举办一个画展,现在给你一份工作,在入口处记下每一个访客,并统计出有多少个不重复的访客,允许小的误差,你会如何完成任务呢?

你可以把所有的用户电话号码都记下来,然后每次都做一次全量的对比,统计,得到基数,但这需要耗费大量的工作,也没法做到实时的统计,有没有更好的方法呢? image.png 我们可以跟踪每个访客的手机号的后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 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编码有个认识,我们来拿我们的贝吉塔行星来分析,如下图,我们把行星按照地理位置系统展开,得到如下图所示的坐标系统:

image.png

我们可以给坐标系统上面的区块进行分块标识,规则如下:

  • 把纬度二等分:左边用0表示,右边用1表示;
  • 把经度二等分:左边用0表示,右边用1表示。

偶数位放经度,奇数位放维度,划分后可以得到下图:

image.png

其中,箭头为数值递增方向。上图可以映射为一维空间上的连续编码: 00 01 10 11,相邻的编码一般距离比较接近。

我们进一步递归划分,划分的左边或者下边用0表示,右边或者上边用1表示,可以得到下图: image.png

image.png

GeoHash Base32编码 最后,使用以下32个字符对以上区块的经纬度编码进行base 32编码,最终得到区块的GeoHash Base32编码。

image.png

同一个区块内,编码是相同的。可以发现:

  • 编码长度越长,那么编码代表的区块就越精确;
  • 字符串相似编码所代表的区块距离相近,但有特殊情况;

根据Geohash可知,当GeoHash Base32编码长度为8的时候,距离精度在19米左右。

关于区块距离的误差 基于GeoHash产生的空间填充曲线最大缺点是突变性:有些编码相邻但是距离却相差很远,如下图: image.png

因此,为了避免曲突变造成的影响,在查找附近的坐标的时候,首先筛选出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执行流程如下: image.png

在发起BGSAVE命令之后,Redis会fork出一个子进程用于执行生成RDB文件。fork的时候采用的是写时复制(Copy-on-write)技术。不会立刻复制所有的内存,只是复制了页表,保证了fork执行足够快。如上图,Redis父进程和执行BGSAVE的子进程的页表都指向了相同的内存,也就是说,内存并没有立刻复制。

然后子进程开始执行生成RDB。

在生成RDB过程中,如果父进程有执行新的操作命令,那么会复制需要操作的键值对,父子进程之间的内存开始分离: image.png

如上图,父进程执行命令修改了一些键值对的时候,该部分键值对实际上会复制一份进行修改,修改完成之后,父进程中的该内存数据的指针会指向被复制的的内存。而子进程继续指向原来的数据,原来的数据内容是不会被修改的。

在生成RDB文件过程中,父进程中对数据的修改操作不会被持久化。

执行BGSAVE会消耗很多内存吗? 由上面描述可知,BGSAVE并不会立刻复制内存数据,而是采用了写时复制技术,所以并不会立刻消耗很多内存。

但是如果Redis实例写比读的执行频率高很多,那么势必会导致执行BGSAVE过程中大量复制内存数据,并且消耗大量CPU资源,如果内存不足,并且机器开启了Swap机制,把部分数据从内存swap到磁盘上,那么性能就会进一步下降了。

服务端什么时候会触发BGSAVE? Redis怎么知道什么时候应该创建RDB文件呢?我们得来看看redisServer中的几个关键属性了,如下图: image.png

  • dirty计数器:记录距离上一次成功执行SAVE或者BGSAVE命令之后,服务器数据库进行了多少次修改操作(添加、修改、删除了多少个元素);
  • lastsave时间:一个Unix时间戳,记录服务器上一次成功执行SAVE或者BGSAVE命令的时间;
  • saveparams配置:触发BGSAVE命令的条件配置信息,如果没有手动设置,那么服务器默认设置如上图所示:
    • 服务器在900秒内对数据库进行了至少1次修改,那么执行BGSAVE命令;
    • 服务器在300秒内对数据库进行了至少10次修改,那么执行BGSAVE命令;
    • 服务器在60秒内对数据库进行了至少10000次修改,那么只需BGSAVE命令。

Redis默认的每隔100毫秒会执行一次serverCron函数,检查并执行BGSAVE命令,大致的处理流程如下图所示: image.png

2.1.2、如何从RDB文件恢复?

Redis只会在启动的时候尝试加载RDB文件,但不是一定会加载RDB文件的,关键处理流程如下图: image.png

服务器在载入RDB文件期间,一直处于阻塞状态。

2.1.3、RDB文件结构是怎样的?

我们用一张图来大致了解下RDB文件的结构,如下图所示: image.png

具体格式以及格式说明参考上图以及图中的描述。

而具体的value,根据不同的编码有不同的格式,都是按照约定的格式,紧凑的存储起来。

2.2、AOF

从上一节的内容可知,RDB是把整个内存的数据按照约定的格式,输出成一个文件存储到磁盘中的。

而AOF(Append Only File)则有所不同,是保存了Redis执行过的命令。

AOF,即Append Only File的简写。

我们先来看看,执行命令过程中是如何生成AOF日志的: image.png

如上图,是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命令只能在客户端上下文中执行,所以会先创建一个不带网络套接字的伪客户端进行执行命令,大致流程如下:

image.png

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重写运行原理 重写运行原理如下图所示: image.png

  • 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。

https://github.com/Qihoo360/pika

不过该方案也是有缺点的,如果要频繁的从SSD中加载数据,那么查询的性能就会低很多。另外SSD硬盘的使用寿命也和擦写次数有关,频繁的改写,SSD硬盘成本也是一个问题。

这种方案适合需要大容量存储、访问延迟没有严格要求低业务场景中使用。

 

参考: https://www.itzhai.com/articles/redis-technology-insider-cache-data-structure-concurrency-clustering-and-algorithm.html

https://www.cnblogs.com/weihl/p/13255374.html