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 类型存放了我本人的一些信息:

1
2
3
4
5
6
7
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缓冲区,客户端状态中的输入缓冲区

1
2
3
4
5
6
7
8
9
10
11
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服务器还用链表保存多个客户端的状态信息,以及构建客户端输出缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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的字典使用哈希表作为底层实现,每个哈希表节点就保存了字典中的一个键值对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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计算哈希值和索引值的方法如下:

1
2
3
4
5
# 使用字典设置的哈希函数,计算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

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

1
2
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
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用作内部数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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结构表示一个整数集合:

1
2
3
4
5
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结构表示:

1
2
3
4
5
6
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的配置文件中存在三种同步方式,它们分别是:

1
2
3
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被修改了,那么事务就会被驳回
1
2
3
4
5
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对应当前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发送:

1
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)给节点负责。

记录节点的槽指派信息

1
2
3
4
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)到正确的节点,再次发送命令。

计算槽:

1
2
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的命令请求,先查找key所属的槽i是否在自己的数据库里,如果在,直接执行命令。

如果不在,节点会检查自己的clusterState.migrating_slots_to[i],看槽i是否正在被迁移。如果是,返回客户端一个ASK错误。

接到ASK错误的客户端根据错误提供的IP地址和端口,转向目标节点,先向其发送一个ASKING命令,之后再重新发送原来要执行的命令。如果不先发送一个ASKING命令,那么会被节点拒绝执行,并返回MOVED错误。

ASKING命令唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识。该标识是一次性标识,节点执行了一个带有该标识的客户端发来的命令后,标识就被移除。换句话说每次ASKING相当于通知redis 节点我接下来要访问一次你正在迁移的slot中的数据。

ASK和MOVED的区别:

  • MOVED错误代表槽的负责权已经转移。
  • ASK错误是迁移槽过程中的临时措施。接收ASK指引的转向,不会对客户端今后发送关于槽i的命令请求有任何影响,客户端仍会将请求发送至目前负责处理槽i的节点,除非ASK错误再次出现。

复制数据

Redis集群中的master用于处理槽,slave用于复制某个master,并在被复制的master下线时,代替master继续处理命令请求。

故障转移方案

集群中的每个节点都会定期向其它节点发送PING消息,检测对方是否在线。各个节点都会通过消息来交换其它节点的状态信息。

当一个master A通过消息得知master B认为master C进入疑似下线状态。

如果在一个集群里,半数以上负责处理槽的master都将某个master X报告为疑似下线,那么X就被标记为下线。将X标记为下线的节点向集群广播关于X的FAIL消息,收到消息的节点会立即将X标记为已下线。

当一个slave发现自己正在复制的master已下线,会开始对其进行故障转移:

  • 复制master的所有从节点里,会有一个slave被选中。
  • 被选中的slave执行SALVEOF no one命令,成为新的master。
  • 新master会撤销所有对已下线master的槽指派,并指派给自己。
  • 新master向集群广播一条PONG消息,宣布自己成为master。
  • 新master开始接收和处理自己负责的槽有关的命令请求。

新的master是选举产生的:

  • 集群中的配置纪元是一个自增计数器,初始值为0。
  • 集群中的某个节点开始一次故障转移操作时,集群配置纪元的值+1。
  • 对于每个配置纪元,集群中每个负责处理槽的master都有一次投票机会,而第一个向master要求投票的slave将获得投票权。
  • 当slave发现自己正在复制的master已下线,会广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST,要求收到消息的master给自己投票。
  • 如果一个master有投票权(正在处理槽),且未投票给其它slave,那么master会向要求投票的slave返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示支持它成为新master。
  • 每个参与选举的slave都会接收到CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,根据消息的个数来统计自己获得几票。
  • 一个slave收集到大于N/2+1的支持票后,会当选新master。
  • 因为每个配置纪元里,拥有投票权的master只有一票,因此新的master只会有一个。
  • 如果一个配置纪元中没有选举出新master,那么集群进入一个新的配置纪元,继续选举。

Redis分布式锁

Redis 官方站的一篇文章提出了一种权威的基于 Redis 实现分布式锁的方式名叫 Redlock,此种方式比原先的单节点的方法更安全。它可以保证以下特性:

  • 安全特性:互斥访问,即永远只有一个 client 能拿到锁
  • 避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的 client crash 了或者出现了网络分区
  • 容错性:只要大部分 Redis 节点存活就可以正常提供服务

接下来我们会先后介绍单节点的实现,以及多节点的实现。

单节点实现

SET resource_name my_random_value NX PX 30000

主要依靠上述命令,该命令仅当 Key 不存在时(NX保证)set 值,并且设置过期时间 3000ms (PX保证),值 my_random_value 必须是所有 client 和所有锁请求发生期间唯一的,释放锁的逻辑是:

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

上述实现可以避免释放另一个client创建的锁,如果只有 del 命令的话,那么如果 client1 拿到 lock1 之后因为某些操作阻塞了很长时间,此时 Redis 端 lock1 已经过期了并且已经被重新分配给了 client2,那么 client1 此时再去释放这把锁就会造成 client2 原本获取到的锁被 client1 无故释放了,但现在为每个 client 分配一个 unique 的 string 值可以避免这个问题。至于如何去生成这个 unique string,方法很多随意选择一种就行了。

RedLock

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

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

失败重试

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

放锁

放锁操作很简单,就是依次释放所有节点上的锁就行了

故障恢复

如果我们的节点没有持久化机制,client 从 5 个 master 中的 3 个处获得了锁,然后其中一个重启了,这是注意 整个环境中又出现了 3 个 master 可供另一个 client 申请同一把锁! 违反了互斥性。如果我们开启了 AOF 持久化那么情况会稍微好转一些,因为 Redis 的过期机制是语义层面实现的,所以在 server 挂了的时候时间依旧在流逝,重启之后锁状态不会受到污染。但是考虑断电之后呢,AOF部分命令没来得及刷回磁盘直接丢失了,除非我们配置刷回策略为 fsnyc = always,但这会损伤性能。解决这个问题的方法是,当一个节点重启之后,我们规定在 max TTL 期间它是不可用的,这样它就不会干扰原本已经申请到的锁,等到它 crash 前的那部分锁都过期了,环境不存在历史锁了,那么再把这个节点加进来正常工作。

RedLock的问题

在分布式环境下,锁比 mutex 这类复杂,因为涉及到不同节点、网络通信并且他们随时可能无征兆的 fail 。假设了一个场景,一个 client 要修改一个文件,它先申请得到锁,然后修改文件写回,放锁。另一个 client 再申请锁 … 代码流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// THIS CODE IS BROKEN
function writeData(filename, data) {
var lock = lockService.acquireLock(filename);
if (!lock) {
throw 'Failed to acquire lock';
}

try {
var file = storage.readFile(filename);
var updated = updateContents(file, data);
storage.writeFile(filename, updated);
} finally {
lock.release();
}
}

可惜即使你的锁服务非常完美,上述代码还是可能跪,下面的流程图会告诉你为什么:
lock-boom
上述图中,得到锁的 client1 在持有锁的期间 pause 了一段时间,例如 GC 停顿。锁有过期时间(一般叫租约,为了防止某个 client 崩溃之后一直占有锁),但是如果 GC 停顿太长超过了锁租约时间,此时锁已经被另一个 client2 所得到,原先的 client1 还没有感知到锁过期,那么奇怪的结果就会发生,曾经 HBase 就发生过这种 Bug。即使你在 client1 写回之前检查一下锁是否过期也无助于解决这个问题,因为 GC 可能在任何时候发生,即使是你非常不便的时候(在最后的检查与写操作期间)。 如果你认为自己的程序不会有长时间的 GC 停顿,还有其他原因会导致你的进程 pause。例如进程可能读取尚未进入内存的数据,所以它得到一个 page fault 并且等待 page 被加载进缓存;还有可能你依赖于网络服务;或者其他进程占用 CPU;或者其他人意外发生 SIGSTOP 等。

时钟问题导致RedLock不可靠:

  1. client1 从 ABC 三个节点处申请到锁,DE由于网络原因请求没有到达
  2. C节点的时钟往前推了,导致 lock 过期
  3. client2 在CDE处获得了锁,AB由于网络原因请求未到达
  4. 此时 client1 和 client2 都获得了锁

网络延时导致RedLock不可靠:

  1. client1 从 ABCDE 处获得了锁
  2. 当获得锁的 response 还没到达 client1 时 client1 进入 GC 停顿
  3. 停顿期间锁已经过期了
  4. client2 在 ABCDE 处获得了锁
  5. client1 GC 完成收到了获得锁的 response,此时两个 client 又拿到了同一把锁

这些例子说明了,仅有在你假设了一个同步性系统模型的基础上,Redlock 才能正常工作,也就是系统能满足以下属性:

  1. 进程停顿边界,即进程停顿一定在某个最大时间之内
  2. 时钟错误边界,即不会从一个坏的 NTP 服务器处取得时间
  3. 网络延时边界,即假设数据包一定能在某个最大延时之内到达

Redlock 实在不是一个好的选择,对于需求性能的分布式锁应用它太重了且成本高;对于需求正确性的应用来说它不够安全。因为它对高危的时钟或者说其他上述列举的情况进行了不可靠的假设,如果你的应用只需要高性能的分布式锁不要求多高的正确性,那么单节点 Redis 够了;如果你的应用想要保住正确性,那么不建议 Redlock,建议使用一个合适的一致性协调系统。

Fencing

为了解决RedLock的问题,有人提出每次写操作时加入一个 fencing token。这个场景下,fencing token 可以是一个递增的数字(lock service 可以做到),每次有 client 申请锁就递增一次:
fencing
client1 申请锁同时拿到 token33,然后它进入长时间的停顿锁也过期了。client2 得到锁和 token34 写入数据,紧接着 client1 活过来之后尝试写入数据,自身 token33 比 34 小因此写入操作被拒绝。注意这需要存储层来检查 token,但这并不难实现。如果你使用 Zookeeper 作为 lock service 的话那么你可以使用 zxid 作为递增数字。 但是对于 Redlock 来说,没什么生成 fencing token 的方式,那怎么修改 Redlock 算法使其能产生 fencing token 呢?这似乎并不简单,因为产生 token 需要单调递增,除非在单节点 Redis 上完成,但是这又没有高可靠性,这就需要引进一致性协议来让 Redlock 产生可靠的 fencing token。

Fencing的依赖

我认为即便使用Fencing也需要建立在一个假设之上:确认token+存储过程这个整体需要是原子性的并且是互斥的。也就说在存储的过程中不应该出现clientX获取到锁从而得到token35,并且在Client2的存储过程还没结束的时候,开始了自己的存储过程。我觉得这个问题和引入Fencing机制的原因本质上是一致的,我们因为client1无法保证在锁的持有期间执行完互斥操作才引入了Fencing,但是它只保证了明确得知锁超时后,无法执行互斥操作。但是如果在互斥操作的过程中锁超时了,另一个clientX获取到锁,也会和当前正在进行的操作冲突。

我们使用分布式锁一般都是用来控制多个节点之间对某一共享资源的访问,如前面提到的Client1,Client2,和Storage,一般来说他们都是互相独立的。

如果我们要进行的互斥操作(图中修改Storage),是在单机上执行的,那么问题很好解决。可以对修改的操作入队,然后从队列中依次取出并按如下过程操作:

  1. 确认token合法性
  2. 如果token合法,执行存储过程,否则驳回

这样的话,就能达到前面提到的条件『确认token+存储过程这个整体需要是原子性的并且是互斥的』,即便在存储过程中锁被另一个ClientX获得,ClientX的操作也会在当前Client2的操作执行完成后才开始,并且整个过程也是逻辑上说的通的,Client2先获得了锁,它的存储过程应该先于ClientX完成。而如果ClientX获得锁的时候,Client2还没开始确认token,那么Client2的操作就会被驳回。

但是如果图中的Storage是一个网络存储服务,并且网络存储服务没办法帮你确认token的可靠性,这时候Fencing机制就形同虚设,我们最终会绕回最初的问题。

很多人都认为,如果你想构建一个更安全的分布式锁,那么应该使用ZooKeeper,而不是Redis。那么,为了对比的目的,让我们先暂时脱离开本文的题目,讨论一下基于ZooKeeper的分布式锁能提供绝对的安全吗?它需要fencing token机制的保护吗?

ZooKeeper锁

我们不得不提一下分布式专家Flavio Junqueira所写的一篇blog,题目叫“Note on fencing and distributed locks”。
他在文中给出了一个基于ZooKeeper构建分布式锁的描述(当然这不是唯一的方式):

  • 客户端尝试创建一个znode节点,比如/lock。那么第一个客户端就创建成功了,相当于拿到了锁;而其它的客户端会创建失败(znode已存在),获取锁失败。
  • 持有锁的客户端访问共享资源完成后,将znode删掉,这样其它客户端接下来就能来获取锁了。
  • znode应该被创建成ephemeral的。这是znode的一个特性,它保证如果创建znode的那个客户端崩溃了,那么相应的znode会被自动删除。这保证了锁一定会被释放。

看起来这个锁相当完美,没有Redlock过期时间的问题,而且能在需要的时候让锁自动释放。但仔细考察的话,并不尽然。

ZooKeeper是怎么检测出某个客户端已经崩溃了呢?实际上,每个客户端都与ZooKeeper的某台服务器维护着一个Session,这个Session依赖定期的心跳(heartbeat)来维持。如果ZooKeeper长时间收不到客户端的心跳(这个时间称为Sesion的过期时间),那么它就认为Session过期了,通过这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。

设想如下的执行序列:

  • 客户端1创建了znode节点/lock,获得了锁。
  • 客户端1进入了长时间的GC pause。
  • 客户端1连接到ZooKeeper的Session过期了。znode节点/lock被自动删除。
  • 客户端2创建了znode节点/lock,从而获得了锁。
  • 客户端1从GC pause中恢复过来,它仍然认为自己持有锁。

最后,客户端1和客户端2都认为自己持有了锁,冲突了。这与之前Martin在文章中描述的由于GC pause导致的分布式锁失效的情况类似。

看起来,用ZooKeeper实现的分布式锁也不一定就是安全的。该有的问题它还是有。但是,ZooKeeper作为一个专门为分布式应用提供方案的框架,它提供了一些非常好的特性,是Redis之类的方案所没有的。像前面提到的ephemeral类型的znode自动删除的功能就是一个例子。

还有一个很有用的特性是ZooKeeper的watch机制。这个机制可以这样来使用,比如当客户端试图创建/lock的时候,发现它已经存在了,这时候创建失败,但客户端不一定就此对外宣告获取锁失败。客户端可以进入一种等待状态,等待当/lock节点被删除的时候,ZooKeeper通过watch机制通知它,这样它就可以继续完成创建操作(获取锁)。这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。这样的特性Redlock就无法实现。

最后分布式锁的问题又落到了资源服务器的责任上来,我们无法在一个不提供任何锁校验支持的共享资源服务上使用分布式锁。接下来让我们通过Chubby的例子来看一下,如果共享资源服务提供了锁校验支持,整个流程应该是什么样的。

Chubby锁

Chubby是Google内部使用的分布式锁服务,有点类似于ZooKeeper,但也存在很多差异。

Chubby自然也考虑到了延迟造成的锁失效的问题。论文里有一段描述如下:

a process holding a lock L may issue a request R, but then fail. Another process may ac- quire L and perform some action before R arrives at its destination. If R later arrives, it may be acted on without the protection of L, and potentially on inconsistent data.
(译文: 一个进程持有锁L,发起了请求R,但是请求失败了。另一个进程获得了锁L并在请求R到达目的方之前执行了一些动作。如果后来请求R到达了,它就有可能在没有锁L保护的情况下进行操作,带来数据不一致的潜在风险。)

这跟我们之前的分析大同小异。

Chubby给出的用于解决(缓解)这一问题的机制称为sequencer,类似于fencing token机制。锁的持有者可以随时请求一个sequencer,这是一个字节串,它由三部分组成:

  • 锁的名字。
  • 锁的获取模式(排他锁还是共享锁)。
  • lock generation number(一个64bit的单调递增数字)。作用相当于fencing token。

客户端拿到sequencer之后,在操作资源的时候把它传给资源服务器。然后,资源服务器负责对sequencer的有效性进行检查。检查可以有种方式:

  • 调用Chubby提供的API,CheckSequencer(),将整个sequencer传进去进行检查。这个检查是为了保证客户端持有的锁在进行资源访问的时候仍然有效。
  • 将客户端传来的sequencer与资源服务器当前观察到的最新的sequencer进行对比检查。

当然,出于兼容性的考虑,资源服务本身可能是已经实现的产品,要想让其加入检查逻辑可能并不容易,所以Chubby还提供了一种自身的机制:

  • lock-delay。Chubby允许客户端为持有的锁指定一个lock-delay的时间值(默认是1分钟)。当Chubby发现客户端被动失去联系的时候,并不会立即释放锁,而是会在lock-delay指定的时间内阻止其它客户端获得这个锁。这是为了在把锁分配给新的客户端之前,让之前持有锁的客户端有充分的时间把请求队列排空(draining the queue),尽量防止出现延迟到达的未处理请求。

可见,为了应对锁失效问题,Chubby提供的三种处理方式:CheckSequencer()检查、与最新的sequencer对比、lock-delay,它们对于安全性的保证是从强到弱的。而且,这些处理方式本身都没有保证提供绝对的正确性(correctness)。但是,Chubby确实提供了单调递增的lock generation number,这就允许资源服务器在需要的时候,利用它提供更强的安全性保障。

看了很多文章,最后对分布式锁的讨论都止于Chubby,似乎整个工业界就没有达到完全安全性的分布式锁实现。我觉得要想达到完全的安全性,可能就要求整个分布式资源服务中大部分节点进行原子性的check and set,只有当大部分节点都成功check and set后,才算是本次加锁操作成功,如果最终只有低于一半的节点check and set成功,其他节点check时发现token失效,那么就要进行回滚。其中每一个节点确认超过半数节点修改成功或者自己回滚结束后,就可以处理下一次加锁写请求了,而在此之前下一次加锁写的请求需要被阻塞,以此来达成单节点的check and set的原子性。

常见问题

缓存雪崩

缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决办法:

  • 事前:尽量保证整个 redis 集群的高可用性,发现机器宕机尽快补上。选择合适的内存淘汰策略。
  • 事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL崩掉
  • 事后:利用 redis 持久化机制保存的数据尽快恢复缓存

snowslide

缓存穿透

一般是黑客故意去请求缓存中不存在的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决办法:有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法(我们采用的就是这种),如果一个查询返回的数据为空(不管是数 据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

缓存击穿

在平常高并发的系统中,大量的请求同时查询一个 key 时,此时这个key正好失效了,就会导致大量的请求都打到数据库上面去。这种现象我们称为缓存击穿。

解决办法:上面的现象是多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

热点数据集中失效

我们在设置缓存的时候,一般会给缓存设置一个失效时间,过了这个时间,缓存就失效了。对于一些热点的数据来说,当缓存失效以后会存在大量的请求过来,然后打到数据库去,从而可能导致数据库崩溃的情况。

解决办法:为了避免这些热点的数据集中失效,那么我们在设置缓存过期时间的时候,我们让他们失效的时间错开。或者队列限流。

并发竞争 Key

所谓 Redis 的并发竞争 Key 的问题也就是多个系统同时对一个 key 进行操作,但是最后执行的顺序和我们期望的顺序不同,这样也就导致了结果的不同!

解决办法: 分布式锁(zookeeper 和 redis 都可以实现分布式锁)。

与数据库存储一致

你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?

一般来说,就是如果你的系统不是严格要求缓存+数据库必须一致性的话,缓存可以稍微的跟数据库偶尔有不一致的情况,最好不要做这个方案,读请求和写请求串行化,串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况

串行化之后,就会导致系统的吞吐量会大幅度的降低,用比正常情况下多几倍的机器去支撑线上的一个请求。

参考内容

[1] Snailclimb Redis 总结
[2] Snailclimb Redis 持久化
[3] Snailclimb RedLock
[4] 关于【缓存穿透、缓存击穿、缓存雪崩、热点数据失效】问题的解决方案
[5] 如何做可靠的分布式锁,Redlock真的可行么
[6] 《Redis设计与实现》读书笔记
[7] 基于Redis的分布式锁到底安全吗
[8] 《Redis 实战》
[9] 《Redis 设计与实现》

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/distributed/redis/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。