Redis相关
Reids没有直接使用C语言传统的字符串表示,而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。
SDS的类型定义
struct sdshdr{
//记录buf数组中已使用字节的数量,等于SDS所保存字符串的长度
int len;
//记录buf数组中位数字字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
其中SDS遵循C字符串以空字符串结尾的管理,保存空字符的1字节不计算在SDS的len属性里边
SDS与C字符串的区别
常数复杂度获取字符串长度
C字符串并不记录自身的长度信息,所以获取C字符串长度,需要遍历数组,所以操作复杂度为O(N),而SDS在len属性中记录了SDS本身的长度获取复杂度为O(1)
避免缓冲区溢出
当SDS API需要对SDS进行修改时,API会先根据free属性来检查SDS的剩余空间十分满足,
如果不满足,API会自动将SDS的空间扩展至执行修改所需要的大小,然后才执行实际的修改操作,所以使用SDS不需要手动修改SDS的空间大小,可以避免缓冲区溢出问题。
减少修改字符串时带来的内存重分配次数
1.空间预分配策略
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS
进行空间扩展的时候,程序不仅会为SDS分配修改所需要的空间,还会为SDS分配额外的未使用空间。
空间预分配公式
SDS的len< 1MB ,则设置free和len属性值相同,表示预分配一倍的空间数组;
SDS的len>= 1MB,则预分配1MB的未使用空间。
2.惰性空间释放策略
惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序
并不立即使用内存重新分配来回收缩短多出来的字节,而是使用free属性,将这些字节的
数量记录下来,并等待将来使用。与此同时,SDS也提供了相应的API,让我可以在有需要时,
真正的释放SDS的使用空间,可以不用担心惰性空间释放策略会造成内存浪费。
二进制安全
C字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里边不能包含空字符,
否则最先被程序读入的空字符串被误认为是字符串结尾,这些限制使的C字符串只能保存文本数据,不能保存图片、音频、视频、压缩文件等二进制数据。
而SDS使用len属性来判断字符串是否结束,不会对buf数组里的数据进行任何限制过滤等操作。
使得SDS可以保存任意格式的二进制数据。
兼容部分C字符串函数
虽然SDS的API是二进制安全的,但是一样遵循了C字符串以空字符串结尾的惯例,使得
SDS可以重用一部分C字符串库定义的函数。
C字符串和SDS之间的区别
C字符串 | SDS |
---|---|
获取字符串长度的复杂度为O(N) | 获取字符串长度的复杂度为O(1) |
API不安全,可能会造成缓冲区溢出 | API安全,不会造成缓冲区溢出 |
修改字符串长度N次需要执行N次内存重分配 | 空间预分配,避免每次都进行内存重分配 |
只能保存文本数据 | 可以保存文本或者二进制数据 |
可以使用所有字符库中函数 | 可以使用部分字符库中的函数 |
Redis-链表
链表作为一种常用的数据结构,但是在C语言中并没有内置这种数据结构,所以Redis构建了自己的链表实现。Redis中链表应用非常广泛,主要有列表键,发布与订阅,慢查询,监视器还有使用链表保存多个客户端的状态信息,构建客户端输出缓冲区等。
链表和链表节点实现伪代码
typedef strcut listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}
typedef struct list{
//链表头节点
listNode *head;
//链表尾部节点
listNode *tail;
//链表包含的节点数量
unsigned long len;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值对比函数
int (*match)(void *ptr,void *key);
}
通过使用多个listNode结构可以组成链表,然后在使用list结构来持有链表,内置部分函数,方便对链表进行操作处理。
Redis链表实现的特性
- 双端:链表节点带有prev和next指针,获取前后节点复杂度都为O(1)。
- 无环:链表头尾节点的prev和next指针分别指向NULL;对链表访问以NULL为终点。
- 内置链表长度计数器:获取链表节点数量复杂度为O(1)。
- 多态:链表节点使用void*指针保存节点值,支持链表存储不同类型的值。
Redis-字典
- 字典被广泛用于实现Redis的各种功能,其中包含数据库和哈希键。
- Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,一个尽在进行rehash时使用。
- 当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值
- 哈希表使用链地址法解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
- 在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里边,并且这个reahsh过程并不是一次性完成的,而是渐进式的完成的。
Redis - 跳跃表
- 跳跃表是有序集合的底层实现之一。
- Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用来保存跳跃表信息(比如表头节点、尾节点和长度),而zskiplistNode则用于表示跳跃表节点。
- 每个跳跃表节点的层高都是1到32之间的随机数。
- 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的。
- 跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序。
Redis - 整数集合
- 整数集合是集合键的底层实现之一。
- 整数集合的底层实现为数组,这个数组以有序,无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
- 升级操作为整数集合带来了操作上的灵活性,并且尽可能地地节约了内存。
- 整数集合只支持升级操作,不支持降级操作。
Redis - 压缩列表
- 压缩列表是一种为节约内存而开发的顺序型数据结构。
- 压缩列表别用作列表键和哈希键的底层实现之一。
- 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
- 添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引起连锁更新操作,但这种操作出现的几率并不高。
对象类型与编码
Redis使用对象来表示数据库汇总的键和值,每次在redis数据库中新建一个键值对时,至少会创建两个对象,一个对象用作键值对的键对象,另一个对象用作键值对的值对象。
示例命令
SET msg ‘hello world’
键值对的键是一个包含了字符串值“msg”的对象,而键值对的值则是一个包含了字符串值“hello world"的对象。
Redis 对象结构
typedef strcut redisObject{
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
}
类型常量
类型常量 | 对象名称 |
---|---|
REDIS_STRING | 字符串对象 |
REDIS_LIST | 列表对象 |
REDIS_HASH | 哈希对象 |
REDIS_SET | 集合对象 |
REDIS_ZSET | 有序集合对象 |
编码
对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。
encoding属性记录了对象所使用的编码,这个对象使用了什么数据结构作为对象的底层实现,这个属性的值可以如下常量中选择。
不同类型和编码对象映射关系
类型 | 编码 | 对象 |
---|---|---|
REDIS_STRING | REDIS_ENCODING_INT | 使用整数值实现的字符串对象 |
REDIS_STRING | REDIS_ENCODING_EMBSTR | 使用embstr编码的简单动态字符串实现的字符串对象 |
REDIS_STRING | REDIS_ENCODING_RAW | 使用简单动态字符串实现的字符串对象 |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的列表对象 |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST | 实现双端列表实现的列表对象 |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的哈希对象 |
REDIS_HASH | REDIS_ENCODING_HT | 使用字典实现的哈希对象 |
REDIS_SET | REDIS_ENCODING_INTSET | 使用整数集合实现的集合对象 |
REDIS_SET | REDIS_ENCODING_HT | 使用字典实现的集合对象 |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的有序集合对象 |
REDIS_ZSET | REDIS_ENCODING_SKIPLIST | 使用跳跃表和字典实现的有序集合对象 |
字符串对象
编码 | 适用场景 | 优势 |
---|---|---|
int | 当字符串对象保存的是整数值,并且这个整数值可以用long类型 来表示,则采用int编码类型 | 可以进行数字自增运算 |
embstr | 如果保存的字符串值的长度小于等于32字节,则采用该编码方式 保存这个字符串值 | embstr编码创建和释放字符串对象所需要的内存分配和释放次数为一次; 字符串对象的所有数据都保存在一块连续的内存中。 |
raw | 字符串值得长度大于32字节时,将使用SDS来保存这个字符串值 | raw编码创建和释放字符串对象所需要内存分配和释放次数为两次。 |
列表对象
编码 | 适用场景 | 优势 |
---|---|---|
ziplist | 列表对象保存的所有字符串元素的长度都小于64字节 并且对象保存的元素数量小于512个。 | 可以修改配置文件 List-max-ziplist-value和list-max-ziplist-entries选项修改阈值 |
linkedlist | 列表对象中的字符串长度超过64字节或者列表元素数量大于512个 | 使用双端列表数据结构存储 |
哈希对象
编码 | 适用场景 | 优势 |
---|---|---|
ziplist | 键值对的字符串长度都小于64字节并且键值对的数量小于512个 | 先将key节点压缩列表表尾,再将value节点推入表尾,键值对节点总数紧挨在一起 |
hashtable | 键值对的字符串长度有一个超过64字节或数量超过512个 | 使用字典数据结构存储,键和值都是字符串对象,保存了键值对的值 |
集合对象
编码 | 适用场景 | 优势 |
---|---|---|
intset | 集合对象保存的所有元素都是整数值并且元素数量不超过512个 | 配置文件set-max-intset-entries选项修改阈值 |
hashtable | 集合对象中添加一个字符串对象或元素数量超过512 | 转换为字典结构,每个键都是字符串对象,键对应的值设置为NULL |
有序集合对象
编码 | 适用场景 | 优势 |
---|---|---|
ziplist | 有序集合的元素数量小于128个并且元素成员的长度都小于64字节 | 每个集合元素使用两个紧挨在一起的压缩列表节点保存,第一个节点保存元素成员,第二个节点保存元素分值,集合元素按照分值从小到大排序 |
skiplist | 元素数量超过128个或元素成员长度超过64字节 | 通过跳跃表可以对有序集合进行范围操作,比如zrank、zrange命令,zset结构同时使用跳跃表和字典保存有序集合元素,通过指针来共享相同元素的成员和分值。 |
对象类型检查与命令多态
- SET GET APPEND STRLEN 等命令智能对字符串键执行;
- hdel hset hget hlen 等命令只能对hash键执行;
- rpush lpop linsert llen 等命令只能对列表键执行;
- sadd spop sinter scard 等命令只能对集合键执行;
- zadd zcard zrank zscore 等命令只能对有序集合键执行;
类型检查的实现
在执行一个类型特定的命令之前,Redis会先检查输入键的类型是否正确,然后在决定是否执行命令。
类型检查是通过redisObject结构的type属性来实现。
内存回收
- C语言不具备自动内存回收功能
- Redis在自己的对象系统中构建一个引用计数计数实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。
伪代码如下
typedef struct redisObject{
// ...
// 引用计数
int refcount;
/ ...
}
创建新对象是,引用计数的值初始化为1,当有方法使用时,计数值加1,使用完毕后减1
当对象的引用计数值为0时,改对象占用的内存会被释放。
生命周期:创建对象 -> 操作对象 -> 释放对象
对象共享
多个键共享同一个值对象需要执行两个步骤:
1、将数据库键的值指向一个现有的值对象;
2、将被共享的值对象的引用计数加1;
Redis初始化服务器器,默认创建一万一个字符串对象,从0到9999。
当服务器用到值为0到9999的字符串对象时,服务器会使用这些共享对象。
创建共享字符串对象的数量可以通过修改redis.h/REDIS_SHARED_INTEGERS常量配置
当一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需要的复杂度就会越高,需要消耗更多地CPU,所以只针对整数值的字符串对象进行共享。
对象空转时长
typedef struct redisObject{
// ...
// 记录了对象最后一次被命令程序访问的时间
unsigned lru:22;
/ ...
}
如果服务器配置了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者
allkeys-lru,当服务器占用内存超过了maxmemory配置阈值,空转时长较高的部分键会有些被服务器释放,从而回收内存。
Redis - 数据库
- Redis服务器的所有数据库都保存在redisServer.db数组中,而数据库的数量则由redisServer.dbnum属性保存。
- 客户端通过修改目标数据库指针,让它指向redisServer.db数组中不同元素来切换不同的数据库。
- 数据库主要由dict和expires两个字典构成,其中dict字典负责保存键值对,而expires字典则负责保存键的过期时间。
- 因为数据库由字典构成,所以对数据库的操作都是建立在字典操作之上。
- 数据库的键总是一个字符串对象,而值则可以是任意一种redis对象类型,包括字符串对象,哈希表对象,集合对象,列表对象和有序集合对象,分别对应字符串键,哈希表键,集合键,列表键和有序集合键。
- expires字典的键指向数据库中的某个键,而值则记录了数据库键的过期时间,过期时间是以毫秒为单位的unix时间戳。
- Redis使用惰性删除和定期删除两种策略来删除过期的键:惰性删除策略只在碰到过期键时才进行删除操作,定期删除策略则每隔一段时间主动查找并删除过期键,随机选择过期key。
- 执行SAVE命令或者BGSAVE命令所产生的新RDB文件不会包含已经过期的键。
- 执行BGREWRITEAOF命令所产生的重写AOP文件不会包含已经过期的键。
- 当一个过期键被删除之后,服务器会追加一条DEL命令到现有的AOF文件的末尾,显式的删除过期键。
- 当主服务器删除一个过期键之后,他会向所有从服务器发送一条DEL命令,显式的删除过期键。
- 从服务器即使发现过期键也不会自作主张的删除它,而是等待主节点发来DEL命令,这种统一、中心化的过期键删除策略可以保证主从服务器数据一致性。
- 当Redis命令对数据库进行修改之后,服务器会根据配置向客户端发送数据库通知。
Redis - 持久化
RDB持久化
- RDB文件用于保存和还原Redis服务器所有数据库中的所有键值对数据。
- SAVA命令由服务器进程直接执行保存操作,会阻塞服务器。
- BGSAVA命令由子进程执行保存操作,不会阻塞服务器。
- 服务器状态中会保存所有用save选项设置的保存条件,当任意一个保存条件被满足时,服务器会自动执行BGSAVE命令。
- RDB文件是一个经过压缩的二进制文件,由多个部分组成。
- 对不同类型的键值对,RDB文件会使用不同的方式来保存它们。
AOF持久化
- AOF文件通过保存所有修改数据库的写命令请求来记录服务器的数据库状态。
- AOF文件中的所有命令都使用Redis命令请求协议的格式保存。
- 命令请求会先保存到AOF缓冲区里边,之后再定期写入并同步到AOF文件。
- appendfsync选项的不同值对AOF持久化功能的安全性以及Redis服务器的性能有很大的影响。
- 服务器只要载入并重新执行保存在AOF文件中的命令,就可以还原数据库本来的状态。
- AOF重写可以产生一个新的AOF文件,这个新的AOF文件和原有的AOF文件所保存的数据库状态一样,但体积更小。
- AOF重写是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有AOF文件进行任何读入、分析或者写入操作。
- 在执行BGREWRITEAOF命令时,Redis服务器会维护一个AOF重写缓冲区,该缓冲区会在子进程创建新AOF文件期间,记录服务器执行的所有写命令。当子进程完成创建新AOF文件工作后,服务器会将重写缓冲区中的所有内容追加到新AOF文件的末尾,使得新旧两个AOF文件所保存的数据库状态一致。最后,服务器用新的AOF文件替换旧的AOF文件,以此来完成AOF文件重写操作。
Redis - 事件
- Redis服务器是一个事件驱动程序,服务器处理的事件分为时间事件和文件事件两类。
- 文件事件处理器是基于Reactor模型来实现的网络通信模式。
- 文件事件是对套接字操作的抽象:每次套接字变为可应答、可写或者可读时,相应地文件事件就会产生。
- 文件事件分为读事件和写事件两类。
- 时间事件分为定时事件和周期性时事件:定时事件只在指定的时间到达一次,而周期性事件则每隔一段时间到达一次。
- 服务器在一般情况下只执行serverCron函数一个时间事件,并且这个事件是周期性事件。
- 文件事件和时间事件之间是合作关系,服务器会轮流处理这两种事件,并且处理事件的过程也不会进行抢占。
- 时间事件的实际处理时间通常会比设定的到达时间晚一些。
Redis - 客户端
- 服务器状态结构使用clients链表连接起多个客户端状态,新添加的客户端状态会放到链表的末尾。
- 客户端状态的flags属性使用不同标志来表示客户端的角色,以及客户端当前所处的状态。
- 输入缓冲区记录了客户端发送的命令请求,这个缓冲区的大小不能超过1GB。
- 命令的参数和参数个数会被记录在客户端状态的argv和argc属性里边,而cmd属性则记录了客户端要执行命令的实现函数。
- 客户端有固定大小缓冲区和可变大小缓冲区两种缓冲区可用,其中固定大小缓冲区的最大大小为16KB,而可变大小缓冲区的最大大小不能超过服务器设置的硬性限制值。
- 输出缓冲区限制值有两种,如果输出缓冲区的大小超过了服务器设置的硬性限制,那么客户端会被立即关闭;除此之外,如果客户端在一定时间内,一直超过服务器设置的软性限制,那么客户端也会被关闭。
- 当一个客户端通过网络连接上服务器时,服务器会为这个客户端创建相应地客户端状态。网络连接关闭、发送了非法协议格式的命令请求、成为client kill命令的目标、空转时间超时、输出缓冲区的大小超出限制,以上这些原因都会造成客户端被关闭。
- 处理lua脚本的伪客户端在服务器初始化时创建,这个客户端会一直存在,直到服务器关闭。
- 载入AOF文件时使用的伪客户端在载入工作开始时动态创建,载入工作完毕之后关闭。
Redis - 服务器
服务器读取命令请求
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:
- 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面。
- 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,分别将参数和参数个数保存到客户端状态argv属性和argc属性里面。
- 调用命令执行器,执行客户端指定的命令。
服务器执行命令过程
- 命令执行器:查找命令实现
- 命令执行器:执行预报操作
- 命令执行器:调用命令的实现函数
- 命令执行器:执行后续操作
- 将命令回复发送给客户端
命令请求从发送到完成主要步骤
- 客户端将命令请求发送给服务器;
- 服务器读取命令请求,并分析出命令参数;
- 命令执行器根据参数查找命令的实现函数,然后执行实现函数并得出命令回复;
- 服务器将命令回复返回给客户端。
serverCron函数默认每隔100毫秒执行一次,它的工作主要包括更新服务器状态信息,处理服务器接收的SIGTERM信号,管理客户端资源和数据库状态,检查并执行持久化操作。
服务器启动到处理客户端命令过程
- 初始化服务器状态;
- 载入服务器配置;
- 初始化服务器数据结构;
- 还原数据库状态;
- 执行事件循环。
Redis - 主从复制
用户可以通过执行slaveof命令或者设置slaveof选项,让一个服务器去复制另一个服务器。
被复制的服务器为主服务器(master);对主服务器进行复制的服务器则被称为从服务器(slave)。
旧版主从同步过程 (只支持完整重同步)
- 从服务器向主服务器发送SYNC命令;
- 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令;
- 当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态;
- 主服务器将记录在缓冲区里边的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。
主从命令传播
主服务器会将自己执行的写命令,发送给从服务器执行。
新版主从同步 (从redis2.8版本开始支持)
- 使用PSYNC命令代替SYNC命令执行主从复制的同步操作。
- PSYNC命令具有完整重同步和部分重同步两种模式。
- 完整重同步和SYNC命令执行步骤基本一致。
- 部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将那个数据库更新至主服务器当前所处状态。
- 部分重同步模式解决了旧版复制功能在处理断线后重复制时出现的低效情况。
部分重同步实现
- 执行复制的双方--主服务器和从服务器会分别维护一个复制偏移量。
- 通过对比主从服务器的复制偏移量,判断主从服务器是否处于一致状态。
- 主服务器维护一个固定大小的先进先出的队列(复制积压缓冲区),默认大小为1MB。
- 主服务器在进行命令传播的同时,还会将写命令入队到复制积压缓冲区,缓冲区队列中同样记录复制偏移量。
- 从服务器会通过PSYNC命令将自己的复制偏移量发送给主服务器,主服务器会根据这个复制偏移量判断执行哪种同步操作:偏移量之后的数据仍然存在复制积压缓冲区,则进行部分重同步操作,反之进行完整重同步操作。
- 复制积压缓冲区大小修改方法参考配置文件repl-backlog-size选项。
- 每个Redis服务器,不论主从服务器都会有自己的运行ID。
- 运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成。
- 可以通过从服务器保存的主运行ID和当前连接主服务器的运行ID进行比较来判断是部分同步还是完整重同步。
心跳检测
- 主服务器通过向从服务器传播命令来更新从服务器状态,保持主从服务器一致,而从服务器则通过向主服务器发送命令(包含复制偏移量数据)来进行心跳检测,以及命令丢失检测。
- 主服务器向从服务器补发缺失数据是在主从服务器没有断线的情况下执行,部分重同步则是在主从服务器断线重连之后执行。
- REPLCONF ACK命令和复制积压缓冲区都是Redis2.8版本新增的,28版本之前,命令在传播过程中丢失,主从服务器都不会注意到,可能会存在主从数据不一致问题,建议升级到2.8以上版本。
Redis - 哨兵(Sentinel)
哨兵是Reids的高可用性解决方案:由一个或多个哨兵实例组成的哨兵系统可以监视任意多个主服务器,以及这些主服务器属下的从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。
- Sentinel只是一个运行在特殊模式下的Redis服务器,它使用了和普通模式不同的命令表。
- 哨兵会读入用户指定的配置文件,为每个要被监控的主服务器创建相应地实例结构,并创建连向主服务器的命令连接和订阅连接,其中命令连接用于向主服务器发送命令请求,而订阅连接用于接收指定频道的消息。
- 哨兵通过向主服务器发送INFO命令来获得主服务器属下所有从服务器的地址信息,并为这些从服务器创建相应地实例结构,以及连向这些从服务器的命令连接和订阅连接。
- 在一般情况下,哨兵已每十秒一次的频率向被监视的主服务器和从服务器发送INFO命令,当主服务器处于下线状态,或者哨兵正在对主服务器进行故障转移操作时,哨兵向从服务器发送INFO命令的频率会改为每秒一次。
- 对于监视同一个主服务器和从服务器的多个哨兵来说,它们会以每两秒一次的频率,通过向被监视服务器的sentinel:hello频道发送消息来向其他哨兵宣告自己的存在。
- 每个哨兵也会从sentinel:hello频道中接收其他哨兵发来的信息,并根据这些消息为其他的哨兵创建相应地实例结构,以及命令连接。
- 哨兵只会与主从服务器创建命令连接和订阅连接,哨兵和哨兵直接则只创建命令连接。
- 哨兵以每秒一次的频率向实例(包括主从服务器和其他哨兵)发送PING命令,并根据实例对PING命令的回复判断实例是否在线,当一个实例在指定的时长中连续没有回复时,哨兵将这个实例判断为主观下线。
- 当哨兵将一个主服务器判断为主观下线时,它会向同样这个主服务器的其他哨兵进行询问,看他们是否同意将这个主服务器设置主观下线状态。
- 当哨兵收集到足够多的主观下线投票之后,它会将主服务器判断为客观下线,并发起一次针对主服务的故障转移操作。
- 哨兵系统选举领头羊的方法是对Raft算法的领头选举方法的实现。
Redis - 集群
- 节点通过握手来将其他节点添加到自己所处的集群当中。
- 集群中的16384个槽可以分别指派给集群中的各个节点,每个节点都会记录槽与各个节点之间关系。
- 节点在接到一个命令请求时,会先根据这个命令请求要处理的键所在的槽是否由自己负责,如果不是,节点向客户端发送一个MOVED错误,MOVED错误携带的信息可以指引客户端转向正在负责相关槽的节点。
- 对Redis集群的重新分片工作是由redis-trib负责执行,重新分片的关键是将属于某个槽的所有键值对从一个节点转移到另一个节点。
- 如果节点A正在迁移槽i到节点B,那么当节点A没能在自己的数据库中找到命令指定的数据库键时,节点A会向客户端返回一个ASK错误,指引客户端到节点B继续查找指定的数据库键。
- MOVED错误表示槽的负责权已经从一个节点转移到了另一个节点,而ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施。
- 集群里的从节点用于复制主节点,并在主节点下线时,代替主节点继续处理命令请求。
- 集群中的节点通过发送和接受消息来进行通信,常见的消息包括MEET/PING/PONG/PUBLIST/FALL五种。
为什么是16384(2^14)个?
在redis节点发送心跳包时需要把所有的槽放到这个心跳包里,以便让节点知道当前集群信息,16384=16k,在发送心跳包时使用char进行bitmap压缩后是2k(2 * 8 (8 bit) * 1024(1k) = 2K),也就是说使用2k的空间创建了16k的槽数。
虽然使用CRC16算法最多可以分配65535(2^16-1)个槽位,65535=65k,压缩后就是8k(8 * 8 (8 bit) * 1024(1k) = 8K),也就是说需要需要8k的心跳包,作者认为这样做不太值得;并且一般情况下一个redis集群不会有超过1000个master节点,所以16k的槽位是个比较合适的选择。
Redis集群脑裂
redis集群脑裂是指因为网络问题,导致redis master节点和redis slave节点和哨兵集群处于不同的网络分区,此时哨兵集群无法感知到master的存在,所以将salve节点提升为master节点,此时存在两个不同的master节点,就像一个大脑分裂成了两个。
如果此时客户端还在基于原来的master节点继续写入数据,那么新的master节点将无法同步这些数据,当网络问题解决之后,哨兵集群将原来的master节点降为slave节点,此时再从新的master中同步数据,将会造成大量的数据丢失。
解决方案
修改redis配置文件两个参数
min-slaves-to-write 3 # 表示控制连接到master的最少slave数量,否则拒绝写入
min-slaves-max-lag 10 # 表示slave连接到master得最大延迟时间,数据复制和同步延迟不能超过10秒,否则master拒绝写入
控制master写入时的slave数量,必须超过所有slave数量的一半以上时才允许写入,防止同时写入两个master,
如果发生集群脑裂,原先的master节点则会拒绝写入请求,可以减少数据同步之后的数据丢失。
Redis - 事务
- 事务提供了一种将多个命令打包,然后一次性、有序地执行的机制。
- 多个命令会被入队到事务队列中,按照先进先出的顺序执行。
- 事务在执行过程中不会被中断,当事务队列中的所有命令都执行完毕后,事务才会结束。
- 带有WATCH命令的事务会将客户端和被监视在数据库的watched_keys字典进行关联,当键被修改时,程序会将所有监视被修改的客户端的REDIS_DIRTY_CAS标志打开。
- 只有在客户端的REDIS_DIRTY_CAS标志未被打开时,服务器才会执行客户端提交的事务,否则拒绝执行客户端提交的事务。
- Redis的事务总是具有ACID的原子性、一致性和隔离性。当服务器运行在AOF持久化模式下,并且appendFsync选项值为always时,事务也具有持久性。
Redis - Lua脚本
- Redis服务器在启动时,会对内嵌的Lua环境执行一系列修改操作,从而确保内嵌的Lua环境可以满足Redis在功能性、安全性等方面的要求。
- Redis服务器专门使用一个伪客户端来执行Lua脚本中包含的Redis命令。
- Redis使用脚本字典来保存所有被eval命令执行过,或者被script load命令载入过的lua脚本,这些脚本可以用于实现script exists命令,以及实现脚本复制功能。
- eval命令为客户端输入的脚本在lua环境中定义一个函数,并通过调用这个函数来执行脚本。
- evalsha命令通过直接调用lua环境中已经定义的函数来执行脚本。
- script flush命令会清空服务器lua_scripts字典中保存的脚本,并重置lua环境。
- script exists命令接收一个或多个SHA1校验和为参数,并通过检查lua_scripts字典来确认校验和对应的脚本是否存在。
- script load命令接收一个lua脚本为函数,为该脚本在lua环境中创建函数,并且脚本保存到lua_scripts字典中。
- 服务器在执行脚本之前,会为lua环境设置一个超时处理钩子,当脚本出现超时运行情况时,客户端可以通过向服务器发送script kill命令来让钩子停止正在执行的脚本,或者发送shutdown masave命令来钩子关闭整个服务器。
- 主服务器复制eval、script flush、script load 三个命令的方法和复制普通redis命令一样,只要将相同的命令传播给从服务器就可以。
- 主服务器在复制evalsha命令时,必须确保所有从服务器都已经载入了evalsha命令指定的sha1校验和所对应的lua脚本,如果不能确保这一点,主服务器会将evalsha命令转换成等效的eval命令,并通过传播eval命令来或得相同的脚本执行效果。
Redis - 排序
- SORT命令通过将被排序键包含的元素载入到数组里,然后对数组进行排序来完成对键进行排序的工作
- 默认情况下,SORT命令假设被排序键包含的都是数字值,并且以数字值得方式来进行排序。
- 如果SORT命令使用了APLHA选项,那么SORT命令假设被排序键包含的都是字符串值,并且以字符串的方式进行排序。
- SORT命令的排序操作由快速排序算法实现。
- SORT命令根据用户是否使用DESC选项来决定使用升序还是降序。
- 当SORT命令使用了BY选项,命令使用其他键的值作为权重来进行排序操作。
- 当SORT命令使用LIMIT选项,命令值保留包旭结果集中LIMIT选项指定的元素。
- 当SORT命令使用了GET选项时,命令会根据排序结果集中的元素,以及GET选项给定的模式,查找并返回其他键的值,而不是返回被排序的元素。
- 当SORT命令使用了STORE选项时,命令会将排序结果集保存在指定的键里面。
- 当SORT命令同时使用多个选项时,命令先执行排序操作,然后执行LIMIT选项,指挥执行GET选项,在之后执行STORE选项,最后才将排序结果集返回给客户端。
- 除了GET选项外,调整选项的摆放位置不会影响SORT命令的排序结果。
Redis - 二进制位数组
二级制位数组使用
Redis提供了setbit、getbit、bitcount、bitop四个命令用于处理二进制位数组
setbit命令用户为位数组指定偏移量上的二进制位设置值,位数组的偏移量从0开始计算,而二进制位的值则可以是0或者1;
setbit bit 0 1 // 设置 0000 0001
setbit bit 3 1 // 设置 0000 1001
setbit bit 0 0 // 设置 0000 1000
getbit bit 0 // get 0000 1000
(integer) 0
getbit bit 3 // get 0000 1000
(integer) 1
bitcount bit // 0000 1000 统计位数组中,值为1的二进制位的数量
(interger) 1
bitcount bit // 0000 1001 统计位数组中,值为1的二进制位的数量
(interger) 2
//bittop命令可以对多个位数组进行按位与运算、按位或运算、按位异或运算
bittop and and_result_key bit1 bit2 bit3
bittop or or_result_key bit1 bit2 bit3
Redis使用SDS来保存位数组,SDS使用逆序来保存位数组,这种保存顺序简化了SETBIT命令实现
二进制位数组应用场景
用户签到场景
每天的日期字符串作为key,用户ID作为offset,统计每天用户签到情况,总的用户签到数量。
活跃用户统计
用户日活、月活、留存率等均可以用位数组存储,以每天的日期作为key,用户活跃了就写入osffset用用户id的位值为1。
用户是否在线以及在线人数统计
使用一个位数组,用户ID映射偏移量,在线标识为1,下线标识为0,即可实现用户上下线查询和总得在线人数统计。
APP内用户全局消息提示小红点
App站内信功能,当有消息的时候,提示一个小红点,代表用户有新的消息。
Redis 缓存问题
缓存穿透
缓存穿透问题描述
缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从DB查不到数据则不写入缓存,这将导致这个不存在的数据每次都要到DB去查询,失去了缓存的意义。在流量大时,DB可能会挂掉,黑客可以利用不存在的key频繁攻击我们的应用。
缓存穿透解决方案
- 采用布隆过滤器,将所有可能存在的数据key哈希到一个足够大的bitmap中,一个一定不存在的数据key会被这个bitmap拦截掉,避免对底层DB系统的查询压力。
- 如果一个key在DB中查询为空时,仍然把这个空结果进行缓存,过期时间设置很短,不超过五分钟。
缓存雪崩
缓存雪崩问题描述
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致大量缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重发生雪崩效应。
缓存雪崩解决方案
- 写缓存时考虑采用加锁或者队列的方式保证缓存的单线程写,避免大量缓存key同时失效。
- 缓存过期时间分散开,可以在原有的缓存失效时间基础上增加一个随机时间,这样每个缓存key的过期时间重复率会很低。
- 缓存key不设置过期时间,可以在缓存值中保存过期时间字段。
缓存击穿
缓存击穿问题描述
对于一些设置了过期时间的缓存key,如果这些key在某个时间点被超高并发访问,是一种非常热点的数据。和缓存雪崩的区别是针对某一个key缓存,缓存雪崩是多个key。
热点缓存key在某个时间点过期的时候,恰好这个时间点有大量并发请求这个缓存key,这些请求发现缓存key已经过期会从后端DB加载数据并回填缓存 ,这个大并发请求可能会瞬间把DB压垮。
缓存击穿解决方案
- 使用互斥锁(mutex key),就是在缓存失效的时候(根据缓存key查询缓存为null),不是立即去查询DB,而是先去获取锁(JVM锁或分布式锁都可以),只有成功获取锁的线程去查询DB然后回填缓存,通过锁保证只有一个线程去查询DB,从而减小DB压力,当缓存回填成功后,其他线程再去查询缓存即可。
- 提前使用互斥锁,在value内部设置缓存过期时间,每次查询缓存时判断缓存是否即将过期,如果超过一定阈值,可以提前触发查询数据库更新缓存操作,查询数据库更新缓存操作同样需要加锁实现。
- 缓存不设置过期时间,这就不会出现热点key过期问题,如果需要对缓存进行更新或者删除操作,可以通过后台异步任务方式进行缓存更新。
- 访问数据库增加限流功能,可以采用netflix的hystrix实现降级限流功能。
Redis - 热点key
热key问题描述
热key问题就是突然有几十万的请求去访问redis上的某个特定key,那么这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis服务器直接宕机。
如何发现热点key
- 凭借业务经验,进行预估哪些是热key。比如某些商品要做秒杀,则商品key就可以判断为热key。但并非所有业务都能预估出热key。
- 在客户端进行收集。比如在redis客户端执行redis命令之前,加入一行代码进行命令数据收集,,然后通过网络将收集的命令发送出去,确定是对客户端代码有入侵。
- 在Proxy层做收集。但是并非所有的redis集群都有proxy。
- 用redis自带命令。monitor命令可以实时抓取出redis服务器接收到的命令,然后写代码统计出热key是啥,不过高并发条件下,有内存暴增的隐患,影响redis的性能。redis4.0.3提供了客户端热点key发现功能,如果key比较多,执行比较慢。
- 自己抓包评估,redis客户端使用TCP协议与服务端进行交互,通信协议采用RESP,自己写程序监听端口,按照RESP协议规则解析数据进行分析,不过开发成本较高,不易维护。
如何解决热key
- 增加二级缓存,发现热key以后,可以把热key数据加载到系统JVM并设置合适的缓存过期时间,针对热key的请求就会直接分散到各业务服务器上,防止所有请求同时访问同一台redis。
- 备份热key。可以把热点key的数据备份到所有redis的集群节点中,可以通过在热点key后面拼接集群节点编号,然后将这些备份key分散到所有集群节点中,客户端访问热点key的时候也在热点key后面随机拼接集群节点编号,将热点key的请求分散到不同集群节点上。
有赞透明多级缓存解决方案(TMC)
TCM对原生jedis包得jedisPool和jedis类做改造,在JedisPoll初始化过程中集成了TCM“热点发现”+“本地缓存"功能Hermes-sdk包得初始化逻辑。
Redis - 过期策略和内存淘汰策略
Redis内存过期策略
Redis是key-value数据库,我们可以设置Redis中缓存的key的过期时间。Redis的过期策略就是指当Redis中缓存的key过期了,Redis如何处理。
过期策略通常有以下三种:
- 定时过期:每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
- 惰性过期:只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
- 定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
(expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。)
Redis中同时使用了惰性过期和定期过期两种过期策略。
Redis的内存淘汰策略
Redis的内存淘汰策略是指在Redis的用于缓存的内存不足时,怎么处理需要新写入且需要申请额外空间的数据。
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。
总结
Redis的内存淘汰策略的选取并不会影响过期的key的处理。内存淘汰策略用于处理内存不足时的需要申请额外空间的数据;过期策略用于处理过期的缓存数据。
Redis - 高性能原因
- 纯内存操作,类似于HashMap查找和操作时间复杂度都是O(1).
- 采用单线程,避免上下文切换和竞争条件,CPU不是瓶颈没有必要多线程。
- 使用多路I/O复用模型,非阻塞IO。利用select、poll或epoll同时监听多个流的I/O事件。
- 数据结构简单高效,对数据操作也简单。
- 合理的数据编码,大部分采用压缩列表进行数据存储,内存是连续分配的,遍历速度快。
- 使用自定义字符串对象,通过空间预分配,惰性空间释放等手段进行操作性能优化。
Redis分布式锁
Redis分布式锁实现
Redis 锁主要利用 Redis 的 setnx 命令。
- 加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
- 解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
- 锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。
存在的问题
- SETNX和EXPIRE非原子性
如果SETNX成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时时间变成死锁。
解决方案:可以使用lua脚本来保证原子执行
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;
- 锁误解除
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
解决方案:在value中设置能够表示当前线程的身份信息,比如UUID或者服务器信息+线程ID,然后判断身份信息是否是持有当前锁的线程,持有锁的线程才能执行释放锁逻辑。
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
then return redis.call('del', KEYS[1])
else return 0
end
-
超时解锁出现并发
如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
解决方案:
将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成;
为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。 -
锁不可重入
当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。
解决方案:可以使用Redis Hash数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。
// 如果 lock_key 不存在
if (redis.call('exists', KEYS[1]) == 0)
then
// 设置 lock_key 线程标识 1 进行加锁
redis.call('hset', KEYS[1], ARGV[2], 1);
// 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 如果 lock_key 存在且线程标识是当前欲加锁的线程标识
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
// 自增
then redis.call('hincrby', KEYS[1], ARGV[2], 1);
// 重置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 如果加锁失败,返回锁剩余时间
return redis.call('pttl', KEYS[1]);
-
无法等待锁释放
解决方案:
可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功 后释放时,发送锁释放消息。 -
主从切换问题
为了保证 Redis 的可用性,一般采用主从方式部署。主从数据同步有异步和同步两种方式,Redis 将指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一致的状态,一边向主节点反馈同步情况。
在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功。 -
Redis集群脑裂问题
集群脑裂指因为网络问题,导致 Redis master 节点跟 slave 节点和 sentinel 集群处于不同的网络分区,因为 sentinel 集群无法感知到 master 的存在,所以将 slave 节点提升为 master 节点,此时存在两个不同的 master 节点。Redis Cluster 集群部署方式同理。
当不同的客户端连接不同的 master 节点时,两个客户端可以同时拥有同一把锁。
可以通过设置连接到master的最少slave数量,否则拒绝写入。 -
结论
Redis 以其高性能著称,但使用其实现分布式锁来解决并发仍存在一些困难。Redis 分布式锁只能作为一种缓解并发的手段,如果要完全解决并发问题,仍需要数据库的防并发手段。
如果是为了效率(efficiency)而使用分布式锁,允许锁的偶尔失效,那么使用单Redis节点的锁方案就足够了,简单而且效率高。Redlock则是个过重的实现(heavyweight)。
如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用Redlock。它不是建立在异步模型上的一个足够强的算法,它对于系统模型的假设中包含很多危险的成分(对于timing)。而且,它没有一个机制能够提供fencing token。那应该使用什么技术呢?Martin认为,应该考虑类似Zookeeper的方案,或者支持事务的数据库。
网友评论