一、Redis基本工作原理
Redis 命令执行流程和线程模型之前有分享过(http://openxm.cn/#/article/info.html?149),这里在简单梳理一下:
Redis Server内部通过使用Epoll
来实现对网络IO的管理,同时基于 Epoll
又在内部实现了AeEvent
事件模型,其能够在最大资源利用下保证单线程的最高性能。
Redis有以下几种类型的事件(这里只针对文件事件来说明):
-
Listen 监听事件 -
Read 事件 -
Send 事件
我们结合Epoll基本工作原理来去整体分析一下(http://openxm.cn/#/article/info.html?146):
Redis 会通过命令 epoll_create
创建epoll句柄,然后打开TCP监听链接,之后会将该文件句柄交给Epoll管理,同时针对该类型设置回调函数为acceptTcpHandler
,其保存在aeFileEvent
中,其内部保存了该socket要进行回调时的回调函数,在当前场景下回调函数为acceptTcpHandler
。这样当Redis Server 接收到一个新的客户端请求的时候,则会触发acceptTcpHandler
回调函数,在该函数内部则针对新的socket添加对应的read回调函数,具体如下:
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
(...)
while(max--) {
// accept 客户端连接
cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
if (cfd == ANET_ERR) {
if (errno != EWOULDBLOCK)
redisLog(REDIS_WARNING,
"Accepting client connection: %s", server.neterr);
return;
}
redisLog(REDIS_VERBOSE,"Accepted %s:%d", cip, cport);
// 为客户端创建客户端状态(redisClient)
acceptCommonHandler(cfd,0);
}
}
// 之后会通过下面的方法为该socket创建read可读事件readQueryFromClient。
redisClient *createClient(int fd) {
// 当 fd 不为 -1 时,创建带网络连接的客户端
// 如果 fd 为 -1 ,那么创建无网络连接的伪客户端
// 因为 Redis 的命令必须在客户端的上下文中使用,所以在执行 Lua 环境中的命令时
// 需要用到这种伪终端
if (fd != -1) {
// 非阻塞
anetNonBlock(NULL,fd);
// 禁用 Nagle 算法
anetEnableTcpNoDelay(NULL,fd);
// 设置 keep alive
if (server.tcpkeepalive)
anetKeepAlive(NULL,fd,server.tcpkeepalive);
// 绑定读事件到事件 loop (开始接收命令请求)
if (aeCreateFileEvent(server.el,fd,AE_READABLE,
readQueryFromClient, c) == AE_ERR)
{
close(fd);
zfree(c);
return NULL;
}
}
}
当执行完上面的代码之后,针对新创建的客户端则会通过aeCreateFileEvent
交给epoll来管理,后续当当前客户端上有新的数据到来时,epoll
则会通知Redis进程,当前进程所关联的epoll上有事件发生,此时Redis进程执行的epoll_wait
结束,并获取到可读事件的个数,然后执行对应的回调。
执行回调,读和写是分开的,所以在eventLoop中只处理读时间,当epoll告知有事件发生时,会通过event去执行对应的可读事件。而针对客户端连接socket来说,可读事件内就会去解析本次对应的命令,然后通过命令找到对应命令函数的执行逻辑去执行。
Redis服务的基本工作原理如上,我们现在来总结下其具体的线程模型(Redis 6.0之前为单线程,6.0支持多线程,但是需要手动开启):
Redis通过持有Epoll的文件句柄,将后续所有表示客户端的Socket文件句柄交给Epoll管理,当客户端有数据到达时,Epoll 会将对应的数据包放到就绪事件队列中,然后通知Redis Server进程,也就是释放epoll_wait的阻塞状态,Redis Serve 进程向下执行,取出就绪事件队列中的就绪事件,然后执行当前事件对应的可读回调函数。
这种单线程模型和数据存储在内存上,极大的提高了Redis单机性能,因为其不涉及磁盘操作,所有的数据全部保存在内存中,同时,由于单线程特性,其所有操作不涉及竞争问题,只需要等待Epoll的事件通知,然后去执行对应的回调即可。
二、Redis常用数据结构
2.1 字符串
struct sdshdr {
//记录buf数组中已使用字节的数量
//等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
};
特点:
-
二进制安全 -
查询长度方便 -
杜绝缓冲区溢出 -
减少修改时的内存重分配(内存等大预分配)
2.2 链表
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;
特点:
-
双线链表 -
获取某个节点的前驱节点或者后驱节点的时间复杂度为0(1) -
无环,对链表的访问通过 NULL
表示终点 -
长度计数器
2.3 字典
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_tu64;
int64_ts64;
} v;
//指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
typedef struct dict {
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
// rehash索引
//当rehash不在进行时,值为-1
in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
哈希冲突:拉链法解决哈希冲突
当解决哈希冲突时,其并不是将后来的Key追加到尾部,如果是追加到尾部其时间复杂度为0(N),所以其将后面相同Hash的放在链表头部,这样来节省时间。
扩容:渐进式哈希
-
为ht[1]分配空间 -
将保存在ht[0]中的所有键rehash到ht[1]上面,重新计算哈希值和索引值,然后放到ht[1]上 -
注意,这里并不是一下全部将ht[0]rehash到ht[1]上,而是渐进式的进行重分配 -
每次对字典进行增加、删除、查询、修改时将ht[0]上要进行rehashindex的位置上的键进行rehash到ht[1]上 -
随着字典的不断操作,最终rehashindex会变成-1,宣告rehash完成 -
当ht[0]包含的所有键值对都迁移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并创建一个新的空哈希表赋值给ht[1],为下次rehash做准备
2.4 跳表
typedef struct zskiplist {
//表头节点和表尾节点
structz skiplistNode *header, *tail;
//表中节点的数量
unsigned long length;
//表中层数最大的节点的层数
int level;
} zskiplist;
typedef struct zskiplistNode {
//层
struct zskiplistLevel {
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
} zskiplistNode;
跳表类似二分,针对有序的单链表来说,我们想要查询某一个数据的时间复杂度为O(n),如果我们按照二分的想法,每两个节点抽象出来一个索引,所以有序单链表会有一个一级索引,其索引的个数为N/2,那么只需要遍历O(n/2)个节点就可以找到对应的数据,同理按照二分的想法,最高层的索引只有2个,倒数第二层有三个,以此类推,那么查询一个数据的时间复杂度则为0(log(n))
参考下面文章:https://blog.csdn.net/Appleeatingboy/article/details/119948340
跳表查找过程:
-
首先访问跳表头节点,然后从最高的层次向后遍历,确定下一个要遍历的层次 -
当所有索引层次都遍历完之后,就会将目标放在有序单链表上,此时,将不会在遍历O(n),而是只需要遍历O(1)即可找到确定的值
三、Redis过期删除策略和内存淘汰策略
这里有两个问题需要考虑一下:
-
Redis如何保存每个键的生存时间和过期时间
Redis针对每一个DB都保存了一个键值对的字典,同时也保存了一个过期字典,Key为键值对,值为过期时间。
-
服务器如何自动删除已经过期的键
按照配置的过期策略来进行删除已经过期的键
4.1 Redis过期删除策略
-
定期删除 -
每隔一段时间执行一次删除过期键的操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响 -
通过定期删除过期键有效的减少了内存浪费 -
惰性删除 -
只有当某些Key被访问时去检查一下过期时间,确定下是否过期,如果过期在进行删除 -
弊端:如果某个Key永远也不会访问,则该Key对应的内存则永远不会被清理掉
4.2 Redis内存淘汰策略
当Redis内存满了之后,淘汰那个内存就成为了关键,内存总是有上限的,但是用户的操作是无限的,到达了临界值时,我们就需要选择一个内存淘汰策略来实现内存的淘汰:
-
noeviction
:当内存不足以容纳新写入的数据时,报错 -
allkeys-lru
:内存不足时,移除掉最近最少使用的Key -
allkeys-random
:当内存不足时,随机移除掉某个Key -
volatile-lru
:当内存不足时,在设置了过期时间的键空间中,移除最近最少使用的Key -
volatile-random
:当内存不足时,在设置了过期时间的键空间中,随机移除掉一个Key -
volatile-ttl
:当内存不足时,在设置了过期时间的键空间中,删除最早要过期的Key
四、Redis持久化
4.1 RDB持久化(Redis快照)
RDB开启配置:只要满足下面的配置之一就会默认执行bgsave
命令,bgsave
命令是会开启一个子进程去执行,所以在执行 bgsave
的同时主进程还是能够接受客户端的命令,而 save
命令则会阻塞主进程,导致无法处理客户端的命令
save 900 1 # Redis进程在900S内执行了一次修改
save 60 1000 # Redis进程在60S内执行了1000次修改
RDB文件结构:
REDIS|db_version|databases_0|databases_3|EOF|check_sum
其中REDIS
可以认为RDB文件的魔数,db_version为RDB版本号,databases为所有数据库要持久化的键值对,EOF表示RDB文件结束,check_sum校验和。
其中databases结构如下:
SELECTDB|db_number|key_value_pairs
带有过期时间的pair
EXPIRETIME_MS|ttl|Type|key|value
不带过期时间的pair
Type|key|value
4.2 AOF持久化
AOF通过保存Redis写命令到.aof文件中来保存Redis的状态
持久化工作原理
-
命令追加:当AOF打开时,会通过追加的形式,将当前写命令追加到AOF缓冲区
-
文件写入:Redis主进程其实就是
eventLoop
,每次当执行完一个文件事件后,都会检查下AOF缓冲区是否存在要写入文件的数据,具体写入时机通过下面参数配置来决定:appendfsync: always # 每次文件事件都会将AOF缓冲区内容同步到文件中
appendfsync: everysec # 最少每秒同步一次AOF文件,如果上次同步时间距离现在还不超过一秒则不同步
appendfsync: no # 将aof_buf缓冲区中的所有内容写入到AOF文件,但并不对AOF文件同步,何时同步由操作系统决定 -
文件同步:将文件内容同步到磁盘。当我们调用操作系统
Write
函数时,操作系统本身并不会立马将要写入的数据实时同步到磁盘上,而是先将要写入的数据缓存到内核缓冲中,等缓冲满了之后由操作系统决定是否需要将缓冲区的内容同步到磁盘上,也就是磁盘文件上。只有同步到磁盘上才算持久化成功。
由于AOF保存的是Redis命令形式,当Redis进程未关闭的情况下,AOF文件将会越来越大(也有一种可能得情况:AOF文件内有很多重复修改的键值记录),这样导致在加载的时候效率特别低,所以Redis进程会针对特别大的AOF文件进行重写。
AOF重写
AOF重写的含义:生成新的AOF文件,替换掉老的AOF文件
在进行AOF重写时,肯定不希望Redis主进程阻塞掉客户端的所有请求,所以AOF重写也是放在子进程中执行的,并不会阻塞主进程处理客户端的命令。重写过程中,主进程新的命令如何传递给子进程?
当Redis主进程开启一个子进程去执行AOF重写时,其也会保存一个AOF重写缓冲区,用于记录在AOF重写时,主进程接收到的命令,后续当AOF重写完成之后,会将AOF重写缓冲区的内容同步到新的AOF文件,此时AOF文件的状态就和预期内的保持一致了。(有一个疑问:AOF缓冲区如果满了会怎么样?)
AOF重写子进程会将原先的AOF文件中的多个命令合并成一个命令,对于哈希、List等,每64个相同命令会合成一个命令,来降低内存使用率。当AOF重写子进程完成之后,会向Redis主进程发送一个信号通知,当主进程收到该通知信号后就会执行对应的处理函数,此时就会阻塞客户端的命令。主要做以下具体事情:
-
将AOF重写缓冲区中的所有内容写入到新的AOF文件中,这时新的AOF文件保存的数据库状态将和Redis服务器当前的数据库状态保持一致。 -
对新的AOF文件进行改名,原子的覆盖现有的AOF文件,完成AOF文件替换
当Redis主进程完成这两个事情的处理之后,继续处理客户端命令,此时AOF重写也就完成了。
五、数据同步
数据同步命令PSYNC
两种方式:
-
完成重同步复制:用于初次复制的情况,从服务器将复制主服务器的RDB快照文件,以及主服务器AOF缓冲区里面的写命令来执行 -
增量重同步:当从服务器在断线后重新连接主服务器时,主服务器将与从服务器断开时之后的写命令同步到从服务器,从服务器只需要接受并执行这些修改的命令就可以和主服务器保持同步状态。
当我们有多台Redis服务时,我们需要进行数据冗余来保证在极端情况下能够保证Redis服务对外提供能力,此时我们就需要将一台Redis作为主服务,其他作为从服务,只有主服务对外提供读写能力。
同步过程:
-
当从服务第一次进行数据同步时,从服务主动发送命令 PSYNC
给主服务,然后主服务将当前服务内的RDB快照文件同步给从服务,从服务接收到RDB快照之后执行快照内的内容保持和主服务数据一致,首次同步完之后,Redis主服务将会通过增量的方式将命令同步给从服务,从而保证数据一致性。 -
全同步之后,主服务会将每次的命令通过增量的方式同步给从服务器,同时记录同步偏移量 offset
,从服务器接收到之后,执行对应的命令,然后记录同步偏移量,当主从服务的偏移量一致时,则认为主从服务的数据库的状态是一致的。 -
从服务断线重连,从服务断线后,主服务内部维护有一个复制积压缓冲区的一个Buffer,当从服务断线且过了一会又连接上来之后,向主服务发送 PSYNC
命令,主服务会通过偏移量确定对应的偏移量是否在复制积压缓冲区内,如果在则增量同步,如果不在则全同步。
如果此时主服务挂掉之后,重新起来之后的复制积压缓冲区是会丢失还是会怎么?
六、Sentinel机制
Redis 高可用的解决方案
Sentinel
是Redis的高可用的解决方案,由一个或多个Sentinel实例组成的Sentinel系统,其可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线的主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器替代已经下线的主服务器处理命令请求。
我们先来看一下整体Sentinel工作机制:
我们现在一共有4个节点,其中Server1为主节点,其他三个节点为从节点,当Sentinel和Server1由于网络原因导致无法通信时,Sentinel内部会选举Server2为新的主节点,同时变更Server3、Server4为Server2的从节点。
6.1 Sentinel 之间信息同步
每个Sentine初始化时都会监听一个Key:SUBSCRIBE __sentinel__:hello
,当某一个Sentinel向该Key发送消息时,所有的Sentinel都能收到该消息,发送该消息的Sentinel也能收到该消息,通过该监听该Key,所有的Sentinel共同维护了一个Sentinel系统,通过该系统来决定下一个Master是谁。
6.2 检查节点是否下线
Sentinel会通过每秒来发送Ping
命令来检查其他节点是否下线,如果某个节点下线之后,检测到下线的节点会询问其他Sentinel,当前下线的节点是否下线,如果其他Sentinel节点认为已经下线,那么Sentinel整个系统就会认为该节点已经下掉了。
6.4 选举领头Sentinel
当一个主服务器被判定为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并且由领头Sentinel对下线的主服务器执行故障转移操作。
选举步骤如下:假设现在有ABC三个Sentinel,A是首先发现主服务器下线的Sentinel
-
发现主服务器下线的Sentinel默认选举自己为领头Sentinel,同时向其他监听该主服务器的Sentinel发送选举命令 -
其他Sentinel选举其他Sentinel为领头Sentinel的规则是先到先得,并且在同一个纪元中只选举一次 -
A向其他Sentinel发送选举自己为的选举命令,此时BC接收到命令之后,由于A是第一个选举的Sentinel,此时BC同意A作为领头Sentinel,并且回复A -
如果在给定的时间内未选举出领头Sentinel,则会重新选举
领头Sentinel执行故障转移:
-
挑选从服务器为主服务器 -
让其他从服务器修改主服务器为新的主服务器 -
让下线的主服务器的从服务器归属到新挑选出的主服务器
领头Sentinel选举新的主服务器过程:
-
优先选取最近五秒内有回复过领头Sentinel的从服务器 -
选取偏移量最大的从服务器 -
选取运行ID最小的从服务器
七、Redis集群
集群通过数据分片来实现数据共享
7.1 集群添加节点步骤
背景:节点AB都是运行在集群模式下的Redis服务,我们通过客户端对A执行命令让其添加B到集群模式下
-
在A节点通过命令 CLUSTER MEET ip port
将B添加到A的cluster
字典中 -
节点A通过命令向节点B发送MEET命令 -
节点B收到命令之后,将节点A添加到节点B的 cluster
字典中 -
节点B回复节点A表示完成添加过程 -
节点A发送Ping命令,用来告诉B确认节点A收到Pong命令,有点类似三次握手 -
节点B收到后恢复Pong命令,至此握手完成
7.2 Redis 集群数据分片
Redis通过分片的方式来保存数据库中的键值对,集群整个数据库被分成16384个槽(slot),集群中的每个节点至多可以处理16384个槽。
当我们对某个节点分配指定槽时,该节点也会通过消息同步到其他节点,以此来表明指定的槽已经被我这个节点用掉了。同时其他节点都会记录下该值,用于后续判断。当数据库中有槽被指派时,整个集群就处于上线状态。
Redis集群下命令执行流程:
-
如果键所在的槽正好指派给当前处理命令的节点,那么节点直接执行命令 -
如果节点不是处理槽的节点,那么就返回客户端MOVED错误,由客户端 redirect
到正确的节点,并再次发送之前要执行的命令。
原文始发于微信公众号(社恐的小马同学):深入浅出Redis
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/269691.html