redis是一个高性能的 key-value 数据库,其Key的类型均为 String 。
底层数据结构
- 简单动态字符串 SDS
- 链表 linkedlist
- 字典 dict
- 跳跃表 skiplist
- 整数集合 intset
- 压缩列表 ziplist
- 快速列表 quicklist
SDS
redis默认并未直接使用C字符串(C字符串仅仅作为字符串字面量,用在一些无需对字符串进行修改的地方)。而是以Struct的形式构造了一个SDS的抽象类型。当redis需要一个可以被修改的字符串时,就会使用SDS来表示。在Redis数据库里,包含字符串值的键值对都是由SDS实现的。
SDS的实现
// sds.h
struct sdshdr{
//int 记录buf数组中未使用字节的数量
int free;
//int 记录buf数组中已使用字节的数量即sds的长度
int len;
//字节数组用于保存字符串 sds遵循了c字符串以空字符结尾的惯例目的是为了重用c字符串函数库里的函数
char buf[];
}
SDS相对于C字符串的优点
- 常数复杂度获取字符串长度:
C字符串不记录字符串长度,获取长度必须遍历整个字符串,复杂度为O(N);而SDS结构中本身就有记录字符串长度的
len
属性,复杂度为O(1)。 - 杜绝缓冲区溢出: 当API需要对SDS进行修改时, API会首先会检查SDS的空间是否满足条件, 如果不满足, API会自动对它动态扩展, 然后再进行修改。
- 减少字符串操作中的内存重分配次数: SDS的空间分配策略有两种,一是空间预分配,而是惰性空间释放。
- 二进制安全: C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这限制C字符串。而SDS的buf字节数组不是在保存字符,而是一系列二进制数组,SDS API都会以二进制的方式来处理buf数组里的数据,使用len属性的值而不是空字符来判断字符串是否结束。
- 兼容部分C字符串函数: 虽然SDS的API是二进制安全的,但还是像C字符串一样以空字符结尾,目的是为了让保存文本数据的SDS可以重用一部分C字符串的函数。
链表
redis的链表使用双向无环链表。
链表的实现
链表上的结点定义如下:
// 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);
}list;
Redis链表结构其主要特性
- 双向:链表节点带有前驱、后继指针获取某个节点的前驱、后继节点的时间复杂度为0(1)。
- 无环: 链表为非循环链表表头节点的前驱指针和表尾节点的后继指针都指向
NULL
,对链表的访问以NULL
为终点。 - 带表头指针和表尾指针:通过list结构中的
head
和tail
指针,获取表头和表尾节点的时间复杂度都为O(1)。 - 带链表长度计数器:通过list结构的
len
属性获取节点数量的时间复杂度为O(1)。 - 多态:链表节点使用
void*
指针保存节点的值,并且可以通过list结构的dup
、free
、match
三个属性为节点值设置类型特定函数,所以链表可以用来保存各种不同类型的值。
字典
redis的字典底层是使用哈希表实现的,一个哈希表里面可以有多个哈希表结点,每个哈希节点中保存了字典中的一个键值对。
字典的实现
哈希表结构定义:
// dict.h/dicth
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值,等于size-1
unsigned long sizemask;
// 哈希表已有节点的数量
unsigned long used;
} dictht;
size
属性记录了哈希表的大小,也是table数组的大小used
属性则记录哈希表目前已有节点(键值对)的数量sizemask
属性的值总是等于size-1(从0开始)
,这个属性和哈希值一起决定一个键应该被放到table
数组的哪个索引上面(索引下标值)。table
属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry
结构的指针,每个 dictEntry 结构保存着一个键值对
// dict.h/dictEntry
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
key
属性保存着键值中的键,而v
属性则保存着键值对中的值,其中键值(v属性)可以是一个指针,或uint64_t
整数,或int64_t
整数。 next
属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,解决键冲突问题。
字典结构:
// dict.h/dict
typedef struct dict{
//类型特定函数
void *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash 索引 当rehash不在进行时 值为-1
int trehashidx;
}dict;
type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的。
- type属性是一个指向dictType结构的指针,每个dictType用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
- privdata属性则保存了需要传给给那些类型特定函数的可选参数。
typedef struct dictType {
// 计算哈希值的行数
uint64_t (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
} dictType;
ht
属性是一个包含两个项的数组,数组中的每个项都是一个dictht
哈希表, 一般情况下,字典只使用ht[0]哈希表
,ht[1]哈希表
只会对ht[0]哈希表
进行rehash
时使用。rehashidx
记录了rehash
目前的进度,如果目前没有进行rehash
,值为-1
。
redis解决散列冲突的方法
- 链表法
- redis rehash
- 渐进式 rehash
跳跃表
跳跃表(skiplist)是一种有序数据结构, 它通过在每个节点中维持多个指向其他节点的指针, 从而达到快速访问节点的目的。
它具有如下性质:
- 多层的结构组成,每层是一个有序的链表
- 最底层(level 1)的链表包含所有的元素
- 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
- 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。
- 跳跃表的查找次数近似于层数,时间复杂度为
O(logn)
,插入、删除也为O(logn)
- 跳跃表是一种随机化的数据结构(通过抛硬币来决定层数)
跳跃表的实现
Redis的跳跃表由zskiplistNode
和skiplist
两个结构定义,其中zskiplistNode
结构用于表示跳跃表节点,而zskiplist
结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
// redis.h/zskiplistNode
typedef struct zskiplistNode {
// 成员对象 (robj *obj;)
sds ele;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
// 跨度实际上是用来计算元素排名(rank)的,在查找某个节点的过程中,将沿途访过的所有层的跨度累积起来,得到的结果就是目标节点在跳跃表中的排位
unsigned long span;
} level[];
} zskiplistNode;
// redis.h/zskiplist
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
跳跃表的时间复杂度
整数集合
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t
、int32_t
或者int64_t
的整数值,并且保证集合中不会出现重复元素。
整数集合的实现
// inset.h/inset
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
//每个元素在数组中按值的大小从小到大排序,且不包含重复项
int8_t contents[];
} intset;
整数集合升级
步骤:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。 PS:整数集合不支持降级,一旦升级,编码就会一直保持升级后的状态
优点:
- 提升灵活性
- 节约内存
压缩列表
压缩列表(ziplist)是为了节约内存而设计的,是由一系列特殊编码的连续内存块组成的顺序性(sequential)数据结构,一个压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者一个整数值
压缩列表的构成
压缩列表结构:
zlbytes
:记录整个压缩列表占用的内存字节数。zltail
:记录压缩列表表尾节点距离压缩列表起始地址有多少字节。zllen
:记录了压缩列表包含的节点数量。entryN
:压缩列表的节点,节点长度由节点保存的内容决定。zlend
:特殊值0xFF(十进制255),用于标记压缩列表的末端。
压缩列表节点结构:
previous_entry_ength
:记录压缩列表前一个字节的长度encoding
:节点的encoding保存的是节点的content的内容类型content
:content区域用于保存节点的内容,节点内容类型和长度由encoding决定
快速列表
quicklist 实际上是ziplist
和linkedlist
的混合体,它将linkedlist
按段切分,每一段使用ziplist
来紧凑存储,多个ziplist
之间使用双向指针串接起来。
快速列表的实现
快速列表节点:
typedef struct quicklistNode {
struct quicklistNode *prev; //上一个node节点
struct quicklistNode *next; //下一个node
unsigned char *zl; //保存的数据 压缩前ziplist 压缩后压缩的数据
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
quicklistLZF结构表示一个被压缩过的ziplist。
typedef struct quicklistLZF {
unsigned int sz; //表示压缩后的ziplist大小
char compressed[]; //柔性数组,存放压缩后的ziplist字节数组。
} quicklistLZF;
快速列表结构:
typedef struct quicklist {
quicklistNode *head; //指向头节点(左侧第一个节点)的指针。
quicklistNode *tail; //指向尾节点(右侧第一个节点)的指针。
unsigned long count; //所有ziplist数据项的个数总和。
unsigned long len; //quicklist节点的个数。
int fill : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
数据结构
- String
- Hash
- List
- Set
- ZSet
String
String是redis最基础的数据结构,可以是简单的字符串,复杂的字符串(JSON、XML),数字,二进制(图片、音频、视频),但最大值不能超过512M。
redis的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于Java的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。
其内部编码有三种:int
、embstr
、raw
,redis会根据当前值的类型和长度来决定使用哪种编码来实现。
- 如果字符串对象保存的是整数值,并且这个整数值可以用
long
类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr
属性里面(将void*
转换成1ong
),并将字符串对象的编码设置为int
。 - 如果字符串对象保存的是一个字符串值,并且这个字符申值的长度小于等于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为
embstr
- 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为
raw
。
Hash
Hash是一种 string 类型的 field(字段) 和 value(值) 的映射表,特别适合用于存储对象。
Hash的内部编码有两种:ziplist
和hashtable
。
ziplist
:当哈希元素类型的元素个数小于list-max-ziplist-entries
配置(默认512个),同时每个哈希类型元素的值都小于list-max-ziplist-value
(默认64字节)配置时.hashtable
:当哈希类型元素无法满足ziplist
的条件时,Redis会使用hashtable
作为列表的内部实现。
List
List用来存储多个有序的可重复的字符串,一个列表最多可有2^32-1个元素;可以对列表两端插入和弹出,还可以取指定范围的元素列表、获取指定索引下标的元素等。列表是一种比较灵活的数据结构,可以充当栈和队列的角色。 redis的列表相当于Java中的 linkedList,它是链表而不是数组。这意味着list的插入操作和删除操作非常快。
在Redis3.2版本以前List类型的内部编码有两种:ziplist
和linkedlist
。
ziplist(压缩列表)
:当列表的元素个数小于list-max-ziplist-entries
配置(默认512个),同时列表中每个元素的值都小于list-max-ziplist-value
(默认64字节)配置时。linkedlist(链表)
:当列表类型无法满足ziplist
的条件时,Redis会使用linkedlist
作为列表的内部实现。
而在Redis3.2版本开始对List数据结构进行了改造,使用quicklist
代替了ziplist
和linkedlist
。
Set
集合中不允许有重复元素,并且集合中的元素是无序的。一个set集合最多可有2^32-1个元素,支持多个集合的交集、并集、差集。
集合类型的内部编码有两种:intset
和hashtable
。
intset
:当集合中元素都是整数且整数的个数小于set-max-intset-entries
配置(认512个)。hashtable
:当集合中的元素不满足上述条件时。
ZSet
有序集合,不能有重复元素,但元素可以排序,通过给每一个元素设置一个分数作为排序的依据。提供了获取指定分数和元素范围查询、计算成员排名等功能。
有序集合的内部编码有两种:ziplist
和skiplist
。
ziplist
:当有序集合的元素个数小于zset-max-ziplist-entries
配置(默认128个)且每个元素的值小于zset-max-ziplist-value
配置(默认64字节)时。skiplist
:当有序集合的元素不满足上述条件时。
事务
Redis中的事务和通常意义上的事务基本上是一致的。
开启事务
MULTI
命令的执行标记着事务的开始:
redis> MULTI
OK
命令入队
当客户端处于非事务状态下时,所有发送给服务器端的命令都会立即被服务器执行:
redis> SET name "saligialin"
OK
redis> GET name
"saligialin"
但是当客户端进入事务状态之后,服务器在收到来自客户端的命令时,不会立即执行命令,而是将这些命令全部放进一个事务队列里,然后返回QUEUED
,表示命令已入队:
redis> MULTI
OK
redis> SET name "saligialin"
QUEUED
redis> GET name
QUEUED
执行事务
使用EXEC
命令执行事务
redis> MULTI
OK
redis> SET name "saligialin"
QUEUED
redis> GET name
QUEUED
redis> EXEC
1) OK
2) "saligialin"
使用DISCARD
命令取消事务
redis> SET name "saligialin"
QUEUED
redis> MULTI
OK
redis> SET name "username"
QUEUED
redis> DISCARD
OK
redis> GET name
"saligialin"
WATCH/UNWATCH
Redis Watch 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
redis Watch 命令基本语法如下:
WATCH key [key ...]
Redis Unwatch 命令用于取消 WATCH 命令对所有 key 的监视。
redis Watch 命令基本语法如下:
UNWATCH
消息通知
任务队列
任务队列的优点:
- 松耦合:生产者和消费者无需知道彼此实现细节,只要约定好任务的描述格式,这使得生产者和消费者可以由不同的团队使用不同的语言编写。
- 易于扩展:消费者可以有多个,可以分布在不同的服务器上,可以降低单台服务器的负载。
Redis的任务队列实现是基于列表类型的基础上。
如果要实现任务队列,只需要让生产者将任务使用LPUSH
命令加入到某个键中,另一边让消费者不断地会用RPOP
命令从该键从取出任务即可。
redis> LPUSH taskQueue task1
(integer) 1
redis> LPUSH taskQueue task2
(integer) 2
redis> RPOP taskQueue
"task1"
redis> RPOP taskQueue
"task2"
有个问题就是,当队列中没有消息了,消费者在执行PROP
时会返回null
。
redis提供了阻塞式拉取消息的命令:BRPOP / BLPOP
,此处的B指的是阻塞Block。
使用BRPOP
这种阻塞式方式拉取消息时,还支持传入一个超时时间,如果设置为0
,则表示不设置超时,直到有新消息才返回,否则会在指定的超时时间后返回NULL
。
redis> BRPOP taskQueue 0
优先级队列
当一个队列中有许多任务仍然没有来得及被消费者及时消费时,如果出现紧急的消息,则不得不等待队列中的任务被一一取出,因此,需要实现一个优先级队列,当优先级队列不为空时,消费者优先取出优先级队列中的任务去执行。
BLPOP
命令可以同时接收多个键BLPOP key [key ...] timeout
,当所有键(列表类型)都为空时,则阻塞,当其中一个有元素则会从该键返回。
reids> LPUSH queue1 first
(integer) 1
reids> LPUSH queue2 second
(integer) 1
# 当两个键都有元素时,按照从左到右的顺序取第一个键中的一个元素
reids> BRPOP queue1 queue2 0
1) "queue1"
2) "first"
reids> BRPOP queue1 queue2 0
1) "queue2"
2) "second"
发布/订阅模式
包含两种角色,分别是发布者和订阅者。订阅者可以订阅一个或若干个频道(channel),而发布者可以向指定的频道发送消息,所有订阅此频道的订阅者都会收到此消息。
发布者发布消息的命令是PUBLISH
,返回值表示接收到这条消息的订阅者数量。
redis> PUBLISH channel message
(integer) 0
订阅频道的命令是SUBSCRIBE
,可以同时订阅多个频道,执行该命令后客户端会进入订阅状态。
redis> SUBSCRIBE channel [channels]
使用UNSUBSCRIBE
命令可以取消订阅指定的频道,如果不指定频道则会取消订阅所有频道。
redis> UNSUBSCRIBE [channel [channels]]
进入订阅状态后客户端可能受到三种类型的回复。每种类型的回复都包含三个值,第一个值是消息的类型,根据消息类型的不同,后两个值的含义也不同。
subscribe
:表示订阅成功的反馈信息。第二个值是订阅成功的频道名称,第三个值是当前客户端订阅的频道数量message
:表示接收到的消息。第二个值表示产生消息的频道名称,第三个值是消息的内容。unsubscribe
:表示成功取消订阅某个频道。第二个值是对应的频道名称,第三个值是当前客户端订阅的频道数量。
按照规则订阅
使用PSUBSCRIBE
命令订阅指定的规则。
规则支持glob风格通配符格式。
PUNSUBSCRIBE 命令可以退订指定的规则,如果没有参数则会退订所有规则。
PUNSUBSCRIBE [pattern [patterns]]
管道
Redis是一种基于客户端-服务端模型以及请求/响应协议的TCP服务器。 这意味着请求通常按如下步骤处理:
- 客户端发送一个请求到服务器,并以阻塞的方式从socket读取数据,获取服务端响应 。
- 服务端处理请求命令并发送响应回给客户端。
按照这样的描述,每个命令的执行时间 = 客户端发送时间+服务器处理和返回时间+一个网络来回的时间
其中一个网络来回的时间是不固定的,它的决定因素有很多,比如客户端到服务器要经过多少跳,网络是否拥堵等等。但是这个时间的量级也是最大的,也就是说一个命令的完成时间的长度很大程度上取决于网络开销。
Redis Pipelining
为了解决这种问题,redis在很早就支持了管道技术。也就是说客户端可以一次发送多条命令,不用逐条等待命令的返回值,而是到最后一起读取返回结果,这样只需要一次网络开销,速度就会得到明显的提升。
在redis中,如果客户端使用管道发送了多条命令,那么服务器就会将多条命令放入一个队列中,这一操作会消耗一定的内存,所以管道中命令的数量并不是越大越好(太大容易撑爆内存),而是应该有一个合理的值。
持久化
redis是内存数据库,它将自己的数据存储到内存中,然而一旦服务器进程退出,那么数据将会丢失。为了解决这个问题,redis可以通过持久化来将数据持久化到硬盘上。持久化有两种方式:RDB快照和AOF日志。
快照是一次全量备份,AOF日志是连续的增量备份。快照是内存数据的二进制序列化形式,在存储上非常紧凑,而AOF日志记录的是内存数据修改的指令记录文本。
RDB快照
触发机制
手动触发: redis提供了两个命令用于手动生成快照:
SAVE
:命令会使用同步的方式生成RDB快照文件,执行该命令的同时,会阻塞所有其他的客户端请求。不要在生产环境中去使用此命令。BGSAVE
:命令会使用后台的方式保存RDB文件,调用此命令后,会立即返回Background saving started
,同时redis会创建一个子进程来负责生成RDB快照文件,主进程继续执行其他的客户端请求。
自动触发:
redis可以通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次bgsave
命令。
save <seconds> <changes>
# 在 seconds 秒内 完成 changes 次操作
# 当用户设置了多个save的选项配置,只要其中任一条满足,Redis都会触发一次BGSAVE操作
其他自动触发的情况:
- 执行
shutdown
命令关闭服务器时,如果没有开启AOF持久化功能,那么会自动执行一次bgsave
命令。 - 在主从复制场景下,如果从节点执行全量复制操作,则主节点会执行
bgsave
命令,并将RDB文件发送给从节点。
工作流程
- 执行
bgsave
命令的时候,Redis主进程会检查是否有子进程在执行RDB/AOF持久化任务,如果有的话直接返回。 - redis主进程会fork一个子进程来执行执行RDB操作,fork操作会对主进程造成阻塞(子进程不会),fork操作完成后会发消息给主进程,从而不再阻塞主进程。
- 子进程会根据redis主进程的内存生成临时的快照文件,持久化完成后会使用临时快照文件替换掉原来的RDB文件。(该过程中主进程的读写不受影响,但Redis的写操作不会同步到主进程的主内存中,而是会写到一个临时的内存区域作为一个副本)
- 子进程完成RDB持久化后会发消息给主进程,通知RDB持久化完成,把旧的RDB文件替换掉。
优点
- RDB文件是一个很简洁的单文件(保存了某个时间点的redis数据),非常适合定时备份,用于灾难恢复。
- RDB性能很好,需要进行持久化时,主进程会fork一个子进程出来,把持久化的工作交给子进程,主进程本身不会有相关的I/O操作。
- 在数据量大的情况下,redis加载RDB文件的速度优于AOF文件。
缺点
- RDB无法做到实时持久化,若在两次
bgsave
之间宕机,那么这段时间内的数据将会丢失无法记录到RDB文件中。 fork
操作产生子进程进行数据的持久化,在fork操作时,会阻塞服务器。- 存在老版本redis不兼容新版本RDB格式文件的问题。
快照的时候修改数据
快照时需要时间的,如果在某时刻进行快照,而在快照完成前(快照未完成且为快照修改的数据),进行了修改了数据,那么快照记录的则是修改后的数据,而非执行命令时刻的数据。
如果持久化的过程中,有些数据被修改了。那么就会破坏快照的正确性与完整性。
redis借助操作系统提供的写时复制技术,可以在执行快照的同时,正常处理操作。
简单来说,执行bgsave
命令fork子进程的时候,并不会完全复制主进程的内存数据,而是只复制必要的虚拟数据结构,并不为其分配真实的物理空间,它与父进程共享同一个物理内存空间。bgsave子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作,则主线程和 bgsave 子进程相互不影响。但如果主线程要修改一块数据,此时会给子进程分配一块物理内存空间,把要修改的数据复制一份,生成该数据的副本到子进程的物理内存空间。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。
能否频繁进行快照操作
不可以。
- 持久化是一个写入磁盘的过程,频繁地将全量数据写入磁盘,会给磁盘带来很大压力,频繁执行快照也容易导致前一个快照还没有执行完,后一个又开始了,这样多个快照竞争有限的磁盘带宽,容易造成恶性循环。
bgsave
命令fork出来的子进程执行操作虽然并不会阻塞父进程的操作,但是fork
出子进程的操作却是由主进程完成的,会阻塞主进程,fork子进程需要拷贝进程必要的数据结构,这个拷贝过程会消耗大量CPU资源,拷贝完成之前整个进程是会阻塞的,阻塞时间取决于整个实例的内存大小,实例越大,内存页表越大,fork阻塞时间也就越久。
AOF日志
AOF日志是持续增量的备份,是基于写命令存储的可读的文本文件。AOF日志会在持续运行中持续增大,由于Redis重启过程需要优先加载AOF日志进行指令重放以恢复数据,恢复时间会无比漫长。所以需要定期进行AOF重写,对AOF日志进行瘦身。目前AOF是Redis持久化的主流方式。
开启
redis默认开启RDB快照,关闭AOF日志。
把配置项appendonly
设置为yes
即可开启AOF。
appendonly yes
工作流程
- 命令追加(append):redis先将命令追加到AOF缓冲区,而不是写入文件,主要是为了避免每次有写命令时都直接写入硬盘,导致硬盘io称为redis负载的瓶颈。
- 文件写入(write)和文件同步(sync):根据策略将命令同步到文件。reids AOF机制提供了三种回写磁盘的策略:
always
、everysec
、no
。 - 文件重写:定期重写AOF文件,减小AOF文件的体积。需要注意的是:AOF重写是把Redis进程内的数据转化为写命令,同步到新的AOF文件;不会对旧的AOF文件进行任何读取、写入操作。
文件回写三种策略
always
:同步回写。命令写入AOF缓冲区后调用系统fsync
操作同步到AOF文件,fsync
完成后线程返回。everysec
:每秒回写。命令写如AOF缓冲区后调用系统write
操作,write
完成后线程返回。fsync
同步文件操作由专门线程每秒调用一次no
:操作系统自动回写。命令写入AOF缓冲区后调用系统write
操作,不对AOF文件做fsync
同步,同步硬盘操作由操作系统负责,通常同步周期最长30秒。
文件重写
触发
手动:
直接调用bgrewriteaof
命令
redis-cli -h ip -p port bgrewriteaof
自动::根据auto-aof-rewrite-min-size和auto-aof-rewrite-percentage参数确定自动触发时机。
自动触发时机:
(aof_current_size > auto-aof-rewrite-min-size ) && (aof_current_size - aof_base_size) / aof_base_size >= auto-aof-rewrite-percentage
其中:
auto-aof-rewrite-min-size
:表示运行AOF重写时文件最小体积,默认为64MB。auto-aof-rewrite-percentage
:代表当前AOF文件空间和上一次重写后AOF文件空间的值aof_current_size
:当前文件空间的大小。aof_base_size
:重写后文件空间的大小。
重写流程:
- redis调用
fork()
,生成一个子进程。 - 子进程把AOF写到一个临时文件中。
- 主进程持续把新的变动写到内存里的buffer中,同时也会把这些新的变动写到旧的AOF里,这样即使重写失败也能保证数据的安全。
- 当子进程完成文件的重写后,主进程会获得一个信号,然后把内存里的buffer追加到子进程生成的那个新AOF里。
- redis使用新的AOF文件替代旧的AOF文件,完成AOF重写。
AOF文件恢复
在redis服务器重启后,会优先去载入AOF日志文件。因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。
而由于redis命令只能在客户端上下文中执行,redis会创建一个没有网络连接的伪客户端来执行AOF
文件中的内容。
优点
AOF只是追加写日志文件,对服务器性能影响较小,速度比RDB要快,消耗的内存较少。
缺点
- AOF生成的日志文件太大,需要不断进行AOF重写,进行瘦身。
- AOF重演命令式的恢复数据,速度显然比RDB方式慢。
混合持久化
redis 4.0之后,提供了混合持久化的方式:第一次执行快照之后,将后续命令写入AOF文件,直到第二次执行快照。而在第二次执行快照的时候会清除AOF文件的内容,循环往复。
集群
redis集群是redis提供的分布式数据库方案,集群通过分片(sharding)来实现数据共享,并提供复制和故障转移。
redis提供三种集群方案:
- 主从复制模式
- Sentinel(哨兵)模式
- Cluster集群模式
主从复制
虽然持久化可以把数据存储在硬盘中,在服务器重启时可以从硬盘上加载数据,但是当服务器出现硬盘故障时,仍会导致数据丢失。
为了避免单点故障,通常的做法是将数据库复制多个副本以部署在不同的服务器上,这样即使有一台服务器出现故障,其他服务器依然可以继续提供服务。为此, Redis 提供了复制(replication)功能,可以实现当一台数据库中的数据更新后,自动将更新的数据同步到其他数据库上。
在复制的概念中,数据库分为两类,一类是主数据库(master),另一类是从数据库(slave)。主数据库可以进行读写操作,当写操作导致数据变化时会自动将数据同步给从数据库。而从数据库一般是只读的,并接受主数据库同步过来的数据。一个主数据库可以拥有多个从数据库,而一个从数据库只能拥有一个主数据库。
引入主从复制机的目的:
- 读写分离,分担master的读写压力。
- 便于做容灾恢复。
工作机制
- slave 成功启动后,连接 master ,发生
SYNC
命令。 - master 接收到
SYNC
命令后,执行BGSAVE
命令生成RDB文件比通过缓冲区记录在这之后的所有写命令。 - master 的
BGSAVE
执行完成后,向所有 slave 发送RDB快照文件,并在此期间持续记录写命令。 - slave 收到RDB快照后,丢弃旧数据,通过快照载入新数据。
- master 发送完快照后,开始执行缓冲区中记录的命令。
- slave 完成RDB快照的载入,开始接收命令请求,并执行来自 master 缓冲区的写名。
- master 每执行一个写命令就像 slave 发送同样的命令, slave接受并执行命令。
- 断开重连后,redis会将断线期间的命令传给 slave数据库,增量复制。
- master 与 slave 刚连接时,进行全量同步;全量同步结束后,进行增量同步。在需要时,slave 任何时候都可以发起全量同步。 redis 的策略是:无论如何,首先尝试进行增量同步,不成功则进行全量同步。
优点
- master 能自动将数据同步到 slave, 可以进行读写分离,分担 master 的读压力。
- master 和 slave 之间的同步是以非阻塞的方式进行的,同步期间,客户端仍然可以发起查询和更新请求。
- slave 同样可以接收其他 slave 的连接和同步请求,这样可以有效分担 master 的压力。
缺点
- 不具备自动容错和恢复功能,master 或者 slave 的宕机都可能导致客户端请求失败,需要等待机器重启或手的切换客户端IP才能恢复(即需要人工介入)。
- master 宕机,如果宕机前数据同步未完成,则切换IP后可能会导致数据不一致。
- 多个 slave 重启时,可能会导致 master 的I/O负担剧增从而导致宕机。
- redis难以支持在线扩容,在集群容量达到上限时扩容会变得复杂。
Sentinel(哨兵)模式
由上文可知,主从复制模式的缺点之一就是主服务器宕机后,需要人工干预才能使整个集群恢复正常,费事费力,还会造成一段时间内服务不可用。
哨兵模式基于主从复制模式,只是引入了哨兵来监控与自动处理故障。 哨兵是一个独立的进程,作为进程,它会独立运行。其原理是哨兵通过发送命令,等待redis服务器响应,从而监控运行的多个 redis 实例。
作用:
- 监控 master、slave 是否正常运行。
- 当 master 出现故障时,能自动将一个 slave 转换为 master。
- 多个哨兵可以监控同一个Redis,哨兵之间也会自动监控。
工作机制
- 哨兵启动后,会与要监控的master建立两条连接:
- 一条连接用来订阅master的
_sentinel_:hello
频道与获取其他监控该 master 的哨兵节点信息。 - 另一条连接定期向 master 发送
INFO
等命令获取master本身的信息。
- 一条连接用来订阅master的
- 在一般情况下,每个 sentinel 进程会以每10秒一次的频率向所有 master 和 slave 发送
INFO
命令。 - 每个 sentinel 进程以每秒钟一次的频率向整个集群中的 master、slaves 以及其他 sentinels 进程发送一个
PING
命令。(保持联系) - 如果一个 master 距离最后一次有效回复
PING
命令的时间超过down-after-milliseconds
选项所指定的值,则这个实例会被 sentinel 进程标记为主观下线(SDOWN)。(疑似失联) - 如果 master 被标记为主观下线,则监视这个 master 的所有 sentinel 要以每秒一次的频率确认 master 的确进入了主观下线状态。(大家一起联系TA)
- 当足够多(大于等于配置文件指定的值)的 sentinel 进程确认 master 在指定时间范围内进入主观下线状态,则 master 会被标记为客观下线(ODOWN)。(大家都找不到,确认失联)
不足够多的 sentinel 确认时,master 的客观下线状态会被移除。若 master 向 sentinel 发送PING
命令的有效回复,则 master 的主观下线状态会被移除。(有人找到了,告诉了大家,联系上后确认没失联) - 当 master 被标记为客观下线之后,sentinel 进程向该 master 所有的 slave 发送
INFO
命令的频率变为每秒一次。 - sentinel 会开始一次自动故障迁移操作, 它会将失效 master 的一个 slave 升级为新的 master, 并让 master 的其他 slavve 改为复制新的master。(寻找一个人代替失联的人工作,并进行交接仪式)
- 当客户端试图连接失效的 master 时,集群也会向客户端返回新 master 的地址,使得集群可以使用新 master 代替失效的 master 。(客户无法联系到那人,给他新的与他对接的人联系方式)
优点
- 哨兵模式基于主从模式,同样具有主从模式的优点。
- 可以自主切换主从服务器,系统更加健壮,可用性更高。
缺点
- 在集群容量达到上限时扩容会变得复杂。
- 需要额外的资源来启动 sentinel 进程,实现相对复杂一点。
Cluster集群模式
Redis Cluster是一种服务器 Sharding 技术,从redis 3.0版本开始正式提供。
优于哨兵模式下的每台redis服务器都存储相同的数据,浪费内存。而cluster集群模式实现了redis的分布式存储,即每台redis节点上存储不同的内容。
Cluster采用无中心结构,它的特点如下:
- 所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽
- 节点的fail是通过集群中超过半数的节点检测失效时才生效。
- 客户端与redis节点直连,不需要中间代理层.客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
工作机制
- redis的每个节点上,都有一个哈希槽(hash slot),共16384个,其取值范围为0-16383。
- 每次存取key时,redis会根据
CRC16
算法得到一个结果,然后把这个结果对16384取余数,这样每个key都会对应一个编号在0-16383之间的哈希槽。通过这个值,去寻找对应的哈希槽对应的节点,然后直接自动跳转到这个节点上进行存储操作。 - cluster 模式也引入了主从复制模式,一个主节点对应一个或多个从节点,当主节点宕机时,就会启用从节点。
- 当其他主节点ping某个主节点时,如果半数以上的主节点都无法与该节点有效通信,那么认为该主节点宕机了。如果该主节点和它的从节点都宕机了,那么该集群就无法提供服务了。