Redis - Go语言中文社区

Redis


引言

本文整理了Redis相关的知识,方便以后查阅。更多相关文章和其他文章均收录于贝贝猫的文章目录

简介

简单来说 redis 就是一个数据库,不过与传统数据库不同的是 redis 的数据是存在内存中的,所以读写速度非常快,因此 redis 被广泛应用于缓存方向。另外,redis 也经常用来做分布式锁。redis 提供了多种数据类型来支持不同的业务场景。除此之外,redis 支持事务 、持久化、LUA脚本、LRU驱动事件、多种集群方案。更多关于分布式系统的文章均收录于<分布式系列文章>中。

作用

高性能:
假如用户第一次访问数据库中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据存在缓存中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。如果数据库中的对应数据改变之后,同步改变缓存中相应的数据即可!
high-performance
高并发:
直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
high-concurrent

缓存分为本地缓存和分布式缓存。以 Java 为例,使用自带的 map 或者 guava 实现的是本地缓存,最主要的特点是轻量以及快速,生命周期随着 jvm 的销毁而结束,并且在多实例的情况下,每个实例都需要各自保存一份缓存,缓存不具有一致性。

使用 redis 之类的称为分布式缓存,在多实例的情况下,各实例共用一份缓存数据,缓存具有一致性。缺点是需要保持 redis 或 memcached服务的高可用,整个程序架构上较为复杂。

常用数据结构

String

常用命令: set,get,decr,incr,mget 等。

String数据结构是简单的key-value类型,value其实不仅可以是String,也可以是数字。 常规key-value缓存应用; 常规计数:微博数,粉丝数等。

Hash

常用命令: hget,hset,hgetall 等。

hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。 比如我们可以 hash 数据结构来存储用户信息,商品信息等等。比如下面我就用 hash 类型存放了我本人的一些信息:

key=JavaUser293847
value={
  “id”: 1,
  “name”: “SnailClimb”,
  “age”: 22,
  “location”: “Wuhan, Hubei”
}

List

常用命令: lpush,rpush,lpop,rpop,lrange等

list 就是列表,Redis list 的应用场景非常多,也是Redis最重要的数据结构之一,比如微博的关注列表,粉丝列表,消息列表等功能都可以用Redis的 list 结构来实现。

Redis list 的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。

另外可以通过 lrange 命令,就是从某个元素开始读取多少个元素,可以基于 list 实现分页查询,这个很棒的一个功能,基于 redis 实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西(一页一页的往下走),性能高。

Set

常用命令: sadd,spop,smembers,sunion 等

set 对外提供的功能与list类似是一个列表的功能,特殊之处在于 set 是可以自动排重的。

当你需要存储一个列表数据,又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的。可以基于 set 轻易实现交集、并集、差集的操作。

比如:在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程,具体命令如下:
sinterstore key1 key2 key3 将交集存在key1内

Sorted Set

常用命令: zadd,zrange,zrem,zcard等

和set相比,sorted set增加了一个权重参数score,使得集合中的元素能够按score进行有序排列。

举例: 在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息,适合使用 Redis 中的 Sorted Set 结构进行存储。

底层数据

SDS

Redis里,C字符串只会作为字符串字面量用在一些无需对字符串值进行修改的地方,比如打印日志。Redis构建了 简单动态字符串(simple dynamic string,SDS)来表示字符串值。

在Redis里,包含字符串值的键值对在底层都是由SDS实现的。除此之外,SDS还被用作缓冲区:AOF缓冲区,客户端状态中的输入缓冲区

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

sds
SDS遵循C字符串以空字符结尾的管理,空字符不计算在len属性中。这样,SDS可以重用一部分C字符串函数库,如printf。
优点

  • 常数复杂度获取字符串长度
    C字符串必须遍历整个字符串才能获得长度,复杂度是O(N)。
    SDS在len属性中记录了SDS的长度,复杂度为O(1)。
  • 杜绝缓冲区溢出
    C字符串不记录长度的带来的另一个问题是缓冲区溢出。假设s1和s2是紧邻的两个字符串,对s1的strcat操作,有可能污染s2的内存空间。
    SDS的空间分配策略杜绝了缓冲区溢出的可能性:但SDS API修改SDS时,会先检查SDS的空间是否满足修改所需的要求,不满足的话,API会将SDS的空间扩展至执行修改所需的大小,然后再执行实际的修改操作。
  • 减少修改字符串时带来的内存重分配次数
    每次增长或缩短一个C字符串,程序都要对保存这个C字符串的数组进行一次内存重分配操作。
    Redis作为数据库,数据会被频繁修改,如果每次修改字符串都会执行一次内存重分配的话,会性能该造成影响。SDS通过未使用空间解除了字符串长度和底层数组长度的关联:在SDS中,buf数组的长度不一定就是字符数量+1,数组里面可以包含未使用的字节,由free属性记录。对于未使用空间,SDS使用了空间预分配和惰性空间释放两种优化策略:
    1. 空间预分配
      当SDS的API对SDS修改并需要空间扩展时,程序不仅为SDS分配修改所需的空间,还会分配额外的未使用空间(取决于长度是否小于1MB)。
    2. 惰性空间释放
      当SDS的API需要缩短时,程序不立即触发内存重分配,而是使用free属性将这些字节的数量记录下来,并等待将来使用。与此同时,SDS API也可以让我们真正释放未使用空间,防止内存浪费。
  • 二进制安全
    C字符串中的字符必须复合某种编码(如ASCII),除了字符串末尾之外,字符串里不能包含空字符。这些限制使得C字符串只能保存文本,而不是不能保存二进制数据。
    SDS API会以处理二进制的方式处理SDS存放在buf数组中的数据,写入时什么样,读取时就是什么样。
  • 兼容部分C字符串函数
    遵循C字符串以空字符结尾的管理,SDS可以重用<string.h>函数库。

链表

Redis构建了自己的链表实现。列表键的底层实现之一就是链表。发布、订阅、慢查询、监视器都用到了链表。Redis服务器还用链表保存多个客户端的状态信息,以及构建客户端输出缓冲区。

typedef struct listNode {
  struct listNode *prev;
  struct listNode *next;
  void *value;
} listNode;

typedef struct list {
  listNode *head;
  listNode *tail;
  unsigned long len;
  void *(dup)(void *ptr); // 节点复制函数
  void (*free)(void *ptr); // 节点释放函数
  int (*match)(void *ptr, void *key); // 节点值对比函数
} list;

link
特点

  • 双向
  • 无环。表头结点的prev和表尾节点的next都指向NULL
  • 带表头指针和表尾指针
  • 带链表长度计数器
  • 多态。使用void*指针来保存节点值,并通过list结构的dup、free。match三个属性为节点值设置类型特定函数

字典

Redis的数据库就是使用字典来作为底层实现的,对数据库的增删改查都是构建在字典的操作之上。

字典还是哈希键的底层实现之一,但一个哈希键包含的键值对比较多,又或者键值对中的元素都是较长的字符串时,Redis就会用字典作为哈希键的底层实现。

Redis的字典使用哈希表作为底层实现,每个哈希表节点就保存了字典中的一个键值对。

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

typedef struct dictEntry {
  void *key; // 键
  
  // 值
  union {
    void *val;
    uint64_t u64;
    int64_t s64;
  } v;
  
  // 指向下个哈希表节点,形成链表。一次解决键冲突的问题
  struct dictEntry *next;
}

typedef struct dict {
  dictType *type; // 类型特定函数
  void *privdata; // 私有数据
  
  /*
  哈希表
  一般情况下,字典只是用ht[0]哈希表,ht[1]只会在对ht[0]哈希表进行rehash时使用
  */
  dictht ht[2];
  
  // rehash索引,但rehash不在进行时,值为-1
  // 记录了rehash的进度
  int trehashidx;
} dict;

map

哈希算法

Redis计算哈希值和索引值的方法如下:

# 使用字典设置的哈希函数,计算key的哈希值
hash = dict.type.hashFucntion(key)
# 使用哈希表的sizemask属性和哈希值,计算出索引值
# 根据情况的不同,ht[x]可以使ht[0]或ht[1]
index = hash & dict.ht[x].sizemask

当字典被用作数据库或哈希键的底层实现时,使用MurmurHash2算法来计算哈希值,即使输入的键是有规律的,算法仍能有一个很好的随机分布性,计算速度也很快。

解决冲突

Redis使用链地址法解决键冲突,每个哈希表节点都有个next指针。

rehash

随着操作的不断执行,哈希表保存的键值对会增加或减少。为了让哈希表的负载因子维持在合理范围,需要对哈希表的大小进行扩展或收缩,即通过执行rehash(重新散列)来完成:

  1. 为字典的ht[1]哈希表分配空间:
    • 如果执行的是扩展操作,ht[1]的大小为第一个大于等于ht[0].used * 2 的2^n
    • 如果执行的是收缩操作,ht[1]的大小为第一个大于等于ht[0].used的2^n
  2. 将保存在ht[0]中的所有键值对rehash到ht[1]上。rehash是重新设计的计算键的哈希值和索引值
  3. 释放ht[0],将ht[1]设置为ht[0],并为ht[1]新建一个空白哈希表

扩展收缩

满足以下任一条件,程序会自动对哈希表执行扩展操作:

1、服务器目前没有执行BGSAVE或BGREWRITEAOF,且哈希表负载因子大于等于1 2、服务器正在执行BGSAVE或BGREWRITEAOF,且负载因子大于5

其中负载因子的计算公式:

# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

注:执行BGSAVE或BGREWRITEAOF过程中,Redis需要创建当前服务器进程的子进程,而多数操作系统都是用写时复制来优化子进程的效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间扩展哈希表,避免不避免的内存写入,节约内存。

渐进式rehash

将ht[0]中的键值对rehash到ht[1]中的操作不是一次性完成的,而是分多次渐进式的:

  1. 为ht[1]分配空间
  2. 在字典中维持一个索引计数器变量rehashidx,设置为0,表示rehash工作正式开始
  3. rehash期间,每次对字典的增删改查操作,会顺带将ht[0]在rehashidx索引上的所有键值对rehash到ht[1],rehash完成之后,rehashidx属性的值+1
  4. 最终ht[0]会全部rehash到ht[1],这是将rehashidx设置为-1,表示rehash完成

渐进式rehash过程中,字典会有两个哈希表,字典的增删改查会在两个哈希表上进行。

跳表

跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问的目的。跳跃表支持平均O(logN)、最坏*O(N)*的查找,还可以通过顺序性操作来批量处理节点。

Redis使用跳跃表作为有序集合键的底层实现之一,如果有序集合包含的元素数量较多,或者有序集合中元素的成员是比较长的字符串时,Redis使用跳跃表来实现有序集合键。

在集群节点中,跳跃表也被Redis用作内部数据结构。

typedef struct zskiplist {
  struct zskiplistNode *header, *tail; # 指向跳跃表的表头结点 指向跳跃表的表尾节点
  unsigned long length; # 记录跳跃表的长度, 即跳跃表目前包含节点的数量(表头结点不计入)
  int leve; # 记录跳跃表内,层数最大的那个节点的层数(表头结点不计入)
} zskiplist;

typedef struct zskiplistNode {
  struct zskiplistLevel {
    struct zskiplistNode *forward;
    unsigned int span; // 跨度
  } level[];
  
  struct zskiplistNode *backward;
  double score;
  robj *obj;
} zskiplistNode;

level:节点中用L1、L2、L3来标记节点的各个层,每个层都有两个属性:前进指针和跨度。前进指针用来访问表尾方向的其他节点,跨度记录了前进指针所指向节点和当前节点的距离(图中曲线上的数字)。
level数组可以包含多个元素,每个元素都有一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点。层数越多,访问速度就越快。每创建一个新节点的时候,根据幂次定律(越大的数出现的概率越小)随机生成一个介于1-32之间的值作为level数组的大小。这个大小就是层的高度。
跨度用来计算排位(rank):在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到就是目标节点的排位。
后退指针:BW,指向位于当前节点的前一个节点。只能回退到前一个节点,不可跳跃。
分值(score):节点中的1.0/2.0/3.0保存的分值,节点按照各自保存的分值从小到大排列。节点的分值可以相同。
成员对象(obj):节点中的o1/o2/o3。它指向一个字符串对象,字符串对象保存着一个SDS值。

skiplist

整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且数量不多时,Redis采用整数集合作为集合键的底层实现。

整数集合,可以保存int16_t、int32_t或者int64_t的整数值,且元素不重复,intset.h/intset结构表示一个整数集合:

typedef struct intset {
  uint32_t encoding; // 决定contents保存的真正类型
  uint32_t length;
  int8_t contents[]; // 各项从小到大排序
} inset;

intset
上图中,contents数组的大小为 sizeof(int16_t) * 5 = 80 位。

升级

每当添加一个新元素到整数集合中,且新元素的类型比现有所有元素的类型都要长时,整数集合需要先升级(update),然后才能添加新元素:

  • 根据新元素的类型,扩展底层数组的空间大小,并为新元素分配空间。
  • 将底层数组现有元素转换成与新元素相同的类型,并放置在正确的位置上(从后向前遍历)。放置过程中,维持底层数组的有序性质不变。
  • 将新元素添加到底层数组里。
  • 因为每次升级都可能对所有元素进行类型转换,所以复杂度为O(N)。

因为引发升级的新元素长度比当前元素都大,所以它的值要么大于当前所有元素,要么就小于。前种情况放置在底层数组的末尾,后种情况放置在头部。

升级有两个好处

  • 提升整数集合的灵活性
    我们可以随意地将int16_t、int32_t 添加到集合中,不必担心出现类型错误,毕竟C是个静态语言。
  • 尽可能节约内存
    避免用一个 int64_t 的数组包含所有元素。

压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度较短的字符串,那么Redis就会使用压缩列表来实现列表键。

当一个哈希键只包含少量键值对,并且每个键值对要么是小整数值,要么是长度较短的字符串,Redis就会使用压缩列表来实现哈希键。

压缩列表是Redis为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

压缩列表的各组成部分:zlbytes | zltail | zllen | entry1 | entry2 | … | entryN | zlend

ziplist
压缩列表的节点可以保存一个字节数组或者一个整数值。压缩节点的各个组成部分:previous_entry_length | encoding | content

previous_entry_length以字节为单位,记录前一个节点的长度。previous_entry_length 属性的长度可以是1字节或5字节:

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

程序可以通过指针运算,根据当前节点的起始地址来计算出前一个结点的起始地址。压缩列表的从尾向头遍历就是据此实现的。

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

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

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

连锁更新

因为previous_entry_length的长度限制,添加或删除节点都有可能引发「连锁更新」。在最坏的情况下,需要执行N次重分配操作,而每次空间重分配的最坏复杂度是O(N),合起来就是O(N^2)。

尽管如此,连锁更新造成性能问题的概率还是比较低的:

  • 压缩列表里有多个连续的、长度介于250和253字节之间的节点,连锁更新才有可能触发。
  • 即使出现连锁更新,只要需要更新的节点数量不多,性能也不会受影响。

对象

Redis并没有使用SDS、双端链表、字典、压缩列表、整数集合来实现键值对数据库,而是基于这些数据结构创建了一个对象系统。这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象。

通过这五种类型的对象,Redis可以在执行命令之前,根据对象的类型判断一个对象是否执行给定的命令。使用对象的好处是,可以针对不同的场景,为对象设置多种不同的数据结构的实现,从而优化使用效率。

除此之外,Redis还实现了引用计数的内存回收机制。当程序不再需要某个对象的时候,它所占用的内存会被自动释放。另外,Redis还用引用计数实现了对象共享,让多个数据库键共享同一个对象来节约内存。

最后,Redis的对象带有访问时间记录信息,空转时长较大的键可能被优先删除。

Redis使用对象来表示数据库中的键和值。创建一个新键值对时,至少会创建两个对象,一个对象用作键,一个对象用作值。每个对象都由一个redisObject结构表示:

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

键总是一个字符串对象,值可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象。

但数据库执行TYPE命令时,返回的结果为数据库键对应的值对象的类型,而不是键对象的类型。

线程模型

redis 内部使用文件事件处理器 file event handler,这个文件事件处理器是单线程的,所以 redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket,根据 socket 上的事件来选择对应的事件处理器进行处理。

文件事件处理器的结构包含 4 个部分:

  • 多个 socket
  • IO 多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,会将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

过期时间

Redis中有个设置时间过期的功能,即对存储在 redis 数据库中的值可以设置一个过期时间。作为一个缓存数据库,这是非常实用的。如我们一般项目中的 token 或者一些登录信息,尤其是短信验证码都是有时间限制的,按照传统的数据库处理方式,一般都是自己判断过期,这样无疑会严重影响项目性能。

我们 set key 的时候,都可以给一个 expire time,就是过期时间,通过过期时间我们可以指定这个 key 可以存活的时间。

如果假设你设置了一批 key 只能存活1个小时,1小时后,redis会通过定期删除+惰性删除进行删除:

  • 定期删除:redis默认是每隔 100ms 就随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除。注意这里是随机抽取的。为什么要随机呢?你想一想假如 redis 存了几十万个 key ,每隔100ms就遍历所有的设置过期时间的 key 的话,就会给 CPU 带来很大的负载!
  • 惰性删除:定期删除可能会导致很多过期 key 到了时间并没有被删除掉。所以就有了惰性删除。假如你的过期 key,靠定期删除没有被删除掉,还停留在内存里,除非你的系统去查一下那个 key,才会被redis给删除掉。这就是所谓的惰性删除。

但是仅仅通过设置过期时间还是有问题的。我们想一下:如果定期删除漏掉了很多过期 key,然后你也没及时去查,也就没走惰性删除,此时会怎么样?如果大量过期key堆积在内存里,导致redis内存块耗尽了。怎么解决这个问题呢? redis 内存淘汰机制。

内存淘汰策略

  • volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  • volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  • volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key(这个是最常用的)
  • allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
  • no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
  • volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
  • allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的key

持久化

Redis支持持久化,而且支持两种不同的持久化操作。Redis的一种持久化方式叫快照(snapshotting,RDB),另一种方式是只追加文件(append-only file,AOF)。

RDB

Redis可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。Redis创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis主从结构,主要用来提高Redis性能),还可以将快照留在原地以便重启服务器的时候使用。
创建快照的办法有如下几种:

  • BGSAVE命令: 客户端向Redis发送 BGSAVE命令 来创建一个快照。对于支持BGSAVE命令的平台来说(基本上所有平台支持,除了Windows平台),Redis会调用fork来创建一个子进程,然后子进程负责将快照写入硬盘,而父进程则继续处理命令请求。
  • SAVE命令: 客户端还可以向Redis发送 SAVE命令 来创建一个快照,接到SAVE命令的Redis服务器在快照创建完毕之前不会再响应任何其他命令。SAVE命令不常用,我们通常只会在没有足够内存去执行BGSAVE命令的情况下,又或者即使等待持久化操作执行完毕也无所谓的情况下,才会使用这个命令。
  • save选项: 如果用户设置了save选项(一般会默认设置),比如 save 60 10000,那么从Redis最近一次创建快照之后开始算起,当“60秒之内有10000次写入”这个条件被满足时,Redis就会自动触发BGSAVE命令。
  • SHUTDOWN命令: 当Redis通过SHUTDOWN命令接收到关闭服务器的请求时,或者接收到标准TERM信号时,会执行一个SAVE命令,阻塞所有客户端,不再执行客户端发送的任何命令,并在SAVE命令执行完毕之后关闭服务器。
  • 一个Redis服务器连接到另一个Redis服务器: 当一个Redis服务器连接到另一个Redis服务器,并向对方发送SYNC命令来开始一次复制操作的时候,如果主服务器目前没有执行BGSAVE操作,或者主服务器并非刚刚执行完BGSAVE操作,那么主服务器就会执行BGSAVE命令

如果系统真的发生崩溃,用户将丢失最近一次生成快照之后更改的所有数据。因此,快照持久化只适用于即使丢失一部分数据也不会造成一些大问题的应用程序。不能接受这个缺点的话,可以考虑AOF持久化。

AOF

与RDB持久化相比,AOF持久化 的实时性更好,因此已成为主流的持久化方案。开启AOF持久化后每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入硬盘中的AOF文件。

在Redis的配置文件中存在三种同步方式,它们分别是:

appendfsync always     #每次有数据修改发生时都会调用fsync写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec   #每秒钟调用fsync同步一次,显示地将多个写命令同步到硬盘
appendfsync no         #所有数据写入操作系统的文件缓存,让操作系统决定何时进行同步

appendfsync always 可以实现将数据丢失减到最少,不过这种方式需要对硬盘进行大量的写入而且每次只写入一个命令,十分影响Redis的速度。另外使用固态硬盘的用户谨慎使用appendfsync always选项,因为这会明显降低固态硬盘的使用寿命。不过,我个人觉得可以将redis服务器连上带写缓存和电源保护的RAID,这样AOF文件顺序写入也很快,而且每次都是写入到RAID的缓存中,并不是实际写盘,电池保护也保证了数据的持久性。

为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec选项 ,让Redis每秒同步一次AOF文件,Redis性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户一般只会丢失一秒之内产生的数据,但如果redis的写入压力很大时,最多还会多写入1秒的数据量(相对于AOF文件缓冲区),所以理论上最多会丢失2秒的数据。当硬盘忙于执行写入操作的时候,Redis还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。

appendfsync no 选项一般不推荐,这种方案会使Redis丢失不定量的数据而且如果用户的硬盘处理写入操作的速度不够的话,那么当缓冲区被等待写入的数据填满时,Redis的写入操作将被阻塞,这会导致Redis的请求速度变慢。

虽然AOF持久化非常灵活地提供了多种不同的选项来满足不同应用程序对数据安全的不同要求,但AOF持久化也有缺陷——AOF文件的体积太大。

AOF重写

AOF虽然在某个角度可以将数据丢失降低到最小而且对性能影响也很小,但是极端的情况下,体积不断增大的AOF文件很可能会用完硬盘空间。另外,如果AOF体积过大,那么还原操作执行时间就可能会非常长。

为了解决AOF体积过大的问题,用户可以向Redis发送 BGREWRITEAOF命令 ,这个命令会通过移除AOF文件中的冗余命令来重写(rewrite)AOF文件来减小AOF文件的体积。BGREWRITEAOF命令和BGSAVE创建快照原理十分相似,所以AOF文件重写也需要用到子进程,这样会导致性能问题和内存占用问题,和快照持久化一样。更糟糕的是,如果不加以控制的话,AOF文件的体积可能会比快照文件大好几倍。
aof-rewrite

持久化机制优化

Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。

如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的,RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

事务

Redis 通过 MULTI、EXEC、WATCH 等命令来实现事务(transaction)功能。事务提供了一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求,它会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求。

  • MULTI:表示要开始一个事务,之后的命令都会放入一个队列中,等待被执行
  • EXEC:表示开始执行队列中的命令
  • WATCH:可以watch一些key,如果其中任何一个key被修改了,那么事务就会被驳回
watch key1 key2
multi
set key1 value1
set key2 value2
exec # 如果key1或者key2被其他人修改了那么上述的两条命令就不会执行,redis借此来实现事务机制

在传统的关系式数据库中,常常用 ACID 性质来检验事务功能的可靠性和安全性。在 Redis 中,事务总是具有原子性(Atomicity)、一致性(Consistency)和隔离性(Isolation),并且当 Redis 运行在某种特定的持久化模式下时,事务也具有持久性(Durability)。

复制

Redis中,用户可以执行SAVEOF命令或设置saveof选项,让一个服务器去复制(replicate)另一个服务器。被复制的服务器叫做master,对master进行复制的服务器叫做slave。

进行复制中的master和slave应该保存相同的数据,这称作“数据库状态一致”。

旧版同步

复制开始时,slave会先执行同步操作,步骤如下:

  • slave对master发送SYNC命令
  • master收到SYNC执行BGSAVE,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
  • master的BGSAVE执行完毕后,将生成的RDB文件发送给slave,slave接收并载入这个RDB,更新自己的数据库状态
  • master将记录在缓冲区中的所有写命令发送给slave,后者执行这些操作,再次更新自己的数据库状态

命令传播

同步完成后,主从服务器的一致状态仍有可能改变,每当master执行写命令时,主从服务器的状态就会不一致。为此,master执行写命令,并将其发送给slave一并执行。

旧版缺陷

Redis的复制可以分为两种情况:

  • 初次复制:slave没有复制过,或者slave要复制的master和上一次复制的master不同。
  • 断线后重复制:处于命令传播阶段的master和slave中断了复制,但重连后,slave继续复制master。

对于初次复制,旧版复制功能可以很好完成。但是断线后复制,效率却很低,因为重连后会浪费一次SYNC操作。

新版复制

为了解决旧版复制功能在断线后的低效问题,Redis从2.8之后,使用PSYNC代替SYNC执行复制时的同步操作。PSYNC具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:

  • 完整重同步用于处理初次复制,执行步骤和SYNC命令基本一样。
  • 部分重同步用于处理断线后重复制,重连后,如果条件允许,master可以将断开期间的写命令发送给slave执行。

复制偏移量

master和slave分别维护一个复制偏移量:

  • master每次向slave传播N个字节的数据时,就将自己的复制偏移量+N。
  • slave每次收到master的N个字节数据时,就将自己的复制偏移量+N。

对比两者的复制偏移量,就知道它们是否处于一致状态。

复制积压缓冲区

复制积压缓冲区是master维护的一个固定长度的FIFO队列,默认大小为1MB。当服务器进行命令传播时,不仅会将命令发送给所有slave,还会入队到积压缓冲区。因此,积压缓冲区保存了最近被传播的写命令,且为队列中的每个字节记录相应的复制偏移量。

slave重连上master时,slave通过PSYNC将自己的复制偏移量offset发送给master,master会根据这个offset决定slave执行何种同步操作:

  • 如果offset之后的数据仍在复制积压缓冲区中,执行部分重同步操作。
  • 否则,执行完整重同步操作。

服务器运行ID

部分重同步还要用到服务器运行ID,主从服务器都有自己的ID。初次复制时,master将自己的ID传给slave,后者将其保存。

断线重连后,slave向当前连接的master发送之前保存的ID:

  • master发现接收的ID和自己的相同,那么说明断线之前复制的就是自己,继续执行部分重同步。
  • 如果不同,完整重同步啦!

PSYNC实现

PSYNC的调用方式有两种:

  • slave没有复制过任何master,则在开始一个新的复制时向master发送PSYNC ? -1命令,请求完整重同步。
  • slave复制过某个master,则发送PSYNC runid offset命令,接收到这个命令的master会根据runid和offset来判断执行哪种同步。

psync

复制过程

  1. 设置master的地址和端口
  2. 建立套接字连接
  3. 发送PING命令
  4. 身份验证
  5. 发送端口信息
    • 身份验证之后,slave将执行REPLCONF listening-port port-number,向master发送slave的监听端口号。master收到后,会将端口号放到客户端状态的slave_listening_port属性中该属性的唯一作用就是master执行INFO replication命令时打印slave的端口号。
  6. 同步
    • 这一步,slave发送PSYNC,执行同步操作。执行同步之后,master也成了slave的客户端,master发送写命令来改变slave的数据库状态。
  7. 命令传播
    • 完成同步之后,主从服务器就进入命令传播阶段,master将自己执行写命令发送给slave,slave接到后就执行,这样两者的状态就一直保持一致了。
  8. 心跳检测
    • 命令传播阶段,slave默认每秒给master发送一次命令:REPLCONF ACK <replication_offset>,其中replication_offset对应当前slave的复制偏移量。该命令有三个作用:
      • 检测网络连接状态
      • 辅助实现min-slaves选项
        该选项防止master在不安全的情况下执行写命令,比如slave数量小于3的时候。
      • 检测命令丢失
        这个根据复制偏移量来判断,如果两者不一致,master就会把复制积压缓冲区的命令重新发送。

Sentinel

Sentinel(哨兵)是Redis的高可用性解决方案,由一个或多个Sentinel实例组成的Sentinel系统可以监视任意多个master以及属下的所有slave。Sentinel在被监视的master下线后,自动将其属下的某个slave升级为新的master,然后由新的master继续处理命令请求。

通讯建立

当一个Sentinel启动时,会执行以下几步:

  • 初始化服务器
  • 将普通Redis服务器使用的代码替换成Sentinel专用代码
  • 初始化Sentinel状态
  • 根据配置文件,初始化监视的master列表
  • 创建与master的网络连接

连接建立后,Sentinel将成为master的客户端,可以向其发送命令。对于被监视的master来说,Sentinel会创建两个异步网络连接:

  • 命令连接,用于发送和接收命令。
  • 订阅连接。用于订阅master的__sentinel__:hello频道。

Sentinel以默认10秒一次的频率,向master发送INFO命令,获取其当前信息:

  • master本身的信息,包括运行ID、role等。据此,Sentinel更新master实例的结构。
  • master的slave信息。据此,Sentinel更新master实例的slaves字典。

Sentinel发现master有新的slave时,除了会为这个slave创建相应的实例结构外,还会创建到它的命令连接和订阅连接。

通过命令连接,Sentinel会向slave每10秒发送一次INFO命令,根据回复更新slave的实例结构:

  • slave的运行ID
  • slave的角色role
  • master的地址和端口
  • 主从的连接状态
  • slave的优先级
  • slave的复制偏移量

默认情况下,Sentinel会以两秒一次的频率,通过命令连接向所有被监视的master和slave发送:

PUBLISH __sentinel__:hello "<s_ip>, <s_port>, <s_runid>, <s_epoch>, <m_name>, <m_ip>, <m_port>, <m_epoch>"

其中以s_开头的参数表示Sentinel本身的信息,m_开头的参数是 master 的信息。如果Sentinel 正在监视的是 slave,那就是 slave 正在复制的 master 信息。

当Sentinel与一个master或slave建立订阅连接后,会向服务器发送以下命令:SUBSCRIBE __sentinel__:hello

Sentinel对__sentinel__:hello频道的订阅会持续到两者的连接断开为止。也就是说,Sentinel既可以向服务器的__sentinel__:hello频道发送信息,又通过订阅连接从__sentinel__:hello 频道接收信息。

对于监视同一个server的多个Sentinel来说,一个Sentinel发送的信息会被其他Sentinel收到。这些信息用于更新其他Sentinel对发送信息Sentinel和被监视Server的认知。

Sentinel为master创建的实例结构中,有sentinels字典保存了其他监视这个master的Sentinel:

  • 键是Sentinel名字,格式为ip: port。
  • 值是Sentinel实例的结构。

当一个Sentinel收到其他Sentinel发来的信息时,目标Sentinel会从信息中提取出:

  • 与Sentinel有关的参数:源Sentinel的IP、端口、运行ID、配置纪元。
  • 与master有关的参数:master的名字、IP、端口、配置纪元。

根据提取的参数,目标Sentinel会在自己的Sentinel状态中更新sentinels和masters字典。

Sentinel通过频道信息发现一个新的Sentinel时,不仅会为其创建新的实例结构,还会创建一个连向新Sentinel的命令连接,新的Sentinel也会创建连向这个Sentinel的命令连接,最终,监视同一master的多个Sentinel成为相互连接的网络。各个Sentinel可以通过发送命令请求来交换信息。

sentinel-normal

故障检测

默认情况下,Sentinel会每秒一次地向所有与它创建了命令连接的实例(master、slave、其他sentinel)发送PING命令,并通过回复来判断其是否在线。只有+PONG/-LOADING/-MASERDOWN三种有效回复。

Sentinel的配置文件中down-after-milliseconds选项指定了判断实例主观下线所需的时间长度。在down-after-milliseconds毫秒内,如果连续返回无效回复,那么Sentinel会修改这个实例对应的实例结构,将flags属性中打开SRI_S_DOWN标识,标识主观下线。

当Sentinel将一个master判断为主观下线后,为了确认是真的下线,会向监视这一master的其他Sentinel询问。有足够数量(quorum)的已下线判断后,Sentinel会将master判定为客观下线,并对master执行故障转移。

故障转移

master被判定为客观下线后,监视这个master的所有Sentinel会进行协商,选举一个领头Sentinel,并由其对该master执行故障转移。选举的规则如下:

  • 所有Sentinel都可以成为领头。
  • 每次进行领头Sentinel选举后,不论选举是否成功,所有Sentinel的配置纪元都会+1。这个配置纪元就是一个计数器。
  • 一个配置纪元里,所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,且局部领头一旦设定,在这个配置纪元内就不可修改。
  • 每个发现master进入客观下线的Sentinel都会要求其他Sentinel将自己设为局部领头Sentinel。
  • 当一个Sentinel向另一个Sentinel发送SENTINEL is-master-down-by-addr,且命令中的runid参数是自己的运行ID,这表明源Sentinel要求目标Sentinel将他设置为局部领头。
  • Sentinel设置局部领头的规则是先到先得。
  • 目标Sentinel收到SENTINEL is-master-down-by-addr后,会返回一条命令回复,恢复中的leader_runid和leader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元。
  • 源Sentinel收到目标Sentinel的回复后,检查回复中的leader_runid和leader_epoch是否和自己相同。
  • 如果某个Sentinel被半数以上的Sentinel设置为局部领头,那么这个Sentinel就成为领头Sentinel。
  • 因为领头Sentinel需要半数以上的支持,且每个Sentinel在每个配置纪元里只设置一次局部领头,所以一个配置纪元里,只能有一个领头。
  • 如果给定时限内,没有产生领头Sentinel,那么各个Sentinel过段时间再次选举,直到选出领头为止。

领头Sentinel会对已下线的master执行故障转移,包括以下三个步骤:

  • 从已下线master属下的所有slave选出一个新的master。
  • 让已下线master属下的所有slave改为新复制新的master。
  • 让已下线master成为新master的slave,重新上线后就是新slave。

新master的挑选规则:

  • 在线(必须)
  • 五秒内回复过领头Sentinel的INFO命令(必须)
  • salve的自身的优先级最高选择
  • 如果优先级相同,按复制偏移量最大选择
  • 如果偏移量一致,按照run id最小选择

sentinel-change-master

集群

Redis集群是分布式的数据库方案,通过分片(sharding)来进行数据共享,并提供复制或故障转移功能。

启动

一个节点就是运行在集群模式下的Redis服务器,根据cluster-endabled配置选项是否为yes来决定是否开启集群模式。

节点在集群模式下会继续使用单机模式的组件,如:

  • 文件事件处理器
  • 时间事件处理器
  • 使用数据库来保存键值对数据
  • RDB和AOF持久化
  • 发布与订阅
  • 复制模块
  • Lua脚本

连接

通过向节点A发送CLUSTER MEET命令,客户端可以让接受命令的节点A将另一个节点B接入到A所在的集群中。

收到CLUSTER MEET命令的节点A,会进行以下操作:

  • 为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典。
  • 节点A根据CLUSTER MEET命令的IP和端口,先节点B发送MEET消息。
  • 节点B收到MEET消息,为节点A创建一个clusterNode结构,并加入字典。
  • 节点B回给节点A一条PONG消息。
  • 节点A收到PONG,知道节点B已经接收了自己的MEET消息。
  • 节点A向节点B返回一条PING消息。
  • 节点B收到PING之后,双方握手完成。

cluster-meet

槽指派

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

当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok),如果任何一个槽都没有得到处理,就处于下线状态(fail)。

CLUSTER MEET只是将节点连接起来,集群仍处于下线状态,通过向节点发送CLUSTER ADDSLOTS,可以为一个或多个槽指派(assign)给节点负责。

记录节点的槽指派信息

struct clusterNode {
  unsigned char slots[16384/8];
  int numslots;
};

slots数组中的索引i上的二进制位的值来判断节点是否负责处理槽i。numslots记录节点负责处理的槽的数量,即slots数组中二进制1的数量。

一个节点除了会将自己处理的槽记录在clusterNode结构中的slots和numslots属性之外,还会将自己的slots数组通过消息发送给集群中的其它节点。

节点A通过消息从节点B接收到节点B的slots数组会,会在自己的clusterState.nodes字典中查找节点B对应的clusterNode结构,并对结构中的slots数组进行更新。

最终,集群中的每个节点都知道数据库中的16384个槽分别被指派给了哪些节点。

执行命令

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

如果指派给了自己,节点直接执行命令。否则,节点向客户端返回一个MOVED错误,指引客户端转向(redirect)到正确的节点,再次发送命令。

计算槽:

def slot_number(key):
    return CRC16(key) & 16383

节点计算出键所属的槽i之后,会检查自己在clusterState.slots数组中的第i项,判断键所在的槽是不是自己负责。

如果不是由自己负责,它会通过一个从slot->node的映射(跳表实现)来找到负责该槽的节点,并发送一个MOVED回应来通知客户端应该去找哪个节点。

重新分片

Redis集群的重新分片指的是将任意数量已经指派给某个节点的槽改为指派给另一个节点,且相关槽所属的键也从源节点移动到目标节点。重新分片可以在线(online)进行,分片过程中,集群不需要下线,且源节点和目标节点都可以继续处理命令请求。

重新分片是由Redis的集群管理软件redis-trib负责的,Redis提供了重新分片所需的所有命令,redis-trib则通过向源节点和目标节点发送命令来实现重新分片:

  • 向目标节点发送CLUSTER SETSLOT slot IMPORTING source_id命令,让目标节点准备好导入源节点中属于槽slot的键值对。
  • 向源节点发送CLUSTER SETSLOT slot MIGRATING target_id命令,让源节点准备好迁移键值对。
  • 向源节点发送CLUSTER GETKEYINSLOT slot count命令,获得最多count个属于槽slot的键值对的键名。
  • 对于步骤3获得的每个键名,向源节点发送一个MIGRATE target_ip target_port key_name 0 timeout命令,将选中的键原子地从源节点迁移到目标节点。
  • 重复执行步骤3和4,直到所有键值对都被迁移至目标节点
  • 向集群中的任一节点发送CLUSTER SETSLOT slot NODE target_id命令,将槽slot指派给目标节点,这一指派信息通过消息传送至整个集群。

cluster-reslot
在重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现:属于被迁移槽的一部分键值对保存在源节点中,而另一部分保存在目标节点中。

当客户端向源节点发送一个与数据库键有关的命令,且要处理的键恰好就属于正在被迁移的槽时:

  • 源节点先在自己的数据库中查找键,如果找到,直接执行命令。
  • 否则,源节点向客户端返回ASK错误,指引客户端转向正在导入槽的目标节点,再次发送命令。

节点收到一个关于键key的命

版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/BeiKeJieDeLiuLangMao/article/details/115264473
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢