深入学习Redis-基础知识回顾


1. 说一说对Redis的理解

Redis是基于内存操作的一种NoSQL数据库,实际上,在MySQL中有一种存储引擎叫做Memory的引擎与之类似,但是Redis作为一个重要的中间件,还有更加丰富的功能。

我理解Redis的话,就好像cache之于计算机,Mysql等持久化数据库就相当于磁盘/内存,它将数据库的一部分数据载入了内存,从而加快了读写速度,它对于处理秒杀事件具有比较高的性能,能够轻松达到10w的TPS,而使用Mysql(单机)的话只能够达到极限的1wTPS,性能差距是非常大的。

同时,作为缓存中间件,它提供了几种特色数据结构,比如说给每个key-value提供排序机制的zset,这个可以帮助我们实现基于时间戳/算法得分的一种feed流机制,还有比如说stream,能够使得redis成为一个简单的消息代理器,同时由于TTL的机制,甚至可以用redis作为注册中心,定期检查节点的健康状态,类似于一个心跳的功能

除此之外,其提供的指令特性如setnx等这些指令以及Lua脚本的原子性执行特性,可以基于redis实现分布式锁,在如抢红包等场景下,可以基于redis实现令牌桶

因此,我认为redis的意义不仅仅是一款高性能的缓存软件来提供系统的吞吐量和并发量,更是一个非常重要的分布式中间件,基于这个中间件,可以实现简易的消息代理,注册中心,分布式锁,其提供的集群机制更加是提高了高可用性。

2. Redis与Memcached有什么区别?

要搞懂这个问题,首先要了解Memcached和redis的异同之处:

首先相同点:

  • 他们都是基于内存操作的数据库,一般来说是当做缓存来使用的
  • 都有过期淘汰的策略
  • 两者的读写性能都非常高

不同点

  • Redis支持的数据类型非常丰富,比如说有list、String、Hash、Set、Zset,但是Memcached只支持最简单的key-value模型
  • Redis支持数据的持久化,通过AOFRDB等机制,可以将内存中的脏数据定期刷回数据库,通过这种异步的极值来保证数据库与缓存的最终一致性,但是Memcached中的数据存储在易失性存储设备中,因此一旦机器挂掉数据就没了
  • Redis通过特殊数据结构以及Lua脚本的支持,能够实现分布式锁,消息中间件,而Memcached不支持这些数据结构,因此无法实现这些特殊功能
  • Redis原生就是支持分布式集群的,而Memcached是不支持的

3. 为什么要使用Redis作为Mysql的缓存?直接使用Memory引擎不可以吗?

这个问题我这样进行形容,Redis作为缓存对于Memory引擎来说简直就是降维打击

首先,Memory引擎的特点有三点:第一点,事务方面,Memory是不支持事务的

第二点,锁方面,Memory仅支持表锁,这使得它在并发的时候,由于同步锁的限制,导致大量的阻塞等待,性能非常差

第三点,数据持久化方面,它是基于内存的,除非在Mysql内部编写相关SQL,否则无法持久化


基于上面的三点,Redis在各方面都全面超越了Memory

首先,Redis可以通过multi开启事务,通过exec提交事务, 通过discard取消事务,这是一个简单的事务模型,可以进行编程式事务的开发,同时,对于事务的并发控制是基于乐观锁实现的,也就是CAS,在这种情况下能够极高的提高性能。

第二点,性能问题,从存储层面来看,假设用户第一次访问数据库中的某些数据,那么必须进行IO,而Redis是直接操作内存的,因此性能更高,同时,由于Redis的单线程模型,因此避免了加锁的阻塞限制,从而为高并发提供了存储基础。

而正因如此,Redis能够轻松突破10w的QPS,因此将数据库中的部分数据转移到Redis能够提高读写性能,但是存在数据库与缓存的一致性问题,维护此一致性问题也是需要付出一定的代价的

第三点,Redis提供的AOF和RDB机制能够实现数据的持久化

4. Redis的数据类型以及使用场景

有这五种常见的数据类型:有StringHashListSetZset

下面是这些数据类型的一些常见操作

结构类型 结构存储的值 结构的读写能力
String字符串类型 可以是整数类型,字符串类型,浮点数类型 对整个字符串或者字符串的一部分进行操作,对整数或者浮点数进行自增或者自减的操作
List列表 一个链表,链表上的每个节点都包含有一个字符串 对链表两端进行push和pop操作,读取单个或者多个元素,根据值进行元素的查找和删除
Set集合 包含字符串的无序集合 字符串的集合,包含基础的方法看是否存在添加、获取、删除;还包含有计算交集,并集,差集等
Hash散列 包含键值对的无序散列集表 包含有添加、获取、删除单个元素
ZSet有序集合 包含字符串的有序集合 字符串成员与浮点数分数之间的有序映射,元素的排列依据于这个浮点数分数,包含方法有添加、获取、删除单个元素以及根据分值范围或者成员来获取元素

在之后,还添加了

  • bitmap
  • GEO
  • HyperLogLog:实现UV
  • Stream

下面来谈谈常见的场景:

String:缓存对象,常规的计数器实现,分布式锁,共享session

List:消息队列(生产者需要自行实现全局性的唯一性ID,不能以消费组形式消费数组)

Hash:缓存对象,购物车

Set:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。

Zset 类型:排序场景,比如排行榜、电话和姓名排序等。

  • BitMap(2.2 版新增):二值状态统计的场景比如签到判断用户登陆状态连续签到用户总数等;
  • HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

5. 说说五种常见的数据类型及其底层实现原理

  • 关于String的底层实现

String的底层实现是SDS,全称为SimpleDynamicString,叫做简单动态字符串,具有动态扩容的能力

SDS的特点有:SDS能够保存文本数据,还可以保存二进制数据,也就是说不仅仅能够保存文本数据,还能够保存视频、音乐等数据,SDS获取字符串长度的复杂度为O(1),这是因为其底层在判断其字符串是否到达末尾是通过外部变量len来进行实现的,而不是\0。同时,它的相关API是安全的,在添加字符之前会对字符串的容量进行检查,不会造成缓冲区的溢出

  • 关于List的底层实现

List 类型的底层数据结构是由双向链表或压缩列表实现的:

  • 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
  • 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;

但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表

  • Hash 类型内部实现

Hash 类型的底层数据结构是由压缩列表或哈希表实现的

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了

Set 类型内部实现

Set 类型的底层数据结构是由哈希表或整数集合实现的:

ZSet 类型内部实现

Zset 类型的底层数据结构是由压缩列表或跳表实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

6. Redis是单线程的吗?

Redis既可以说是单线程的也可以说是多线程的

首先说它是单线程的原因是:

从接收客户端请求->解析请求->对数据进行读写->返回结果到客户端

这个过程都是由Redis中的主线程进行操作的,然而在操作的过程中,可能会存在:

对bigKey的申请/释放,这种数据对内存的申请操作需要耗费大量的时间,因此如果在主线程中完成,就会大大降低性能,同时,如果开启了容灾策略,那么就会进行AOF的写回,如果每一次对内存的操作都要写回到磁盘中,那么就会导致性能大大下降,同时,还有一种任务是涉及到内核操作,比如说关闭文件,其本质上是一个系统调用close(fd),因此就会使得用户态陷入内核态,并且发起系统调用,从而使得主线程阻塞

以上说的三种情况都会导致主线程处理任务的速率降低,性能下降,因此为了解决这种问题,那么就将这些耗时的任务交给后台线程,目前来说,一共有三种

  • AOF写回异步线程
  • 内存释放异步线程,也就是lazyFree
  • 关闭文件异步线程

以上三个线程在CPU上的时机完全取决于操作系统的调度策略,不过将这种耗时的任务从同步任务中解耦了出来,使得主线程能够处理更多的请求,避免这些耗时的任务影响主线程

从这个角度上看,它又是多线程的

关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:

  • BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
  • BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘
  • BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;

如果仅仅聊的是核心业务部分(命令部分),答案是单线程

如果是聊整个redis,那么答案就是多线程

redis4.0引入多线程异步处理一些耗时较长的任务,例如异步删除unlink

redis6.0引入了核心网络模型中的多线程技术,进一步提高多核CPU的利用率

为什么要坚持单线程?

  • 抛开持久化不谈,Redis是纯内存操作的,读写速度非常快,它的性能瓶颈不在于(命令的执行速度)对内存的读写,而在于网络I/O操作,因此在处理普通的命令,也就是对内存进行读写的时候,并不会带来巨大的收益,反而会因为加锁等操作导致性能的下降
  • 多线程会还会导致线程上下文的切换。

7. 单线程的工作模型?

7.1 IO模型理论基础

要搞明白Redis的线程模型,首先要了解基本的I/O模型,这里的话稍微复习一下

BIO阻塞式IO

BIO可以描述为,从用户发起系统调用recvfrom的两个过程

  • 第一个过程,系统内核检查缓冲区中是否有数据,如果无数据,则需要使用磁盘,将数据读取到内核缓冲区中
  • 第二个过程,内核缓冲区数据准备完毕,将内核缓冲区数据读取到用户空间中

完成以上两个过程,发起recvfrom的进程才能继续往下执行。

我们假设这是Redis的线程模型,如果每次来一个请求,它都需要执行这样的阻塞式的数据准备和拷贝,那么将会导致大量的请求积压,关键的因素是如果数据没有准备完毕,主线程将会一直被阻塞,无法处理新的请求

一个解决办法是:当每来一个新的请求,本线程在发起recvfrom调用时,开启一个新的线程监听请求。

弊端非常明显,就是请求和线程数是1:1的,在高并发的情况下,10w个请求就意味着10w个线程,世界上再先进的CPU也无法同时运行这么多线程。因此必须进行优化

NIO非阻塞式IO

实际上这个NIO是比较鸡肋的,因为它除了在第一个阶段没有阻塞以外,并没有产生多大的用处,而且还造成了CPU的忙等,但是实际上,这是非常灵活的,比如说我可以创建一个任务队列,在这个任务队列中放入I/O任务

然后不断轮询内核里面的任务完成好没有,如果完成好了,那我就把任务弹出,返回数据

这个好处是什么呢?首先,在由于存在任务队列的记忆性质,使得每一次轮询都有针对性的检查之前的任务是否完成,并且在之前的任务处理的时候,先把当前这个任务进行存储检查,如果用户区已经有数据了

那么就直接返回就可以了,而不是等待其他那些需要内核调用和拷贝的任务结束

这样就提高了并发度,而这种思路,正是IO多路复用的基本思路

IO多路复用

  • 如果调用recvform时,恰好没有数据,阻塞IO会使得进程阻塞,非阻塞IO使得CPU忙等,无法充分发挥CPU
  • 如果调用recvform时,恰好有数据,则用户进程可以进入第二阶段,直接处理数据

文件描述符:简称为fd,是一个从0开始递增的无符号整数,用来关联Linux系统中的一个文件,一切皆文件,例如常规文件,视频等硬件设备,包括网络套接字Socket

那么I/O的多路复用实际上就是利用单个线程来监听多个fd,并在某个fd可读、可写的时候得到通知,从而避免无效的等待,充分利用CPU资源

简述select模式,在这个模式下,首先它将所有的已连接的Socket的文件描述符装载进一个文件描述符集合,然后通过select函数将这些集合装载进内核的缓冲区中,然后通过轮询文件描述符的方式,来判断是否有socket的数据准备完毕了,如果有,那么就将缓冲区中的数据重新拷贝回用户空间,然后用户程序还需要再对用户缓冲区中的socket进行遍历,看到底是哪一个socket准备好了

typedef long int __fd_mask;

typedef struct{
    //fds_bits是long类型的数组,长度为1024/32=32
    //共1024个bit,是bitmap的实现
    __fd_mask fds_bits[];
}fd_set;


int select(
    int nfds;//要监视的fd_set的最大fd+1
    fd_set *readfds;//要监听的读事件的fd集合
    fd_set *writefds;//要监听的写事件的fd集合
    fd_set *exceptfds;//要监听的异常事件的fd集合
    //超时时间,null-永不过时,0-不阻塞等待,大于0固定等待的时间
    struct timeval *timeout;
){}

这种方式是十分低效的,主要在于

  • 首先,它一次只能够监听一批的fd,当有新的连接到来,然后接收完连接后,但是这时候因为没有更新fd到集合中,这时候如果主线程是阻塞的,就会导致等待
  • 第二,存在大量的拷贝操作,文件描述符fds需要从用户空间拷贝到内核空间,当有一个或者多个准备好的时候,又要从内核拷贝到用户空间,耗时严重
  • 第三,存在大量的无效遍历,比如说在轮询socket的准备状态的时候,此时需要CPU不断的运行,但是有可能是无效的,而在返回到用户态的时候,又需要将整个集合遍历一遍以确定到底是哪个fd准备好了

简述poll模式,这个模式优化了select模式中的数据结构部分

#define POLLIN   //可读事件
#define POLLOUT  //可写事件
#define POLLERR  //错误事件
#define POLLNVAL //fd未打开

struct pollfd{
    int fd;//要监听的fd
    short int events;//要监听的事件类型:读、写、异常
    short int revents;//实际发生的事件类型,这个东西是内核传进来的
}
int poll(
    struct pollfd *fds;//pollfd数组,可以自定义大小
    nfds_t nfds;//数组元素个数
    int timeout;//超时时间
);

执行流程

  • 首先创建一个pollfd数组,向其中添加关注的fd信息,数组的大小是自定义的,数组大小理论上无限制
  • 调用poll函数,将pollfd数组拷贝到内核空间,转为链表存储
  • 内核遍历fd,判断是否就绪
  • 数据就绪或者超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
  • 用户进程判断n是否大于0
  • 大于0则遍历数组,找到就绪的fd

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll模型

epoll模型大大改进了select/poll模型,首先我们先来看看select/poll的弊端

  • 为什么每次都需要把要监控的fd都从用户空间拷贝到内核空间,为什么不在内核空间中直接操作?
  • 为什么socket来告诉select的线程有socket准备好了,但是却无法告诉人家是哪个socket准备好了?
struct eventpoll{
    struct rb_root rbr;//一棵红黑树,记录要监听的FD
    struct list_head rd_list;//一个链表,记录就绪的事件
};

//1.会在内核中创建`eventpoll`结构体,返回对应的句柄epfd,eventpoll的唯一表示
int epoll_create(int size);

//2.将一个FD添加到epoll的红黑树中,并且设置ep_poll_callback
// callback触发的时候,就把对应的FD加入到rdlist这个就绪队列中
int epoll_ctl{
    int epfd;//epoll实例的唯一表示
    int op;//要执行的操作,包括ADD,MOD,DEL,添加到红黑树上?
    int fd;//要监听的FD
    struct epoll_event * event;//要监听的类型
}
//3. epoll_wait 
int epoll_wait(
    int ep_fd,//eventpoll实例的句柄
    struct epoll_event *events // 空event数组,用于接收就绪的FD
    int maxevents	//events数组的最大长度
    int timeout // 超时时间
);

首先,它修改了fd的存储结构,在内核中维护了一棵红黑树,这棵红黑树上存储了相关的fd,我们知道fd是一个无符号的整数,因此对他可以实现有序查找,时间复杂度是O(logn),在这样的情况下,当有新连接准备完毕之后,就可以直接通过将socket传入到内核中,将这个socket插入到红黑树上

第二,epoll是基于事件驱动机制实现的, 内核中维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用epoll_wait()函数的时候,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

这个意思就是说,当事件发生时,会将socket对象记录在就绪队列中,然后将就绪队列中的数据加入到程序精简中,其调用的函数是__put_user函数

什么是边缘触发和水平触发?

Linux对于事件的处理策略有边缘触发和水平触发

  • 当使用边缘触发的时候,当监控的Socket上有可读的事件发生时,服务器端也只会苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;

无法保证怎么办?那只能在第一次读取完数据之后,使用epoll_ctl将这个事件重新注册回epoll中去

  • 当使用水平触发的时候,当内核的缓冲区有数据的时候,服务端就会不断地苏醒

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 readwrite)返回错误,错误类型为 EAGAINEWOULDBLOCK

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用

信号驱动IO

当有大量的I/O操作的时候,信号较多,SIGIO处理函数不能及时处理可能会导致信号队列溢出

**AIO **

进程完全不阻塞,用户进程调用完这个API后就可以去做别的事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程

这个模式需要控制并发,限流,避免内核压力过大

7.2 Redis网络模型详解

server.c

int main(){
    initServer();//初始化服务
    aeMain(server.el);//开始监听事件循环
}

initServer.c

void initServer(void){
    //内部调用一个epoll_create
    server.el = aeCreateEventLoop(config);
    //相当于是那个epoll实例,创建红黑树和链表
    //监听TCP端口
    listenToPort(server.port,&server.ipfd);
    //注册连接处理器,将fd注册到epoll上
    //这个操作是这样的,它定义了一个acceptTcpHandler的回调函数
    //这个回调函数的操作是:接收对方发过来的Socket对应的fd,然后将这个fd注册到epoll上
    createSocketAcceptHandler(&server.ipfd,acceptTcpHandler);
    //注册ae_api_BeforeSleepProc
    aeSetBeforeSleep(server.el,beforeSleep)
}
struct eventpoll{
    struct rb_root rbr;//一棵红黑树,记录要监听的FD
    struct list_head rd_list;//一个链表,记录就绪的事件
};
void aeMain(aeEventLoop *eventLoop){
    eventLoop->stop = 0;
    while(!eventLoop->stop){
        aeProcessEvents(eventLoop,ALL_EVENT|BEFORE_SLEEP|AFTER|SLEEP)
    }
}
int aeProcessEvents(aeEventLoop *eventLoop,int flags){
    //调用前置处理器
    eventLoop->beforesleep(eventLoop);
    //等待FD就绪
    numevents = aeApiPoll(eventLoop,tvp);
    //遍历获取的FD
}
void acceptTcpHandler(){
    //接收socket请求,获取FD
    fd = accept(s,sa,len);
    //创建conncetion,关联fd
    conncetion *conn = connCreateSocket();
    conn.fd = fd;
    //绑定读取处理器
    
}

这里的话我进行简述:

首先Redis在内部会进行服务端的初始化,这个初始化首先会先创建epoll实例,并且返回一个epoll实例的句柄,创建这个句柄的过程实际上就是在创建监听fd的红黑树和就绪任务列表

你可以理解这个epoll是一个封装类,其内部的数据结构就是一棵红黑树和一个就绪队列

接着,会创建一个serverSocket监听socket,它本质上是一个fd,因为可以对其进行写入和读取,然后就会调用epoll_ctl将该fd注册到红黑树上,同时在内核中注册连接事件处理函数

这个所谓的连接事件处理函数,就是将远程的连接的socket对象中包含的fd,注册到红黑树上,加入监听的列表

然后就开始调用epoll_wait函数,等待一个或者若干个fd可用

这个调用的超时时间是可以设定的,-1代表永远不超时,>0则是有效的超时时间

首先,先调用处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。

然后epoll_wait函数会等待事件的到来

  • 如果是连接事件到来,那么就会调用连接事件处理函数,将fd注册到红黑树上,注册读事件处理函数

这个读事件处理函数就是说规定了怎么来处理这个fd进入了可读状态的函数

  • 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。

  • 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据-> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;

8. Redis 采用单线程为什么还这么快?

  • Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
  • Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
  • Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

9. 在6.0之后为什么引入了多线程

通过之前的分析可以知道,Redis的性能瓶颈通常出现在网络I/O上,因此为了提高性能,可以引入多线程来对网络I/O进行提速,在默认的情况下只针对于发送响应数据进行多线程的优化,而对于数据的读取需要通过conf文件来进行配置。

关于线程数的配置,一般来说,对于4核CPU来说,通常配置2-3个线程数,对于8核CPU,通常配置6个线程,线程数一定要小于机器核数,线程数并不是越大越好。

  • Redis-server : Redis的主线程,主要负责执行命令;
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。

网络的I/O线程是如何处理的?

它就类似于线程池任务处理模型,当有多个I/O请求的时候,会将这些请求压入队列中,然后线程通过队列取任务来进行执行,从而使得多个请求能够同时被响应,避免请求挤压

10. Redis如何实现数据不丢失?

Redis是基于内存的NoSql,因此如果没有将数据库的数据写入到持久化设备中的话,就会导致系统挂掉后数据直接丢失,因此通过额外的机制来实现数据的持久化。

Redis一共有三种数据持久化的方式

  • AOF日志:AOF叫做Append Only File ,每次执行一次写的操作,就会把该命令追加写入到一个文件中
  • RDB快照:RDB是Redis DataBase的缩写,它在Redis中是一个文件的后缀,而这个文件就是当前数据库的状态,每隔一段时间,Redis就会将内存中的二进制数据写出到这个文件中
  • 混合持久化方式:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点

11. 说说你对AOF的理解及其实现原理

AOF叫做只追加写文件,这个文件记录了每次写操作的命令记录,当Redis重启的时候,就会读取这个文件中的记录,然后逐一执行执行来进行数据的恢复

set name Lumxi

其对应的日志文件中的记录是

*3
$3
set
$4
name
$5
Lumxi

*3:代表着当前的指令具有三个部分,每个部分都是以$num开头的,后面紧跟着具体的命令、键或者值,然后这里的数字表示这部分的指令,一共有多少个字节

appendonly yes
appendfilename "appendonly.aof"

这个日志文件与Mysql中的redo log有什么区别?

首先我们先来讲讲redo log是什么,redo log是MySQL中InnoDB引擎提供的一种机制,其主要工作机制可以简要描述成这样:在涉及到事务对磁盘的写入操作的时候,并不立即写磁盘,而是先记录日志,等到合适的实际再进行刷盘,同时,由于有一部分数据已经持久化磁盘中,因此需要checkpoint机制来记录哪些记录已经刷盘,哪些记录没有被刷盘。总结来说有两点:①先写日志再刷盘②有checkpoint机制检查当前日志哪些已经执行过了

AOF log作为日志,也具有类似的功能,但是有很多不一样

首先,AOF log的操作是先执行指令再写日志,没有checkpoint机制,在Redis启动的时候需要读取整个文件

解释一下:为什么要先执行指令再写日志呢?

相比于刷盘,执行指令的速度快了许多,而且后续的那个写日志操作虽然是顺序I/O,但是它本质上还是I/O,它的效率是远远低于操作内存的速度的,因此先执行指令能够使得服务端能够更快的进行响应

同时,考虑到写入日志的I/O操作是一个耗时的操作,因此如果执行一条就写入一条,就可能会导致后续的执行阻塞,因此还设计了不同的写回策略来尽量避免I/O操作对主线程执行指令的影响。

还有一点,就是操作指令和写入日志不是原子性的,不像Mysql中具有两阶段提交的事务服务,AOF可能会导致部分日志丢失导致数据丢失的问题,这也是一个缓存一致性的问题。

下面来具体讲讲日志是如何写入的

Redis执行完写操作之后,会先将命令追加到本地的server.aof.buf缓冲区

如何通过write()的调用,将aof_buf缓冲区的数据写入到内核缓冲区中PageCache,等待内核将数据写入到硬盘,具体内核缓冲区的数据什么时候写入到硬盘,由内核决定

而这个具体写入到磁盘的过程,就是一个写回磁盘的策略

可以看到,当开启了AOF持久化后,由于存在用户态和内核态的切换,最终会导致执行性能的下降

12. AOF的写回策略有几种?

一般有三种机制,一般是在redis.conf这个文件里面配置的,这个配置叫做appendfsync

Alawys:总是写回磁盘,实现的是强一致性,每次写完指令就将日志刷回磁盘,由redis主进程完成这个操作

Everysec:每秒写回磁盘,它的意思是每次执行完指令后,先将数据写入到AOF文件的内核缓冲区,然后每隔一秒将数据写回到磁盘,最多会丢失一秒钟内的数据

No:意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

13.当AOF的日志太大的时候会发生什么?会导致磁盘爆掉吗?

AOF日志文件太大的时候会触发一个重写机制,其目的就是为了避免AOF无限制的扩大,导致出现性能以及存储问题。

说说AOF重写是怎么实现的?

简单来说,它就是当AOF日志文件超过了一个阈值之后,就会开始遍历数据库,将当前数据库中所有的键值对以指令的形式保存到AOF文件中,当所有的指令记录完毕后,就可以将新的AOF文件替换掉旧的AOF文件了

有个问题,如果在读取数据库键值对的时候,主线程接收的请求修改了已经记录的键值对,那么怎么办?

这个问题会导致数据的不一致,因此提出了一种再追加的策略

首先我们先要理解完成AOF重写的进程bgrewriteaof,它是在后台执行的

由进程来完成重写任务有这样的好处

  • 首先,主进程可以处理请求而不需要阻塞,提供响应速度
  • 第二,如果采用线程的方案的话,那么就会导致内存的共享问题,那么在多线程并发的情况下,为了保证临界资源的安全性,必须通过加锁的方案来实现,因此会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。

这个fork出来的共享内存是这样工作的,假设有数据A和数据B,被进程A和进程B共享,而且为父子进程关系,它实际上是在fork的时候在内存中生成了一块快照 ,为只读的状态,也就是说当A和B都去读取这个快照的时候,它只能被读,当被写的时候,就会把数据重新生成并且一份到另外的空间中,然后修改这个数据的指向

理论上使用线程也可以达到相同的效果,但是为什么不用呢?我个人认为这是因为redis为了保证线程安全,为了便于扩展的目的,我们现在知道,这个记录log的线程实际上是只需要读取的,那么实际上不加锁最终都不会导致结果发生变化,因为后来的缓冲区的覆盖会覆盖掉之前的数据,但是这始终是线程不安全的行为,如果在后续的优化中,涉及到这一块的内容,那么软件就需要大改,是不好的。

为什么要采用这种共享内存+copy-on-write的方案?

这是因为主进程在fork()的时候实际上是陷入阻塞态的,因此在这样的情况下,我们希望越快完成fork()越好,如果需要将内存数据复制一份的话,那么就会导致阻塞时间长的问题,但是如果没有内存复制的情况话,那么就速度是相对较快的,因为只需要修改内存指针的指向(页表)

但是重写过程中,主进程依然可以正常处理命令,那问题来了,重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,那么会发生写时复制,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?

为了解决这种问题,Redis设置了一个AOF重写缓冲区,这个缓冲区在创建bgrewriteaof 子进程的时候就被创建了

在重写AOF的期间,当Redis执行完一个指令后,它会同时将这个写命令写入到AOF缓冲区和AOF重写缓冲区

这里的话,只写AOF重写缓冲区不行吗?为什么还要写AOF缓冲区,反正最终都会被替代

假设在完成AOF重写的时候就发生了宕机,那么AOF的重写就是无效的,那么我后面怎么来恢复数据呢?只能找到原来的AOF文件,因此为了保证持久化的鲁棒性,在没有完成AOF的重写之前必须保证原来的AOF能够得到原来的数据库的状态,因此我们必须保证原来的AOF文件的完整性

因此,我们总结一下,在bgrewriteaof进程执行的时候,要完成三件事情

  • 执行客户端发来的指令
  • 将执行后的写命令追加到AOF缓冲区中
  • 将执行后的写命令追加到AOF重写缓冲区中,记录当前主线程修改的所有值

当子进程完成了AOF的重写工作后,会向主进程发送一条信号,主进程收到信号后就会调用一个已经注册的信号处理函数,该函数要完成的工作是:

  • 将AOF缓冲区中的所有内容追加到新的AOF文件中
  • 新的AOF文件进行更名,覆盖现在有的AOF文件

信号函数执行完后,主进程就可以继续像往常一样处理命令了。

14. 知道RDB吗?RDB实现持久化的机制和AOF有什么区别?

因为AOF日志记录的是操作指令的集合,在启动的时候是需要全量遍历,一旦日志数量非常多,就会导致非常缓慢

为了解决这个问题,Redis引入了快照技术,RDBRedisDataBase的缩写,它记录了某个瞬间的内存中的数据,这是根本的区别

RDB通过记录内存中的Redis数据来保存数据库的状态,而AOF则是通过记录指令的方式来保存数据库的状态

在这种情况下,使用RDB来恢复Redis的状态只需要将内存数据导入到内存中,而不需要执行指令,因此相对来说比较快

AOF的执行过程中,如果不异步的话可能会导致阻塞,那么使用RDB技术会导致这样的问题吗?

save#阻塞式保存
bgsave#后台子进程保存
save 900 1 #900s内至少有一次key的修改,那么就保存

这里提一点,Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。

15. RDB在执行快照的时候,数据可以被修改吗?

首先RDBAOF的机制都是基于子进程实现的,而子进程对于共享内存的写入访问,使用了写时复制的技术

简单来说,就是父子进程在fork的时候的那个共享内存已经定下来了,假如有一个线程想要写这个内存,那么就会触发这个机制,这个机制会对你写入的那个页进行复制,这样的话,除了只读的共享内存之外,它还新增了一个复制页,然后之后这个进程去读取相关的数据的时候,就回去读取自己产生的那个页

RDB会在什么时候执行?save 60 1000代表什么?

默认是在服务停止的时候就会执行,而当执行了save 60 1000,它的含义就是说60秒内至少执行1000次修改那么就会触发RDB

RDB的缺点是什么?

可以通过RDB的过程进行回答,因为RDBfork子进程的过程涉及到页表的复制,RDB文件的导出等操作,这些操作都可能耗时比较长,一旦耗时比较长,那么就意味着备份的频率不能太高,一但不能太高,那么就意味着内存数据持久化失败的风险

16. 既然AOF和RDB都有各自的缺点,还有什么方案吗?

首先先来梳理一下AOFRDB的特点

  • AOF的特点记录了指令的逻辑日志,每次追加的内容都比较少,造成阻塞的可能性很低,但是在Redis启动的时候比较慢
  • RDB的特点是记录了当前内存的数据情况,每次需要记录的数据量巨大,造成阻塞的可能性很大,但是在Redis中不需要执行指令,因此启动速度很快

为了集成两者的优点,提出了混合使用AOFRDB(内存快照)

工作机制是这样进行描述的

混合持久化工作在AOF日志重写过程,当开启了混合持久化的时候,在AOF重写日志的时候

  • 第一步,AOF复制当前数据库状态,同样会fork子进程,原本是检查所有的键值对,而现在就是直接将整个内存的数据载入到AOF文件中
  • 第二步,如果此时主线程在执行指令,那么就会将指令追加到AOF缓冲区中,写入完成后通知主进程将文件进行替换

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快

加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失

混合持久化优点:

  • 混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。

混合持久化缺点:

  • AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;
  • 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本

17. 说说AOF和RDB的优缺点

如果要比较AOFRDB的优缺点,可以从AOFRDB的主要不同点来说

首先AOF的持久化策略是一种基于增量持久化的方式来进行持久化的,而RDB的持久化策略是一种基于全量持久化的方式来进行持久化的,增量持久化意味着AOF写入日志的时间是比较短的,因此通常来说丢失的数据较少,一般来说我们都是使用everySecond这种策略来进行持久化,这种持久化是说:每次都将日志写入到缓冲区,然后后台的异步线程将这个缓冲区刷回到磁盘,always则是每条记录都将缓冲区中的内容写回到磁盘,no则是将日志写入到缓冲区就结束了,具体的持久化到磁盘的时机交给操作系统来决定

因此从这个角度上来讲AOF的持久化过程比较短,两次持久化之间的时间间隔短,对于数据的完整性的保护是比较高的,但是RDB因为每次都需要读取整个内存,包括说fork(),这个过程需要重新创建进程,复制页表,然后发生写时复制时候还要修改页表项,这就意味着在每次执行RDB持久化的时候都会花费比较长的时间,因此两次持久化的时间间隔长,因此对数据的完整性保护不好,因为两次备份中间一旦宕机,可能会丢失很多数据

从对系统资源的占用来说,RDB需要进行fork(),需要将当前的内存数据导出为文件,这涉到内核态=>用户态的相互转换,涉及到磁盘IO,因此对系统的占用是较大的

而对于AOF来说,由于每次只需要向磁盘发出IO,然后写入指令,主要是占用磁盘IO资源,在不同的写回策略中,占用表现不一样,但是RDB文件的大小是做过压缩的,文件体积小,而AOF文件体积大,在Redis服务重新加载的时候,RDB文件由于只需要将数据载入到内存中,速度很快,而AOF文件需要Redis解析其中指令,并且在底层调用相关API,这样的话就会导致恢复速度很慢

当AOF文件过大的时候,就会产生AOF重写,这个所谓的重写就是说将当前Redis中的键值对以指令的方式重新记录下来,从而过滤掉无用的操作指令,这个AOF重写和RDB的开销几乎相当,因为也是要fork()出来一个新的进程来完成操作,同时为了保证重写期间的AOF指令能够被正确记录,还开辟了一个AOF重写缓冲区,它会将开启AOF之后的追加的指令追加到这个AOF重写缓冲区中。


文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录