前言
今天我们来说说Redis为什么高性能?如何做高可用?
Redis为什么这么快?
Redis是单线程的,避免了多线程的上下文切换和并发控制开销;Redis大部分操作时基于内存,读写数据不需要磁盘I/O,所以速度非常快;Redis采用了I/O多路复用机制,提高了网络I/O并发性;Redis提供高效的数据结构,如跳跃表、哈希表等;基础数据结构
SDS
Redis的简单动态字符串SDS是可变的,遵循C字符串以1字节空字符结尾,最大长度为512M。
SDS为什么使用1字节空字符结尾呢? 使用1字节空字符结尾可重用C字符串的部分函数。
结构定义
SDS底层使用一个字节数组保存字符串内容,通过len属性可O(1)的复杂度获取字符串长度。
struct sdshdr{
//字符串长度,即buf[]已使用字节数
int len;
//buf[]剩余字节数
int free;
//字节数组,用于保存字符串内容
char buf[];
};
内存分配策略
SDS采用空间预分配和惰性空间释放来优化SDS的内存分配次数(n次 → 最多n次)。
空间预分配
空间预分配用于优化字符串的增长操作。当修改SDS需要扩展内存空间时,不仅会分配所需的空间,还会根据len属性分配额外的未使用空间。
如果修改SDS后,len < 1MB,将分配和len同样大小的未使用空间;如果修改SDS后,len > 1MB,将分配1MB的未使用空间;惰性空间释放
惰性空间释放用于优化SDS缩短操作。当缩短SDS时,程序不立即回收未使用的空间,使用free记录未使用空间长度,等待将来使用。(也可调用函数手动释放空间)
SDS和C字符串的区别获取字符串长度C字符串需要遍历整个字符串计数统计长度,时间复杂度为O(n);SDS只需要获取sdshdr.len即可,时间复杂度为O(1);缓冲区溢出C字符串不记录自身长度,在进行修改时如果没有分配足够的内存可能造成缓冲区溢出;SDS在修改时会先根据sdshdr.free属性校验内存是否足够,如果不够会先进行扩容,再执行修改操作;二进制安全C字符串除末尾之外不能包含空字符,否则最先被读入的空字符会被误认为是字符串结尾;(所以C字符串只能保存文本数据,不能用于保存二进制数据)SDS通过sdshdr.len判断字符串是否结束,可以用于保存二进制数据。内存分配次数C字符串每次修改操作都需要进行内存重分配;SDS需要最多n次内存重分配;
链表
特点双向链表:获取某个节点的前驱节点和后继节点复杂度O(1);无环:头节点前驱指针和尾节点后继指针指向NULL;插入和删除快,时间复杂度O(1);查找慢,时间复杂度O(n);结构定义
节点定义:
typedef struct listNode{
//前驱节点指针
struct listNode *prev;
//后继节点指针
struct listNode *next;
//节点值
void *value;
}listNode;
链表定义:
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;
字典
特点
字典用于保存键值对,包含的每个键都是唯一的。
结构定义
字典ht[2]是一个包含两个哈希表的数组,一般情况下只使用ht[0],只有在rehash时会使用ht[1]。dict.rehashidx记录目前rehash进度。
typedef struct dict {
//特定类型操作函数
dictType *type;
//私有数据(传给特定类型操作函数的参数)
void *privdata;
//哈希表
dictht ht[2];
//rehash索引,没有在进行rehash时rehashidx=-1
long rehashidx;
} dict;
哈希表底层基于一个dictEntry数组实现,每一项保存一个键值对,哈希到同一个数组项的节点通过next连接。
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于哈希函数计算索引值,sizemask=size-1
unsigned long sizemask;
//已有节点数量
unsigned long used;
} dictht;
typedef struct dictEntry {
//键
void *key;
//值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//下一个节点
struct dictEntry *next;
} dictEntry;
哈希过程根据哈希函数计算键值对键的哈希值;根据哈希值对哈希表掩码dictht.sizemask取模计算索引值;根据索引值将键值对放入哈希表数组的对应索引位置上;
如何解决哈希冲突? 使用链地址法解决哈希冲突。每个哈希表节点dictEntry都保存一个next指针,得分配到同一个数组项的节点通过next指针连接成一个单向链表。
rehash过程为ht[1]分配空间:如果是扩容操作,ht[1]大小等于第一个≥ht[0].used*2的2n;如果是收缩操作,ht[1]大小等于第一个≥ht[0].used的2n;将ht[0]上的所有键值对rehash(重新计算键的哈希值和索引值)后放入ht[1];——渐进式rehash:rehash期间每次对字典执行增/删/改/查操作都会将ht[0]在rehashidx索引上的所有键值对rehash到ht[1],rehashidx++;当ht[0]上的所有键值对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],新建一个ht[1]为下次rehash做准备;
1. 什么时候会触发rehash? 根据哈希表的负载因子(dictht.used/dictht.size),在哈希表过大或过小时会触发rehash:
当服务器没有在执行BGSAVE/BGWRITEAOF命令时,哈希表的负载因子≥1;当服务器正在执行BGSAVE/BGWRITEAOF命令时,哈希表的负载因子≥5;
2. 渐进式rehash的好处 将rehash操作分摊到字典的每个增/删/改/查操作上,避免集中rehash带来庞大的计算量而导致服务器停顿。
3. rehash过程中的查找/插入操作
查找:在rehash过程中会同时使用ht[0]和ht[1],如果要查找某个key,会先在ht[0]中查找,如果没找到,继续在ht[1]中查找。插入:在rehash过程中新增的键值对会被保存到ht[1],保证了ht[0]的键值对数量只减不增。
跳跃表
特点
跳跃表基于分值从小到大排序,查找的过程近似二分查找。
结构定义
跳跃表基于有序链表实现,通过在链表的基础上增加多级索引提升查找的效率。跳跃表每一层都是一个链表,最底层链表包含所有元素,链表的每个节点包含2个指针,一个forward指针指向同一链表中该节点的下一个节点,一个backward指向同一链表中该节点的前一个节点。
typedef struct zskiplist {
//头节点、尾节点
struct zskiplistNode *header, *tail;
//跳跃表长度(包含的节点数量)
unsigned long length;
//跳跃表内层数最大的节点的层数
int level;
} zskiplist;
typedef struct zskiplistNode {
//对象,唯一
robj *obj;
//分值,跳跃表根据分值从小到大排列
double score;
//后退指针,指向当前节点的上一个节点,用于从表尾遍历
struct zskiplistNode *backward;
//层级数组
struct zskiplistLevel {
//前进指针,用于从表头遍历
struct zskiplistNode *forward;
//前进指针指向的节点和当前节点的跨度
unsigned int span;
} level[];
} zskiplistNode;
操作过程查找:从当前最高的level开始,向前查找,如果当前节点的score小于插入节点的score,继续查找下一个节点;反之,则往下一层继续查找(类似于二分查找)。
插入:从当前最高的level开始,向前查找,如果当前节点的score小于插入节点的score,继续查找下一个节点;反之,则往下一层继续查找,直到第一层为止。跳跃表插入操作的时间复杂度是O(logN)。删除:自上而下查找第一次出现该节点的索引,并逐层找到每一层对应的节点。删除每一层查找到的节点,如果该层只剩下1个节点,删除整个一层(原链表除外)。跳跃表删除操作的时间复杂度是O(logN)。
整数集合
特点
整数集合可以去重且有序的保存整数值。
整数集合通过二分查找法获取元素查找和插入位置定位。
结构定义
整数集合可以保存16位、32位、64位的整数,集合中整数的类型取决于encoding属性。
typedef struct intset {
//编码方式:int16_t、int32_t、int64_t
uint32_t encoding;
//元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
类型升级
当插入的新元素的类型比整数集合现有的元素类型长,会触发类型升级。
类型升级的过程:
根据新元素类型扩展整数集合底层数组intset.contents空间大小,并为新元素分配空间;将现有的元素转换成与新元素相同的类型,并有序放到数组正确的位置;将新元素添加到数组;
整数集合通过类型升级操作提升了集合的灵活性,不用担心出现类型错误,而且升级操作只会在需要的时候进行,节约内存。
整数集合不支持类型降级操作。
压缩列表
特点
压缩列表是顺序存储的数据结构,为了节约内存而开发的。
结构定义
一个压缩列表可以包含任意个节点,每个节点可以包含一个字节数组或一个整数值。
zlbytes:记录压缩列表大小;zltail:尾节点偏移量(通过zltail可以确定尾节点位置);zllen:记录压缩列表节点数量;entry:压缩列表节点;zlend:标记压缩列表末端;
previour_entry_length:记录前一个节点所占字节数(可以根据当前节点起始地址+previour_entry_length计算前一个节点的起始地址,实现从表尾向表头遍历);如果前一个节点<254字节,previour_entry_length=1字节;如果前一个节点>254字节,previour_entry_length≥5字节;encoding:content数据类型和长度;content:节点值,可以是一个字节数组或整数值;连锁更新
压缩列表的每个节点previour_entry_length属性都记录了前一个节点的长度,前一个节点大小变化(增/删/改)可能会导致previour_entry_length需要进行内存重分配(1字节→5字节或5字节→1字节),可能产生连锁反应,导致多个节点的previour_entry_length都需要进行内存重分配。
基础数据类型
Redis使用基本数据结构实现了对象系统,包含字符串对象、列表对象、哈希对象、集合对象、有序集合对象五种类型。可以根据使用场景选择对象的编码,提高Redis的灵活性和效率。
typedef struct redisObject {
//对象类型(字符串、列表、哈希表、集合、有序集合)
unsigned type:4;
//对象编码
unsigned encoding:4;
//记录对象最后一次被访问的时间,用于计算对象的空转时长
unsigned lru:REDIS_LRU_BITS;
//对象引用计数,用于内存回收和对象共享
int refcount;
//指向底层实现数据结构
void *ptr;
} robj;
在执行命令时,Redis根据对象类型判断该对象是否能执行执行的命令,通过获取redisObject.type实现。
字符串(string)
Redis字符串是可变的,基于SDS实现。
在Redis中,long、double类型的浮点数会先转为字符串再进行保存,读取时再将字符串转为浮点数。
编码int:long型整数;raw:长度>32字节的字符串;embstr:长度<32字节的字符串,只读;
raw编码会调用2次内存分配函数来创建redisObject结构和sdshdr结构,embstr编码只调用一次内存分配函数分配一块连续的内存空间,空间中一次包含redisObject结构和sdshdr结构。
编码转换int编码的字符串保存的不再是整数值,int → raw;修改embstr编码的字符串,embstr → raw;命令
127.0.0.1:6379> set name1 java # 添加
OK
127.0.0.1:6379> mset name2 c name3 go # 批量添加
OK
127.0.0.1:6379> keys * # 获取所有key
1) "name3"
2) "name2"
3) "name1"
127.0.0.1:6379> mget name1 name3 # 批量获取
1) "java"
2) "go"
127.0.0.1:6379> set a 1
OK
127.0.0.1:6379> incr a # 自增1
(integer) 2
127.0.0.1:6379> incrby a 10 # 自增10
(integer) 12
127.0.0.1:6379> decr a # 自减1
(integer) 11
127.0.0.1:6379> decrby a 5 # 自减5
(integer) 6
列表(list)
编码ziplist:使用压缩列表实现,每个节点保存一个列表元素;
linkedlist:使用双向链表实现,每个节点保存一个字符串类型的列表元素;
列表所有字符串元素长度<64字节且列表包含的元素数量<512个时使用ziplist编码,否则使用linkedlist编码。
命令
127.0.0.1:6379> rpush books java c go python 添加
(integer) 4
127.0.0.1:6379> llen books # 获取长度
(integer) 4
127.0.0.1:6379> lpop books # 出队(先进先出)
"java"
127.0.0.1:6379> rpop books # 出栈(后进先出)
"python"
127.0.0.1:6379> lrange books 0 -1 # 获取所有元素
1) "c"
2) "go"
127.0.0.1:6379> lindex books 1 # 获取索引为1的元素
"go"
哈希表(hash)
编码ziplist:使用压缩列表实现,同一个键值对的两个节点紧挨在一起,键在前,值在后;
hashtable:使用字典实现;
编码转换
哈希表所有键和值得字符串长度<64字节且哈希表包含的键值对数量<512个时使用ziplist编码,否则使用hashtable编码。
命令
127.0.0.1:6379> hset books book1 java # 添加
(integer) 1
127.0.0.1:6379> hmset books book2 go book3 c # 批量添加
OK
127.0.0.1:6379> hgetall books # 获取所有键值对
1) "book1"
2) "java"
3) "book2"
4) "go"
5) "book3"
6) "c"
127.0.0.1:6379> hlen books # 获取长度
(integer) 3
集合(set)
编码intset:使用整数集合实现;
hashtable:使用字典实现,每个键都是字符串类型的集合元素,值为NULL;
编码转换
集合所有元素都是整数值且集合包含的元素数量<512个时使用intset编码,否则使用hashtable编码。
命令
127.0.0.1:6379> sadd books c # 添加
(integer) 1
127.0.0.1:6379> sadd books java go python # 批量添加
(integer) 3
127.0.0.1:6379> smembers books # 获取所有元素(无序,并不会和插入顺序一致)
1) "java"
2) "python"
3) "go"
4) "c"
127.0.0.1:6379> sismember books html # 查询某个元素是否存在
(integer) 0
127.0.0.1:6379> sismember books java
(integer) 1
127.0.0.1:6379> scard books # 获取长度
(integer) 4
127.0.0.1:6379> spop books # 弹出一个元素
"java"
有序集合(zset)
编码ziplist:使用压缩列表,每个集合元素使用两个紧挨在一起的节点保存,第一个节点保存元素,第二个节点保存元素的分值;
skiplist:使用zset结构实现,一个zset结构包含一个跳跃表和一个字典,跳跃表和字典中的元素通过指针共享对象。跳跃表按分值从小到大保存所有元素,通过跳跃表可以对有序集合进行范围操作;字典实现对象到分值的映射,字典中的每个键值对都保存了一个集合元素,实现O(1)复杂度查找对象分值;
编码转换
有序集合所有元素长度<54字节且包含的元素数量<128个时使用ziplist编码,否则使用skpilist编码。
命令
127.0.0.1:6379> zadd books 5 java # 添加
(integer) 1
127.0.0.1:6379> zadd books 2 c
(integer) 1
127.0.0.1:6379> zadd books 6 go
(integer) 1
127.0.0.1:6379> zadd books 9 html
(integer) 1
127.0.0.1:6379> zrange books 0 10 # 获取正序排名[0,10]的元素
1) "c"
2) "java"
3) "go"
4) "html"
127.0.0.1:6379> zrange books 2 3 # 获取正序排名[2,3]的元素
1) "go"
2) "html"
127.0.0.1:6379> zrevrange books 0 -1 # 获取逆序排名的所有元素
1) "html"
2) "go"
3) "java"
4) "c"
127.0.0.1:6379> zcard books #获取长度
(integer) 4
127.0.0.1:6379> zscore books java # 获取指定元素的分值
"5"
127.0.0.1:6379> zrangebyscore books 3 7 #获取分值[3,7]的元素
1) "java"
2) "go"
127.0.0.1:6379> zrem books c # 删除元素
(integer) 1
127.0.0.1:6379> zrange books 0 -1 # 获取所有元素
1) "java"
2) "go"
3) "html"
总结
特殊数据类型
Geo
Redis 3.2版本以后,提供了基于geohash和有序集合实现地理位置相关功能的Geo模块。
实现原理
Geo基于geohash和有序集合实现,使用有序集合保存位置对象,分值为位置对象经纬度对应的52为geohash值。
命令
127.0.0.1:6379> geoadd users:locations 120.346111 31.556381 user1 120.375821 31.5603682 user2 # 添加地理位置信息
(integer) 2
127.0.0.1:6379> geopos users:locations user1 # 获取user1地理位置
1) 1) "120.34611314535140991"
2) "31.55637987511895659"
127.0.0.1:6379> geodist users:locations user1 user2 m # 计算user1和user2位置距离(m:距离以米为单位)
"2850.3519"
127.0.0.1:6379> georadius users:locations 120.375821 31.556381 5 km withcoord withdist withhash asc count 100 #获取指定范围5km范围以内最多100个元素(asc:按距离从近到远排序,desc:按距离从远到近排序,count 100:最多返回100个元素)
1) 1) "user2"
2) "0.4433" # withdist:距离目标位置的距离
3) (integer) 4054421167795118
4) 1) "120.37582129240036011" # withcoord :元素的经纬度
2) "31.5603669915025975"
2) 1) "user1"
2) "2.8157"
3) (integer) 4054421060663027
4) 1) "120.34611314535140991"
2) "31.55637987511895659"
127.0.0.1:6379> georadiusbymember users:locations user1 5 km withcoord withdist withhash asc count 100 # 获取指定元素5km范围以内最多100个元素
1) 1) "user1"
2) "0.0000"
3) (integer) 4054421060663027
4) 1) "120.34611314535140991"
2) "31.55637987511895659"
2) 1) "user2"
2) "2.8504"
3) (integer) 4054421167795118
4) 1) "120.37582129240036011"
2) "31.5603669915025975"
应用场景查找附近的人;
HyperLogLog
HyperLogLog是一种概率数据结构,提供不精确的去重计数方案,标准误差是0.81%。
HyperLogLog和set的区别set存储统计元素,所以元素较多时需要占据大量内存空间;HyperLogLog不存储统计元素,仅存储存在的标记,最多只需要12K内存空间;命令
127.0.0.1:6379> pfadd users1 user1 user3 user5 user7 # 添加
(integer) 1
127.0.0.1:6379> pfcount users1 # 统计users1
(integer) 4
127.0.0.1:6379> pfadd users2 user1 user2 user4
(integer) 1
127.0.0.1:6379> pfcount users2
(integer) 3
127.0.0.1:6379> pfmerge users1 users2 # 将users1和users2合并累加计数,形成新的users1
OK
127.0.0.1:6379> pfcount users1
(integer) 6
应用场景统计日/月活用户:每天使用一个HyperLogLog记录日活,使用用户id作为标识,合并当月每天HyperLogLog即可得到月活;统计网页访客UV:使用cookie作为标识,相同的客户端访问最多只计数1次;
布隆过滤器
布隆过滤器是一种数据结构。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。
实现原理
布隆过滤器基于一个位数组+多个哈希函数实现。
插入 向布隆过滤器中添加key时,首先使用多个哈希函数对key进行hash得到一个哈希值,然后再将各个哈希值对位数组长度进行取模运算得到索引值,每个hash函数都会算得一个不同的索引值,再将位数组对应索引位置都置为1。查找 向布隆过滤器查找某个key是否存在时,首先使用多个哈希函数对key进行hash得到一个哈希值,然后再将各个哈希值对位数组长度进行取模运算得到索引值,每个hash函数都会算得一个不同的索引值,判断位数组中这几个索引位置的值是否都为 1,只要有一个位为 0,那么说明这个key不存在,如果都为1,并不说明这个key就一定存在,只是极有可能存在,因为这些位被置为1可能是因为其它的key存在所致。应用场景
1.过滤垃圾邮件; 2.URL去重; 3.防止缓存穿透;
持久化
Redis的持久化方式主要有2种:RDB持久化和AOF持久化。
RDB持久化
RDB持久化可手动执行,也可根据服务器定期执行bgsave。
实现原理
RDB持久化将某个时间点上的数据保存到RDB文件中(快照),再将RDB文件保存到磁盘。在服务器启动时,可以通过载入RDB文件还原数据。
服务器启动时会自动执行RDB文件的载入(RDB文件存在且未开启AOF功能),载入过程中服务器处于阻塞等待状态。
可以通过save/bgsave命令执行RDB持久化操作。
save命令
执行save命令会使Redis服务器进程会阻塞等待直到RDB文件创建完毕,在服务器进程阻塞期间,服务器不能处理任何命令请求。
bgsave命令
bgsave命令会派生出一个Redis服务器进程的子进程,由子进程负责创建RDB文件,当RDB创建完成后,子进程向父进程发送信息,服务器进程在此期间可以继续处理命令请求并轮询等待子进程的信号。
1. 为什么bgsave要使用子进程而不是用线程? 利用操作系统的写时复制机制,避免不必要的内存拷贝。
2. 自动执行bgsave 服务器会根据配置的save选项(保存条件)定期执行bgsave,save选项的内容以【秒数-修改数】格式保存在redisServer.saveparam数组中。每次调用serverCron()函数时会检查save选项保存的条件是否满足(满足save选项的任一条件),如果满足就执行bgsave命令。
根据redisServer.dirty(上一次成功执行save/bgsave命令后服务器数据执行了多少次修改)获取修改次数;根据redisServer.lastsave(上一次成功执行save/bgsave命令的时间)获取间隔时间。
RDB文件结构
REDIS:RDB文件的开头是一个5字节的“REDIS”字符串,在服务器启动载入文件时校验是否为RDB文件;db_version:4字节的字符串整数,记录RDB文件的版本号,如006代表RDB文件的版本为第6版;database:各个数据库保存的键值对数据;
SELECTDB:1字节,标记接下来要读取的是数据库号码;db_number:数据库号码,读入db_number后会执行select命令切换数据库;key_value_pairs:数据库中所有键值对,未过期键会记录键类型、键、值,过期键会额外保存过期键标志、过期时间;EOF:1字节,标志RDB文件正文内容结束。check_sum:8字节无符号整数的校验和,通过REDIS+db_version+database+EOF计算得出,在载入RDB文件时用于校验RDB文件是否损坏。
AOF持久化
AOF持久化以Redis命令请求协议的格式(纯文本)保存服务器执行的写命令,在服务器启动时,可以通过执行AOF文件中的写命令还原数据。
执行过程命令追加:服务器每执行完一个写命令,会将写命令追加到redisServer.aof_buf缓冲区(字符串)末尾;文件写入:每次执行serverCron()函数时将redisServer.aof_buf缓冲区中的数据写入AOF文件中;文件同步:根据服务器配置的持久化行为判断是否需要AOF文件刷入磁盘;always:将redisServer.aof_buf缓冲区中所有数据写入AOF文件并将AOF文件刷入磁盘。最慢,但是安全性最高;everysec:默认值,将redisServer.aof_buf缓冲区中所有数据写入AOF文件,如果距离上一次将AOF文件刷入磁盘时间超过1s,则再次将AOF文件刷入磁盘。较快,但是出现故障会丢失1s的命令数据;no:将redisServer.aof_buf缓冲区中所有数据写入AOF文件,由操作系统决定何时将AOF文件刷入磁盘。最快,但是出现故障会丢失上次AOF文件同步之后的数据;
在操作系统中,write()系统调用先将写入的数据保存在内核缓冲区中,再刷入磁盘。
如何载入AOF文件
服务器启动载入AOF文件时,只需要将AOF文件中的所有命令执行一遍就可以还原数据。
创建一个不带网络连接的伪客户端(Redis命令只能在客户端上下文中执行);通过伪客户端执行AOF文件中的命令;AOF重写
服务器运行的越久,AOF文件中的内容将会越多,文件体积越来越大,使用AOF文件还原数据耗时也就越长,通过AOF重写能解决AOF文件膨胀的问题。
什么是AOF重写?
Redis会创建一个新的AOF文件替代现有的AOF文件,新旧两个AOF文件保存的数据相同,但是新的AOF文件中不包含冗余命令,所以新的AOF文件通常会比旧的AOF文件体积小很多。
AOF重写过程
AOF重写不需要操作当前AOF文件。
rewriteaof命令会派生出一个Redis服务器进程的子进程,由子进程负责读取服务器当前数据,并为每个键值对生成一条命令写入新的AOF文件中;服务器会将在子进程重写期间执行的写命令保存到AOF重写缓冲区redisServer.aof_rewrite_buf_blocks中,也就是在AOF重写期间服务器每执行一个写命令,都需要保存进AOF缓冲区和AOF重写缓冲区;当子进程完成AOF重写后,向父进程发送一个信号;父进程接收到子进程的信号,将AOF重写缓冲区取中的所有内容添加到新的AOF文件中,并对新的AOF文件改名,覆盖现有的AOF文件;(这个过程父进程是阻塞不接收客户端请求的)
比较AOF和RDB
RDB保存某一刻的数据快照,全量备份;RDB的bgsave命令通过多进程的写时复制机制来实现持久化;AOF通过缓冲区保存写命令后再写入文件,增量备份;AOF更新频率高于RDB,如果服务器开启了AOF,会优先使用AOF文件还原数据;继续
Redis 中采用了以下两种方式实现高可用:
主从复制;采用Sentinel机制监控节点的运行情况,一旦主节点出现问题将由从节点顶上继续提供服务;主从复制
Redis主从复制是异步同步的,主从复制双方都保存相同的数据,保证最终一致性。
复制过程
slaveof :通过向从服务器发送slaveof命令,可以让该从服务器去复制一个主服务器。从服务器会将master_ip和master_port保存在redisServer结构中。从服务器根据master_ip和master_port创建连向主服务器的连接,连接成功从服务器将创建一个文件事件处理器专门用于处理该socket。主服务器接受从服务器的连接请求后,为该socket创建相应的客户端状态。从服务器向主服务器发送ping命令,检查socket是否正常、主服务器能否正常处理请求。主服务器回复ping命令。从服务器根据配置的masterauth选项决定是否进行身份验证,如果需要进行身份验证,将向主服务器发送一条auth命令,参数为masterauth选择的值。从服务器向主服务器发送监听端口号。主服务器收到从服务器的监听端口号后记录在从服务器对应客户端状态中。从服务器向主服务器发送psync命令,执行同步操作。主服务器进入命令传播阶段,将执行的写命令发送个从服务器,从服务器只要一直接收并执行主服务器发送的写命令。
主从复制步骤
同步命令传播
主从同步过程
主从同步可以通过sync/psync命令实现。
sync命令
Redis 2.8之前使用sync命令进行主从复制,sync命令执行的是全量同步。
从服务器通过sync命令请求同步主服务器数据过程:
从服务器向主服务器发送sync命令;主服务器收到sync命令,派生一个子进程执行bgsave生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令;当bgsave执行完毕,主服务器将生成的RDB文件发送给从服务器;从服务器接收并载入RDB文件;主服务器将缓冲区中的所有写命令发送给从服务器;从服务器执行写命令;
sync命令的缺点
每次sync命令主服务器都需要执行bgsave,消耗主服务器大量CPU、内存、磁盘I/O资源;主服务器向从服务器发送RDB文件消耗主从服务器大量的网络资源;从服务器载入RDB期间是阻塞操作,无法处理请求;
psync命令
Redis 2.8之后使用psync命令进行主从复制,psync命令分为完整重同步和部分重同步两种方式。
psync命令解决了sync命令每次都需要全量同步的问题。
完整从同步
完整重同步用于初次复制,执行步骤同sync命令。
部分重同步
部分重同步用于处理中断后重新同步。当从服务器重连主服务器时,主服务器只发送从服务器断开期间的所有写命令(sync命令需要发送整个RDB文件)。
部分重同步实现原理
服务器运行id
当从服务器对主服务器进行初次复制时,主服务器将自己的运行id发送给从服务器,从服务器会将该运行id保存,当从服务器重新连上主服务器时,向主服务器发送保存的运行id,主服务器根据该运行id校验从服务器中断之前连接的主服务器是否是自己,如果是,则执行部分重同步操作,如果不是,则执行完整重同步操作。
主/从服务器复制偏移量
主从复制的双方都会维护一个复制偏移量,通过这2个偏移量可以校验主从同步是否一致。
主服务器每次向从服务器发送n个字节数据,就将自己的复制偏移量+n;从服务器每次收到主服务器发送的n个字节数据,就将自己的复制偏移量+n;
当从服务器重新连接上主服务器时,从服务器通过psync命令将自己的复制偏移量发送给主服务器,主服务器查找该偏移量之后的数据是否还存在复制积压缓冲区,如果存在,那么主服务器将对从服务器执行部分重同步操作,如果不存在,则执行完整重同步操作。
主服务器复制积压缓冲区
固定长度的先进先出队列,默认大小为1MB,保存最近发送的命令。当主服务器向从服务器发送命令时,也会将命令发向复制积压缓冲区。
Sentinel机制
Sentinel是Redis的高可用解决方案,本质上是一个运行在特殊模式下的Redis服务器。
由一个或多个Sentinel实例组成的Sentinel系统可以监视任意多个主服务器,以及这些主服务器的从服务器,并在被监视的主服务器下线时,自动将该主服务器的某个从服务器升级为主服务器,由新的主服务器代替已下线的主服务器处理命令请求。
Sentinel作用
集群监控:负责监控主服务器和从服务器进程是否正常工作;消息通知:如果某个Redis实例有故障,那么Sentinel负责发送消息作为报警通知给管理员;故障转移:如果主服务器挂掉了,会自动转移到从服务器上;配置中心:如果故障转移发生了,通知客户端新的主服务器地址;
初始化Sentinel
初始化一个普通Redis服务器;将普通Redis服务器使用的一部分代码替换成Sentinel专用代码,如使用的端口号、命令表等;初始化Sentinel状态sentinelState结构;根据配置文件初始化Sentinel的监视主服务器列表的sentinelState.masters字典,key为主服务器名称,key为主服务器对应的sentinelRedisInstance结构(sentinelRedisInstance结构会保存该服务器对应的IP地址和端口号),每个sentinelRedisInstance结构代表一个被Sentinel监视的Redis服务器实例(可以是主服务器、从服务器、Sentinel);创建向主服务器的异步网络连接,Sentinel将成为主服务器的客户端,向主服务器发送命令获取相关的信息;命令连接:用于发送/接收命令;订阅连接:用于订阅主服务器的sentinel:hello频道;
Sentinel如何监控节点?
获取主服务器信息
Sentinel默认会每10秒一次的频率通过命令连接向被监视的主服务器发送info命令获取主服务器当前信息:
主服务器自身信息,如运行id等;主服务器下的所有从服务器信息,如IP地址、端口号等, sentinelRedisInstance.slaves使用一个字典结构保存该主服务器下的从服务器,key为ip:port,value为从服务器对应的sentinelRedisInstance结构,Sentinel根据这些信息自动发现从服务器并监视;获取从服务器信息
当Sentinel发现主服务器有新的从服务器时,Sentinel会为这个从服务器创建相应的sentinelRedisInstance结构,还会创建连接到从服务器的命令连接和订阅连接。创建命令连接后,Sentinel默认会每10秒一次的频率通过命令连接向从服务器发送info命令获取信息,如运行id、主服务器IP地址、主服务器端口号等。
Sentinel如何确认节点故障?
主观下线
在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例发送ping命令,通过ping命令的回复判断对方是否在线。
Sentinel根据配置的down-after-millseconds选项指定Sentinel判断实例进入主观下线需要的时间:如果一个实例在down-after-millseconds时间内连续向Sentinel返回无效回复,那么Sentinel就会将该实例sentinelRedisInstance.flags的SRI_S_DOWN标识打开,标识该实例已进入主观下线状态。
客观下线
当Sentinel将一个主服务器判定为主观下线后,它会向一同监视该主服务器的其他Sentinel发送命令询问,看它们是否也认为该主服务器为下线状态(主观/客观下线),当Sentinel收到超过配置的quorum值的已下线判断,Sentinel会将该主服务器判定为客观下线,将该主服务器sentinelRedisInstance.flags的SRI_O_DOWN标识打开,标识该服务器进入客观下线状态,并对该主服务器执行故障转移操作。
选举领头Sentinel
当一个主服务器被判定为客观下线时,监视这个主服务器的各个Sentinel会协商选举出一个领头Sentinel对主服务器执行故障转移操作。
规则
每次进行领头选举,不管是否成功,所有Sentinel的纪元+1;在一个纪元里,所有Sentinel只有一次将某个Sentinel设置为局部领头的机会,一旦设置在该纪元内将不能修改;每个发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头,设置局部领头的规则是先到先得;
步骤
源Sentinel向目标Sentinel发送is-master-down-by-addr命令;最先向目标Sentinel发送is-master-down-by-addr命令的源Sentinel会被目标Sentinel设置为局部领头,并向源Sentinel发送命令回复(包含leader_runid局部领头运行id、纪元);源Sentinel收到目标Sentinel的回复,如果目标Sentinel的leader_runid、纪元和自己一致,则表示目标Sentinel将自己设置为了局部领头,计数+1;如果某个Sentinel被半数以上的Sentinel设置成局部领头,那么这个Sentinel将成为领头Sentinel;
Sentinel机制基于Raft算法进行选举领头Sentinel。
故障转移
在选举出领头Sentinel后,领头Sentinel将对已下线的主服务器执行故障转移。
故障转移步骤在已下线主服务器的所有从服务器中选出一个从服务器转为新的主服务器(Sentinel通过向从服务器发送info命令查看服务器的角色);将已下线主服务器的所有从服务器改为同步新的主服务器(向所有从服务器发送slave命令);将已下线主服务器设置为新的主服务器的从服务器;选择哪个从服务器作为新的主服务器?删除所有处于已下线或断线状态的从服务器,保证剩余从服务器都是正常在线;删除所有最近5秒没有回复过领头Sentinel info命令的从服务器,保证剩余从服务器都是最近成功进行过通信的;删除所有与已下线主服务器连接断开超过[down-after-millseconds*10]毫秒的服务器,保证剩余的从服务器数据都是比较新的;领头Sentinel根据从服务器的优先级,对剩余从服务器进行排序,选择优先级最高的;如果最高优先级有多个从服务器,则选择复制偏移量最大的从服务器,复制偏移量最大的从服务器保存着最新数据;如果存在多个最高优先级、最大复制偏移量的从服务器,则选择运行id最小的从服务器;大家看完有什么不懂的可以在下方留言讨论也可以关注.谢谢你的观看。觉得文章对你有帮助的话记得关注我点个赞支持一下!