redis

主题

内容

备注

数据类型

redis支持的数据类型:string字符串类型、hash散列类型、list列表类型、set集合类型、zset有序集合类型

 

数据类型与底层数据结构对应关系

 

redis底层数据结构

Redis使用了一个哈希表来保存所有键值对。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。这也就是说,不管值是String,还是集合类型,哈希桶中的元素都是指向它们的指针。

查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系。也就是说,不管哈希表里有 10 万个键还是 100 万个键,我们只需要一次计算就能找到相应的键。 

哈希冲突

两个key的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中,就出现了哈希冲突。毕竟,哈希桶的个数通常要少于key的数量,这也就是说,难免会有一些key的哈希值对应到了同一个哈希桶中。

Redis解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

 

rehash

如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。Redis会对哈希表做rehash操作。rehash也就是增加现有的哈希桶数量,让逐渐增多的entry元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。

为了使rehash操作更高效,Redis默认使用了两个全局哈希表:哈希表1和哈希表2。一开始,当你刚插入数据时,默认使用哈希表1,此时的哈希表2并没有被分配空间。随着数据逐步增多,Redis开始执行rehash,这个过程分为三步:

  1. 给哈希表2分配更大的空间,例如是当前哈希表1大小的两倍;

  2. 把哈希表1中的数据重新映射并拷贝到哈希表2中;

  3. 释放哈希表1的空间。

Redis采用了渐进式rehash。

简单来说就是在第二步拷贝数据时,Redis仍然正常处理客户端请求,每处理一个请求时,从哈希表1中的第一个索引位置开始,顺带着将这个索引位置上的所有entries拷贝到哈希表2中;等处理下一个请求时,再顺带拷贝哈希表1中的下一个索引位置的entries。

当没有新请求时,Redis 会执行定时任务,按照一定的频率(例如每 100ms/ 次)执行的任务

 
数据类型与底层数据结构对应关系     
 redis不同数据结构的时间复杂度  

 单元素操作是基础;

范围操作非常耗时;

统计操作通常高效;

例外情况只有几个。

单元素操作 第一,单元素操作,是指每一种集合类型对单个数据实现的增删改查操作。例如,Hash 类型的 HGET、HSET 和 HDEL,Set 类型的 SADD、SREM、SRANDMEMBER 等。这些操作的复杂度由集合采用的数据结构决定,例如,HGET、HSET 和 HDEL 是对哈希表做操作,所以它们的复杂度都是 O(1);Set 类型用哈希表作为底层数据结构时,它的 SADD、SREM、SRANDMEMBER 复杂度也是 O(1)。这里,有个地方你需要注意一下,集合类型支持同时对多个元素进行增删改查,例如 Hash 类型的 HMGET 和 HMSET,Set 类型的 SADD 也支持同时增加多个元素。此时,这些操作的复杂度,就是由单个元素操作复杂度和元素个数决定的。例如,HMSET 增加 M 个元素时,复杂度就从 O(1) 变成 O(M) 了。  
范围操作

第二,范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据,比如 List 类型的 LRANGE 和 ZSet 类型的 ZRANGE。这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免。

不过,Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于 HGETALL、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻塞。

 
统计操作 第三,统计操作,是指集合类型对集合中所有元素个数的记录,例如 LLEN 和 SCARD。这类操作复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。  
例外操作 第四,例外情况,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作。  

简单动态字符串

SDS:简单动态字符串

三个字段:len;free;buf

为什么Redis没有使用C字符串

  • C字符串获取字符串长度,需要遍历整个字符串

  • C字符串容易造成缓冲区溢出

  • 减少修改字符串时带来的内存重分配次数

当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改,如果不满足,会自动扩展,修改SDS空间的大小。

SDS空间预分配:

  • SDS的长度小于1MB,那么程序分配和len属性大小的未使用空间,这时SDS len属性值和free属性的值相同。

  • 如果SDS的长度大于或等于1MB,程序会分配1MB的空间

SDS惰性空间释放

当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录下来,并等待将来使用。

struct sdshdr{

//字符串长度

int len;

//未使用空间

int free;

//字符串

char buf[]

}

链表

整数数组和双向链表也很常见,它们的操作特征都是顺序读写,也就是通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是O(N),操作效率比较低;

listNode:

list:

 

 

list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len。

Redis的链表特征:

  • 双端:链表节点带有prev和next指针

  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL

  • 带表头指针和表尾指针:通过list结构的head和tail指针

  • 带链表长度计数器

  • 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

 

字典

字典结构:

ht属性是包含两个哈希表的数组,一般情况下字典只是用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。

rehash:

1.为ht[1]分配空间(扩展:ht[0].used*2的2的n次方幂;收缩:ht[0].used*2的n次方幂)

2.把ht[0]的数据重新映射到ht[1]

3.释放ht[0]的空间

 

跳跃表

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。

zskiplistNode:跳跃表节点

  • level:层,每个层都有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。

  • backward:后退指针

  • score:分值,在跳跃表中,节点按各自所保存的分值从小到大排列。

  • obj:成员对象

zskiplist:跳跃表

  • header:表头节点

  • tail:表尾节点

  • level:层数最大的那个节点的层数

  • length:跳跃表长度

 

跳跃表

https://www.cnblogs.com/hunternet/p/11248192.html

跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:

补充

如果我们要在链表中查找33这个元素,只能从头开始遍历链表,查找6次,直到找到33为止。此时,复杂度是O(N),查找效率很低。

为了提高查找速度,我们来增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。例如,从前两个元素中抽取元素1作为一级索引,从第三、四个元素中抽取元素11作为一级索引。此时,我们只需要4次查找就能定位到元素33了。

如果我们还想再快,可以再增加二级索引:从一级索引中,再抽取部分元素作为二级索引。例如,从一级索引中抽取1、27、100作为二级索引,二级索引指向一级索引。这样,我们只需要3次查找,就能定位到元素33了。

可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是O(logN)。

 

整数集合

整数结合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t、int64_t的整数值,并且保证集合中不会出现重复元素。

intset:

  • encoding:编码方式

  • length:元素数量

  • contents[]:元素数组

升级

当向一个底层为int16_t数组的整数集合中添加一个int64_t类型的整数值时,整数集合已有的所有元素都会被转换成int64_t类型。好处:一个是提升了整数集合的灵活性,另一个是尽可能的节约内存

升级分为3步:

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。

  2. 将底层数组现有的所有元素都转换成与新元素相同的类型

  3. 将新元素添加到底层数组里

整数数组和双向链表也很常见,它们的操作特征都是顺序读写,也就是通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是O(N),操作效率比较低;

 

压缩列表

https://www.cnblogs.com/hunternet/p/11306690.html

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。

  • zlbytes:记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或者计算zlend位置时使用。

  • ztail:记录压缩列表表尾节点举例压缩列表的起始地址有多少字节:通过这个偏移量,程序无需遍历整个叶索列表就可以确定表尾节点的地址。

  • zllen:记录压缩列表包含的节点数量

  • zlend:特殊值,用于标记压缩列表的末端。

 

压缩列表

压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段zlbytes、zltail和zllen,分别表示列表长度、列表尾的偏移量和列表中的entry个数;压缩列表在表尾还有一个zlend,表示列表结束。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是O(N)了。

 

对象

Redis基于动态字符串、链表、字典、跳跃表、整数集合、压缩列表创建了一个对象系统,这个系统包含字符串对象、哈希对象、列表对象、集合对象、有序集合对象这5种类型的对象,每个对象都泳道了至少一种底层的数据结构。

对象类型type

对象编码encoding

 

使用encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了Redis的灵活性和效率,因为Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。

举例:在对象包含的元素比较少是,Redis使用压缩列表作为列表对象的底层实现:

  • 因为压缩列表比双端链表更节省内存,并且在元素数量较少时,在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载入到缓存中;

  • 随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表专项功能更强、也更适合保存大量元素的双端链表上;

 

字符串对象

字符串对象的编码可以是int、raw或者embstr

  • 如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性,并将字符串对象的编码设置为int;

  • 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr的编码方式来保存这个字符串。注意浮点数在Redis中是作为字符串值来保存的

关于embstr:embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间一次包含redisObject和sdshdr两个结构

使用embstr的好处:

  • embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降为一次。

  • 释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数

  • 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里,所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。

 

字符串对象编码的转换

int、embstr编码的字符串在某些情况下回转换为raw编码的字符串对象

int转raw

embstr转raw:

Redis没有为embstr编码的字符串对象编写任何相应的修改程序,所以embstr编码的字符串对象实际是只读的。当对embstr编码的对象修改时,会将对象的编码从embstr转为raw

 

字符串命令的实现

 

列表对象

列表对象的编码可以是ziplist(压缩列表)也可以是linkedlist(双端链表)

列表编码转换

当列表对象同时满足以下两个条件时,对象使用ziplist,否则linkedlist(当然这两个条件可以在配置文件修改)

  • 列表对象保存的所有字符串元素的长度都小于64字节

  • 列表对象保存的元素数量小于512个;

 

列表命令的实现

 

哈希对象

哈希对象的编码可以是ziplist(压缩列表)或者hashtable(哈希)

ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序先保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾。

举例:

 

哈希对象编码转换

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist,否则使用hashtable(哈希)

  • 哈希对象保存的所有键值对的键和值字符串长度都小于64字节

  • 哈希对象保存的键值对数量小于512个

 

哈希命令的实现

 

集合对象

集合对象的编码可以是intset(整数数组)或者hashtable(哈希)

集合对象编码的转换:

当集合对象同时满足以下两个条件时,使用intset,否则使用hashtable

  • 集合对象保存的所有元素都是整数值

  • 集合对象保存的元素数量小于512个

 

集合对象的命令实现方法

 

 

有序结合对象

有序集合的编码可以是ziplist(压缩列表)或者skiplist(跳表)

当编码为压缩列表时,每个集合元素使用两个紧挨的节点来保存,第一个保存元素的成员,第二个元素则保存元素的分值。压缩列表的集合元素按照分值从小到大排序,分值小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。

当编码为跳表时,一个zset结构同事包含一个字典和一个跳跃表

zset结构中zsl跳跃表按照分值从小到大保存了所有元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性保存了元素的分值。除此之外,zset结构中的dics字段为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找到给定成员的分值。

 

有序集合实现方法

 

内存回收

Redis在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制。

  • 创建一个新对象时,引用计数的值会被初始化为1;

  • 当对象被一个新程序使用时,它的引用计数会加一;

  • 当对象不再被一个程序使用时,它的引用计数会减一;

  • 当对象的引用计数变为0时,对象所占用的内存会被释放。

 

对象共享

假设A创建了一个包含整数值100的字符串对象,这时B也要创建一个同样的100的字符串对象,Redis就会让A和B共享同一个字符串对象。

具体步骤:

  1. 将数据库键的值指向一个现有的值对象;

  2. 被共享的值对象的引用计数加一;

 

对象的空转时长

redisObject结构包含的最后一个属性为lru,记录了对象最后一次被命令程序访问的时间;

空转时间 = 当前时间 - lru

如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。

 

不同操作的复杂度

第一,单元素操作,是指每一种集合类型对单个数据实现的增删改查操作。例如,Hash类型的HGET、HSET和HDEL,Set类型的SADD、SREM、SRANDMEMBER等。这些操作的复杂度由集合采用的数据结构决定,例如,HGET、HSET和HDEL是对哈希表做操作,所以它们的复杂度都是O(1);Set类型用哈希表作为底层数据结构时,它的SADD、SREM、SRANDMEMBER复杂度也是O(1)。

这里,有个地方你需要注意一下,集合类型支持同时对多个元素进行增删改查,例如Hash类型的HMGET和HMSET,Set类型的SADD也支持同时增加多个元素。此时,这些操作的复杂度,就是由单个元素操作复杂度和元素个数决定的。例如,HMSET增加M个元素时,复杂度就从O(1)变成O(M)了。

第二,范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如Hash类型的HGETALL和Set类型的SMEMBERS,或者返回一个范围内的部分数据,比如List类型的LRANGE和ZSet类型的ZRANGE。这类操作的复杂度一般是O(N),比较耗时,我们应该尽量避免。

不过,Redis从2.8版本开始提供了SCAN系列操作(包括HSCAN,SSCAN和ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于HGETALL、SMEMBERS这类操作来说,就避免了一次性返回所有元素而导致的Redis阻塞。

第三,统计操作,是指集合类型对集合中所有元素个数的记录,例如LLEN和SCARD。这类操作复杂度只有O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。

第四,例外情况,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于List类型的LPOP、RPOP、LPUSH、RPUSH这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有O(1),可以实现快速操作。

 

redis单线程

 

 

 

Redis是单线程,主要是指Redis的网络IO和键值对读写是由一个线程来完成的,这也是Redis对外提供键值存储服务的主要流程。但Redis的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

为什么使用单线程?

一个关键的瓶颈在于,多线程编程模式面临的共享资源的并发访问控制问题。系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销。

Redis单线程是指它对网络IO和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。单线程的Redis也能获得高性能,跟多路复用的IO模型密切相关,因为这避免了accept()和send()/recv()潜在的网络IO操作阻塞点。

 

单线程Redis为什么那么快?

Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。另一方面,就是 Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。

 

单线程Redis为什么那么快之多路复用

 

 

select/epoll提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。

select/epoll一旦监测到FD上有请求到达时,就会触发相应的事件。

这些事件会被放进一个事件队列,Redis单线程对该事件队列不断进行处理。这样一来,Redis无需一直轮询是否有请求实际发生,这就可以避免造成CPU资源浪费。同时,Redis在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为Redis一直在对事件队列进行处理,所以能及时响应客户端请求,提升Redis的响应性能。

这两个请求分别对应Accept事件和Read事件,Redis分别对这两个事件注册accept和get回调函数。当Linux内核监听到有连接请求或读数据请求时,就会触发Accept事件和Read事件,此时,内核就会回调Redis相应的accept和get函数进行处理。

 

AOF日志

 

 

redis是先执行写操作,后写AOF日志,因为它是在命令执行后才记录日志,所以不会阻塞当前的写操作。AOF文件是以追加的方式,逐一记录接收到的写命令的(注意日志记录的是命令,不是数据)

AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。

redis AOF 3种写日志策略

 

为了避免AOF日志太大,Redis有AOF重写机制,简单来说就是旧日志文件中的多条命令,在重写后的新日志中变成了一条命令。当一个键值对被多条写命令反复修改时,AOF文件会记录相应的多条命令。但是,在重写的时候,是根据这个键值对当前的最新状态,为它生成对应的写入命令。这样一来,一个键值对在重写日志中只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。

 

AOF重写

和AOF日志由主线程写回不同,重写过程是由后台子进程bgrewriteaof来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。

我把重写的过程总结为“一个拷贝,两处日志”。

“一个拷贝”就是指,每次执行重写时,主线程fork出后台的bgrewriteaof子进程。此时,fork会把主线程的内存拷贝一份给bgrewriteaof子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。

“两处日志”又是什么呢?

因为主线程未阻塞,仍然可以处理新来的操作。此时,如果有写操作,第一处日志就是指正在使用的AOF日志,Redis会把这个操作写到它的缓冲区。这样一来,即使宕机了,这个AOF日志的操作仍然是齐全的,可以用于恢复。

而第二处日志,就是指新的AOF重写日志。这个操作也会被写到重写日志的缓冲区。这样,重写日志也不会丢失最新的操作。等到拷贝数据的所有操作记录重写完成后,重写日志记录的这些最新操作也会写入新的AOF文件,以保证数据库最新状态的记录。此时,我们就可以用新的AOF文件替代旧文件了。

 

 

总结来说,每次AOF重写时,Redis会先执行一个内存拷贝,用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为Redis采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。

AOF重写总结:一个拷贝,两处日志,①重写时,copy一份AOF日志,然后开始重写; ②如果重写过程中,有写入操作,需要在两处都写入;③重写完成后,新的替代旧的文件

内存快照RDB

Redis把某一时刻的状态以文件的形式写到磁盘上,形成快照。这个快照文件就称为RDB文件,其中,RDB就是Redis DataBase的缩写。

Redis提供了两个命令来生成RDB文件,分别是save和bgsave。

  • save:在主线程中执行,会导致阻塞;

  • bgsave:创建一个子进程,专门用于写入RDB文件,避免了主线程的阻塞,这也是Redis RDB文件生成的默认配置。

简单来说,bgsave子进程是由主线程fork生成的,可以共享主线程的所有内存数据。bgsave子进程运行后,开始读取主线程的内存数据,并把它们写入RDB文件。

Redis就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。

Redis 4.0中提出了一个混合使用AOF日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用AOF日志记录这期间的所有命令操作。

这样一来,快照不用很频繁地执行,这就避免了频繁fork对主线程的影响。而且,AOF日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。

总结:写时复制技术(Copy-On-Write, COW),①fork一个子线程bgsave,共享主线程所有数据;②开始生成RDB文件;③如果此时主线程要修改一个数据,bgsave线程继续,主线程生成修改内容的副本,并修改副本。

举例:如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。

RDB和AOF总结

Redis使用保存RDB快照文件来避免数据丢失。这个方法的优势在于,可以快速恢复数据库,也就是只需要把RDB文件直接读入内存,这就避免了AOF需要顺序、逐一重新执行操作命令带来的低效性能问题。

不过,内存快照也有它的局限性。它拍的是一张内存的“大合影”,不可避免地会耗时耗力。虽然,Redis设计了bgsave和写时复制方式,尽可能减少了内存快照对正常读写的影响,但是,频繁快照仍然是不太能接受的。而混合使用RDB和AOF,正好可以取两者之长,避两者之短,以较小的性能开销保证数据可靠性和性能。

最后,关于AOF和RDB的选择问题

  • 数据不能丢失时,内存快照和AOF的混合使用是一个很好的选择;

  • 如果允许分钟级别的数据丢失,可以只使用RDB;

  • 如果只用AOF,优先使用everysec的配置选项,因为它在可靠性和性能之间取了一个平衡。

 

主从

第一次:主从全量复制

后期:一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库

 

主从复制

 

 

  

FULLRESYNC响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。

第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。

第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。

主库执行bgsave命令,生成RDB文件,接着将文件发给从库。从库接收到RDB文件后,会先清空当前数据库,然后加载RDB文件。这是因为从库在通过replicaof命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。

在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的RDB文件中。为了保证主从库的数据一致性,主库会在内存中用专门的replication buffer,记录RDB文件生成后收到的所有写操作。

第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成RDB文件发送后,就会把此时replication buffer中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

 

主从级联模式

一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成RDB文件和传输RDB文件。

如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于fork子进程生成RDB文件,进行数据全量同步。fork这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输RDB文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。

“主-从-从”模式。

在刚才介绍的主从库模式中,所有的从库都是和主库连接,所有的全量复制也都是和主库进行的。现在,我们可以通过“主-从-从”模式将主库生成RDB和传输RDB的压力,以级联的方式分散到从库上。

简单来说,我们在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库。然后,我们可以再选择一些从库(例如三分之一的从库),在这些从库上执行如下命令,让它们和刚才所选的从库,建立起主从关系。

 

主从断网处理

一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

主从断网怎么办?

在Redis 2.8之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。

从Redis 2.8开始,网络断了之后,主从库会采用增量复制的方式继续同步。当主从库断连后,主库会把断连期间收到的写操作命令,写入replication buffer,同时也会把这些操作命令也写入repl_backlog_buffer这个缓冲区。

repl_backlog_buffer是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是master_repl_offset。主库接收的新写操作越多,这个值就会越大。

同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量slave_repl_offset也在不断增加。正常情况下,这两个偏移量基本相等。

 

 

主从库的连接恢复之后,从库首先会给主库发送psync命令,并把自己当前的slave_repl_offset发给主库,主库会判断自己的master_repl_offset和slave_repl_offset之间的差距。

在网络断连阶段,主库可能会收到新的写操作命令,所以,一般来说,master_repl_offset会大于slave_repl_offset。此时,主库只用把master_repl_offset和slave_repl_offset之间的命令操作同步给从库就行。

 

 

 

主从断网缓存设置

因为repl_backlog_buffer是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。

因此,我们要想办法避免这一情况,一般而言,我们可以调整repl_backlog_size这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即repl_backlog_size = 缓冲空间大小 * 2,这也就是repl_backlog_size的最终值。

 

哨兵机制

 

 

哨兵其实就是一个运行在特殊模式下的Redis进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。

我们先看监控。监控是指哨兵进程在运行时,周期性地给所有的主从库发送PING命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的PING命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的PING命令,哨兵就会判定主库下线,然后开始自动切换主库的流程。

这个流程首先是执行哨兵的第二个任务,选主。主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群里就有了新主库。

然后,哨兵会执行最后一个任务:通知。在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行replicaof命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。

 

哨兵机制-判断主库是否下线

哨兵进程会使用PING命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对PING命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”。

哨兵机制通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

在判断主库是否下线时,不能由一个哨兵说了算,只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”,这个叫法也是表明主库下线成为一个客观事实了。这个判断原则就是:少数服从多数。同时,这会进一步触发哨兵开始主从切换流程。

 

哨兵机制-选择新主库

 

 

筛选的条件:

  • 肯定要先保证所选的从库仍然在线运行。不过,在选主时从库正常在线,这只能表示从库的现状良好,并不代表它就是最适合做主库的。

  • 在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就有理由相信,这个从库的网络状况并不是太好,就可以把这个从库筛掉了。

  • 具体怎么判断呢?你使用配置项down-after-milliseconds * 10。其中,down-after-milliseconds是我们认定主从库断连的最大连接超时时间。如果在down-after-milliseconds毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了10次,就说明这个从库的网络状况不好,不适合作为新主库。

打分:

  • 从库优先级:优先级最高的从库得分高,用户可以通过slave-priority配置项,给不同的从库设置不同优先级。比如,你有两个从库,它们的内存大小不一样,你可以手动给内存大的实例设置一个高优先级。在选主时,哨兵会给优先级高的从库打高分,如果有一个从库优先级最高,那么它就是新主库了。

  • 从库复制进度:和旧主库同步程度最接近的从库得分高,主库会用master_repl_offset记录当前的最新写操作在repl_backlog_buffer中的位置,而从库会用slave_repl_offset这个值记录当前的复制进度。

    此时,我们想要找的从库,它的slave_repl_offset需要最接近master_repl_offset。如果在所有从库中,有从库的slave_repl_offset最接近master_repl_offset,那么它的得分就最高,可以作为新主库。

  • 从库ID号:ID号小的从库得分高

 

哨兵机制总结

哨兵是实现Redis不间断服务的重要保证。具体来说,主从集群的数据同步,是数据可靠的基础保证;而在主库发生故障时,自动的主从切换是服务不间断的关键支撑。

Redis的哨兵机制自动完成了以下三大功能,从而实现了主从库的自动切换,可以降低Redis集群的运维开销:

  • 监控主库运行状态,并判断主库是否客观下线;

  • 在主库客观下线后,选取新主库;

  • 选出新主库后,通知从库和客户端。

为了降低误判率,在实际应用时,哨兵机制通常采用多实例的方式进行部署,多个哨兵实例通过“少数服从多数”的原则,来判断主库是否客观下线。一般来说,我们可以部署三个哨兵,如果有两个哨兵认定主库“主观下线”,就可以开始切换过程。当然,如果你希望进一步提升判断准确率,也可以再适当增加哨兵个数,比如说使用五个哨兵。

但是,使用多个哨兵实例来降低误判率,其实相当于组成了一个哨兵集群

 

哨兵集群-pub/sub机制

哨兵实例之间可以相互发现,要归功于Redis提供的pub/sub机制,也就是发布/订阅机制。

哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信息(IP和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的IP地址和端口。

除了哨兵实例,我们自己编写的应用程序也可以通过Redis进行消息的发布和订阅。所以,为了区分不同应用的消息,Redis会以频道的形式,对这些消息进行分门别类的管理。所谓的频道,实际上就是消息的类别。当消息类别相同时,它们就属于同一个频道。反之,就属于不同的频道。只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换。

在主从集群中,主库上有一个名为“__sentinel__:hello”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。

 

 

哨兵是如何知道从库的IP地址和端口的呢?

这是由哨兵向主库发送INFO命令来完成的。就像下图所示,哨兵2给主库发送INFO命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。哨兵1和3可以通过相同的方法和从库建立连接。

 

 

基于pub/sub机制的客户端事件通知

从本质上说,哨兵就是一个运行在特定模式下的Redis实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供pub/sub机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。

频道有这么多,一下子全部学习容易丢失重点。为了减轻你的学习压力,我把重要的频道汇总在了一起,涉及几个关键事件,包括主库下线判断、新主库选定、从库重新配置。

 

 

知道了这些频道之后,你就可以让客户端从哨兵这里订阅消息了。具体的操作步骤是,客户端读取哨兵的配置文件后,可以获得哨兵的地址和端口,和哨兵建立网络连接。然后,我们可以在客户端执行订阅命令,来获取不同的事件消息。

 

哨兵-leader选举

任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送is-master-down-by-addr命令。接着,其他实例会根据自己和主库的连接情况,做出Y或N的响应,Y相当于赞成票,N相当于反对票。

 

 

一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。这个所需的赞成票数是通过哨兵配置文件中的quorum配置项设定的。例如,现在有5个哨兵,quorum配置的是3,那么,一个哨兵需要3张赞成票,就可以标记主库为“客观下线”了。这3张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

leader选举

 

 

 

哨兵集群总结

哨兵集群的这些关键机制,包括:

  • 基于pub/sub机制的哨兵集群组成过程;

  • 基于INFO命令的从库列表,这可以帮助哨兵和从库建立连接;

  • 基于哨兵自身的pub/sub功能,这实现了客户端和哨兵之间的事件通知。

对于主从切换,当然不是哪个哨兵想执行就可以执行的,否则就乱套了。所以,这就需要哨兵集群在判断了主库“客观下线”后,经过投票仲裁,选举一个Leader出来,由它负责实际的主从切换,即由它来完成新主库的选择以及通知从库与客户端。

最后,我想再给你分享一个经验:要保证所有哨兵实例的配置是一致的,尤其是主观下线的判断值down-after-milliseconds

 

切片集群

当redis实例所占空间比较大时,Redis的响应有时会非常慢。后来,我们使用INFO命令查看Redis的latest_fork_usec指标值(表示最近一次fork的耗时),结果显示这个指标值特别高,快到秒级别了。

这跟Redis的持久化机制有关系。在使用RDB进行持久化时,Redis会fork子进程来完成,fork操作的用时和Redis的数据量是正相关的,而fork在执行时会阻塞主线程。数据量越大,fork操作造成的主线程阻塞的时间越长。

切片集群,也叫分片集群,就是指启动多个Redis实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。

 

Redis Cluster

实际上,切片集群是一种保存大量数据的通用机制,这个机制可以有不同的实现方案。在Redis 3.0之前,官方并没有针对切片集群提供具体的方案。从3.0开始,官方提供了一个名为Redis Cluster的方案,用于实现切片集群。Redis Cluster方案中就规定了数据和实例的对应规则。

具体来说,Redis Cluster方案采用哈希槽(Hash Slot,接下来我会直接称之为Slot),来处理数据和实例之间的映射关系。在Redis Cluster方案中,一个切片集群共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的key,被映射到一个哈希槽中。

具体的映射过程分为两大步:首先根据键值对的key,按照CRC16算法计算一个16 bit的值;然后,再用这个16bit值对16384取模,得到0~16383范围内的模数,每个模数代表一个相应编号的哈希槽。

那么,这些哈希槽又是如何被映射到具体的Redis实例上的呢?

我们在部署Redis Cluster方案时,可以使用cluster create命令创建集群,此时,Redis会自动把这些槽平均分布在集群实例上。例如,如果集群中有N个实例,那么,每个实例上的槽个数为16384/N个。

当然, 我们也可以使用cluster meet命令手动建立实例间的连接,形成集群,再使用cluster addslots命令,指定每个实例上的哈希槽个数。

举个例子,假设集群中不同Redis实例的内存大小配置不一,如果把哈希槽均分在各个实例上,在保存相同数量的键值对时,和内存大的实例相比,内存小的实例就会有更大的容量压力。遇到这种情况时,你可以根据不同实例的资源配置情况,使用cluster addslots命令手动分配哈希槽。

为了便于你理解,我画一张示意图来解释一下,数据、哈希槽、实例这三者的映射分布情况。

 

 

 

切片集群-客户端定位数据

一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。

那么,客户端为什么可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?这是因为,Redis实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:

  • 在集群中,实例有新增或删除,Redis需要重新分配哈希槽;

  • 为了负载均衡,Redis需要把哈希槽在所有实例上重新分布一遍。

此时,实例之间还可以通过相互传递消息,获得最新的哈希槽分配信息,但是,客户端是无法主动感知这些变化的。这就会导致,它缓存的分配信息和最新的分配信息就不一致了,那该怎么办呢?

Redis Cluster方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。

那客户端又是怎么知道重定向时的新实例的访问地址呢?当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的MOVED命令响应结果,这个结果中就包含了新实例的访问地址。

GET hello:key (error) MOVED 13320 172.16.19.5:6379

其中,MOVED命令表示,客户端请求的键值对所在的哈希槽13320,实际是在172.16.19.5这个实例上。通过返回的MOVED命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。这样一来,客户端就可以直接和172.16.19.5连接,并发送操作请求了。

我画一张图来说明一下,MOVED重定向命令的使用方法。可以看到,由于负载均衡,Slot 2中的数据已经从实例2迁移到了实例3,但是,客户端缓存仍然记录着“Slot 2在实例2”的信息,所以会给实例2发送命令。实例2给客户端返回一条MOVED命令,把Slot 2的最新位置(也就是在实例3上),返回给客户端,客户端就会再次向实例3发送请求,同时还会更新本地缓存,把Slot 2与实例的对应关系更新过来。

需要注意的是,在上图中,当客户端给实例2发送命令时,Slot 2中的数据已经全部迁移到了实例3。在实际应用时,如果Slot 2中的数据比较多,就可能会出现一种情况:客户端向实例2发送请求,但此时,Slot 2中的数据只有一部分迁移到了实例3,还有部分数据没有迁移。在这种迁移部分完成的情况下,客户端就会收到一条ASK报错信息,如下所示:

GET hello:key (error) ASK 13320 172.16.19.5:6379

这个结果中的ASK命令就表示,客户端请求的键值对所在的哈希槽13320,在172.16.19.5这个实例上,但是这个哈希槽正在迁移。此时,客户端需要先给172.16.19.5这个实例发送一个ASKING命令。这个命令的意思是,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送GET命令,以读取数据。

看起来好像有点复杂,我再借助图片来解释一下。

在下图中,Slot 2正在从实例2往实例3迁移,key1和key2已经迁移过去,key3和key4还在实例2。客户端向实例2请求key2后,就会收到实例2返回的ASK命令。

ASK命令表示两层含义:第一,表明Slot数据还在迁移中;第二,ASK命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例3发送ASKING命令,然后再发送操作命令。

 

 

和MOVED命令不同,ASK命令并不会更新客户端缓存的哈希槽分配信息。所以,在上图中,如果客户端再次请求Slot 2中的数据,它还是会给实例2发送请求。这也就是说,ASK命令的作用只是让客户端能给新实例发送一次请求,而不像MOVED命令那样,会更改本地缓存,让后续所有命令都发往新实例。

 

Redis切片集群小结

在应对数据量扩容时,虽然增加内存这种纵向扩展的方法简单直接,但是会造成数据库的内存过大,导致性能变慢。Redis切片集群提供了横向扩展的模式,也就是使用多个实例,并给每个实例配置一定数量的哈希槽,数据可以通过键的哈希值映射到哈希槽,再通过哈希槽分散保存到不同的实例上。这样做的好处是扩展性好,不管有多少数据,切片集群都能应对。

另外,集群的实例增减,或者是为了实现负载均衡而进行的数据重新分布,会导致哈希槽和实例的映射关系发生变化,客户端发送请求时,会收到命令执行报错信息。了解了MOVED和ASK命令,你就不会为这类报错而头疼了。

我刚刚说过,在Redis 3.0 之前,Redis官方并没有提供切片集群方案,但是,其实当时业界已经有了一些切片集群的方案,例如基于客户端分区的ShardedJedis,基于代理的Codis、Twemproxy等。这些方案的应用早于Redis Cluster方案,在支撑的集群实例规模、集群稳定性、客户端友好性方面也都有着各自的优势

 

Redis线程

bgsave子线程:专门写RDB快照文件

bgrewriteaof子线程:AOF日志重写

replication buffer和repl_backlog_buffer的区别

在进行主从复制时,replication buffer是主从库在进行全量复制时,主库上用于和从库连接的客户端的buffer,而repl_backlog_buffer是为了支持从库增量复制,主库上用于持续保存写操作的一块专用buffer。

Redis主从库在进行复制时,当主库要把全量复制期间的写操作命令发给从库时,主库会先创建一个客户端,用来连接从库,然后通过这个客户端,把写操作命令发给从库。在内存中,主库上的客户端就会对应一个buffer,这个buffer就被称为replication buffer。Redis通过client_buffer配置项来控制这个buffer的大小。主库会给每个从库建立一个客户端,所以replication buffer不是共享的,而是每个从库都有一个对应的客户端。

repl_backlog_buffer是一块专用buffer,在Redis服务器启动后,开始一直接收写操作命令,这是所有从库共享的。主库和从库会各自记录自己的复制进度,所以,不同的从库在进行恢复时,会把自己的复制进度(slave_repl_offset)发给主库,主库就可以和它独立同步。

 

 

基于list实现mq

  1. list先进先出,满足消息的顺序性
  2. 生产者:lpush保存消息到list
  3. 消费者:rpop从list中取消息(因为不知道生产者什么时候发送消息,所以只能不断轮询拉取消息)
  4. 消费者改进:BRPOP阻塞从list中取消息(BRPOP 命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。)
  5. 消息重复问题如何解决:生产者发送消息时生成递增的id,消费者根据id来判断是否重复
  6. 可靠性:消费者pop一条消息,没有消费就宕机了,如何解决?再备份消息一份,pop的消息保存到另一个list。(如下图)
  7. 无法解决的问题:消费组的问题

 

 

基于Streams实现mq

  1. XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
  2. XREAD:用于读取消息,可以按 ID 读取数据;(消费者也可以在调用 XRAED 时设定 block 配置项,实现类似于 BRPOP 的阻塞读取操作。当消息队列中没有消息时,一旦设置了 block 配置项,XREAD 就会阻塞,阻塞的时长可以在 block 配置项进行设置。)
  3. XREADGROUP:按消费组形式读取消息;
  4. XPENDING 和 XACK:XPENDING 命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息,而 XACK 命令用于向消息队列确认消息处理已完成。
  5. Streams 本身可以使用 XGROUP 创建消费组,创建消费组之后,Streams 可以使用 XREADGROUP 命令让消费组内的消费者读取消息。(使用消费组的目的是让组内的多个消费者共同分担读取消息,所以,我们通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的。)
  6. Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”。如果消费者没有成功处理消息,它就不会给 Streams 发送 XACK 命令,消息仍然会留存。此时,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。
  7. Streams 是 Redis 5.0 专门针对消息队列场景设计的数据类型,如果你的 Redis 是 5.0 及 5.0 以后的版本,就可以考虑把 Streams 用作消息队列了。
 

交互对象和操作

 
 客户端交互阻塞点
  1. 第一个阻塞点:集合全量查询和聚合操作。
  2. bigkey 删除操作就是 Redis 的第二个阻塞点。
  3. Redis 的第三个阻塞点:清空数据库。
 
磁盘交互阻塞点 AOF 日志同步写。  
主从交互时阻塞点 主库在复制的过程中,创建和传输 RDB 文件都是由子进程来完成的,不会阻塞主线程。但是,对于从库来说,它在接收了 RDB 文件后,需要使用 FLUSHDB 命令清空当前数据库,这就正好撞上了刚才我们分析的第三个阻塞点。此外,从库在清空当前数据库后,还需要把 RDB 文件加载到内存,这个过程的快慢和 RDB 文件的大小密切相关,RDB 文件越大,加载过程越慢,所以,加载 RDB 文件就成为了 Redis 的阻塞点。  
总结
  1. 集合全量查询和聚合操作;
  2. bigkey 删除;
  3. 清空数据库;
  4. AOF 日志同步写;
  5. 从库加载 RDB 文件。
 
异步子线程

Redis 主线程启动后,会使用操作系统提供的 pthread_create 函数创建 3 个子线程,分别由它们负责 AOF 日志写操作、键值对删除以及文件关闭的异步执行。

主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。

但实际上,这个时候删除还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对,并释放相应的内存空间。因此,我们把这种异步删除也称为惰性删除(lazy free)。此时,删除或清空操作不会阻塞主线程,这就避免了对主线程的性能影响。

和惰性删除类似,当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入 AOF 日志,这样主线程就不用一直等待 AOF 日志写完了。

删除操作:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删除时,我建议你使用 UNLINK 命令。

清空数据库:可以在 FLUSHDB 和 FLUSHALL 命令后加上 ASYNC 选项,这样就可以让后台子线程异步地清空数据库

 
 Redis变慢
  1.  慢查询命令
    1. 比如:集合的交集并集等
    2. 可以通过其他高效的命令查询
    3. 排序、交集、并集等可以在client端做,不在redis服务端操作
    4. keys等命令,尽量不在线上环境执行,可以使用scan,然后在客户端做排重操作
  2. 过期key操作
    1. 如果过期key超过25%,Redis 就会一直删除以释放内存空间。(注意,删除操作是阻塞的【Redis 4.0 后可以用异步线程机制来减少阻塞影响】)
    2. 解决方式:尽量不要大量key同一时间过期,可以通过在过期时间添加随机数的方式解决

备注:

  1. Redis 键值对的 key 可以设置过期时间。默认情况下,Redis 每 100 毫秒会删除一些过期 key,具体的算法如下:采样 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 个数的 key,并将其中过期的 key 全部删除;
  2. 如果超过 25% 的 key 过期了,则重复删除的过程,直到过期 key 的比例降至 25% 以下。 
 keys命令

1.如果想要获取整个实例的所有key,建议使用SCAN命令代替。客户端通过执行SCAN $cursor COUNT $count可以得到一批key以及下一个游标$cursor,然后把这个$cursor当作SCAN的参数,再次执行,以此往复,直到返回的$cursor为0时,就把整个实例中的所有key遍历出来了。

2.rehash缩容的过程中,scan可能会导致数据重复,重复的数据客户端去重

 
 缓存不一致问题
  • 删除缓存值或更新数据库失败而导致数据不一致,你可以使用重试机制确保删除或更新操作成功。
  • 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是延迟双删。

 
缓存不一致建议方法 

把 Redis 作为只读缓存使用。针对只读缓存来说,我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存。我的建议是,优先使用先更新数据库再删除缓存的方法,原因主要有两个:

  1. 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
  2. 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。

不过,当使用先更新数据库再删除缓存时,也有个地方需要注意,如果业务层要求必须读取一致的数据,那么,我们就需要在更新数据库时,先在 Redis 缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。

 
更新数据为什么不直接更新redis,而是删除呢?

没有并发:

1、先更新数据库,再更新缓存:如果更新数据库成功,但缓存更新失败,此时数据库中是最新值,但缓存中是旧值,后续的读请求会直接命中缓存,得到的是旧值。

2、先更新缓存,再更新数据库:如果更新缓存成功,但数据库更新失败,此时缓存中是最新值,数据库中是旧值,后续读请求会直接命中缓存,但得到的是最新值,短期对业务影响不大。但是,一旦缓存过期或者满容后被淘汰,读请求就会从数据库中重新加载旧值到缓存中,之后的读请求会从缓存中得到旧值,对业务产生影响。

如果有并发:

1、先更新数据库,再更新缓存,写+读并发:线程A先更新数据库,之后线程B读取数据,此时线程B会命中缓存,读取到旧值,之后线程A更新缓存成功,后续的读请求会命中缓存得到最新值。这种场景下,线程A未更新完缓存之前,在这期间的读请求会短暂读到旧值,对业务短暂影响。

2、先更新缓存,再更新数据库,写+读并发:线程A先更新缓存成功,之后线程B读取数据,此时线程B命中缓存,读取到最新值后返回,之后线程A更新数据库成功。这种场景下,虽然线程A还未更新完数据库,数据库会与缓存存在短暂不一致,但在这之前进来的读请求都能直接命中缓存,获取到最新值,所以对业务没影响。

3、先更新数据库,再更新缓存,写+写并发:线程A和线程B同时更新同一条数据,更新数据库的顺序是先A后B,但更新缓存时顺序是先B后A,这会导致数据库和缓存的不一致。

4、先更新缓存,再更新数据库,写+写并发:与场景3类似,线程A和线程B同时更新同一条数据,更新缓存的顺序是先A后B,但是更新数据库的顺序是先B后A,这也会导致数据库和缓存的不一致。

 
 redis事务
  1.  MULTI命令开启事务
  2. 客户端把事务中本身要执行的具体操作(例如增删改数据)发送给服务器端。这些操作就是 Redis 本身提供的数据读写命令,例如 GET、SET 等。不过,这些命令虽然被客户端发送到了服务器端,但 Redis 实例只是把这些命令暂存到一个命令队列中,并不会立即执行。
  3. EXEC 命令执行事务提交,执行命令队列中的所有命令。

注意:

  1. 入队时如果报错,不会执行队列所有命令,也不会事务提交;如果执行过程中报错,会把正确的命令都执行完,并不会回滚
  2. redis没有回滚命令,Redis 提供了 DISCARD 命令,但是,这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
 
 redis事务一致性
  • 命令入队时就报错,会放弃事务执行,保证原子性;
  • 命令入队时没报错,实际执行时报错,不保证原子性;
  • EXEC 命令执行时实例故障,如果开启了 AOF 日志,可以保证原子性。

在执行事务的 EXEC 命令时,Redis 实例发生了故障,导致事务执行失败。在这种情况下,如果 Redis 开启了 AOF 日志,那么,只会有部分的事务操作被记录到 AOF 日志中。我们需要使用 redis-check-aof 工具检查 AOF 日志文件,这个工具可以把未完成的事务操作从 AOF 文件中去除。这样一来,我们使用 AOF 恢复实例后,事务操作不会再被执行,从而保证了原子性。当然,如果 AOF 日志并没有开启,那么实例重启后,数据也都没法恢复了,此时,也就谈不上原子性了。

 
redis事务隔离性

并发操作在 EXEC 命令前执行,此时,隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证;

WATCH 机制的作用是,在事务执行前,监控一个或多个键的值变化情况,当事务调用 EXEC 命令执行时,WATCH 机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。

 
 redis事务命令  

 

 

 
 redis主从数据不一致

 主从复制是异步的,所以存在不一致。

解决方式:可以开发一个监控程序,先用 INFO replication 命令查到主、从库的进度,然后,我们用 master_repl_offset 减去 slave_repl_offset,这样就能得到从库和主库间的复制进度差值了。如果某个从库的进度差值大于我们预设的阈值,我们可以让客户端不再和这个从库连接进行数据读取,这样就可以减少读到不一致数据的情况。

 
从库读到过期数据

Redis 同时使用了两种策略来删除过期的数据,分别是惰性删除策略和定期删除策略。

  • 惰性删除策略:当一个数据的过期时间到了以后,并不会立即删除数据,而是等到再有请求来读写这个数据时,对数据进行检查,如果发现数据已经过期了,再删除这个数据。
  • 定期删除策略是指,Redis 每隔一段时间(默认 100ms),就会随机选出一定数量的数据,检查它们是否过期,并把其中过期的数据删除,这样就可以及时释放一些内存。

第一种情况:

如果客户端从主库上读取留存的过期数据,主库会触发删除操作,此时,客户端并不会读到过期数据。但是,从库本身不会执行删除操作,如果客户端在从库中访问留存的过期数据,从库并不会触发数据删除。

  • 3.2之前的版本:从库在服务读请求时,并不会判断数据是否过期,而是会返回过期数据。
  • 3.2之后的版本:Redis 做了改进,如果读取的数据已经过期了,从库虽然不会删除,但是会返回空值。

第二种情况:

设置过期时间,从库执行与主库执行有时间差距

 
 主从问题总结  

 

 

 
     
     
posted @ 2022-04-08 14:25  刘尊礼  阅读(53)  评论(0)    收藏  举报