Redis知识点整理——用心整理的,不看真的很可惜

数据结构

SDS

struct sdshdr
{
    int len;	// 已使用字节,不包括最后一位 \0
    int free;	// 未使用字节
    char buf[];	// 保存字符串,最后一位为 \0
}

最后一位为 \0 的好处,可以复用一部分C字符串函数库里面的函数

SDS与C字符串的区别

  • 常数复杂度获取字符串长度:时间复杂度由O(N)降到O(1)
  • 杜绝缓冲区溢出:对SDS修改时会先检查实际空间是否满足需求,如果不够,将扩展
  • 空间预分配:减少修改字符串时带来的内存重分配次数
  • 惰性空间释放:减少修改字符串时带来的内存重分配次数

链表

redis中的链表结构被实现成为双向链表,头尾操作快

// listNode	链表节点
typedef struct listNode {
    struct listNode *prev; 	//前驱节点,如果是list的头结点,则prev指向NULL
    struct listNode *next;	//后继节点,如果是list尾部结点,则next指向NULL
    void *value;            //万能指针,能够存放任何信息
} listNode;

// list	使用list来管理链表,每个链表使用一个list结构来表示
typedef struct list {
    listNode *head;     //链表头结点指针
    listNode *tail;     //链表尾结点指针

    //下面的三个函数指针就像类中的成员函数一样
    void *(*dup)(void *ptr);    //复制链表节点保存的值
    void (*free)(void *ptr);    //释放链表节点保存的值
    int (*match)(void *ptr, void *key); //比较链表节点所保存的节点值和另一个输入的值是否相等
    
    unsigned long len;      //链表长度计数器
} list;

image-20200815190140183

字典

由哈希表实现,一个哈希表有多个节点,每个节点保存一个键值对

// hash表节点
typedef struct dictEntry {
    void *key;                  //key
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;                        //value
    struct dictEntry *next;     //指向下一个hash节点,用来解决键冲突
} dictEntry;

// hash表
typedef struct dictht {
    dictEntry **table;      //存放一个数组的地址,数组存放着哈希表节点dictEntry的地址(二维)	tag
    unsigned long size;     //哈希表table的大小,初始化大小为4
    unsigned long sizemask; //用于将哈希值映射到table的位置索引。它的值总是等于(size-1)。
    unsigned long used;     //记录哈希表已有的节点(键值对)数量。
} dictht;

// 字典
typedef struct dict {
    dictType *type;     //指向dictType结构,dictType结构中包含自定义的函数,这些函数使得key和value能够存储任何类型的数据
    void *privdata;     //私有数据,保存着dictType结构中函数的参数
    dictht ht[2];       //两张哈希表
    long rehashidx;     //rehash的标记,rehashidx==-1,表示没在进行rehash
    int iterators;      //正在迭代的迭代器数量
} dict;

image-20200815190249727

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面

rehash

rehash是渐进式的

当哈希表保存的键值对会增多或者减少时,为了让哈希表的负载因子维持在一个合理的范围之内,程序需要对哈希表的大小进行相应的扩展或者收缩

而rehash这个动作不是一次性、集中式完成的,而是分多次、渐进式地完成的(因为redis是单线程的,防止阻塞较长时间)

步骤:

  • 为字典的ht[1]哈希表分配空间(根据需要进行的操作,比原表大或比原表小)
  • 将rehashidx设置为0,标记rehash开始
  • 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht [1],rehashidx的值增1(携带式
  • 直到所有值都被rehash到ht[1],rehashidx被置回-1,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备

在rehash执行期间,字典的删改查会在两个hash表上进行,先找ht[0],再找ht[1],而增则之间保存到ht[1]中

跳表

是一个有序链表,其中每个节点包含不定数量的链接,节点中的第i个链接构成的单向链表跳过含有少于i个链接的节点(给有序链表加索引,使链表可以进行类似二分查找的操作)

// 跳表节点
typedef struct zskiplistNode {
    robj *obj;                          //保存成员对象的地址,指向一个字符串对象
    double score;                       //分值
    struct zskiplistNode *backward;     //后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;  //前进指针
        unsigned int span;              //跨度
    } level[];                          //层级,柔型数组
} zskiplistNode;

// 跳表
typedef struct zskiplist {
    structz skiplistNode *header, *tail;	//表头节点和表尾节点
    unsigned long length;					//表中节点的数量
    int level;								//表中层数最大的节点的层数
} zskiplist;

image-20200815211318131

在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的,分值相同的节点将按照成员对象在字典序中的大小来进行排序

整数集合

是Redis用于保存整数值的集合抽象数据结构,并且保证集合中不会出现重复元素

当SET存储的元素是整型且元素数目较少时,如果使用hash table存储,就会比较浪费内存,使用整数集合(intset)比较节约内存

typedef struct intset {
    uint32_t encoding;  //编码格式,有如下三种格式,初始值默认为INTSET_ENC_INT16
    uint32_t length;    //集合元素数量
    int8_t contents[];  //保存元素的数组,元素类型并不一定是ini8_t类型,柔性数组不占intset结构体大小,并且数组中的元素从小到大排列。
} intset;               //整数集合结构

#define INTSET_ENC_INT16 (sizeof(int16_t))   //16位,2个字节,表示范围-32,768~32,767
#define INTSET_ENC_INT32 (sizeof(int32_t))   //32位,4个字节,表示范围-2,147,483,648~2,147,483,647
#define INTSET_ENC_INT64 (sizeof(int64_t))   //64位,8个字节,表示范围 -9,223,372,036,854,775,808~9,223,372,036,854,775,807

image-20200815204535663

整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素

压缩列表

列表(list)和哈希(hash)的底层实现之一

类似数组,通过一片连续的内存空间,来存储数据。不同的是,它允许存储的数据大小不同(所以也需要记录数据的大小才能找到下一个节点)

压缩列表:

image-20200815215503548

  • zlbytes:整个压缩列表占用的内存字节数
  • zltail_offset:压缩列表尾节点entryN距离压缩列表的起始地址的字节数
  • zllength:压缩列表的节点数量
  • entry[1-N]:长度不定,保存数据
  • zlend:占1个字节,保存一个常数255(0xFF),标记压缩列表的末端

列表中的每一个节点:

image-20200815215103118

  • previous_entry_length:记录压缩列表中前一个节点的长度
  • encoding:记录节点的content属性所保存数据的类型以及长度
  • content:保存节点的值

快速列表

列表的底层实现之一

由压缩列表组成的双向链表(双向链表和压缩列表的合体,分段的压缩列表)。快速列表是双向链表,其中每一个节点都是压缩链表,相当与一个quicklist节点保存的是一片数据

引入原因:

  1. ziplist内部的数据存储是一段连续的空间,想存储很多的数据,需要很大一块内存空间,但是内存中并没有符合要求的连续的存储空间,而是存在很多不连续的小空间(加起来可以符合要求)
  2. 而linkedlist中数据不要求连续

img

对象

在这里插入图片描述

Redis的对象系统实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放;还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存

每个对象都由一个redisObject结构表示

typedef struct redisObject {
    unsigned type:4;		//类型,字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种
    unsigned encoding:4;	//编码
    void *ptr;				//指向底层实现数据结构的指针
    int refcount;			//引用计数
    unsigned lru:22;		//记录对象最后一次被命令程序访问的时间
} robj;

对象的空转时长

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

字符串对象

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

使用场景:常规的key-value操作

如果保存的是整数值,并且这个整数值可以用long类型来表示:

image-20200816140436045

如果保存的是一个字符串值,并且这个大于32字节(raw编码):

image-20200816135441832

如果保存的是一个字符串值,并且这个长度小于等于32字节(embstr编码):

image-20200816140830880

embstr编码

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

避免一味的使用string对象

string对象开销较大

  • 在 SDS 中,buf 保存实际数据,而len、free和预分配的空间 是 SDS 结构体的额外开销
  • 每一个对象都有一个RedisObject,RedisObject结构体本身也有开销。Redis会用一个这个结构体来统一记录这些元数据,同时指向实际数据。一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针等其他数据,这个指针再进一步指向具体数据类型的实际数据所在。在保存单值的键值对时,可以采用基于 Hash 类型的二级编码方法。这里说的二级编码,就是把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为 Hash 集合的 value(例如我们的员工uuid有10位,我们使用前7位作为Hash大key,后3位作为map的小key——测试结果:共100万条数据,使用string占用70mb,使用hash ziplist只占用9mb)

列表对象

列表对象的编码可以是ziplist、linkedlist或者quicklist

使用场景:消息队列系统、取最新N个数据的操作

使用压缩列表编码:

image-20200816141025457

使用双向链表编码:

image-20200816142059999

哈希对象

哈希对象的编码可以是ziplist或者字典

使用场景:存储用户信息等,比string节省空间

使用压缩列表编码:

image-20200816142201867

使用字典编码:哈希对象中的每个键值对使用字典的键值来保存(这张图应该不是很准确)

image-20200816142431208

集合对象

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

使用场景:需要取交集、并集的地方(共同好友、点赞、踩、收藏、标签)

使用intset编码:

image-20200816145120564

使用字典实现:字典的值则全部被设置为NULL

image-20200816145151644

有序集合对象

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

使用场景:排行榜、带权重的队列

使用压缩列表实现:

image-20200816145312894

使用跳表实现:

// skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表	tag
typedef struct zset {
    zskiplist *zsl; 
    diet *dict;
}zset;

跳表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素。通过这个跳跃表,程序可以对有序集合进行范围型操作

字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素。字典的键保存了元素的成员,而字典的值则保存了元素的分值

虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存

image-20200816145433896

选用正确的数据类型能带来非常多的好处 tag

数据库

数据库键空间

服务器中的每个数据库都由一个redisDb结构表示,redisDb结构的dict字典保存了数据库中的所有键值对,将这个字典称为键空间

typedef struct redisDb {
    // ...
    dict *dict;		//数据库键空间,保存着数据库中的所有键值对
    dict *expires;	//过期字典,保存着键的过期时间
    // ...
} redisDb;

image-20200816202245086

当使用Redis命令对数据库进行读写时,服务器不仅会对键空间执行指定的读写操作,还会执行一些额外的维护操作:

  • 在读取一个键之后(读操作和写操作都要对键进行读取),服务器会根据键是否存在来更新服务器的键空间命中次数或键空间不命中次数
  • 在读取一个键之后,服务器会更新键的LRU (最后一次使用)时间
  • 如果服务器在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作
  • 如果有客户端使用WATCH命令监视了某个键,那么服务器在对被监视的键进行修改之后,会将这个键标记为脏(dirty),从而让事务程序注意到这个键已经被修改过
  • 服务器每次修改一个键之后,都会对脏(dirty)键计数器的值增1,这个计数器会触发服务器的持久化以及复制操作
  • 如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知

过期键删除策略

  • 定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作——对CPU最不友好
  • 惰性删除:不管键过期,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键(可能造成内存泄漏)——对内存最不友好
  • 定期删除:每隔一段时间,程序就对数据库进行一次检査,删除里面的过期键。至于要删除多少过期键,以及要检査多少个数据库,则由算法决定——折中

从服务器的过期键删除动作由主服务器控制

  • 主服务器在删除一个过期键之后,会显式地向所有从服务器发送命令,告知从服务器删除这个过期键
  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键
  • 从服务器只有在接到主服务器发来的命令之后,才会删除过期键

RDB持久化

既可以手动执行,也可以根据服务器配置选项定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件中

有两个Redis命令可以用于生成RDB文件

  • SAVE:会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求
  • BGSAVE派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求

如果服务器开启了 AOF 持久化功能,那么服务器会优先使用 AOF 文件来还原数据库状态,只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态

服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止

服务器状态还维持着一个dirty计数器,以及一个lastsave 属性:

  • dirty计数器记录距离上一次成功执行以 SAVE 命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)
  • lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行 SAVE 命令或者 BGSAVE 命令的时间

通过设置服务器配置的save选项,让服务器每隔一段时间(或写入次数达到一定值)自动执行一次 BGSAVE 命令

通过服务器中的dirty计数器、lastsave属性与配置文件中的save选项相比对

AOF持久化

是通过保存Redis服务器所执行的写命令来记录数据库状态的

当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会将写命令追加到服务器状态的aof_buf缓冲区。通过配置appendfsync的值,来决定服务器是否需要将缓冲区中的内容写入AOF文件

  • 当appendfsync的值为always时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容同步到AOF文件——最慢但是最安全
  • 当appendfsync的值为everysec时,服务器在事件循环每隔一秒就要在子线程中对AOF文件进行一次同步——比较快,服务器故障只丢失1s的数据
  • 当appendfsync的值为no时,由操作系统控制何时对AOF文件进行同步——速度最快但是丢失的数据最多

AOF重写:Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新旧两个AOF文件所保存的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令
AOF后台重写:将AOF重写程序放到子进程里执行

问题:子进程在进行AOF重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致

为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区

当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:

  • 将AOF重写缓冲区中的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致
  • 对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两个AOF文件的替换

在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞

Redis线程模型

https://gitee.com/shishan100/Java-Interview-Advanced/blob/master/docs/high-concurrency/redis-single-thread-model.md

tag

复制

用户可以通过执行命令或者设置slaveof选项,让一个服务器去复制另一个服务器,进行复制中的主从服务器双方的数据库将保存相同的数据

复制功能分为同步命令传播

  • 同步:将从服务器的数据库状态更新至主服务器当前所处的数据库状态

    • 完整重同步:用于处理初次复制情况,主服务器生成一个RDB文件和使用缓冲区记录从此时开始执行的命令,将RDB文件发送给从服务器载入,再将缓冲区命令发送给从服务器

    • 部分重同步:用于处理断线后重复制情况,执行复制的双方一主服务器和从服务器会分别维护一个复制偏移量,当主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令入队到复制积压缓冲区里面,主服务器通过比对双方偏移,将断线期间遗漏的重新发送给从服务器

      当从服务器重新连上主服务器时,从服务器会通过命令将自己的复制偏移量offset发送给主服务器,如果offset偏移量之后的数据仍然存在于复制积压缓冲区里面,那么主服务器将对从服务器执行部分同步操作,如果数据以及不在,那么执行完整重同步

  • 命令传播:在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态

心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:

REPLCONF ACK <replication_offset>

其中replication_offset是从服务器当前的复制偏移量

主从服务器通过发送和接收命令来检查两者之间的网络连接是否正常,如果主服务器超过一秒钟没有收到从服务器发来的命令,那么主服务器就知道主从服务器之间的连接岀现问题了

哨兵

是Redis的高可用性解决方案:由一个或多个Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,由新的主服务器继续处理命令请求

Sentinel默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,获取主服务器的当前信息以及主服务器属下所有从服务器的地址信息(tag)。发现主服务器有新的从服务器出现后,Sentinel会创建连接到从服务器的命令连接和订阅连接,以每十秒一次的频率通过INFO命令连接向从服务器发送命令

Sentinel从主服务器获取同样监视着这个主服务器的其他Sentinel的资料并互相创建命令连接,形成网络

image-20200819164011892

在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送PING命令,并通过实例的返回来判断实例是否在线。当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当Sentinel从其他Sentinel那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移操作

选举领头哨兵

  • 所有在线的Sentinel都有可能被选为领头Sentinel

  • 每次进行领头选举后,不论选举结果,所有Sentinel的配置纪元的值都会自增一次,配置纪元实际上就是一个计数器

  • 在一个配置纪元里,所有Sentinel都有一次将其他某个Sentinel设置为局部领头的机会,并且一旦设置,在这个配置纪元里不能再更改

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

    在这里插入图片描述

  • Sentinel(源)向另一个 Sentinel(目标)发送命令,要求目标Sentinel将前者设置为后者的局部领头Sentinel

  • Sentinel设置局部领头Sentinel的规则是先到先得,之后接收到的所有设置要求都会被目标Sentinel拒绝

  • 目标Sentinel在接收到命令之后,向源Sentinel返回一条命令回复,分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元

  • 源Sentinel在接收到目标Sentinel返回的命令回复之后,会检査回复中参数的值和自己的配置纪元是否相同,如果相同,源Sentinel继续取出参数,如果源Sentinel的运行ID—致,那么表示目标Sentinel将源Sentinel设置成了局部领头Sentinel

  • 如果有某个Sentinel被半数以上的Sentinel设置成了局部领头Sentinel,那么这个Sentinel成为领头Sentinel

  • 如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举,直到选出领头Sentinel为止

故障转移

  1. 在已下线主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器

    选断开时间短,服务器优先级高,偏移量最大的从服务器

  2. 让已下线主服务器属下的所有从服务器改为复制新的主服务器

  3. 将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器

选举过程

  1. 故障节点主观下线

    Sentinel集群的每一个Sentinel节点会定时对redis集群的所有节点发心跳包检测节点是否正常。如果一个节点在一定时间内没有回复Sentinel节点的心跳包,则该redis节点被该Sentinel节点主观下线

  2. 故障节点客观下线

    主观下线的Sentinel节点会询问其他Sentinel节点,如果Sentinel集群中超过一定数量的Sentinel节点认为该redis节点主观下线,则该redis客观下线

    如果客观下线的redis节点是从节点或者是sentinel节点,则操作到此为止

    如果客观下线的redis节点为主节点,则开始故障转移,从从节点中选举一个节点升级为主节点

  3. Sentinel集群选举Leader

    《选举领头哨兵》

  4. Sentinel Leader决定新主节点

    选断开时间短,服务器优先级高,偏移量最大的从服务器

集群

每个节点使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode结构,以此来记录其他节点的状态

每个节点保存着一个clusterState结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元等

image-20200823164702576

Redis集群通过分片的方式来保存数据库中的键值对,整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,每个节点可以处理0个或最多16384个槽。节点内部会记录自己处理哪些槽,并告知其他节点,因此,集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点

执行

在对数据库中的16384个槽都进行了指派之后,集群进入上线状态,这时客户端就可以向集群中的节点发送数据命令。当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽(将键值使用CRC16计算后与16383取模,就是哪个槽)

复制与故障转移

Redis集群中的节点分为主节点和从节点,主节点用于处理槽,从节点用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求

如果主节点下线,那么集群中仍在正常运作的几个主节点将在从节点中选择一个作为新的主节点,之前下线的主节点再上线,将成为从节点,集群中的所有节点都会知道某个从节点正在复制某个主节点

集群中的每个节点都会定期向其他节点发送PING消息,如果接收PING消息的节点没有在规定的时间内返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线,如果半数以上的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线,将主节点x标记为已下线的节点会向集群广播消息,所有收到这条消息的节点都会立即将主节点x标记为已下线

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移

  1. 复制下线主节点的所有从节点里面,会有一个从节点被选中(选举过程
  2. 被选中的从节点成为新的主节点
  3. 新的主节点会将已下线主节点的槽全部指派给自己
  4. 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽
  5. 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成

选举过程 tag

  1. 判断节点宕机

    一个节点认为另一个节点宕机时,该节点进入主观宕机状态。并使用gossip ping消息,告知其他节点,其他节点再去判断该节点是否宕机。若超过半数节点认为该节点宕机,则该节点进入主观宕机状态

  2. 从节点过滤

    检查每个从节点与主节点的断开时间,如果超过某个值,就没有资格被切换为master

  3. 从节点选举

    每个从节点的offset越大,就越优先进行选举

    其他master会对从节点投票,过半的则升级为主节点

节点间内部通信机制

在redis集群架构下,每个 redis 要放开两个端口号,比如一个是 6379,另外一个就是 加1w 的端口号,比如 16379

16379 端口号是用来进行节点间通信的,用来进行故障检测、配置更新、故障转移授权。使用gossip 协议,用于节点间进行高效的数据交换,占用更少的网络带宽和处理时间

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

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

  • 10000 端口:每个节点都有一个专门用于节点间通信的端口,就是自己提供服务的端口号+10000,比如 7001,那么用于节点间通信的就是 17001 端口。每个节点每隔一段时间都会往另外几个节点发送 ping 消息,同时其它几个节点接收到 ping 之后返回 pong
  • 交换的信息:信息包括故障信息,节点的增加和删除,hash slot 信息等等

发布与订阅

通过执行SUBSCRIBE命令订阅一个或多个频道,从而成为这些频道的订阅者,每当有其他客户端向被订阅的频道发送消息时,频道的所有订阅者都会收到这条消息

通过执行PSUBSCRIBE命令订阅一个或多个模式,从而成为这些模式的订阅者,每当有其他客户端向某个频道发送消息时,消息不仅会被发送给这个频道的所有订阅者,它还会被发送给所有与这个频道相匹配的模式的订阅者

image-20200822153441416

事务

通过MULTIEXECWATCH等命令来实现事务功能。事务提供了一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求

实现:

  1. 事务开始

    MULTI命令的执行标志着事务的开始,将执行该命令的客户端从非事务状态切换至事务状态

  2. 命令入队

    服务器并不立即执行这些命令,而是将这个命令放入一个事务队列里面,然后向客户端返回QUEUED回复

    如果这个命令执行会出现错误(像对string类型执行RPUSH操作,而不是语法错误等),事务的后续命令也可以继续执行下去,并且之前的命令也不会有任何影响

  3. 执行事务

    当一个处于事务状态的客户端向服务器发送EXEC命令时,这个EXEC命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端

WATCH

WATCH命令是一个乐观锁,它可以在EXEC命令执行之前,监视任意数量的数据库键,并在EXEC命令执行时,检査被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务

慢日志查询

用于记录执行时间超过给定时长的命令请求,用户可以通过这个功能产生的日志来监视和优化查询速度

  • slowlog-log-slower-than选项指定执行时间超过多少微秒(1秒等于1 000 000微秒)的命令请求会被记录到日志上
  • slowlog-max-len选项指定服务器最多保存多少条慢査询日志(先进先出)

使用规范

Redis性能问题排查解决手册(七)

  1. 不要使用特别大的键(bigkey)

    防止网卡流量、慢查询,string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000

  2. 非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除

    要注意bigkey过期自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且该操作不会不出现在慢查询中(latency可查))

    Redis的延迟数据是无法从info信息中获取的。倘若想要查看延迟时间,可以用 Redis-cli工具加–latency参数运行

    Redis-cli --latency -h 127.0.0.1 -p 6379
    
  3. 选择适合的数据类型

    将数据存储为数千(或者数百万)独立的字符串,可以考虑使用哈希数据结构将相关数据进行分组,哈希表是非常有效率的,并且可以减少你的内存使用

    如果不需要使用set特性,使用list代替set,List在使用更少内存的情况下可以提供比set更快的速度

    Sorted sets是最昂贵的数据结构,不管是内存消耗还是基本操作的复杂性,如果只需要一个查询记录的途径,并不在意排序这样的属性,那么建议使用哈希表

  4. 控制key的生命周期

    Redis不是垃圾桶,建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期

    不过期的数据要关注idletime(键空闲时间)

  5. O(N)命令关注N的数量

    hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。

    在N值过大时候,有遍历的需求可以使用hscan、sscan、zscan代替

  6. 禁用命令

    keys:客户端可查询出所有存在的键(键太多导致Redis崩溃,缓存被穿透)
    flushdb:删除当前所选数据库的所有键。此命令永远不会失败
    flushall:删除所有现有数据库的所有键,而不仅仅是当前选定的数据库。此命令永远不会失败

    禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理

  7. 使用批量操作提高效率

    原生命令:例如mget、mset
    非原生命令:可以使用pipeline提高效率

    两者不同:

    原生的命令是原子操作,pipeline是非原子操作

    pipeline可以打包不同的命令,原生做不到,pipeline需要客户端和服务端同时支持

  8. 不建议过多使用Redis事务功能

    Redis的事务功能较弱(不支持回滚),而且集群版要求一次事务操作的key必须在一个slot上(可以使用hashtag—分槽功能解决)

  9. 设置合理的淘汰策略

    根据自身业务类型,选好maxmemory-policy(最大内存淘汰策略),设置好过期时间

    默认策略是volatile-lru,即超过最大内存后,在过期键中使用lru算法进行key的剔除,保证不过期数据不被删除,但是可能会出现OOM问题(申请内存过大导致自杀)

常见问题

为什么要用缓存?

高性能

假设有这么一个场景,有一个查询操作,从MySQL查出结果需要600ms,但是这个结果可能是接下来一段时间都不会变的,或者是即使要变也不是要立即反馈给用户的。这种情况下,将查出来的结果以key-value的形式放到缓存中,接下来有相同的查询请求,直接使用一个key就能获得结果,只需要2ms,性能提升300倍。

对于一些需要复杂操作耗时查出来的结果,且确定后面不怎么变化,但是有很多读请求,那么直接将查询出来的结果放在缓存中,后面直接读缓存就好

高并发

MySQL对高并发的支持并不友好,单机撑到2000QPS可能就开始报警。若高峰期1s内有1w个请求过来,MySQL一定会挂掉。把很多数据放缓存,缓存是走内存的,单机并发量轻松达到几万,替MySQL挡住了大部分请求,保证服务的高可用性

热key问题

热key:突然有几十万的请求去访问redis上的某个特定key,造成流量过于集中,达到物理网卡上限,导致redis的服务器宕机,接下来这个key的请求,就会直接打数据库上,导致服务不可用

如何发现

  • 凭借业务经验,进行预估哪些是热key

    例如秒杀商品的key。缺点:并非所有业务都能预估出热key

  • 在客户端进行收集

    在操作redis之前,加入一行代码进行数据统计。缺点:对客户端代码造成入侵

  • 用redis自带命令

    monitor命令:该命令可以实时抓取出redis服务器接收到的命令。缺点:内存增长,降低redis性能

    hotkeys参数:redis 4.0.3提供了redis-cli的热点key发现功能。缺点:如果key比较多,执行起来比较慢

如何解决

  • 利用二级缓存:发现热key后,将它缓存到向redis发请求的服务上
  • 备份热key:将这个key部署在多个redis服务器上,当热key请求过来时,随机分配一台服务器

业内方案

  • 监控热key

    利用方式二(客户端收集), 做热点发现本地缓存

  • 通知系统做处理

    利用方案一(二级缓存),用二级缓存处理。在监控到热key后,做本地缓存

缓存雪崩

缓存雪崩:缓存由于某些原因(比如宕机、cache服务挂了或者不响应)整体crash掉或缓存时间大面积失效,导致大量请求到达后端数据库,从而导致数据库崩溃,整个系统崩溃,发生灾难

可能产生的原因

  • 缓存并发,缓存穿透,缓存颠簸等
  • 在某个时间点,系统预加载的缓存集中失效(通过设置不同的过期时间,错开缓存过期,避免缓存集中失效)

解决方案

  • 事前:redis高可用,主从+哨兵,搭建集群,避免全盘崩溃。数据预热,在正式部署之前,把可能大量访问的数据先预先访问一遍,设置不同的过期时间,让缓存失效的时间点尽量均匀
  • 事中:限流降级,在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待
  • 事后:redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据

缓存穿透

缓存穿透:用户发起的请求是缓存和数据库中都没有,导致这类请求一直都会被打到数据库上,给持久层数据库造成巨大压力,甚至引发宕机(可能是攻击者)

解决方案

  • 布隆过滤器:

    有一个m位的bitmap,k个哈希函数,每个哈希函数的输出范围都大于m,接着对m取余,得到k个0—m-1的值,将这k个值所对应的bitmap位记为1。

    在判断时,将输入对象经过k个哈希函数,判断得到的值是否在bitmap上被记为1,如果有一位不为1,则这个对象一定不在这个集合中;都为1说明在这个集合中(存在误判,但是可以忍受,剩下的攻击请求可能只有很小一部分)

  • 缓存空对象:当存储层未命中时,将这个空对象也缓存起来,同时设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护后端数据源

缓存击穿

缓存击穿:一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞

解决方案

  • 将这类key设置为永远不过期
  • 使用互斥锁:或者基于 redis 实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该 key 访问数据(防止一瞬间打到mysql上导致mysql崩溃)

缓存与数据库的双写一致性

https://gitee.com/shishan100/Java-Interview-Advanced/blob/master/docs/high-concurrency/redis-consistence.md

如果需要严格要求缓存与数据库的一致性,那么只能读请求和写请求串行化(像读写锁)——会导致吞吐量大幅度降低

如果允许缓存可以稍微的跟数据库偶尔有不一致的情况:

Cache Aside Pattern

  • 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应
  • 更新的时候,先更新数据库,然后再删除缓存

为什么是删除缓存,而不是更新缓存?

在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值,可能还包含了大量的计算。而有的时候,更新操作十分频繁,但这个缓存并不一定会被频繁访问,造成很多不必要的开销。删除缓存是一个懒加载的思想,只有在下一次查询请求来的时候,才会从数据库中计算一次,放到缓存中

缓存不一致问题及解决方案

问题:先更新数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,出现不一致

解决思路先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,在读的时候缓存没有,所以去读数据库中的旧数据,然后更新到缓存中,不会出现不一致

问题:先删除缓存,再修改数据库,若此时还没修改。另一个查询请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。而随后前面的请求完成了数据库的修改,出现不一致

解决思路:更新数据的时候,根据该数据的唯一标识,将更新操作放入这个唯一标识的队列。后续如果读取该数据,若发现数据不在缓存中,那么将重新从MySQL读取数据+更新缓存操作放入队列。后续操作将一条一条的从队列中执行。优化点:在这个队列中,多个更新缓存的请求是没有意义的,保留一个即可

请求长阻塞:可能数据更新很频繁,导致队列中积压了大量更新操作在里面,然后读请求会发生大量的超时,最后导致大量的请求直接走数据库——加机器,让每个实例处理更少的数据

并发竞争

多客户端同时并发写一个 key,导致数据最后的结果不是预期的

  • 使用分布式锁,确保同一时间,只能有一个系统实例在操作某个 key
  • 从MySQL查数据时同时将时间戳查出来,在更新缓存时比较时间戳,如果当前的比缓存的要新,则允许更新(CAS

分布式锁

分布式锁的 3 个的考量点:

  • 互斥(只能有一个客户端获取锁)
  • 不能死锁
  • 容错(只要大部分 redis 节点创建了这把锁就可以)

最普通的分布式锁

单机模式下可以使用,但是主从、集群使用这种方式会有问题

加锁:

SET resource_name my_random_value NX PX 30000
  • NX:表示只有 key 不存在的时候才会设置成功
  • PX 30000:意思是 30s 后锁自动释放

**解锁:**一般使用lua脚本

-- 删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

为啥要用 random_value 随机值呢?

如果某个客户端获取到了锁,但是阻塞了很长时间才执行完,比如说超过了 30s,锁可能已经自动释放了,若此时别的客户端已经获取到了这个锁,这个时候直接删除 key 的话相当于释放了别人的锁。这时可能会有第三个客户端获取到锁,但是会被第二个客户端任务结束后释放掉。导致一连串任务异常结束

主从失效带来的问题

客户端A从master获取到锁,在master将这个key同步到slave前,master挂掉了,而slave节点被升级为master节点,客户端B就能获取同一个已经被A上锁的资源,安全性失效

RedLock 算法

这个场景是假设有一个redis cluster,有 5 个 redis master 实例

  1. 获取当前时间戳,单位是毫秒

  2. 依次尝试从N个实例,使用相同的key和随机值获取锁

    当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例

  3. 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功

  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)

  5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)

CAS机制

多线程环境下,对共享变量进行数据更新有两种模式

悲观锁模式:第一个获取资源的线程会将资源锁定起来,其他没争夺到资源的线程只能进入阻塞队列,等第一个获取资源的线程释放锁之后,这些线程才能有机会重新争夺资源

乐观锁模式:乐观锁认为在更新数据的时候其他线程争抢这个共享变量的概率非常小,所以更新数据的时候不会对共享数据加锁。但是在正式更新数据之前会检查数据是否被其他线程改变过,如果未被其他线程改变过就将共享变量更新成最新值,如果发现共享变量已经被其他线程更新过了,就重试,直到成功为止(CAS)

CAS:Compare and Swap

  • 主内存中存放的共享变量的值:V(一般情况下这个V是内存的地址值,通过这个地址可以获得内存中的值)
  • 工作内存中共享变量的副本值,也叫预期值:A
  • 需要将共享变量更新到的最新值:B

将B值写入到V之前要比较A值和V值是否相同,如果不相同证明此时V值已经被其他线程改变,重新将V值赋给A,并重新计算得到B,如果相同,则将B值赋给V

缺点:

  • CPU开销过大

    在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很到的压力

  • ABA问题

    假设有三个线程想使用CAS的方式更新一个变量的值,此时线程1和线程2已经获得当前值A,并期望更新为B。

    线程1执行成功,将A更新为B;而线程2被某种原因阻塞住,没有做更新操作

    线程3希望将B更新为A,此时获取到了线程1更新的值并成功更新为A

    线程2恢复运行,经过Compare检查,此时为A,并将其更新为B

    解决:在Compare阶段不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致

分布式寻址算法

hash算法

先对一个key通过hash函数计算出hash值,然后对节点取模,打到对应的master节点上

一旦有mater宕机或新增一个节点,大量key对节点取模的值改变,导致不能直接从redis集群中获取到值,从而导致大量请求涌入数据库

一致性hash算法

将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织,将各个 master 节点(使用服务器的 ip 或主机名)进行 hash,每个节点在其哈希环上有自己的位置

当有key来时,先计算hash值,确定此数据在环上的位置,然后顺时针寻找,遇到的第一个master就是key所在的位置

如果有一个节点挂了,受影响的数据仅仅是此节点到前一个节点之间的数据,增加节点也是如此。避免了大量数据失效导致请求涌入数据库

虚拟节点机制:当hash节点过少,导致节点分布不均而造成的缓存热点问题,通过对每一个节点计算多个hash,在计算出的每个节点都放置一个虚拟节点。实现数据的均匀分布,负载均衡

hash slot算法

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

redis集群中每个 master 都会持有部分slot。增加一个 master,就将其他master的 hash slot 移动部分过去,减少一个 master,就将它的 hash slot 移动到其他 master 上去。任何一台机器宕机,对另外的节点都没有影响。因为 key 找的是 hash slot,不是机器

Redis变慢了?

对大量数据的操作

  • 如果需要返回一个 SET 中的所有成员时,不要使用 SMEMBERS 命令,而是要使用 SSCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞
  • 需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例
  • 慎用KEYS命令,它用于返回和输入模式匹配的key,因为 KEYS 命令需要遍历存储的键值对,所以操作延时高

过期KEY操作

Redis定期删除策略,每100ms会删除一些key,删除过程:

  1. 采样n(配置文件配置)个key,并将其中过期的key删除
  2. 若第一步超过25%的key过期了,则重复删除过程,直到过期key的比例降到25%以下

若同一时间有大量的key过期,就会导致第二步被一直触发,引起redis操作阻塞,导致性能变慢

避免同一时间有大量的key过期

未解决

pipeline、scan、latency、hashtag、OOM、淘汰策略

https://gitee.com/shishan100/Java-Interview-Advanced/tree/master/docs/high-concurrency

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值