备战面试日记(6.1) - (缓存相关.Redis全知识点)

本人本科毕业,21届毕业生,一年工作经验,简历专业技能如下,现根据简历,并根据所学知识复习准备面试。

记录日期:2022.1.15

大部分知识点只做大致介绍,具体内容根据推荐博文链接进行详细复习。

文章目录

Redis

书籍推荐:《Redis的设计与实现》

博客面试文章推荐:全网最硬核 Redis 高频面试题解析(2021年最新版)

Redis这篇主要要讲解的内容包括:数据结构redis持久化(aof、rdb)文件事务处理器redis内存淘汰机制事务redis集群(一致性hash等...)redis分布式锁都放在Redis的文章里说明。

还有一部分缓存问题,比如缓存设计以及缓存数据一致性解决方案-缓存雪崩缓存穿透缓存击穿等另起一篇写。

数据结构与对象

数据类型分类(对象)

数据类型概述

Redis主要有5种数据类型,包括String,List,Set,Zset,Hash,满足大部分的使用要求。

数据类型可以存储的值操作应用场景
STRING字符串、整数或者浮点数对整个字符串或者字符串的其中一部分执行操作;对整数和浮点数执行自增或者自减操作。做简单的键值对缓存
LIST列表从两端压入或者弹出元素;对单个或者多个元素进行修剪;只保留一个范围内的元素存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的数据
SET无序集合添加、获取、移除单个元素;检查一个元素是否存在于集合中;计算交集、并集、差集;从集合里面随机获取元素交集、并集、差集的操作,比如交集,可以把两个人的粉丝列表整一个交集
HASH包含键值对的无序散列表添加、获取、移除单个键值对;获取所有键值对;检查某个键是否存在结构化的数据,比如一个对象
ZSET有序集合添加、获取、删除元素;根据分值范围或者成员来获取元素;计算一个键的排名去重但可以排序,如获取排名前几名的用户

另外还有高级的4种数据类型:

  • HyperLogLog:通常用于基数统计。使用少量固定大小的内存,来统计集合中唯一元素的数量。统计结果不是精确值,而是一个带有0.81%标准差(standard error)的近似值。所以,HyperLogLog适用于一些对于统计结果精确度要求不是特别高的场景,例如网站的UV统计。
  • Geo:redis 3.2 版本的新特性。可以将用户给定的地理位置信息储存起来, 并对这些信息进行操作:获取2个位置的距离、根据给定地理位置坐标获取指定范围内的地理位置集合。
  • Bitmap:位图。
  • Stream:主要用于消息队列,类似于 kafka,可以认为是 pub/sub 的改进版。提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
编码和底层实现

主要是讲述上述五种基本类型的底层编码实现:

类型编码对象
REDIS_STRINGREDIS_ENCODING_INT使用整数值来实现的字符串对象
REDIS_STRINGREDIS_ENCODING_EMBSTR使用embstr编码的简单动态字符串实现的字符串对象
REDIS_STRINGREDIS_ENCODING_RAW使用简单动态字符串实现的字符串对象
REDIS_LISTREDIS_ENCODING_ZIPLIST使用压缩列表实现的列表对象
REDIS_LISTREDIS_ENCODING_LINKEDLIST使用双端链表实现的列表对象
REDIS_HASHREDIS_ENCODING_ZIPLIST使用压缩列表实现的哈希对象
REDIS_HASHREDIS_ENCODING_HT使用字典实现的哈希对象
REDIS_SETREDIS_ENCODING_INTSET使用整数集合实现的集合对象
REDIS_SETREDIS_ENCODING_HT使用字典实现的集合对象
REDIS_ZSETREDIS_ENCODING_ZIPLIST使用压缩列表实现的有序集合对象
REDIS_ZSETREDIS_ENCODING_SKIPLIST使用跳跃表字典实现的有序集合对象

参考《Redis设计与实现》第一部分 数据结构与对象 的 第八章 对象,p63。

通过上面的整理我们就可以知道他们的具体编码实现了,整理如下:

  • String:SDS
  • list:压缩列表、双向链表。
  • hash:压缩列表、字典。
  • set:整数集合、字典。
  • zset:压缩列表、跳表。

在Redis中我们可以通过 OBJECT ENCODING命令来查看一个数据库键的值对象的编码:

redis> SET msg "hello world"
OK
redis> OBJECT ENCODING msg
"embstr"

关于他们具体在什么时候使用什么编码格式,我们在下文详细说明!

数据结构

主要说明七种对象:简单动态字符串链表字典跳跃表整数集合压缩列表

SDS字符串

简单动态字符串(SDS),用作Redis的默认字符串表示。

SDS定义

每个 sds.h/sdshdr 结构标识一个SDS值:

struct sdshdr {
	int len; // 记录buf数组中已使用的字节数量,等于SDS所保存字符串的长度
    int free; // 记录buf数组中未使用的字节数量
    char buf[]; // 字节数组,用于保存字符串
}

tip:buf数组最后一个字节会用来保存’/0’,这也是遵循C字符串以空字符结尾的惯例,但是这个字符不会被计算在len长度中。

遵循的好处就是它可以直接重用一部分C字符串函数库里面的函数。

SDS 与 C字符串的区别

如果一张表来说明,即:

C字符串SDS
获取字符串长度的复杂度为O(N)获取字符串长度的复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出API是安全的,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存重分配修改字符串长度N次最多需要执行N次内存重分配
只能保存文本数据可以保存文本数据或者二进制数据
可以使用所有的<string.h>库中的函数可以使用一部分<string.h>库中的函数

那我们根据这五点来说明,这五大区别的产生原因:

获取字符串长度

原因如下:

  • C字符串必须遍历字符串直到碰到结尾的空字符为止复杂度为O(N)
  • SDS字符串在len属性中记录了SDS本身的长度复杂度为O(1)

其中SDS长度的设置与更新是由SDS的API执行时自动完成的。

缓冲区溢出

因为C字符串没有记录字符串长度,所以如果使用如下方法:

char *strcat(char *dest, const char *src);

当开发者已经为 dest 字符串分配了一定的内存,此时如果 src 字符串中内容拼接进去后的内存大于分配的内存,则会造成缓冲区溢出。

那么SDS字符串是如何解决的呢?

SDS API 需要对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足所需的要求,如果不满足的话,API 会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要后动修改 SDS 的空间大小,也不会出现C字符串中的缓冲区溢出问题。

内存重分配次数

因为C字符串的底层实现总是 N + 1 个字符串长度的数组。所以每次执行 增长字符串 或是 缩短字符串时,都要先通过重分配扩展底层数组的空间大小 或是 释放字符串不再使用的空间,来防止缓冲区溢出 或者 内存泄漏

那么SDS字符串是如何解决的呢?

SDS中使用free属性记录未使用空间的字节数量。

通过未使用的空间,SDS 实现了 空间预分配惰性空间释放 两种优化策略。

空间预分配的操作是:当 SDSAPI 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。

这里存在两种修改情况:

  1. 对SDS修改后,SDS长度(即len值)< 1MB:这是 len值 会和 free值 相同。此时 buf数组 实际长度是 len + free + 1
  2. 对SDS修改后,SDS长度(即len值)> 1MB:会多分配 1MB 未使用空间,比如 len值 为30MB时,此时 buf数组 实际长度是 30MB + 1MB + 1byte

惰性空间释放的操作是:当 SDSAPI 对 一个 SDS 进行修改,并且需要对 SDS 所保存的字符串进行缩短时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free属性 将这些字节的数量记录起来,并等待将来使用。

当然,如果需要真正地释放 SDS 的未使用空间,会有 API 去实现,这里不说明。

二进制安全

C字符串的字符必须符合某种编码(比如ASCII),并且除了末尾空字符外,不能包含任何空字符,否则会被程序误认为是末尾,这使得C字符串只能保存文本数据,而不能保存二进制数据。

那么SDS字符串是如何解决的呢?

SDSAPI 都是二进制安全的,所有的 SDS API 都会以处理二进制的方式来处理 SDS 存放的 buf数组 里的数据。

所以SDS 的 buf属性被称为字节数组,就是因为它是用来保存一系列二进制数据。

兼容< string.h >库的函数

上面说过了,SDS 也遵循C字符串以空字符结尾的惯例,就是为了能让它使用部分<string.h>库的函数。

链表
链表定义

每个链表节点使用一个 adlist.h/listNode 结构来表示:

typedef struct listNode {
    struct listNode *prev; // 前置指针
    struct listNode *next; // 后置指针
    void *value; // 节点的值
}

说明该链表是一个双向链表。

当我们使用多个 listNode 组成链表,就会直接使用 adlist.h/list 来持有该链表进行操作:

typedef struct list {
    listNode *head; // 表头节点
    listNode *tail; // 表尾节点
    unsigned long len; // 链表所包含的节点数量
    void *(*dup) (void *ptr); // 节点值复制函数
    void *(*free) (void *ptr); // 节点值释放函数
    int (*match) (void *ptr, void *key); // 节点值对比函数
}
特性总结
  • 双端:节点有 prev 和 next 指针,复杂度为O(1)。
  • 无环:对链表的访问都是以NULL为终点。
  • 带头尾指针:list 中有head 和 tail 指针,复杂度为O(1)。
  • 带链表长度计数属性:len属性保存节点数,复杂度为O(1)。
  • 多态:使用 void*指针保存节点值,可以保存不同类型的值。
字典

即数组 + 链表实现。

字典定义

Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:

typedef struct dictht {
    dictEntry **table; // 哈希表数组
    unsigned long size; // 哈希表大小
    unsigned long sizemask; // 哈希表大小掩码,用于计算索引值,总是等于 size - 1
    unsigned long used; // 哈希表已有节点数量
}

哈希表节点使用 dictEntry 结构表示,每个 dictEntry 结构都保存着一个kv对:

typedef struct dictEntry {
    void *key; // 键
    union { // 值
        void *val;
        uint64_t u64;
        uint64_t s64;
    }
    struct dictEntry *next; // 指向下个哈希表节点,形成链表
}

Redis 中的字典由 dict.h/dict 结构表示:

typedef struct dict {
	dictType *type; // 类型特定函数
    void *privdata; // 私有数据
    dictht ht[2]; // 哈希表
    int trehashidx; // rehash索引,当rehash不在进行时,值为1
}
哈希冲突
哈希算法

在添加新的键值到字典里是,要先进行对key的哈希,根据哈希值计算出索引值,根据索引将新的kv对放到哈希表数组的指定索引上。

index = hash&dict -> ht[0].sizemask

Redis 使用 MurmurHash 算法。

解决哈希冲突

Redis 的哈希表使用链地址法解决哈希冲突,并且使用的是头插法

rehash

hash 对象在扩容时使用了一种叫 “渐进式 rehash” 的方式。

rehash概述

扩展收缩哈希表的工作都是通过执行 rehash 来完成的。

reash的步骤如下:

  1. 计算新表(ht[1])的空间大小,取决于旧表(ht[0])当前包含的键值以及数量。

    1. 如果是扩展操作,那么新表(ht[1])的大小为第一个大于等于 ht[0].used * 2 的 2^N。
    2. 如果是收缩操作,那么新表(ht[1])的大小为第一个大于等于ht[0].used 的 2^N。
  2. 将保存在旧表(ht[0])的所有键值rehash到新表(ht[1])上。

  3. 当旧表(ht[0])全部迁移完成后,释放旧表(ht[0]),将新表设置为 ht[0] 并在 ht[1]重新创建一张空白哈希表。

这两个哈希表的套路是不是有点像jvm运行时数据区的年轻代的幸存者区?可以引申一下。

rehash条件

当下面两个条件任意一个被满足时,程序就会自动开始对哈希表进行扩展操作:

  1. 当前服务器没有在执行 BGSAVE 命令或 BGREWRITEAOF 指令,并且哈希表的负载因子大于等于1
  2. 当前服务器正在执行 BGSAVE 命令或 BGREWRITEAOF 指令,并且哈希表的负载因子大于等于5。【5是因为已保存节点数量包括冲突节点】

为什么这两个命令的是否正在执行,和服务器执行扩展操作的负载因子并不相同?

答:是因为在执行BGSAVE命令或者BGREWRITEAOF命令的过程中,Redis需要fork子线程,而大多数os都采用与时复制技术来优化子进程的使用效率,所以子进程存在的期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希扩容,可以避免不必要的内存写入操作,节约内存。

与时复制:copy-on-write,即不用复制写入直接引用父进程的物理过程。

BGSAVE命令:fork子进程去完成备份持久化。(区别于SAVE命令,阻塞线程去完成备份持久化)

BGREWRITEAOF命令:异步执行AOF重写,优化原文件大小(该命令执行失败不会丢失数据,成功才会真正修改数据,2.4以后手动触发该命令)

渐进式hash过程

渐进式rehash的详细步骤:

  1. ht[1]分配空间,让字典同时持有ht[0]ht[1]两个哈希表。
  2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设为0,表示rehash工作正式开始。
  3. 在rehash进行过程中,每次对字典进行添加、删除、查找、更新操作时,除了执行指定操作以外,还会顺带将ht[0]rehashidx索引上的所有键值对rehash到ht[1]上,当rehash工作完成时,rehashidx属性值加一。
  4. 随着字典操作的不断执行,最终在某一个时间点上,ht[0]的所有键值对都会被rehash到ht[1]上,这是将rehashidx的值设为-1,表示rehash操作已完成。

渐进式hash采取 分而治之 的思想,将rehash键值对所需的计算工作均摊到字典的每个添加、删除、查找、更新操作上,避免集中式hash。

渐进式hash执行期间进行哈希表操作
  1. 进行删除、查找、更新操作时,都会在两个哈希表上进行。比如说查找操作,现在ht[0]上查找,如果ht[0]上没有就去ht[1]上查找。
  2. 进行添加操作时,新的键值对直接保存在ht[1]中,而ht[0]不进行操作,这样保证ht[0]只减不增。
渐进式hash的缺点
  1. 扩容期开始时,会先给 ht[1] 申请空间,所以在整个扩容期间,会同时存在 ht[0]ht[1],会占用额外的空间。

  2. 扩容期间同时存在 ht[0]ht[1],查找、删除、更新等操作有概率需要操作两张表,耗时会增加。

  3. redis 在内存使用接近 maxmemory 并且有设置驱逐策略的情况下,出现 rehash 会使得内存占用超过 maxmemory,触发驱逐淘汰操作,导致 master/slave 均有有大量的 key 被驱逐淘汰,从而出现 master/slave 主从不一致。

跳跃表

可以把他理解为一个可以二分查找的链表

它在Redis中只用到过两处:一是有序集合zset;二是集群节点的内部数据结构

这块的实现就不整理,看博客 或者 看书吧,《Redis设计与实现》p38。

参考博客链接一:面试准备 – Redis 跳跃表

参考博客链接二:Redis中的跳跃表

参考博客链接三:跳跃表以及跳跃表在redis中的实现

为什么redis选择了跳跃表而不是红黑树?
  • 在做范围查找的时候,平衡树比 skiplist 操作要复杂。
    • 在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。
    • 而在 skiplist 上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
  • 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而 skiplist 的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 从内存占用上来说,skiplist比平衡树更灵活一些。
    • 平衡树每个节点包含2个指针(分别指向左右子树)。
    • skiplist 每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
  • 查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种 Mapdictionary 结构,大都是基于哈希表实现的。
  • 从算法实现难度上来比较,skiplist 比平衡树要简单得多。
整数集合
整数集合定义

每个 intset.h/intset 结构表示一个整数集合:

typedef struct intset {
    uint32_t encoding; // 编码方式
    uint32_t length; // 集合包含的元素数量
    int8_t contents[]; // 保存元素的数组
}

其中 contents[]就是整数集合的底层实现:整数集合的每个元素都是该数组的一个数组项,各个项在数组中是从小到大有序排列,并且不重复。

虽然 contents[] 属性声明是 int8_t,但是真正类型取决于 encoding

整数集合升级

整数升级,即当我们将一个新元素添加到集合中时,新元素的类型比原集合的类型都要长时,整数集合需要升级,然后才能将新元素添加到集合中

具体升级并添加元素的步骤分为三步:

  1. 根据新元素的类型,扩展底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位置。该过程中,底层数组的顺序不可变。
  3. 将新元素加入数组。

该过程的复杂度为 O(N)

升级的好处
  1. 提升整数集合的灵活性。
  2. 尽可能节约内存。
整数集合降级

整数集合不支持降级操作!

压缩列表

它的存在意义就是为了节约内存

压缩列表定义

压缩列表就是一个由一系列特殊编码的连续内存块组成的顺序型数据结构

在这里插入图片描述

压缩列表的各个组成部分说明如下表:

属性类型长度用途
zlbytesuint32_t4字节记录整个压缩链表占用的字节数,在对压缩列表进行内存重分配,或者计算zlend的位置时使用。
zltailuint32_t4字节记录压缩列表表尾节点距离压缩列表起始地址有多少个字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定尾节点的地址。
zllenuint16_t2字节记录了压缩列表包含的字节数量,该属性小于UINT16_MAX(65535)时,该值为压缩列表包含节点的数量;该属性等于UINT16_MAX(65535)时,节点的真实数量需要遍历压缩列表获得。
entryX列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容而定。
zlenduint8_t1字节特殊值0xFF(十进制255),用于标记压缩列表的末端。
列表节点构成

每个压缩列表节点可以保存一个字节数组或者一个整数值。其中,字节数组可以是以下三种长度之一:

  • 长度小于等于63(2^6 - 1)字节的字节数组;
  • 长度小于等于16383(2^14 - 1)字节的字节数组;
  • 长度小于等于4294967295(2^32 - 1)字节的字节数组;

而整数值则可以是以下六种长度的其中一种:

  • 4位长,介于0至12之间的无符号整数;
  • 1字节长的有符号;
  • 3字节长的有符号整数;
  • int16_t类型整数;
  • int32_t类型整数;
  • int64_t类型整数。

每个压缩列表节点都由 previous_entry_lengthencodingcontent三个部分组成:

在这里插入图片描述

previous_entry_length

节点的 previous_entry_length 属性以字节为单位,记录了压缩列表中前一个节点的长度

previous_entry_length 属性的长度可以是1字节 或者 5字节:

  • 如果前一节点的长度小于254字节,那么 previous_entry_length 属性的长度为1字节:前一节点的长度就保存在这一个字节里面。
  • 如果前一节点的长度大于等于254字节,那么 previous_entry_length 属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制254),而之后的四个字节则用于保存前一节点的长度。

它的好处就是,因为节点的 previous_entry_length 属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一节点的起始地址。

压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的 previous_entry_length 属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。

encoding

节点的 encoding 属性记录了节点的 content 属性所保存数据的类型以及长度

  • 1字节、2字节或者5字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的 content 属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录;
  • 1字节长,值的最高位以11开头的是整数编码:这种编码表示节点的 content 属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录。
content

节点的 content 属性负责保存节点的值,节点值可以是一个字节数组或者整数值,值的类型和长度由节点的 encoding 属性决定。

连锁更新

redis中的压缩列表在插入数据的时候可能存在连锁扩容的情况。

在压缩列表中,节点需要存放上一个节点的长度:当上一个entry节点长度小于254个字节的时候,将会一个字节的大小来存放entry中的数据;但是当上一个entry节点长度大于等于254个字节的时候,就会需要更大的空间来存放数据。

在压缩列表中,会把大于等于254字节长度用5个字节来存储,第一个字节是254,当读到254的时候,将会确认接下来的4个字节大小将是entry的长度数据。当第一个字节为255的时候,就证明压缩列表已经到达末端。

由于表示长度的字节大小不一样,当新节点的插入可能会导致下一个节点原本存放表示上一节点的长度的空间大小不够导致需要扩容这一字段。相应的该字段将会由一个字节扩容到五个字节,四个字节的长度变化,当发生变化的节点原本长度在250到253之间的时候,将会导致下一个节点存储上节点长度的空间发生变化,引起一个连锁扩容的情况,这一情况将会直到一个不需要扩容的节点为止。

扩容逻辑代码如下,可参考:

while (p[0] != ZIP_END) {
    zipEntry(p, &cur);
    rawlen = cur.headersize + cur.len;
    rawlensize = zipStorePrevEntryLength(NULL,rawlen);

    /* Abort if there is no next entry. */
    if (p[rawlen] == ZIP_END) break;
    zipEntry(p+rawlen, &next);

    /* Abort when "prevlen" has not changed. */
    if (next.prevrawlen == rawlen) break;

    if (next.prevrawlensize < rawlensize) {
        /* The "prevlen" field of "next" needs more bytes to hold
             * the raw length of "cur". */
        offset = p-zl;
        extra = rawlensize-next.prevrawlensize;
        zl = ziplistResize(zl,curlen+extra);
        p = zl+offset;

        /* Current pointer and offset for next element. */
        np = p+rawlen;
        noffset = np-zl;

        /* Update tail offset when next element is not the tail element. */
        if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) {
            ZIPLIST_TAIL_OFFSET(zl) =
                intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
        }

        /* Move the tail to the back. */
        memmove(np+rawlensize,
                np+next.prevrawlensize,
                curlen-noffset-next.prevrawlensize-1);
        zipStorePrevEntryLength(np,rawlen);

        /* Advance the cursor */
        p += rawlen;
        curlen += extra;
    } else {
        if (next.prevrawlensize > rawlensize) {
            /* This would result in shrinking, which we want to avoid.
                 * So, set "rawlen" in the available bytes. */
            zipStorePrevEntryLengthLarge(p+rawlen,rawlen);
        } else {
            zipStorePrevEntryLength(p+rawlen,rawlen);
        }

        /* Stop here, as the raw length of "next" has not changed. */
        break;
    }
}

代码逻辑是:首先,从新插入的节点的下一个节点开始,如果下一个节点存放上一个字节的空间大小大于或等于当前的节点长度,那么在存放了这一长度数据之后,该次连锁扩容直接宣告结束。如果下一个节点存放长度的空间不能容纳当前节点的长度,那么就会将下一个节点进行扩容,并重新申请内存大小,并复制数据,移动指向尾部节点的指针。最后移动到下一个节点,在下一个循环中判断是否需要继续扩容。

编码转换时机

Redis中的每个对象都由一个 redisObject 结构来表示:

typedef struct redisObject {
    unsigned type:4; // 类型
    unsigned encoding:4; // 编码
    void *ptr; // 指向底层实现数据结构的指针
}

类型包括基本的五种,编码指对应类型下的不同编码实现。

Redis可以根据不同的使用场景,来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。

字符串

字符串的编码可以是 intraw 或者是 embstr

int

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

raw

如果一个字符串对象保存的是一个字符串值,并且长度大于44字节,那么这个字符串对象将使用简单动态字符串(SDS)来保存,并且编码设置为 raw

embstr

如果一个字符串对象保存的是一个字符串值,并且长度小于等于44字节,那么同上,但是编码设置为embstr

embstr 是专门用于保存短字符串的优化编码方式。它和 raw 的区别在于,raw编码会调用两次内存分配函数来分别创建 redisObjectsdshdr 结构,而embstr 编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含 redisObjectsdshdr 结构。

使用 embstr 的好处:

  1. 内存分配次数减少一次。
  2. 释放内存时的调用函数次数也少一次。
  3. embstr 保存在连续的内存中,它可以更好地利用缓存带来的优势。

不过,embstr 编码没有任何相应的修改程序,它实际上只是只读的,当 embstr 编码的字符串执行修改命令时,总会变成 raw

为什么raw和embstr的临界值是44字节?

如果看过书的同学有疑问很正常,因为在《Redis的设计与实现》中,它写的临界值是39字节,但是实际上经过查找资料,在3.2版本之后就改成了44字节了。主要原因是为了内存优化,具体解释如下:

我们知道对于每个 sds 都有一个 sdshdr,里面的 lenfree 记录了这个 sds 的长度和空闲空间,但是这样的处理十分粗糙,使用的 unsigned int 可以表示很大的范围,但是对于很短的 sds 有很多的空间被浪费了(两个unsigned int 8个字节)。而这个 commit 则将原来的 sdshdr 改成了 sdshdr16sdshdr32sdshdr64 ,里面的 unsigned int 变成了 uint8_tuint16_t…(还加了一个char flags)这样更加优化小 sds 的内存使用。

本身就是针对短字符串的 embstr 自然会使用最小的 sdshdr8 ,而 sdshdr8 与之前的 sdshdr 相比正好减少了5个字节(sdsdr8 = uint8_t * 2 + char = 1*2+1 = 3, sdshdr = unsigned int * 2 = 4 * 2 = 8),所以其能容纳的字符串长度增加了5个字节变成了44。

列表

列表的编码可以是 ziplist 或者 linkedlist。(压缩列表 或者 双向链表

ziplist

如果列表对象保存的所有字符串元素的长度都小于64字节,并且列表对象保存的元素数量小于512个时,编码为 ziplist

linkedlist

上面两个条件,只要一个不满足,就采取 linkedlist 编码。

哈希

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

ziplist

如果哈希对象保存的所有键值对的键和值的字符串长度都小于64字节,并且哈希对象保存的键值对数量小于512个时,编码为 ziplist

hashtable

上面两个条件,只要一个不满足,就采取 hashtable 编码。

集合

集合对象的编码可以是 intset 或者 hashtable

intset

如果集合对象保存的所有元素都是整数值,并且哈希对象保存的元素数量小于512个时,编码为 intset

hashtable

上面两个条件,只要一个不满足,就采取 hashtable 编码。

有序集合

有序集合的编码可以是 ziplist 或者 skiplist

ziplist

如果有序集合对象保存的所有元素成员的长度都小于64字节,并且有序集合对象保存的元素数量小于128个时,编码为 ziplist

skiplist

上面两个条件,只要一个不满足,就采取 skiplist 编码。

持久化

详细了解参考文章:Redis的两种持久化RDB和AOF(超详细)

Redis对数据的操作都是基于内存的,当遇到了进程退出、服务器宕机等意外情况,如果没有持久化机制,那么Redis中的数据将会丢失无法恢复。有了持久化机制,Redis在下次重启时可以利用之前持久化的文件进行数据恢复。

Redis支持的两种持久化机制:

  • RDB:把当前数据生成快照保存在硬盘上。
  • AOF:记录每次对数据的操作到硬盘上。
  • 混合持久化:在 redis 4 引入,RDB + AOF 混合使用的方式,RDB 持久化全量数据,AOF 持久化增量数据。

RDB

RDB(Redis DataBase)持久化是把当前Redis中全部数据生成快照保存在硬盘上。RDB持久化可以手动触发,也可以自动触发

触发方式
手动触发

savebgsave 命令都可以手动触发RDB持久化。

  • 执行save命令会手动触发RDB持久化,但是save命令会阻塞Redis服务,直到RDB持久化完成。当Redis服务储存大量数据时,会造成较长时间的阻塞,不建议使用。
  • 执行bgsave命令也会手动触发RDB持久化,和save命令不同是:Redis服务一般不会阻塞。Redis进程会执行fork操作创建子进程RDB持久化由子进程负责,不会阻塞Redis服务进程。Redis服务的阻塞只发生在fork阶段,一般情况时间很短。
    1. 执行 bgsave 命令,Redis进程先判断当前是否存在正在执行的RDB或AOF子线程,如果存在就是直接结束。
    2. Redis进程执行 fork 操作创建子进程,在fork操作的过程中Redis进程会被阻塞。
    3. Redis进程 fork 完成后, bgsave 命令就结束了,自此Redis进程不会被阻塞,可以响应其他命令。
    4. 子进程根据Redis进程的内存生成快照文件,并替换原有的RDB文件。
    5. 子进程通过信号量通知Redis进程已完成。

简单说明,save命令会全程阻塞,bgsave只在创建子线程时会阻塞。

自动触发

在以下几种场景下,会自动触发RDB持久化:

  1. 在配置文件中设置了 save 的相关配置,如sava m n,它表示在 m 秒内数据被修改过 n 次时,自动触发 bgsave 操作。
  2. 当从节点做全量复制时,主节点会自动执行 bgsave 操作,并且把生成的RDB文件发送给从节点。
  3. 执行 debug reload 命令时,也会自动触发 bgsave 操作。
  4. 执行 shutdown 命令时,如果没有开启AOF持久化也会自动触发 bgsave 操作。
RDB优缺点
优点
  1. RDB文件是一个紧凑的二进制压缩文件,是Redis在某个时间点的全部数据快照。所以使用RDB恢复数据的速度远远比AOF的快,非常适合备份、全量复制、灾难恢复等场景。
缺点
  1. 如果数据集非常巨大,并且 CPU 时间非常紧张的话,那么这种停止时间甚至可能会长达整整一秒。
  2. 每次进行bgsave操作都要执行fork操作创建子经常,属于重量级操作,频繁执行成本过高,所以无法做到实时持久化,或者秒级持久化。
  3. 由于Redis版本的不断迭代,存在不同格式的RDB版本,有可能出现低版本的RDB格式无法兼容高版本RDB文件的问题。

AOF

执行流程
  1. 命令追加(append):所有写命令都会被追加到AOF缓存区(aof_buf)中。
  2. 文件同步(sync):根据不同策略将AOF缓存区同步到AOF文件中。
  3. 文件重写(rewrite):定期对AOF文件进行重写,以达到压缩的目的。
  4. 数据加载(load):当需要恢复数据时,重新执行AOF文件中的命令。
触发方式
手动触发

使用 bgrewriteaof 命令。

自动触发

根据 auto-aof-rewrite-min-sizeauto-aof-rewrite-percentage 配置确定自动触发的时机。

  • auto-aof-rewrite-min-size 表示运行AOF重写时文件大小的最小值,默认为64MB。
  • auto-aof-rewrite-percentage 表示当前AOF文件大小和上一次重写后AOF文件大小的比值的最小值,默认为100。

只用前两者同时超过阈值时才会自动触发文件重写。

AOF文件同步策略

AOF持久化流程中的文件同步有以下几个策略:

  • always:每次写入缓存区都要同步到AOF文件中,硬盘的操作比较慢,限制了Redis高并发,不建议配置。
  • no:每次写入缓存区后不进行同步,同步到AOF文件的操作由操作系统负责,每次同步AOF文件的周期不可控,而且增大了每次同步的硬盘的数据量。
  • eversec:每次写入缓存区后,由专门的线程每秒钟同步一次,做到了兼顾性能和数据安全。是建议的同步策略,也是默认的策略。
AOF持久化配置
# appendonly改为yes,开启AOF
appendonly yes
# AOF文件的名字
appendfilename "appendonly.aof"
# AOF文件的写入方式
# everysec 每个一秒将缓存区内容写入文件 默认开启的写入方式
appendfsync everysec
# 运行AOF重写时AOF文件大小的增长率的最小值
auto-aof-rewrite-percentage 100
# 运行AOF重写时文件大小的最小值
auto-aof-rewrite-min-size 64mb

文件事件处理器

推荐博客文章:Redis全面解析一:redis是单线程结构为何还可以支持高并发

我们经常说Redis是单线程的,但是为什么这么说呢?

因为 Redis 内部用的是基于 Reactor 模式开发的文件事件处理器,文件事件处理器是以单线程方式运行的,所以redis才叫单线程模型。

组成部分

基于 Reactor 模式设计的四个组成部分的结构如下所示:

在这里插入图片描述

它们分别是:

  • 套接字
  • IO多路复用程序
  • 文件事件分派器
  • 事件处理器

处理机制

文件事件处理器大致可分为三个处理流程:

  1. 每一个套接字准备好执行连接应答、写入、读取、关闭等操作时,就会产生一个文件事件。一个服务器会连接多个套接字,多个文件事件并发的出现。
  2. I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生的套接字,I/O多路复用程序会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字处理完毕,接受下一个套接字。
  3. 文件事件分派器接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。(执行不同任务的套接字关联不同的事件处理器)。

拓展

关于Redis6.0的多线程升级博客参考链接:Redis6 新特性多线程解析

内存淘汰机制

Redis 缓存使用内存保存数据,避免了系统直接从后台数据库读取数据,提高了响应速度。由于缓存容量有限,当缓存容量到达上限,就需要删除部分数据挪出空间,这样新数据才可以添加进来。Redis 定义了「淘汰机制」用来解决内存被写满的问题。

缓存淘汰机制,也叫缓存替换机制,它需要解决两个问题:

  • 决定淘汰哪些数据。
  • 如何处理那些被淘汰的数据。

内存淘汰策略

截至在 4.0 之后,Redis定义了「8种内存淘汰策略」用来处理 redis 内存满的情况:

  • noeviction:不会淘汰任何数据,当使用的内存空间超过 maxmemory 值时,返回错误。
  • volatile-ttl:筛选设置了过期时间的键值对,越早过期的越先被删除。
  • volatile-random:筛选设置了过期时间的键值对,随机删除。
  • volatile-lru:使用 LRU 算法筛选设置了过期时间的键值对。
  • volatile-lfu:使用 LFU 算法选择设置了过期时间的键值对。
  • allkeys-random:在所有键值对中,随机选择并删除数据。
  • allkeys-lru:使用 LRU 算法在所有数据中进行筛选。
  • allkeys-lfu:使用 LFU 算法在所有数据中进行筛选。

根据它们的名称前缀我们就能如下分类:

  • 不淘汰数据noeviction
  • 淘汰数据
    • 设置了过期时间的键值对中进行淘汰:volatile-ttlvolatile-randomvolatile-lruvolatile-lfu
    • 所有数据进行淘汰:allkeys-randomallkeys-lruallkeys-lfu
策略介绍
noeviction

noeviction 策略,也是 Redis 的默认策略,它要求 Redis 在使用的内存空间超过 maxmemory 值时,也不进行数据淘汰。一旦缓存被写满了,再有写请求来的时候,Redis 会直接返回错误。

我们实际项目中,一般不会使用这种策略。因为我们业务数据量通常会超过缓存容量的,而这个策略不淘汰数据,导致有些热点数据保存不到缓存中,失去了使用缓存的初衷。

volatile-ttl、volatile-random、volatile-lru、volatile-lfu

volatile-randomvolatile-ttlvolatile-lruvolatile-lfu 这四种淘汰策略。它们淘汰数据的时候,只会筛选设置了过期时间的键值对上。

比如,我们使用 EXPIRE 命令对一批键值对设置了过期时间,那么会有两种情况会对这些数据进行清理:

  1. 第一种情况是过期时间到期了,会被删除。
  2. 第二种情况是 Redis 的内存使用量达到了 maxmemory 阈值,Redis 会根据 volatile-randomvolatile-ttlvolatile-lruvolatile-lfu 这四种淘汰策略,具体的规则进行淘汰;这也就是说,如果一个键值对被删除策略选中了,即使它的过期时间还没到,也需要被删除。

其中 volatile-ttlvolatile-random的筛选规则比较简单,而volatile-lruvolatile-lfu分别用到了 LRULFU 算法。

allkeys-random、allkeys-lru、allkeys-lfu

allkeys-randomallkeys-lruallkeys-lfu 这三种策略跟上述四种策略的区别是:淘汰时数据筛选的数据范围是所有键值对。

其中allkeys-random的筛选规则比较简单,而allkeys-lruallkeys-lfu分别用到了LRULFU 算法。

LRU & LFU算法
LRU

LRU 算法全称 Least Recently Used,一种常见的页面置换算法。按照「最近最少使用」的原则来筛选数据,筛选出最不常用的数据,而最近频繁使用的数据会留在缓存中。

LRU 筛选逻辑

RU 会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU 端和 LRU 端,分别代表「最近最常使用」的数据和「最近最不常用」的数据。

每次访问数据时,都会把刚刚被访问的数据移到 MRU 端,就可以让它们尽可能地留在缓存中。

如果此时有新数据要写入时,并且没有多余的缓存空间,那么该链表会做两件事情:

  1. 将新数据放到MRU端。
  2. LRU端的数据删除。

简单说明,即它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在 MRU端;LRU 端的数据被认为是长久不访问的数据,在缓存满时,就优先删除它。

Redis 对 LRU 的实现

Redis 3.0 前,随机选取 N 个淘汰法。

Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。

在 Redis 决定淘汰的数据时,随机选 N(默认5) 个 key,把空闲时间(idle time)最大的那个 key 移除。这边的 N 可通过 maxmemory-samples 配置项修改:

config set maxmemory-samples 100

当需要再次淘汰数据时,Redis 需要挑选数据进入「第一次淘汰时创建的候选集合」。

挑选的标准是:能进入候选集合的数据的 lru 字段值必须小于「候选集合中最小的 lru 值」。

当有新数据进入备选数据集后,如果备选数据集中的数据个数达到了设置的阈值时。Redis 就把备选数据集中 lru 字段值最小的数据淘汰出去

Redis3.0后,引入了缓冲池(默认容量为16)概念。

当每一轮移除 key 时,拿到了 N(默认5)个 key 的 idle time,遍历处理这 N 个 key,如果 key 的 idle time 比 pool 里面的 key 的 idle time 还要大,就把它添加到 pool 里面去。

当 pool 放满之后,每次如果有新的 key 需要放入,需要将 pool 中 idle time 最小的一个 key 移除。这样相当于 pool 里面始终维护着还未被淘汰的 idle time 最大的 16 个 key。

当我们每轮要淘汰的时候,直接从 pool 里面取出 idle time 最大的 key(只取1个),将之淘汰掉。

整个流程相当于随机取 5 个 key 放入 pool,然后淘汰 pool 中空闲时间最大的 key,然后再随机取 5 个 key放入 pool,继续淘汰 pool 中空闲时间最大的 key,一直持续下去。

在进入淘汰前会计算出需要释放的内存大小,然后就一直循环上述流程,直至释放足够的内存。

LFU

在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用内存空间。这种情况,就是缓存污染

为了应对缓存污染问题,Redis 从 4.0 版本开始增加了 LFU 淘汰策略。

LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个「计数器」,来统计这个数据的访问次数。

LFU 筛选逻辑
  • 当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。
  • 如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。
LFU 的具体实现

我们在前面说过,为了避免操作链表的开销,Redis 在实现 LRU 策略时使用了两个近似方法:

  • Redis 在 RedisObject 结构中设置了 lru 字段,用来记录数据的访问时间戳。
  • Redis 并没有为所有的数据维护一个全局的链表,而是通过「随机采样」方式,选取一定数量的数据放入备选集合,后续在备选集合中根据 lru 字段值的大小进行筛选删除。

在此基础上,Redis 在实现 LFU 策略的时候,只是把原来 24bit 大小的 lru 字段,又进一步拆分成了两部分:

  • ldt 值:lru 字段的前 16bit,表示数据的访问时间戳
  • counter 值:lru 字段的后 8bit,表示数据的访问次数

但是我们会发现一个问题,counter 值的最大记录值只有255。当几个缓存数据的 counter 值 都达到255值,就无法正确根据访问次数来决定数据的淘汰了。

所以Redis 针对这个问题进行了优化:在实现 LFU 策略时,Redis 并没有采用数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。

Redis 对 LFU 的实现

Redis 实现 LFU 策略时采用计数规则:

  1. 每当数据被访问一次时,先用「计数器当前的值」乘以「配置项lfu_log_factor ,再加 1;取其倒数,得到一个 p 值。
  2. 然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。

Redis的部分源码实现如下:

double r = (double)rand() / RAND_MAX; // 随机数 r 值
// ......
// baseval 是计数器当前的值,初始值默认是 5,是由代码中的 LFU_INIT_VAL 常量设置
double p = 1.0 / (baseval * server.lfu_log_factor + 1); // ((计数器当前值 * 配置项参数) + 1 )的倒数
if (r < p) counter++;

为什么 baseval 的初始值是5,而不是0?是因为这样可以避免数据刚被写入缓存,就因为访问次数少而被立即淘汰。

使用了这种计算规则后,我们可以通过设置不同的 lfu_log_factor 配置项,来控制计数器值增加的速度,避免 counter 值很快就到 255 了。

这张表是根据Redis官网获得的,进一步说明 LFU 策略计数器递增的效果。
它记录了当 lfu_log_factor 取不同值时,在不同的实际访问次数情况下,计数器值的变化情况。

lfu_log_factor100 hits1000 hits100K hits1M hits10M hits
0104255255255255
11849255255255
101018142255255
10081149143255

通过上表的分析:

  • 当 lfu_log_factor 取值为 1 时,实际访问次数为 100K 后,counter 值就达到 255 了,无法再区分实际访问次数更多的数据了。
  • 当 lfu_log_factor 取值为 100 时,当实际访问次数为 10M 时,counter 值才达到 255。

使用这种非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU 策略也可以有效的区分不同的访问次数,从而合理的进行数据筛选。

从刚才的表中,我们可以看到,当 lfu_log_factor 取值为 10 时,百、千、十万级别的访问次数对应的 counter 值 已经有明显的区分了。所以,我们在应用 LFU 策略时,一般可以将 lfu_log_factor 取值为 10

但是对于一些业务场景,上方的设计会存在问题:比如说有些数据在「短时间内被大量访问后就不会再被访问了」。

那么再按照访问次数来筛选的话,这些数据会被留存在缓存中,但不会提升缓存命中率。

为此,Redis 在实现 LFU 策略时,还设计了一个「 counter 值的衰减机制」。

LFU 中的 counter 值的衰减机制

简单来说,LFU 策略使用 lfu_decay_time(衰减因子配置项) 来控制访问次数的衰减。

  1. LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。
  2. 然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。

通过上方的第二点,我们就能知道一个规律,lfu_decay_time 值越大,那么相应的衰减值会变小,衰减效果也会减弱;反之相应的衰减值会变大,衰减效果也会增强。

所以,如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1

使用总结
  1. 如果业务数据中「有明显的冷热数据区分」,建议使用 allkeys-lru 策略。这样,可以充分利用 LRU 算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。
  2. 如果业务应用中的「数据访问频率相差不大」,没有明显的冷热数据区分,建议使用 allkeys-random 策略,随机选择淘汰的数据。
  3. 如果业务中有「置顶」的需求,比如置顶新闻、置顶视频,那么,可以使用 volatile-lru 策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。

事务

Redis 事务相对于Mysql 事务来说较为简单,大家可以将二者进行对比,下文也会整理。

概念

Redis 事务的本质是一组命令的集合

事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中

简单理解,Redis 中的事务,就是具有一次性、顺序性、排他性地在命令序列中执行多个命令。

它的主要作用就是串联多个命令防止别的命令插队

事务阶段

我们可以把Redis 事务的执行分为三个阶段:

  1. 开始事务
  2. 命令入队
  3. 执行事务

从输入Multi命令开始,输入的命令都会依次进入命令队列中,但不会执行,直到输入 Exec 后,Redis会将之前的命令队列中的命令依次执行。组队的过程中可以通过 discard

事务错误处理

事务的错误分为两种情况:

  • 如果组队中某个命令报出了错误,执行时整个的所有队列都会被取消
  • 如果执行阶段某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行不会回滚

这说明在 Redis 中,虽然单条命令是原子性执行的,但是事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。

Watch 监控

引入

Redis 中的 悲观锁乐观锁,简单提及以下:

悲观锁Pessimistic Lock),每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁表锁等,读锁写锁等,都是在做操作之前先上锁。

乐观锁Optimistic Lock),每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。Redis就是利用这种check-and-set机制实现事务的。

watch 命令

在执行 multi 之前,先执行watch key1 [key2],可以监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断

举例说明:

假如我账户上有100元,此时我们准备再给账户充值50元,准备买149元的传说皮肤。

但是此时,以一位糟糕的程序员修改了我们的账户,改成了999元。

我很生气,因为我充值失败了,但是我去账户上一看,变成999元了,我马上给自己一巴掌,“在生气什么呢?”…

模拟上方情景,这是控制台1的操作:

在这里插入图片描述

模拟上方情景,这是控制台2的操作:

在这里插入图片描述

注意:只要执行了EXEC,之前加的监控锁都会被取消!Redis的事务不保证原子性,一条命令执行失败了,其他的仍然会执行,且不会回滚。

unwatch 命令

取消 WATCH 命令对所有 key 的监视。

如果在执行 WATCH 命令之后,EXEC 命令或 DISCARD 命令先被执行了的话,那么就不需要再执行 UNWATCH 了。

总结说明

redis 的事务不推荐在实际中使用,如果要使用事务,推荐使用 Lua 脚本,redis 会保证一个 Lua 脚本里的所有命令的原子性。

Redis集群

除去Redis的单例模式,Redis 的集群模式可以分为三种:主从复制哨兵模式集群模式

主从复制

Redis 官方文档【主从复制】:REDIS sentinel-old – Redis中国用户组(CRUG)

主从复制架构

主从复制,将 Redis 实例分为两中角色,一种是被复制的服务器称为主服务器(master),而对主服务器进行复制的服务器被称为从服务器(slave)。

当主数据库有数据写入,会将数据同步复制给从节点,一个主数据库可以同时拥有多个从数据库,而从数据库只能拥有一个主数据库。值得一提的是,从节点也可以有从节点,呈现级联结构。

在这里插入图片描述

我们可以看到,在主从复制中,只有一个是主机,其他的都是从机,并且从机下面还可以有任意多个从机。

主数据库可以进行读写操作,从数据库只能有读操作(并不一定,只是推荐这么做)。

开启主从复制方式
命令

通过slaveof 命令,将 127.0.0.1:6380 的redis实例成为 127.0.0.1:6379 的redis实例的从服务器:

slaveof 127.0.0.1 6379

测试如下:

在这里插入图片描述
在这里插入图片描述

配置

通过编写配置文件,例如先为主配置文件命名为 master.conf 进行编写配置:

# 通用配置
# bind 127.0.0.1 # 绑定监听的网卡IP,注释掉或配置成0.0.0.0可使任意IP均可访问
port 6379  # 设置监听端口
#是否开启保护模式,默认开启。
# 设置为no之后最好设置一下密码
protected-mode no
#是否在后台执行,yes:后台运行;no:不是后台运行
daemonize yes
# 复制选项,slave复制对应的master。
# replicaof <masterip> <masterport>
#如果master设置了requirepass,那么slave要连上master,需要有master的密码才行。masterauth就是用来
# 配置master的密码,这样可以在连上master后进行认证。
# masterauth <master-password>

在启动节点时输入命令

redis-server master.conf
redis-server slave1.conf
redis-server slave2.conf

不过在docker容器中的Redis镜像配置存在一些问题,大家自己找一下资料吧。

启动命令

参考博客链接:redis启动命令及集群创建

复制的实现【重点】
1. 设置主服务器的地址和端口

例如客户端操作从服务器执行如下命令:

127.0.0.1> SLAVEOF 127.0.0.1 6379

从服务器会将客户端给定的主服务器IP地址以及端口号保存到当前从服务器状态的 masterhost 属性和 masterport 属性中。

SLAVEOF 命令是一个异步命令,在完成属性的设置工作后,从服务器会向客户端返回"OK",之后开始执行真正的复制工作。

2. 建立套接字连接

从服务器根据指定的 IP地址端口号,创建连向主服务器套接字(socket)连接。

主服务器在接受(accept) 从服务器的套接字连接之后,为该套接字创建相应的客户端状态。

这个时候可以将从服务器理解为主服务器的客户端。

3. 发送 PING 命令

从服务器主服务器发送一个 PING 命令,以检査套接字的读写状态是否正常主服务器能否正常处理命令请求

从服务器在发送 PING 命令后,会遇到三种情况:

  1. 主服务器响应超时,表示当前两者之间网络连接状态不佳,从服务器重新创建连向主服务器的套接字。
  2. 主服务器返回错误,表示主服务器暂时无法处理从服务器的命令请求,从服务器重新创建连向主服务器的套接字。
  3. 主服务器返回 "PONG",表示主从之间网络连接状态正常,主服务器可以正常处理从服务器的命令请求。
4. 身份验证

存在这一步的前提是:从服务器设置了 masterauth 选项,那么就要进行这一步的身份验证,否则跳过。

从服务器masterauth 选项的值封装成AUTH password 命令并向主服务器发送来进行身份验证。

从服务器在身份验证阶段可能会遇到以下几种情况:

  1. 主服务器没有设置 requirepass 选项,并且从服务器也没有设置 masterauth 选项,那么继续执行复制工作。
  2. 如果从服务器的 AUTH 命令发送的密码和主服务器 requirepass 选项的值相同,那么继续执行复制工作;反之,主服务器返回 invalid password 错误。
  3. 主服务器设置 requirepass 选项,但是从服务器没有设置 masterauth 选项,那么主服务器返回 NOAUTH 错误;如果主服务器没有设置 requirepass 选项,但是从服务器设置 masterauth 选项,那么主服务器返回 no password is set错误。
5. 发送端口信息

从服务器主服务器发送当前服务器的监听端口号, 主服务器收到后记录在从服务器所对应的客户端状态的 slave_listening_port 属性中。

执行命令为 REPLCONF listening-port <port-number>port-number 即为端口号。

目前 slave_listening_port 唯一的作用就是在主服务器执行 INFO replication 命令时打印从服务器端口号。

6. 同步

从服务器主服务器发送 PSYNC 命令,执行同步操作,此时两者互为客户端。

PSYNC 命令有两种执行情况:

  1. 如果从服务器以前没有复制过或者执行过 slaveof no one 命令,那么从服务器在开始一次新的复制时,会给主服务器发送 PSYNC ? -1 命令。主动请求进行完整重同步
  2. 相反,如果已经复制过,那么从服务器在开始一次新的复制时,将向主服务器发送 PSYNC <runid > <offset> 命令,runid 是上次主服务器的运行ID,offset是从服务器的复制偏移量。

主服务器返回从服务器也有三种情况:

  1. 如果主服务器返回 +FULLRESYNC <runid> <offset> 回复,表示主服务器执行完整重同步操作,runid 为主服务器的ID,从服务器会将其保存,offset 是主服务器的复制偏移量,从服务器会将其当作自己的起始复制偏移量。
  2. 如果主服务器返回的是 +CONTINUE回复,表示主服务器执行部分重同步操作,从服务器只要等待主服务器发送缺少的那部分数据过来即可。
  3. 如果主服务器返回的是 +ERR 回复,那么表示 Redis 版本低于2.8,识别不了 PSYNC 命令,那么从服务器向主服务器发送 SYNC 命令,并与之执行完整同步操作。

从上方可知,主要包括全量数据同步增量数据同步的情况,这跟Redis是否第一次连接在连接过程中是否离线有关。

7. 命令传播

当完成了同步之后,就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从一致了。

主从复制优缺点
优点
  • 同一个Master可以同步多个Slaves。
  • master能自动将数据同步到slave,可以进行读写分离,分担master的读压力
  • master、slave之间的同步是以非阻塞的方式进行的,同步期间,客户端仍然可以提交查询或更新请求
缺点
  • 不具备自动容错与恢复功能,master或slave的宕机都可能导致客户端请求失败,需要等待机器重启或手动切换客户端IP才能恢复
  • master宕机,如果宕机前数据没有同步完,则切换IP后会存在数据不一致的问题
  • 难以支持在线扩容,Redis的容量受限于单机配置
总结

其实redis的主从模式很简单,在实际的生产环境中很少使用,不建议在实际的生产环境中使用主从模式来提供系统的高可用性,之所以不建议使用都是由它的缺点造成的,在数据量非常大的情况,或者对系统的高可用性要求很高的情况下,主从模式也是不稳定的。虽然这个模式很简单,但是这个模式是其他模式的基础,所以理解了这个模式,对其他模式的学习会很有帮助。

命令传播阶段后的心跳检测 以及 PSYNC 的实现,具体参照书中,不多解释了。

哨兵模式

Redis官方文档【高可用】:REDIS sentinel-old – Redis中国用户组(CRUG)

参考公众号文章:全面分析Redis高可用的奥秘 - Sentinel

哨兵模式架构

哨兵(Sentinel) 是 Redis 的高可用性解决方案:由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器。

Sentinel 可以在被监视的主服务器进入下线状态时,自动将下线主服务器的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

在这里插入图片描述

哨兵进程

哨兵(Sentinel)其实也是Redis 实例,只不过它在启动时初始化将 Redis 服务器使用的代码替换成 Sentinel 专用代码。

哨兵进程的作用
  1. 监控(Monitoring): 哨兵(sentinel) 会不断地检查你的Master和Slave是否运作正常。
  2. 提醒(Notification):当被监控的某个Redis节点出现问题时, 哨兵(sentinel) 可以通过 API 向管理员或者其他应用程序发送通知。
  3. 自动故障迁移(Automatic failover):当一个Master不能正常工作时,哨兵(sentinel) 会开始一次自动故障迁移操作。
哨兵(Sentinel) 和 一般Redis 的区别?
  1. Sentinel 的本质只是一个运行在特殊模式下的 Redis 服务器。
  2. 一般Redis 初始化时加载RDB 或者 AOF 文件还原数据库状态,而Sentinel 不加载是因为它不使用数据库。
  3. Sentinel 使用的代码是 Sentinel专用代码。
  4. Sentinel 会初始化一个 sentinel.c/sentinelState 结构,用于保存所有和 Sentinel 功能相关的状态,比如其中的 masters字典记录了所有被 Sentinel 监视的主服务器相关信息。
哨兵的工作方式
创建连接

这一步是初始化 Sentinel 的最后一步,Sentinel 成为主服务器的客户端,可以向主服务器发送命令。

每个sentinel都会创建两个连向主服务器的异步网络连接

  • 命令连接:用于向master服务发送命令,并接收命令回复。
  • 订阅连接:用于订阅、接收master服务的 __sentinel__:hello 频道。

为什么有两个连接?

命令连接的原因是:Sentinel 必须向主服务器发送命令,以此来与主服务器通信。

订阅连接的原因是:目前Redis版本的发布订阅功能无法保存被发送的信息,如果接收信息的客户端离线,那么这个客户端就会丢失这条信息,为了不丢失 __sentinel__:hello 频道的任何信息,Sentinel 专门用一个订阅连接来接收该频道的信息。

【简单理解:不仅需要发信息,也需要收信息】

获取主服务器信息

Sentinel 默认会以10秒一次通过命令连接向被监视的主服务器发送 INFO 命令,主服务器收到后回复自己的run_idIP、端口、对应的主服务器信息及主服务器下的所有从服务器信息。

Sentinel 根据返回的主服务器信息更新自身的 *masters 实例结构;至于主服务器返回的从服务器信息用于更新对应的slaves 字典列表。

更新 slaves 字典时有两种情况:

  1. 如果存在从服务器对应的实例结构,那么Sentinel会对该实例结构进行更新。
  2. 如果不存在从服务器对应的实例结构,会为这个从服务器新创建一个实例结构。
获取从服务器信息

Sentinel 同样会和从服务器建立异步的命令连接和订阅连接,并也会默认10秒一次从服务器发送 INFO 命令,从服务器会回复自己的运行run_id角色role从服务器复制偏移量offset主服务器的ip和port主从服务器连接状态从服务器优先级等信息,sentinel会根据返回信息更新对应的 slave 实例结构。

向主服务器和从服务器发送信息

Sentinel 默认会以2秒一次通过命令连接向所有被监控的主服务器从服务器_sentinel:hello频道发送信息,信息的内容包含两种参数:

  1. 一种参数是以 s_ 开头的参数,代表 Sentinel 自身的信息。
  2. 另一种参数是以 m_ 开头的参数,代表主服务器的信息。
    1. 如果发送的对象是主服务器,那么这些参数就是主服务器的信息。
    2. 如果发送的对象是从服务器,那么这些参数就是从服务器正在复制的主服务器信息。

参数列表展示参考:

参数意义
s_ipSentinel 的 IP地址
s_portSentinel 的端口号
s_runidSentinel 的运行ID
s_epochSentinel 当前的配置纪元(configuration epoch)
m_name主服务器的名字
m_ip主服务器的IP地址
m_port主服务器的端口号
m_epoch主服务器当前的配置纪元
接收来自主服务器和从服务器的频道信息

Sentinel通过订阅连接向服务器发送命令 SUBSCRIBE __sentinel__:hello,保证对_sentinel_:hello的订阅一直持续到 Sentinel 与 服务器的连接断开为止。

_sentinel_:hello频道 与 Sentinel 的关系是一对多的关系,作用在于发现多个监控同一master的sentinel

在接收到其他 sentinel 发送的频道信息后,会根据信息更新 master 对应的 Sentinel 。

master 数据结构绑定后,会建立 Sentinel 与 Sentinel 的命令连接,为后续通讯做准备。

故障检测
检测主观下线

Sentinel 默认会以1秒一次的频率向与它建立命令连接的所有实例(包括master、slave以及发现的其他sentinel)发送 PING 命令,对方接收后返回两种回复:

  • **有效回复:**包括运行正常(+PONG)、正在加载(-LOADING)、和主机下线(-MASTERDOWN)。
  • **无效回复:**除有效回复的三种以外都是无效回复,或者在指定时限内没有返回任何回复。

在固定时间内,即 down-after-milliseconds(默认单位为毫秒) 配置的时间内收到的都是无效回复,Sentinel 就会标记 master 为主观下线。与此同时,Sentinel 会将 master 数据结构中对应的flags属性更新为 SRI_S_DOWN 标识,表示被监控的master在当前sentinel中已经进入主观下线状态。

down-after-milliseconds 的值,不仅是sentinel 用来判断主服务器主观下线状态,还用来判断主服务器下所有从服务器,以及所有同样监视这个主服务器的其他Sentinel的主观下线状态。

简单说明,即 down-after-millsseconds 配置是作用于当前sentinel所监控的所有服务上的,也就是对应master下的slave,以及其他sentinel。另外每个sentinel可以配置不同down-after-millsenconds,所以判定主观下线的时间也就是不同的。

检测客观下线

判定 master 为主观下线状态的 Sentinel,通过命令询问其他同样监控这一主服务器的 Sentinel,看它们是否认为该 master 真的进入了下线状态。

Sentinel 发送给其他 Sentinel 的命令为:

SEBTUBEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>

参数说明:

  • Ip:被 Sentinel 判断为主观下线的主服务器的IP地址。
  • port:被 Sentinel 判断为主观下线的主服务器的端口号。
  • current_epoch:Sentinel 当前的配置纪元,用于选举领头 Sentinel。
  • runid:可以是 * 符号 或 Sentinel 的 run_id,用 * 符号仅用于检测主服务器的客观下线状态;用Sentinel 的 run_id 是用于选举领头 Sentinel。

其他 Sentinel 接收到 SEBTUBEL is-master-down-by-addr 命令后,会根据其中的主服务器IP端口号,检查主服务器是否已下线,然后向源 Sentinel 返回一条包含三个参数的 Multi Bulk 回复:

<down_state>
<leader_runid>
<leader_epoch>

参数说明:

down_state:返回目标 Sentinel 对主服务器的检查结果,1 代表已下线,0 代表为下线。

leader_runid:可以是 * 符号 或 目标 Sentinel 的 run_id,用 * 符号仅用于检测主服务器的下线状态;用局部领头 Sentinel 的 run_id 是用于选举局部领头 Sentinel。

leader_epoch:目标 Sentinel 的局部领头 Sentinel 的配置纪元,用于选举领头 Sentinel。【仅在 leader_runid 的值不为 * 时有效,如果 leader_runid 的值为 *,则 leader_epoch 总为0】

当 Sentinel 收到从其他 Sentinel 返回的足够数量的已下线判断之后,Sentinel会将主服务器实例结构的 flags 属性的 SRI_O_DOWN 标识打开,表示主服务器已经进入客观下线状态

足够数量的已下线判断是多少呢?

不同的 Sentinel 判断客观下线状态的条件是不同的,具体不解释了,看《Redis设计与实现》P238。

选举领头 Sentinel

当一个主服务器被判断为客观下线时,监测这个下线主服务器的各个 Sentinel 会进行协商,选举出一个领头 Sentinel,并由领头 Sentinel 对下线主服务器执行故障转移操作。

下面尽量直白地介绍选举领头 Sentinel 的规则和方法:

  • 每个在线的 Sentinel 都有被选为领头 Sentinel 的资格。

  • 同一个配置纪元内(本质是计数器,在每次选举后自增一次),每个 Sentinel 都有一次将某个 Sentinel 设置为局部领头 Sentinel 的机会,并且设置后,在这个配置纪元里不能再更改。

  • 每个发现主服务器进入客观下线 的Sentinel 都会要求其他 Sentinel 将自己设置为局部领头Sentinel。

  • 拉票方式为发送 SEBTUBEL is-master-down-by-addr 命令,刚才的 *号替换为源 Sentinel的 run_id,表示希望目标 Sentinel 设置自己为它的局部领头 Sentinel。

  • 接收拉票命令的目标 Sentinel 可是非常单纯,谁的命令先发给它,它就选谁当自己的局部领头 Sentinel,之后的拉票全部拒绝。

  • 当然,既然目标 Sentinel根据先到先得确定了局部领头 Sentinel,那也得和大家回个话,它会为发送拉票命令的源 Sentinel 回复命令,记录了自身选择的局部领头 Sentinel的 run_id配置纪元

  • 如果某个 Sentinel 被半数以上的 Sentinel 设置为了局部领头 Sentinel,那么这个局部领头sentinel就变成了领头sentinel,同一个配置纪元内可能会出现多个局部领头sentinel,但是领头sentinel只会产生一个。

  • 如果在给定的时限内,没有任何一个 Sentinel 被选举为领头 Sentinel,那么各个 Sentinel 会在一段时间后再次选举,直到选出领头 Sentinel 为止。

故障迁移

在选举出领头 Sentinel 之后,领头 Sentinel 会对已下线的主服务器执行故障转移操作,可分为三个步骤:

  1. 在已下线的主服务器下的所有从服务器中,挑选一个从服务器作为新的主服务器。
  2. 让已下线的主服务器下的所有从服务器改为复制新的主服务器。
  3. 将已下线的主服务器设置为新的主服务器的从服务器,当它重新上线时会成为新的主服务器的从服务器。
选出新的主服务器

(一)、新的主服务器是从原主服务器下的从服务器中选择的,所以需要选择状态良好数据完整的从服务器。领头 Sentinel 的数据结构中保存了原master对应的 slave ,Sentinel 会删除状态较差的slave。过滤执行顺序如下:

  1. 删除断线或者下线的从服务器。
  2. 删除最近 5 秒内没有回复过领头 Sentinel 的 INFO 命令的从服务器。
  3. 删除与原 master 断开超过down-after-millisecond * 10 毫秒的从服务器,这样可以排除从服务器与原主服务器过早断开连接,保证备选从服务器的数据都是比较新的。

对应第三条,我可以解释一下,前面提到过,在 down-after-millisecond 设置的时长内没有收到有效回复,可以判定当前复制的主服务器主观下线。所以,越迟和主服务器断开连接的从服务器,数据越新

(二)、现在过滤出的都是健康的从服务器了,然后 Sentinel 开始选择新的主服务器,有以下三个优先级顺序:

  1. 然后根据从服务器的优先级进行排序,选出优先级最高的服务器。
  2. 如果有多个相同最高优先级的从服务器,那么则根据它们的复制偏移量来进行排序。
  3. 如果有多个优先级和复制偏移量相同的从服务器,那么选择 run_id 最小的从服务器。

(三)、选出新的主服务器后,领头 Sentinel 向被选中的从服务器发送 SLAVEOF no one 命令。

在发送 SLAVEOF no one 命令后,领头 Sentinel 会以每秒一次的频率(平时是十秒一次)向被选中的从服务器发送 INFO 命令,当被升级的服务器的 role 字段从 slave 变为 master 时,领头 Sentinel 就知道它已经顺利成为新主服务器了。

修改从服务器的复制目标

领头 Sentinel 给已下线主服务器下的所有从服务器发送 SLAVEOF 命令,让它们去复制新的主服务器。

将旧主服务器变为从服务器

因为旧主服务器下线,领头Sentinel 会修改它对应主服务器下的实例结构中的设置。

等旧主服务器重新上线时,Sentinel 就会向它发送 SLAVEOF 命令,让他成为新的主服务器的从服务器。

集群模式

《Redis设计与实现》第十七章 集群 p245;

官方文档【集群教程】:REDIS cluster-tutorial – Redis中文资料站 – Redis中国用户组(CRUG)

官方文档【集群规范】:REDIS cluster-spec – Redis中文资料站 – Redis中国用户组(CRUG)

官方文档【分区】:REDIS 分区 – Redis中国用户组(CRUG)

集群模式架构

哨兵模式最大的缺点就是所有的数据都放在一台服务器上,无法较好的进行水平扩展。

为了解决哨兵模式的痛点,集群模式应运而生。在高可用上,集群基本是直接复用的哨兵模式的逻辑,并且针对水平扩展进行了优化。

它具有的特点有:

  1. 一个 Redis 集群通常由多个节点(Node)组成。
  2. 采取去中心化的集群模式,将数据按槽存储分布在多个 Redis 节点上。集群共有 16384 个槽,每个节点负责处理部分槽。
  3. 使用 CRC16 算法来计算 key 所属的槽:crc16(key,keylen) & 16383
  4. 所有的 Redis 节点彼此互联,通过 PING-PONG 机制来进行节点间的心跳检测。
  5. 分片内采用一主多从保证高可用,并提供复制故障恢复功能。在实际应用场景下,通常会将主从分布在不同服务器,避免单个服务器出现故障导致整个分片出问题,下图的 内网IP 代表不同的服务器。
  6. 客户端与 Redis 节点直连,不需要中间代理层(proxy)。客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。

在这里插入图片描述

下面将会根据它的特点逐步说明该集群的核心技术。

集群数据结构

使用 clusterNode 结构保存一个节点的当前状态,比如创建时间名称配置纪元IP端口号等。

每个节点都会为自己和集群中所有其他节点都创建一个对应的 clusterNode 结构来记录各自的节点状态。

struct clusterNode {
    // 创建节点的时间
    mstime_t ctime;
    // 节点的名称,由40个十六进制字符组成,例如68eef66df23420a5862208ef5...f2ff
    char name[REDIS_CLUSTER_NAMELEN];
    // 节点标识,使用各种不同表示值记录节点的角色(主节点或从节点);以及节点目前的状态(在线或下线)
    int flags;
    // 节点当前的配置纪元,用于实现故障转移
    uint64_t configEpoch;
    // 节点的IP地址
    char ip[REDIS_IP_STR_LEN];
    // 节点的端口号
    int port;
    // 保存连接节点所需的相关信息
    clusterLink *link;
    // ...
};

其中的 link 属性是一个 clusterLink 结构,该结构保存连接节点所需的相关信息,包括套接字描述符、输入缓冲区、输出缓冲区。

typedef struct clusterLink {
    // 连接的创建时间
    mestime_t ctime;
    // TCP 套接字描述符
    int fd;
    // 输出缓冲区,保存着待发送给其他节点的信息(message)
    sds sndbuf;
    // 输入缓冲区,保存着从其他节点接收到的信息
    sds rcvbuf;
    // 与这个连接相关联的节点,如果没有的话就为 NULL
    struct clusterNode *node;
}

最后一点,每个节点都保存着一个 clusterState 结构,这个结构记录了当前节点视角下,所在集群目前所处的状态。

例如集群在线或下线状态、包含节点个数、集群当前的配置纪元等信息。

typedef struct clsterState {
    // 指向当前节点的指针
	clusterNode *myself;
    // 集群当前的配置纪元,用于实现故障转移
    uint64_t currentEpoch;
    // 集群当前的状态,是在线还是下线
    int state;
    // 集群节点名单(包含myself节点)
    // 字典的key是节点的名字,value是节点对应的 clusterNode 结构
    dict *nodes;
}
集群连接方式

通过发送 CLUSTER MEET 命令,可以让目标节点A将另一个命令携带的节点B添加到目标节点A当前所在的集群中。

CLUSTER MEET <ip> <port>

收到命令后开始进行节点A节点B握手阶段,以此来确认彼此的存在,为后面的通信打好基础,该过程简单说明:

  1. 客户端向节点A发送 CLUSTER MEET 命令后,节点A向节点B发送 MEET 信息,给节点B创建 clusterNode 结构,并更新自己的 clusterState 结构。
  2. 节点B返回节点A PONG 信息。
  3. 节点A返回节点B PING 信息。

之后,节点A和节点B会通过Gossip 协议传播给集群其他的节点,让他们也和节点B握手,最终整个集群达成共识。

一般集群元数据的维护有两种方式:集中式、Gossip 协议。在Redis集群中采用Gossip 协议进行通信,所以说它是去中心化的集群。

下面说一下这两种方式的区别:

集中式:是将集群元数据(节点信息、故障等等)几种存储在某个节点上。集中式元数据集中存储的一个典型代表,就是大数据领域的 storm。它是分布式的大数据实时计算引擎,是集中式的元数据存储的结构,底层基于 zookeeper(分布式协调的中间件)对所有元数据进行存储维护。

gossip 协议所有节点都持有一份元数据,不同的节点如果出现了元数据的变更,就不断将元数据发送给其它的节点,让其它节点也进行元数据的变更。

集中式好处在于,元数据的读取和更新,时效性非常好,一旦元数据出现了变更,就立即更新到集中式的存储中,其它节点读取的时候就可以感知到;不好在于,所有的元数据的更新压力全部集中在一个地方,可能会导致元数据的存储有压力。

gossip 协议好处在于,元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续打到所有节点上去更新,降低了压力;不好在于,元数据的更新有延时,可能导致集群中的一些操作会有一些滞后。

分布式寻址算法【引入】

如果会的同学可以跳过,这里只做引申说明。

一般分布式寻址算法有下列几种:

  • hash 算法(大量缓存重建)
  • 一致性 hash 算法(自动缓存迁移)+ 虚拟节点(自动负载均衡)
  • redis cluster 的 hash slot 算法
hash 算法

来了一个 key,首先计算 hash 值,然后对节点数取模。然后打在不同的 master 节点上。一旦某一个 master 节点宕机,所有请求过来,都会基于最新的剩余 master 节点数去取模,尝试去取数据。这会导致大部分的请求过来,全部无法拿到有效的缓存,导致大量的流量涌入数据库。

在这里插入图片描述
在这里插入图片描述

一致性 hash 算法

一致性 hash 算法将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织,下一步将各个 master 节点(使用服务器的 ip 或主机名)进行 hash。这样就能确定每个节点在其哈希环上的位置

在这里插入图片描述

一致性 hash 算法也是使用取模的方法 hash算法的取模法是对服务器的数量进行取模,而一致性 hash 算法是对 **2^32 ** 取模:

hash(服务器A的IP地址) %  2^32
hash(服务器B的IP地址) %  2^32
hash(服务器C的IP地址) %  2^32

来了一个 key,首先计算 hash 值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,遇到的第一个 master 节点就是 key 所在位置。

使用 hash 算法时,服务器数量发生改变时,所有服务器的所有缓存在同一时间失效了,而使用一致性哈希算法时,服务器的数量如果发生改变,并不是所有缓存都会失效,而是只有部分缓存会失效,例如如果一个节点挂了,受影响的数据仅仅是此节点到环空间前一个节点(沿着逆时针方向行走遇到的第一个节点)之间的数据,其它不受影响。增加一个节点也同理。

hash 环数据倾斜 & 虚拟节点

然而当一致性 hash 算法在节点太少或是节点位置分布不均匀时,容易造成大量请求都集中在某一个节点上,而造成缓存热点的问题。如果i此时该热点节点出现故障,那么失效缓存的数量也将达到最大值,在极端情况下,有可能引起系统的崩溃,这种情况被称之为 数据倾斜

在这里插入图片描述

为了预防 数据倾斜 的问题,一致性 hash 算法引入了虚拟节点机制,即对每一个节点计算多个 hash,每个计算结果位置都放置一个虚拟节点。这样就实现了数据的均匀分布,负载均衡。

具体说明,每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点。具体做法可以在服务器ip或主机名的后面增加编号来实现。可以为每台服务器计算三个虚拟节点,于是可以分别计算 “Node1#1”、“Node1#2”、“Node1#3”、“Node2#1”、“Node2#2”、“Node2#3”的哈希值,这样可以让hash 环中存在多个节点,使节点的分布更均匀,当然可以虚拟出更多的虚拟节点,以便减小hash环偏斜所带来的影响,虚拟节点越多,hash环上的节点就越多,缓存被均匀分布的概率就越大。

图就不画了…理解理解TAT

hash slot 算法

redis 集群采用数据分片的哈希槽来进行数据存储和数据的读取。

redis 集群中有固定的 16384 个槽(slot),对每个 key 计算 CRC16 值,然后对 16384 取模,可以获取 key 对应的 hash slot

redis 集群中每个 master 都会被指派部分的槽(slot),假如说当前集群中有3个节点服务器,可能是这样分配的 [0,5000]、[5001,10000]、[10001,16383]。

槽位的实现其实就是一个长度为 16384 的二进制数组,根据指定索引位上的二进制位值来判断节点是否处理指定索引的槽位

所以槽位的迁移非常简单:

  1. 增加一个 master,就将其他 master 的槽位移动部分过去。
  2. 减少一个 master,就将它的槽位移动到其他 master 上去。

移动槽位的成本是非常低的。客户端的 api,可以对指定的数据,让他们走同一个槽位,通过 hash tag 来实现。

在Redis中通过 CLUSTER ADDSLOTS 命令来指派负责的槽位,后面会详细说明。

每个节点都会记录哪些槽指派给了自己,哪些槽指派给了其他节点。客户端向节点发送键命令,节点要计算这个键属于哪个槽。如果是自己负责这个槽,那么直接执行命令,如果不是,向客户端返回一个 MOVED 错误,指引客户端转向正确的节点。

任何一台机器宕机,另外两个节点,不影响的。因为 key 找的是 hash slot,不是机器。

架构图参照上方《集群模式架构》中。

可能有人问,为什么一致性hash算法是65535(2^32)个位置,而hash slot 算法却是16384(2^14)个位置?【翻译官方回答】

  1. 正常的心跳包携带节点的完整配置,可以用幂等方式替换旧节点以更新旧配置。 这意味着它们包含原始形式的节点的插槽配置,它使用 16384 个插槽只占用 2k 空间,但使用 65535 个插槽时将占用高达8k 的空间
  2. 同时,由于其他设计权衡,Redis Cluster不太可能扩展到超过1000个主节点

因此,16384个插槽处于正确的范围内,以确保每个主站有足够的插槽,最多1000个节点,但足够小的数字可以轻松地将插槽配置传播为原始位图。 请注意,在小型集群中,位图难以压缩,因为当N很小时,位图将设置插槽/ N位,这是设置的大部分位。

一致性 hash 算法 和 hash slot 算法的区别?
定位规则区别

它并不是闭合的,key的定位规则是根据 CRC-16(key) % 16384 的值来判断属于哪个槽区,从而判断该key属于哪个节点,而一致性 hash 算法是根据 hash(key) 的值来顺时针找第一个 hash(ip或主机名) 的节点,从而确定key存储在哪个节点。

应对热点缓存区别

一致性 hash 算法是创建虚拟节点来实现节点宕机后的数据转移并保证数据的安全性和集群的可用性的。

redis 集群是采用master节点有多个slave节点机制来保证数据的完整性的。master节点写入数据,slave节点同步数据。当master节点挂机后,slave节点会通过选举机制选举出一个节点变成master节点,实现高可用。但是这里有一点需要考虑,如果master节点存在热点缓存,某一个时刻某个key的访问急剧增高,这时该mater节点可能操劳过度而死,随后从节点选举为主节点后,同样宕机,一次类推,造成缓存雪崩。(简单说明就是,都是被大量请求一套秒的,谁上来都一样QAQ…)

扩容和缩容区别

一致性 hash 算法在新增和删除节点后,数据会按照顺时针自动来重新分布节点

redis 集群的新增和删除节点都需要手动来分配槽区

集群的槽指派

Redis集群通过分片来保存数据库的键值对:集群整个数据库被分为16384个槽slot),数据库的每个键都属于这16384个槽其中的一个,集群中的每个节点可以处理0个到16384个槽。

指派节点槽信息

当集群使用 CLUSTER MEET 命令,整个集群仍处于下线状态,此时必须通过它们指派槽,通过发送 CLUSTER ADDSLOTS 命令给节点,将一个或多个槽指派给节点负责:

CLUSTER ADDSLOTS <slot> [slot...]

比如说将 0 到 5000 个槽指派给节点7000负责:

CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000

然后以此类推给其他节点指派槽。

槽位是在 clusterNode 结构中的 slots 属性和 numslot 属性记录的,记录当前节点负责处理哪些槽:

struct clusterNode {
    //...
    
    // 二进制位数组
    unsigned char slots[16384/8];
    // 记录节点负责处理的槽的数量,即slots数组中值为1的二进制位的数量
    int numslots;
}

在上面小节《分布式寻址算法》的《hash slot 算法》中说过,槽的本质就是一个二进制位数组,通过对[0,16383]上的对应索引为标记来判断是否处理该槽位:如果slots数组上在指定索引位的二进制位的值为1,标识节点负责处理该槽,反之同理。

CLUSTER ADDSLOTS 的命令实现

CLUSTER ADDSLOTS 命令的实现也比较简单:

  1. 遍历所有输入槽,检查它们是否被指派。
    1. 只要有一个被指派,那么就返回错误并且终止命令执行。
    2. 如果都没有被指派,那么就再次遍历一遍,将它们指派给当前节点。
      1. 设置 clusterState.slot[i]索引位的指针指向 clusterState.myself。(如果不了解它先看下面再回来)
      2. 将数组在指定索引位上的二进制设置为1。

执行完毕后,开始广播通知给集群中的其他节点,自己目前处理的槽位。

传播节点槽信息

节点会将自己的 slots 数组通过消息发送给集群中的其他节点,告知它们自己目前负责的槽位。

当其他节点接收到消息,会更新自己的在 clusterState.nodes 字典中对应节点的 clusterNode 结构中的 slots 数组。

记录集群所有槽的指派信息

clusterState 结构中的 slots 数组记录了集群中所有 16384 个槽的指派信息:

typedef struct clusterState {
	//...
    clusterNode *slots[16384];
    //...
}

slots 数组包含 16384 个项,每个数组项都是一个指向 clusterNode 的指针:对应指针指向 NULL 时,说明还未分配;指向 clusterNode 结构时,说明已经指派给了对应结构所代表的节点。

使用 clusterState.slots 和使用 clusterNode.slots 保存指派信息相比的好处?

使用clusterState.slots 比使用 clusterNode.slots 能够更高效地解决问题。

  • 如果只使用 clusterNode.slots来记录,每次都需要遍历所有 clusterNode 结构,复杂度为O(N)。
  • 但如果使用 clusterState.slots 来记录,只需要访问 clusterState.slots对应的索引位即可,复杂度为O(1)。
集群执行命令

建立集群,并且分配完槽位,此时集群就会进入上线状态,这时候客户端就可以向集群中的节点发送数据指令了。

客户端在向节点发送与数据库键有关的命令时,接收命令的节点就会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派个了自己:

  • 如果键所在的槽正好指派给当前节点,那么节点就直接执行这个命令
  • 如果键所在的槽没有指派给当前节点,那么节点就会向客户端返回 MOVED 错误指引客户端向正确的节点,并再次发送之前想要执行的命令。

节点会使用以下算法来给指定 key 进行计算:

def slot_number(key):
	return CRC16(key) & 16383
  • CRC16(key):计算键 key 的 CRC-16 校验和。
  • & 16383:计算出介于0至16383之间的整数作为键 key 的槽号。

当节点计算出键所属的槽后,节点会检查自己 clusterState.slots 数组中的指定槽位,判断是否由自己负责:

  • 如果 clusterState.slot[i] 等于 clusterState.myself,说明是由当前节点负责的。
  • 如果 clusterState.slot[i] 不等于 clusterState.myself,说明不是由当前节点负责的,会根据 clusterState.slot[i] 指向的 clusterNode 结构中所记录的 IP 和 端口号,返回客户端 MOVED 错误,指引客户端转向正在处理该槽的节点。
MOVED 错误

MOVED 错误的格式为:

MOVED <slot> <ip>:<port>
  • slot:键所在的槽。
  • ip:port:负责处理该槽节点的IP地址和端口号。

MOVED 错误一般是不会打印的,而是根据该错误自动进行节点转向,并打印转向信息。

如果在单机 redis 的情况下,是会被客户端打印出来的。

节点数据库的实现

节点只能使用0号数据库,而单机Redis服务器则没有限制

节点除了将键值对保存在数据库中之外,还会用 clusterState 结构中的 slots_to_keys跳跃表来保存槽和键之间的关系:

typedef struct clusterState {
    //...
    zskiplist *slots_to_keys;
    //...
}

slots_to_keys 跳表中每个节点的分值(score)都是一个槽位号;每个节点的成员(member)都是一个数据库键。

  • 当节点往数据库中添加新的键值对时,节点会将键的槽位号以及这个键关联到 slot_to_keys 跳表中。
  • 当节点删除数据库中的某个键值对时,节点就会在 slot_to_keys跳表中解除它们的关联关系。
重新分片(比如在线扩容)

Redis 集群的重新分片操作可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,并且相关联槽位的键值对也会从源节点移动到目标节点。

重新分片的操作是可以在线进行的,保证了高可用

我们就以在线扩容节点的情况来说吧:比如现在准备在集群中增加一个节点,如何将原有分片中的若干个槽位指派给新添加的节点?

Redis 集群的重新分片操作是由 Redis 集群管理软件 redis-trib 负责执行的:Redis 提供重新分配的所有命令,而 redis-trib 通过向源节点和目标接待你发送命令来进行重新分片操作。

redis-trib 对集群的单个槽进行重新分片的步骤如下:

  1. redis-trib给目标节点发送 CLUSTER SETSLOT <slot> IMPORTING <source_id> 命令,让目标节点准备好从源节点导入对应槽位的键值对
  2. redis-trib 对源节点发送 CLUSTER SETSLOT <slot> MIGRATING <target_id>命令,让源节点准备好将对应槽位的键值对迁移到目标节点
  3. redis-trib 向源节点发送CLUSTER GETKEYSINSLOT <slot> <count> 命令,获取最多 count 个对应槽的键值对的键名称
  4. 根据第三步中所获得的键名,redis-trib 都向源节点发送 MIGRATE <target_ip> <target_port> <key_name> 0 <timeout> 命令,将被选中的键原子性地迁移到目标节点
  5. 重复第三步和第四步,直到源节点中所有对应槽位的键值对都迁移到目标节点为止。
  6. redis-trib 向集群中的任意一个节点发送 CLUSTER SETSLOT <slot> NODE <target_id> 命令,将对应槽指派给了目标节点,这个信息会被广播发给整个集群,最终整个集群都知道了对应槽被指派给了目标节点。

如果涉及多个槽,则给每个槽重复执行上述本步骤。

ASK 错误 - (保证集群在线扩容的安全性)

在重新分片操作期间,可能会出现一部分键值对被迁出,一部分键值还未被迁出,即在源节点和目标节点都由对应槽的数据

当节点向源节点发送一个与数据库键相关的命令,并且该键的槽位正好处在重新分片的过程中:

  1. 源节点现在自己的库中找指定键。
    1. 找到的话,直接执行客户端发送的命令。
    2. 没找到的话,判断当前源节点是否正在迁移对应数据库键所在的槽位。
      1. 如果没有在迁移,说明键不存在,正常执行命令。
      2. 如果在迁移,说明键有可能在目标节点,返回 ASK 错误。

ASK 错误同 MOVED 错误类似,也是不会打印的,也会根据错误提供的 IP 和 端口号自动进行转向操作。

同理,单机模式下会打印错误。

那 ASK 错误 和 MOVED 错误有什么区别呢?

虽然它们能导致客户端转向,但是 MOVED 错误代表槽的负责权已经交给另一个节点了;而 ASK 错误只是两个节点在迁移槽的过程中使用的临时措施。

CLUSTER SETSLOT IMPORTING 命令的实现

clusterState 结构的 importing_slots_from 数组记录了当前节点正在从其他节点导入的槽

typedef struct clusterState {
    //...
    clusterNode *importing_slots_from[16384];
    //...
}

如果 importing_slots_from[i] 的值不为 NULL,而是指向一个 clusterNode 结构,那么表示当前节点正在从 clusterNode 所代表的节点导入该槽。

在对集群重新分片的时候,向目标节点发送 CLUSTER SETSLOT IMPORTING 命令:

CLUSTER SETSLOT <slot> IMPORTING <source_id>

可以将目标节点 clusterState.importing_slots_from[i] 的值设置为 source_id所代表的节点的 clusterNode 结构。

CLUSTER SETSLOT MIGRATING 命令的实现

clusterState 结构的 migrating_slots_to 数组记录了当前节点正在迁移至其他节点的槽

typedef struct clusterState {
    //...
    clusterNode *migrating_slots_to[16384];
    //...
}

如果 migrating_slots_to[i] 的值不为 NULL,而是指向一个 clusterNode 结构,那么表示当前节点正在将该槽迁移到 clusterNode 所代表的节点。

ASKING 命令

当客户端接收到 ASK 错误并转向正在导入槽的节点时,客户端会先向节点发送一个 ASKING 命令,然后才重新发送要执行的命令,这是因为客户端如果不发送 ASKING 命令,而直接发送想要执行的命令的话,那么客户端发送的命令会被节点拒绝执行,并返回 MOVED 错误。

复制和故障转移

Redis 集群中节点可分为主节点(master)和从节点(slave)。

主节点用于处理槽;从节点用于复制某个主节点,并在主节点下线时,代替下线主节点继续处理命令请求。

设置从节点方式

向一个节点发送命令:

CLUSTER REPLICATE <node_id>

可以让接收命令的节点成为 node_id 所指定的节点的从节点,并开始对主节点进行复制操作,具体步骤如下:

  1. 接收命令的节点首先找到 clusterState.nodes 字典中对应 node_id 所对应节点的 clusterNode 结构,并将自身的 clusterState.myself.slaveof 指针指向这个结构,来记录正在复制的主节点。
  2. 修改自身 clusterState,myself.flags 属性,关闭原来的 REDIS_NODE_MASTER 标识,打开 REDIS_NODE_SLAVE 标识,表明该节点已经从主节点变成从节点。
  3. 最后,节点会调用复制代码对主节点进行复制,相当于向从节点发送 SLAVEOF 命令。
故障检测

集群中每个节点都会定期向其他节点发送 PING 信息,以此检测对方是否在线,如果接收 PING 信息的节点没有在规定时间内返回 PONG 信息,那么发送消息的节点会将接收消息的节点标记为疑似下线(PFALL)。

如果在集群中,半数以上负责槽的主节点都将某个主节点标记为疑似下线,那么这个主节点就会被标记为已下线(FALL)。

将该主节点标记为已下线的节点会向集群广播关于该节点的 FALL 消息,所有收到这条 FALL 信息的节点都会立即将该节点标记为已下线

故障转移

当一个从节点发现自己正在复制的主节点进入了下线状态时,从节点会对下线主节点进行故障转移,按照以下的执行步骤:

  1. 从下线主节点的所有从节点中选出一个从节点,让被选中的从节点执行 SLAVE no one 命令,成为新的主节点。
  2. 新的主节点会撤销所有已下线主节点的槽指派,并将这些槽全部指派给自己
  3. 新的主节点向集群广播 PONG 信息,这条信息可以啊让其他主节点直到这个节点已经成为主节点,并且接管了所有已下线的主节点负责处理的槽。
  4. 新的主节点开始接收自己负责处理的槽相关的命令请求,故障转移完成。
选举新的主节点过程

新的主节点也是通过选举产生的,简单介绍一下它的选举过程:

  1. 每一次开始故障转移操作时,集群的配置纪元(自增计数器,初始值为0)会自增加一。
  2. 在每个配置纪元中,集群中每个负责处理槽的主节点都有一次投票机会,而第一个来发送拉票请求的从节点将获得它的投票。
  3. 当从节点发现自己正在复制的主节点已下线时,会向集群广播 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 信息,要求所有收到信息,并且有投票权的主节点给它投票。
  4. 如果一个负责处理槽的主节点尚未投票,在接收到该拉票的 REQUEST 信息时,会返回 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 信息,表示它支持该从节点。
  5. 每个参与选举的从节点都会接收这条 ACK 信息,并且统计自己获得的支持数。
  6. 当一个从节点收集到 N /2 + 1(具有投票权的节点的一半数量加一)时,这个从节点成为新节点。【在一个配置纪元中,只有一个从节点能达到这个数目,确保了主节点只有一个】
  7. 如果在这个配置纪元中没有任何从节点收集到足够多的支持票,那么会进入下一个配置纪元,并再次进行选举,直到选出新的主节点为止。

类似于领头 Sentinel 的选举,可以对比来看。它们都是基于 Raft 算法的领头选举方法来实现的。

有的小伙伴可能觉得 领头Sentinel 的选举不算 Raft,因为它最后是通过领头 Sentinel 来控制故障迁移的具体过程,这个就是仁者见仁智者见智了。

Raft 算法的实现可以参考一下Nacos 源码中 RaftCore 类的实现,比较通俗易懂。有时间我会发一下 Nacos 源码中Raft选举的实现。

Redis应用

Redis 分布式锁

官方文档:REDIS distlock – Redis中国用户组(CRUG)

我最早觉得比较好的实现分布式锁思路文章:10分钟精通Redis分布式锁中的各种门道

引入
为什么需要分布式锁?

我们在开发项目时,如果需要在同进程内的不同线程并发访问某项资源,可以使用各种互斥锁、读写锁

如果一台主机上的多个进程需要并发访问某项资源,则可以使用进程间同步的原语,例如信号量、管道、共享内存等。

但如果多台主机需要同时访问某项资源,就需要使用一种在全局可见并具有互斥性的锁了。

这种锁就是分布式锁,可以在分布式场景中对资源加锁,避免竞争资源引起的逻辑错误

什么时候用分布式锁?

一般我们使用分布式锁有两个场景:

  • 效率:使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。比如用户注册后调用发送邮箱的接口发送通知,可能不同节点会发出多封邮箱
  • 安全:加分布式锁同样可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。
分布式锁需要哪些特性呢?

大部分特性其实都类似于 Java 中的锁,包括互斥性、可重入、锁超时、公平锁和非公平锁、一致性。

  • 互斥性:在同一时间点,只有一个客户端持有锁。
  • 可重入:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
  • 锁超时:在客户端离线(硬件故障或网络异常等问题)时,锁能够在一段时间后自动释放防止死锁,即超时自动解锁。
  • 公平锁和非公平锁:公平锁即按照请求加锁的顺序获得锁,非公平锁即相反是无序的。
  • 一致性:比如说用Redis 实现分布式锁时,发生宕机情况,此时会有主从故障转移的过程中,需要在此过程仍然保持锁的原状态。
  • 续锁:为了防止死锁大多数会有锁超时的设置,但是如果业务的执行时间的不确定性,就需要保证在业务仍在执行过程中时,客户端仍要持有锁。
加锁

在Redis中加锁一般都是使用 SET 命令,使用 SET 命令完成 SETNXEXPIRE 操作,并且这是一个原子操作

set key value [EX seconds] [PX milliseconds] [NX|XX]

上面这条指令是 SET 指令的使用方式,参数说明如下:

  • keyvalue:键值对。
  • EX seconds:设置失效时长,单位秒。
  • PX milliseconds:设置失效时长,单位毫秒。
  • NX:key不存在时设置value,成功返回OK,失败返回(nil),SET key value NX 效果等同于 SETNX key value
  • XX:key存在时设置value,成功返回OK,失败返回(nil)。

其中,NX 参数用于保证在多个线程并发 set 下,只会有1个线程成功,起到了锁的“唯一”性。

举例:

// 设置msg = helloword,失效时长1000ms,不存在时设置
1.1.1.1:6379> set msg helloworld px 1000 nx
解锁

解锁一般使用 DEL 命令,但是直接删除锁可能存在问题。

一般解锁需要两步操作:

  1. 查询当前“锁”是否还是我们持有,因为存在过期时间,所以可能等你想解锁的时候,“锁”已经到期,然后被其他线程获取了,所以我们在解锁前需要先判断自己是否还持有“锁”。

  2. 如果“锁”还是我们持有,则执行解锁操作,也就是删除该键值对,并返回成功;否则,直接返回失败。

由于当前 Redis 还没有原子命令直接支持这两步操作,所以当前通常是使用 Lua 脚本来执行解锁操作,Redis 会保证脚本里的内容执行是一个原子操作

以下是 Redis 官方给出的 Lua 脚本:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

参数说明如下:

  • KEYS[1]:我们要解锁的 key。
  • ARGV[1]:我们加锁时的 value,用于判断当“锁”是否还是我们持有,如果被其他线程持有了,value 就会发生变化。
续锁

一般为了防止死锁,比如服务器宕机或断线的情况下无法手动解锁,此时就需要给分布式锁加上过期时间

但是假如在我们业务执行的过程中,Redis 分布式锁过期了,业务还没处理完怎么办?

首先,我们在设置过期时间时要结合业务场景去设计,尽量设置一个比较合理的值,就是理论上正常处理的话,在这个过期时间内是一定能处理完毕的。

然后我们需要应对一些特殊恶劣情况进行设计。

目前的解决方案一般有两种:

  1. 守护线程“续命”:额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。Redisson 里面就实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间。
  2. 超时回滚:当我们解锁时发现锁已经被其他线程获取了,说明此时我们执行的操作已经是“不安全”的了,此时需要进行事务回滚,并返回失败。

同时,需要进行告警,人为介入验证数据的正确性,然后找出超时原因,是否需要对超时时间进行优化等等。

守护线程“续命”存在的问题

Redisson 使用看门狗(守护线程)“续命”的方案在大多数场景下是挺不错的,也被广泛应用于生产环境,但是在极端情况下还是会存在问题。

问题例子如下:

  1. 线程A首先获取锁成功,将键值对写入 Redis 的 master 节点。
  2. 在 Redis 将该键值对同步到 Slave 节点之前,Master 发生了故障。
  3. Redis 触发故障转移,其中一个 Slave 升级为新的 master。
  4. 此时新的 Master 并不包含线程A写入的键值对,因此线程B尝试获取锁也可以成功拿到锁。
  5. 此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据。

解决方法:上述问题的根本原因主要是由于 Redis 异步复制带来的数据不一致问题导致的,因此解决的方向就是保证数据的一致。

当前比较主流的解法和思路有两种:

  1. Redis 作者提出的 RedLock。
  2. Zookeeper 实现的分布式锁。

这里我们来说一下第一种 RedLock 的解决思路。

RedLock

红锁是Redis作者提出的一致性解决方案。红锁的本质是一个概率问题:如果一个主从架构的Redis在高可用切换期间丢失锁的概率是k%,那么相互独立的 N 个 Redis 同时丢失锁的概率是多少?如果用红锁来实现分布式锁,那么丢锁的概率是(k%)^N。鉴于Redis极高的稳定性,此时的概率已经完全能满足产品的需求。

说明红锁的实现并非这样严格,一般保证M(1<M=<N)个同时锁上即可,但通常仍旧可以满足需求。

RedLock 算法

算法很易懂,起 5 个 master 节点,分布在不同的机房尽量保证可用性。为了获得锁,client 会进行如下操作:

  1. 得到当前的时间,微秒单位。
  2. 尝试顺序地在 5 个实例上申请锁,当然需要使用相同的 keyrandom value,这里一个 client 需要合理设置与 master 节点沟通的 timeout 大小,避免长时间和一个 fail 了的节点浪费时间。
  3. client 在大于等于 3 个 master 上成功申请到锁的时候,且它会计算申请锁消耗了多少时间,这部分消耗的时间采用获得锁的当下时间减去第一步获得的时间戳得到,如果锁的持续时长(lock validity time)比流逝的时间多的话,那么锁就真正获取到了。
  4. 如果锁申请到了,那么锁真正的 lock validity time 应该是 originlock validity time) - 申请锁期间流逝的时间。
  5. 如果 client 申请锁失败了,那么它就会在少部分申请成功锁的 master 节点上执行释放锁的操作,重置状态。
失败重试

如果一个 client 申请锁失败了,那么它需要稍等一会在重试避免多个 client 同时申请锁的情况,最好的情况是一个 client 需要几乎同时向 5 个 master 发起锁申请。另外就是如果 client 申请锁失败了它需要尽快在它曾经申请到锁的 master 上执行 unlock 操作,便于其他 client 获得这把锁,避免这些锁过期造成的时间浪费,当然如果这时候网络分区使得 client 无法联系上这些 master,那么这种浪费就是不得不付出的代价了。

RedLock 的问题
  • 占用的资源过多,为了实现红锁,需要创建多个互不相关的云Redis实例或者自建Redis,成本较高。
  • 严重依赖系统时钟。如果线程1从3个实例获取到了锁,但是这3个实例中的某个实例的系统时间走的稍微快一点,则它持有的锁会提前过期被释放,当他释放后,此时又有3个实例是空闲的,则线程2也可以获取到锁,则可能出现两个线程同时持有锁了。
  • 如果线程1从3个实例获取到了锁,但是万一其中有1台重启了,则此时又有3个实例是空闲的,则线程2也可以获取到锁,此时又出现两个线程同时持有锁了。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

舍其小伙伴

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值