Redis复习大纲

1. I/O 多路复用

Redis 为什么快?

  • 纯内存操作, 不会受到硬盘 I/O 速度的限制
  • 单线程操作, 避免了不必要的上下文切换和竞争条件
  • 非阻塞 I/O 多路复用机制

预备知识

  • 用户态 内核态

    参考文献:


    • linux 架构图

      image-20200809211115374
    • 系统调用: 系统调用组成了用户态跟内核态交互的基本接口. 操作系统一般是通过中断用户态切换到内核态。中断就是一个硬件或软件请求,要求CPU暂停当前的工作,去处理更重要的事情。

      image-20200809213056594

    • 用户态切换到内核态的方式: 系统调用, 异常, 外设中断

    • 为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享


  • 上下文切换

    参考文献


    进程上下文切换 内核态,运行于进程上下文,内核代表进程运行于内核空间

    • 进程的上下文不仅包括了虚拟内存、栈、全局变量用户空间的资源,还包括了内核堆栈、寄存器内核空间的状态。
    • 进程的上下文切换就比系统调用时多了一步:在保存当前进程的内核状态和 CPU 寄存器之前,需先把该进程的虚拟内存、栈等保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。
    • 每次上下文切换都需要几十纳秒到数微妙的 CPU 时间, 耗时

    中断上下文切换 内核态,运行于中断上下文,内核代表硬件运行于内核空间。

    • 跟进程上下文不同,中断上下文切换并不涉及到进程的用户态
    • 所以即便中断过程打断了一个正在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。

    线程上下文切换

    • 线程与进程最大的区别在于,线程是操作系统调度的最小单位,而进程是操作系统分配资源的最小单位

    • 内核调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源

    • 线程 vs 进程 理解

      • 当进程只有一个线程时,可以认为进程就等于线程
      • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。
      • 另外线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也时需要保存的
    • 前后两个线程属于不同进程。此时因为资源不共享,所以切换过程就跟进程上下文切换是一样的。

    • 前后两个线程属于同一个进程。此时虚拟内存是共享的,上下文切换时,虚拟内存这些资源保持不动,只需要切换线程的私有数、寄存器等不共享的数据。


  • 进程的阻塞

    正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

  • 文件描述符: 是一个用于表述指向文件的引用的抽象化概念。

  • 缓存 I/O

    缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

select

  • 参考文献

    【并发】IO多路复用select/poll/epoll介绍, B站视频

    select,poll,epoll 比较

    DMA B站视频

    DMA的出现就是为了解决批量数据的输入/输出问题。DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。这样数据的传送速度就取决于存储器和外设的工作速度。

  • select 函数

    /*
     * @nfds: 待监听的最大fd值+1
     * @readfds: 待监听的可读文件fd集合
     * @writefds: 待监听的可写文件fd集合
     * @exceptfds: 待监听的异常文件fd集合
     * @timeout: 超时设置,在等待指定时间后返回超时
     * return:返回满足条件的fd数量和,如果出错返回-1,如果是超时返回0
     */
    int select(int nfds, fd_set *readfds, fd_set *writefds,
            fd_set *exceptfds, struct timeval *timeout);
    
  • select 实现原理

    • 1、拷贝nfds、readfds、writefds和exceptfds到内核 (从用户态 => 内核态)
    • 2、遍历[0,nfds)范围内的每个流,调用流所对应的设备的驱动 poll 函数 =>
    • 3、检查是否有流发生,如果有发生,把流设置对应的类别,并执行4,如果没有流发生,执行5。或者timeout=0,执行4
    • 4、select返回
    • 5、select阻塞当前进程,等待被流对应的设备唤醒,当被唤醒时,执行2。或者timeout到期,执行4

    流程回答(面试):

    • 执行 select 的时候, OS 将 nfds, readfds 等 拷贝到内核,从用户态切换到内核态.

    • 在内核空间中, select 会遍历 readfds 里面的所有 socket, 然后调用 socket file_operation 的 poll 方法,将进程等待任务加入 socket 等待队列,然后返回 socket 状态. poll() 方法将进程等待任务加入到 socket 的等待队列,然后返回设备当前的状态(可读/可写/异常等),如果内核判断当前状态尚不可用,会执行 wait() 方法将进程挂起。直到设备接收到事件(可读/可写/异常等)的时候,会执行 socket 等待队列中的进程等待任务,并将 socket 的信息通过回调函数通知到内核。

    • 如果 socket 可读,则,对这个 socket 进行标记

    • 如果没有 socket 可读,则内核会将进程挂起, 直到 socket 可读时候, 会执行 socket 等待队列中的进程等待任务, 唤醒后的进程会再次遍历所有 socket,执行 poll() 方法,并在 readfds 中标记可读 socket

    • 执行完成之后,将 readfds 由内核态拷贝回用户态.


    缺点:(针对 实例来看)

    • bitmap 默认大小为 1024
    • fdset 不可重用
    • 拷贝 rset 到 内核态,即 用户态和内核态之间 存在开销
    • select 返回之后,不知道具体哪个 fd 有数据状态(fd 会被置位)
  • 一段使用实例

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    memset(&addr, 0, sizeof (addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(2000);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
    listen (sockfd, 5); 
    
    for (i=0;i<5;i++) 
    {
     memset(&client, 0, sizeof (client));
     addrlen = sizeof(client);
     fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
     if(fds[i] > max)
         max = fds[i];
    }
    // 上面准备 fds 数组以及最大文件描述符 max.  准备了 5 个文件描述符,用于演示
    while(1){
         FD_ZERO(&rset);
         for (i = 0; i< 5; i++ ) {
             FD_SET(fds[i],&rset);
         }
    
         puts("round again");
         //rset 是使用 bitmap 存放文件描述符,比如 文件描述符为 1 2 5 7 9 => 0110 0101 0100...
         //默认 bitmap 大小为 1024.
         /*
            为什么要 max + 1,因为 max 是文件描述符的最大值,bitmap 从 0 开始,
            如果 max = 9,则需要取出 bitmap 的前 10 个 bit
            ------------------------------------------------------------
            select 先将 rset 复制到内核态,然后依次遍历 fd,判断是否有数据到了,没有数据来则阻塞等待,
            如果有数据,则将 fd 置位,并返回
         */
         select(max+1, &rset, NULL, NULL, NULL);
    
         for(i=0;i<5;i++) {
             if (FD_ISSET(fds[i], &rset)){ //FD_ISSET 判断哪个 fd 置位
                 memset(buffer,0,MAXBUF);
                 read(fds[i], buffer, MAXBUF);//读数据
                 puts(buffer); //处理数据
             }
         }	
    }
    

poll

  • poll 函数: https://www.cnblogs.com/zengzy/p/5115679.html

    struct pollfd {
          int fd;//fd属性表示一个打开的文件描述符
          short events; //是一个输入参数,通过bit mask的方式描述程序感兴趣的事件(读、写)
          short revents;//是一个传出参数,同样式通过bit mask的方式描述发生的事件,这个属性的值是由内核设置的
    };
    
    int poll (struct pollfd *fds, unsigned int nfds, int timeout);
    
  • 上面实例改写

    for (i=0;i<5;i++) 
    {
        memset(&client, 0, sizeof (client));
        addrlen = sizeof(client);
        pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
        pollfds[i].events = POLLIN;//读事件
    }
    // 上面构造 pollfd 
    sleep(1);
    while(1){
        puts("round again");
        /*
        	和 select 的原理一样,只不过是这里使用了 pollfd
        	区别:
        		检测到有数据,将 pollfd 的 revents 置位
    		解决了 selcet 缺点:
    			1) bitmap 大小限制
    			2) select 的 fd 不能重用,这里的 pollfd 可以复用
        */
        poll(pollfds, 5, 50000); // 置位 revents
    
        for(i=0;i<5;i++) {
            if (pollfds[i].revents & POLLIN){
                pollfds[i].revents = 0; // 恢复 revents
                memset(buffer,0,MAXBUF);
                read(pollfds[i].fd, buffer, MAXBUF);
                puts(buffer);
            }
        }
    }
    

epoll

  • 函数

    //epoll_create创建epoll文件,其返回epoll的句柄,size用来告诉内核监听文件描述符的最大数目
    int epoll_create(int size);
    
    /*
       epoll_ctl用于用户告知内核自己关心哪个描述符(fd)的什么事件(event)
       
       epfd: 使用epoll_create函数创建的epoll句柄,
       		epfd文件描述符对应的结构中,有一颗红黑树,专门用于管理用户关心的事件集合
       op,用于指定用户行为
       event,用户关心的事件(读,写)
    */
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    
    /*
       epfd,使用epoll_create函数创建的epoll句柄,
       	epfd文件描述符对应的结构中,有一颗红黑树,专门用于管理用户关心的事件集合。
       events,传出参数,表示发生的事件
       maxevents,传入参数,表示events数组的最大容量,其值不能超过epoll_create函数的参数size
       timeout,0,不阻塞;整数,阻塞timeout时间;负数,无限阻塞
       
       epoll_wait函数的原理就是去检查上面提到的rdlist链表中每个结点,
       	rdlist的每一个结点能够索引到监听的文件描述符,
       	就可以调用该文件描述符对应设备的poll驱动函数f_op->poll,用以检查该设备是否可用。
    */
    int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    
  • epoll 讲解

  • 上面改进的实例

    struct epoll_event events[5];
    int epfd = epoll_create(10);
    //...
    //...
    for (i=0;i<5;i++) 
    {
        static struct epoll_event ev;
        memset(&client, 0, sizeof (client));
        addrlen = sizeof(client);
        ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
        ev.events = EPOLLIN;
        epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); 
    }
    //  epoll_ctl 构造了一个 epfd,将 fd 和 event 封装在一起了
    while(1){
        puts("round again");
        /*
        	在水平置位下, 用户态和内核态共享内存 ? 这里可能不对
        	置位: 重排,将有数据的 fd 放在 rdlist链表 的开头, 返回 有数据的 fd 的数量	
        */
        nfds = epoll_wait(epfd, events, 5, 10000);
    
        for(i=0;i<nfds;i++) { //这边直接遍历有数据的 fd 就行了,不需要在寻找了
            memset(buffer,0,MAXBUF);
            read(events[i].data.fd, buffer, MAXBUF);
            puts(buffer);
        }
    }
    
  • 比较

    • select就是轮询,在Linux上限制个数一般为1024个
    • poll解决了select的个数限制,但是依然是轮询
    • epoll解决了个数的限制,同时解决了轮询的方式

Redis I/O 多路复用的封装

  • 参考文章

    redis多路复用的理解

    redis源码–IO多路复用的封装

    I/O 模型

    公众号文章之IO多路复用

  • socket 建立过程

    int main(int argc, char *argv[]) {
        listenSocket = socket(); //调用socket()系统调用创建一个监听套接字描述符
        bind(listenSocket);  //绑定地址与端口
        listen(listenSocket); //由默认的主动套接字转换为服务器适用的被动套接字
        while (1) { //不断循环去监听是否有客户端连接事件到来
            connSocket = accept($listenSocket); //接受客户端连接
            read(connsocket); //从客户端读取数据,只能同时处理一个客户端
            write(connsocket); //返回给客户端数据,只能同时处理一个客户端
        }
        return 0;
    }
    
  • Redis客户端对服务端的每次调用都经历了发送命令,执行命令,返回结果三个过程

    执行命令阶段,由于Redis是单线程来处理命令的,所有每一条到达服务端的命令不会立刻执行

    所有的命令都会进入一个队列中,然后逐个被执行

    并且多个客户端发送的命令的执行顺序是不确定的。但是可以确定的是不会有两条命令被同时执行,不会产生并发问题,这就是Redis的单线程基本模型。

  • 文件事件处理器: Redis 基于 Reactor 模式开发了自己的网络事件处理器: 这个处理器被称为文件事件处理器(file event handler)

    • 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器。

    • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

    • 文件事件处理器

    • 文件事件

      文件事件是 是对套接字操作的抽象, 每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时, 就会产生一个文件事件。 因为一个服务器通常会连接多个套接字, 所以多个文件事件有可能会并发地出现。

    • I/O 多路复用程序: 负责监听多个套接字, 并向文件事件分派器传送那些产生了事件的套接字。

    • I/O 多路复用程序总是会将所有产生事件的套接字都入队到一个队列里面, 然后通过这个队列, 以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字: 当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字。

    è¿éåå¾çæè¿°

  • Redis 线程模型: 执行流程

    文件事件处理器

    • 如果是客户端要连接 redis,那么会为socket关联连接应答处理器
    • 如果是客户端要写入数据到 redis,那么会为 scoket关联命令请求处理器
    • 如果是客户端要从 redis读数据,那么会为socket关联命令回复处理器

    在这里插入图片描述

  • IO复用的封装实现

    为了将所有io复用统一,Redis为所有io复用统一了类型名aeApiState,对于epoll而言,类型成员就是调用epoll_wait所需要的参数

    //ae_epoll.c
    typedef struct aeApiState {
        int epfd; //epollfd,文件描述符
        struct epoll_event *events; //保存激活的事件(epoll_event)
    } aeApiState;
    

    image-20200810130947794


2. 事务

介绍

事务的实现

  • 事务的实现: 包括 事务开始, 事务入队, 事务执行

    事务开始

    • 当客户端输入multi命令,表示事务开始。该命令会将redis从非事务状态切换到事务状态,切换是通过修改客户端的flags属性加上REDIS_MULTI常量,表示打开事务

    事务入队:

    • 当redis开启事务状态,只有4个命令会立即执行:multi、discard、exec、watch
    • 其他命令都不立即执行,而是放入redis的事务队列,并且客户端回复queue。
    • img

    保存事务的状态属性:

    typedef struct multistate{
        //命令列表
        multiCmd *commands;
        //记录入队的命令个数
        int count;
    } multistate;
    
    typedef struct multiCmd{
    	//命令参数
        robj **argv;
    	//参数个数
        int argc;
    	//指向实现命令的 redisCommand
        struct redisCommand*cmd;
    } multiCmd;
    

    multistate 结构图:

    • img

    执行事务:

    • 当处于事务状态的客户端收到命令exec,则会执行事务队列中的所有命令,并将结果按照执行顺序,全部返回给客户端。

    • 执行过程包括:

      1)创建空白队列,用于保存每个命令的执行结果;

      2)按照FIFO的顺序执行命令队列的命令,并将结果按顺序放入空白队列;

      3)移除flags属性的REDIS_MULTI标记,表示客户端退出事务;

      4)清除入队命令计数器、释放事务队列;

      5)将执行结果返回给客户端。

watch

  • watch命令是一个乐观锁,可以在执行exec之前,监视任意数量数据库的键,并在执行exec时,检查监视的键是否有被修改的,如果有一个或以上的键被修改,则拒绝执行事务,客户端返回事务执行失败的空回复(nil)。

  • 如何实现

    watch 监视 key1, key2, …

    • redis数据库结构体 redisDb中,保存着watched_keys 字典,字典的是被watch命令监视的键,值是一个链表记录所有监视相应数据库键的客户端。

      typedef struct redisDb {
          // ... 还有其他的属性
          // 正在被 WATCH 命令监视的键
          dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
          // 数据库号码
          int id;                     /* Database ID */
      } redisDb;
      
      • image-20200810155856748

    监视触发:

    • 所有对数据库的键进行修改的命令,如set、lpush等,执行后都会自动调用multi.c/touchWatchedKey函数,对字典watched_keys 进行检查,查看是否有客户端监视该键
    • 如果数据库监视该键,则将监视被修改键的所有客户端的状态 REDIS_DIRTY_CAS 标识打开,表示该客户端事务的安全性已经被破坏。

    判断事务是否安全:

    • 当客户端执行exec命令时,就会判断对应自身客户端的状态是否被打开 REDIS_DIRTY_CAS
    • 如果被打开,说明至少一个键被修改,则事务不安全,redis服务器拒绝客户端提交事务,并返回nil;
    • 如果没有被打开,表示事务安全,正常执行事务。

    img


事务 ACID

  • 原子性: 要么事务全部操作都执行,要么全部不执行

    redis的命令错误等,在命令入队的时候就会进行校验,如果有命令错误,则入队的时候会报错,等到执行exec时,redis则拒绝执行整个事务redis不支持事务的回滚

  • 一致性: 事务执行前后,数据库是一致的

    redis在三个地方进行校验:

    • 入队出错

      命令不存在或命令格式错误等,表示入队出错,redis会拒绝执行事务。

    • 执行出错

      在执行中发生的错误,不会中断事务,事务会继续进行

      错误的命令会被服务器报出,并且不会将错误的命令进行执行,保证数据一致性。

    • 服务器停机

      无持久化,则没有保存任何数据,数据是一致的。

      rdb或aof持久化,则可以根据rdb或aof文件进行恢复,不会发生数据不一致。

      如果无文件,则无法恢复数据,但数据仍是一致性的。

  • 隔离性: 多个事务并发进行,各个事务不会互相影响,并且并发状态下执行事务与串行状态下执行事务结果完全相同。

    • 由于redis是单线程执行事务,且服务器保证事务执行期间不会有其他命令插入,因此redis的命令总是串行执行的,保证隔离性。
  • 持久性: 一个事务执行完毕后,事务的结果被保存在磁盘里,后面及时服务器停机,数据仍存在。

    • redis事务的持久化与否取决于redis服务器配置的持久化策略。

    • 只有redis在aof持久化状态下,且appendfsync选项的值设置为always,程序才会每次将命令的结果实时强制同步到磁盘中,redis的事务才有真正的耐久性,其他情况下的redis事务不具有耐久性。

    • 虽然可以在执行exec之前,输入一个save命令,强制全磁盘保存,保证事务的耐久性,但是由于save是阻塞的,效率极低,因此不具有实用性。

  • 小结

    1、redis的事务是将一组命令打包,一次性、有序的执行。

    2、事务中的多个命令会被放入到事务队列中,FIFO的被执行

    3、事务执行过程中不会被中断,一个事务执行完才会执行下一个事务。

    4、watch命令通过数据库的redisDb结构体的watched_keys字典中,将字段与要监视的客户端进行关联,当键被修改,则相应的监视该键的全部客户端的REDIS_DIRTY_CAS标识被打开。只有该表示没打开,服务器才会执行客户端的事务,否则服务器会拒绝提交事务。

    5、redis事务具有原子性、一致性、隔离性,其是否具有耐久性取决于redis持久化的配置策略。



先把常见的问题看了, 剩下的再说


3. 过期策略

基本介绍

  • 设置生存时间

    在redis客户端,可以通过 expire 命令设置某个键的以为单位的生存时间(TTL),也可以用 pexpire 设置以毫秒为单位的时间。setex命令可以在对字符串对象设置值的时候,同时设置过期时间,但是其只针对字符串对象可以使用。

  • 设置过期时间

    通过expireat或pexpireat命令,设置数据库键的过期时间。这个时间是一个unix时间戳,当时间到达该时间时,redis会删除该键。

  • 可以用 ttl 或 pttl 命令,查看键的剩余生存时间。如果键不存在数据库,会返回-2;键没有过期时间,返回-1;如果键有过期时间,则用过期时间的unix毫秒时间戳,减去当前时间的unix毫秒时间戳。

  • redis有四个命令设置过期时间,但是实际上,expire、pexpire、expireat三个命令都是通过pexpireat命令实现的。

    image-20200810204122042

  • 移除过期时间

    • redis 客户端执行 persist 命令,可以将某个key移除过期时间。移除后,再用 ttl 命令查询,会得到-1的结果,即-1表示的是没有设定过期时间。

    • 具体实现上,也就是将expires指向key的指针以及其值的内存空间收回即可。redis在用ttl命令查询expires字典,查不到时,就返回-1,表示没有设置过期时间。

结构

  • 保存过期时间

    typedef struct redisDb {
        // 数据库键空间,保存着数据库中的所有键值对
        dict *dict;                 /* The keyspace for this DB */
        // 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
        dict *expires;              /* Timeout of keys with a timeout set *
        // 数据库的键的平均 TTL ,统计信息
        long long avg_ttl;          /* Average TTL, just for stats */
    } redisDb;
    
    • 带过期字典的数据库: 过期字典的键是一个指针,指向键空间的某个对象,也就是数据库的某个键

      image-20200810205251095

过期键的删除方式

  • 定时删除、懒惰删除、定期删除

  • 如何判断是否过期

    从expires字典,去判断当前时间是否大于字典里的时间,如果大于则表示键过期,否则没有过期。

  • 定时删除

    • 过期即删除,这种策略会在设定键的过期时间的时候,同时设定一个定时器,定时器的时间一到,马上将对应的键值对删除。
    • 优点: 定时删除是最节约内存的方式,保证每个键一到过期时间马上删除。
    • 缺点: 删除键需要时间,对CPU 是不友好的,过期键较多的情况下, 删除过期键会占用较多时间.
    • 缺点: 定时删除,需要创建的定时器需要使用时间事件,而时间事件的实现是无序链表,查找一个事件的时间复杂度是 O(n)
  • 懒惰删除

    • 放任过期时间不管,但是每次客户端操作数据库的键的时候,都会判断键是否过期,如果过期则删除键,并返回空给客户端;否则直接将相应结果返回客户端。
    • 优点:懒惰删除是对时间上最友好的,不检查键,也不用定时器。
    • 缺点:缺点也很明显,如果大量键已经过期,并且长期没有客户端访问这些键,那么这些键以及其值都会长期占用内存,不释放空间,可以看成内存泄漏。
  • 定期删除

    • 每隔一段时间,redis会检查键的过期时间,如果过期则删除。至于检查几个键,几个数据库,可以由算法策略决定。
    • 定期删除可以看做定时删除和懒惰删除的折中的方式,每隔一段时间进行一部分的删除,通过限制删除执行的时长和频率来降低对cpu的影响,又避免一些键长期不被删除的风险。
    • 难点在于定期的策略. 即删除的频率和删除的数量
  • Redis 中采用 定期删除 和 懒惰删除的方式

    • 懒惰删除: 在redis是通过db.c文件的expireIfNeeded函数实现。客户端对每个键的访问,都会先调用此函数。如果键过期,则删除键,否则不动作。

    • 定期删除在redis是通过redis.c文件的activeExpireCycle函数实现。

      activeExpireCycle函数工作方式总结如下:

      每次函数运行,都从一定量的数据库中,找出一定量的键,进行检查,并删除过期的键;有一个全局变量 current_db,会记录activeExpireCycle函数的检查进度,下一次执行时会顺着当前的current_db检查下一个db,检查到最后一个db时这个值会重新变成0。

    • Redis过期删除策略是采用惰性删除和定期删除这两种方式组合进行的,惰性删除能够保证过期的数据我们在获取时一定获取不到,而定期删除设置合适的频率,则可以保证无效的数据及时得到释放,而不会一直占用内存数据。

  • 内存淘汰策略

    • 内存达到我们设定的界限后,便自动触发Redis内存淘汰策略

    • 设置Redis最大内存: 在配置文件redis.conf 中,可以通过参数 maxmemory 来设定最大内存

    • 设置内存淘汰方式 (通过设置 maxmemory-policy )

      1)volatile-lru 利用LRU算法移除设置过过期时间的key (LRU:最近使用 Least Recently Used ) 。

      2)allkeys-lru 利用LRU算法移除任何key (和上一个相比,删除的key包括设置过期时间和不设置过期时间的)。通常使用该方式

      3)volatile-random 移除设置过过期时间的随机key 。

      4)allkeys-random 无差别的随机移除。

      5)volatile-ttl 移除即将过期的key(minor TTL)

      6)noeviction 不移除任何key,只是返回一个写错误 ,默认选项,一般不会选用。

4. 缓存问题

缓存淘汰策略

  • 先进先出算法(FIFO)

  • 最近使⽤最少Least Frequently Used(LFU)

  • 最⻓时间未被使⽤的Least Recently Used(LRU)

  • 当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况⽐较严重

  • 实现 LRU

  • 适合做缓存的数据: 数据访问频率高, 数据读多写少, 数据一致性要求低

缓存穿透

  • 名词解释:

    指查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。

  • 解决方案:

    • 接口层加校验: 参数校验
    • 缓存 null 策略. 如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
    • 使用布隆过滤器. 将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层数据库的查询压力.
  • 布隆过滤器: 可以判断 可能在集合中 和 一定不在集合中

    标准的 Bloom Filter 是一种比较简单的数据结构,只支持插入和查找两种操作。

    BF 为什么不支持删除元素?

    • image-20200810221833453

    Counting Bloom Filter 的出现,解决了上述问题

    • 将标准 Bloom Filter 位数组的每一位扩展为一个小的计数器(Counter),在插入元素时给对应的 k (k 为哈希函数个数)个 Counter 的值分别加 1,删除元素时给对应的 k 个 Counter 的值分别减 1。
    • Counting Bloom Filter 通过多占用几倍的存储空间的代价, 给 Bloom Filter 增加了删除操作。
    • image-20200810222402568

缓存雪崩(很多key同时失效)

  • 名词解释:

    缓存雪崩是指在设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,导致所有的查询都落在数据库上,造成了缓存雪崩。

  • 解决方案

    • 不同的key,设置不同的过期时间(把每个Key的失效时间都加个随机值就好了),让缓存失效的时间点尽量均匀。
    • 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
    • 做二级缓存,或者双缓存策略。A1为原始缓存,A2为拷贝缓存,A1失效时,可以访问A2,A1缓存失效时间设置为短期,A2设置为长期。
    • 缓存雪崩解决方案

缓存击穿(单个key,热点key)

  • 名词解释

    对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。

    缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key

    缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮

  • 解决方案

    • 后台刷新: 这种方案比较容易理解,但会增加系统复杂度。比较适合那些 key 相对固定,cache 粒度较大的业务,key 比较分散的则不太适合,实现起来也比较复杂。

      后台定义一个 job(定时任务)专门主动更新缓存数据, 比如,一个缓存中的数据过期时间是30分钟,那么job每隔29分钟定时刷新数据(将从数据库中查到的数据更新到缓存中).

    • 检查更新:

      将缓存key的过期时间(绝对时间)一起保存到缓存中(可以拼接,可以添加新字段,可以采用单独的key保存…不管用什么方式,只要两者建立好关联关系就行).在**每次执行get操作后,都将get出来的缓存过期时间与当前系统时间做一个对比,如果缓存过期时间-当前系统时间<=1分钟(自定义的一个值),则主动更新缓存.**这样就能保证缓存中的数据始终是最新的(和方案一 一样,让数据不过期.)

      存在的问题: 比如缓存过期时间是 12:00, 在最近的一分钟内没有 get 请求,此时到了 12 点,缓存过期, 这时候大量的请求过来,会导致直接请求 后台 db, 造成 缓存击穿.

    • 分级缓存

      采用 L1 (一级缓存)和 L2(二级缓存) 缓存方式,L1 缓存失效时间短,L2 缓存失效时间长。 请求优先从 L1 缓存获取数据,如果 L1缓存未命中则加锁,只有 1 个线程获取到锁,这个线程再从数据库中读取数据并将数据再更新到到 L1 缓存和 L2 缓存中,而其他线程依旧从 L2 缓存获取数据并返回。

      存在的问题:

      这种方式,主要是通过避免缓存同时失效并结合锁机制实现。所以,当数据更新时,只能淘汰 L1 缓存,不能同时将 L1 和 L2 中的缓存同时淘汰。L2 缓存中 可能会存在脏数据,需要业务能够容忍这种短时间的不一致。而且,这种方案 可能会造成额外的缓存空间浪费。

    • 设置热点数据永不过期

    • 加锁: getData03 使用互斥锁 / 使用 分布式锁

      //存在的问题: 缓存失效后,所有的请求还是打到了 db
      public List<String> getData01(){
          List<String> res = new ArrayList<>();
          res = getDataFromCache();
          if (res.isEmpty()){
              synchronized (this){
                  //从数据库查询数据
                  result = getDataFromDB();//排队的请求还是会全部打到 db
                  //将查询的结果放入缓存中
                  setDataToCache(res);
              }
          }
          return res;
      }
      //双重判断虽然能够阻止高并发请求打到数据库,但是第二个以及之后的请求在命中缓存时,还是排队进行的.
      //剩下的29个请求则是依次排队取缓存中取数据.请求排在后面的用户的体验会不爽.
      public List<String> getData02(){
          List<String> res = new ArrayList<>();
          res = getDataFromCache();
          if (res.isEmpty()){
              synchronized (this){
                  res = getDataFromCache(); //避免排队的请求全部打到 db
                  if (res.isEmpty()){
                      //从数据库查询数据
                      result = getDataFromDB();
                      //将查询的结果放入缓存中
                      setDataToCache(res);
                  }
              }
          }
          return res;
      }
      
      //使用互斥锁的方式来实现,可以有效避免前面几种问题.
      Lock lock = new ReentrantLock();
      public List<String> getData03(){
          List<String> res = new ArrayList<>();
          res = getDataFromCache();
          if (res.isEmpty()){
              if (lock.tryLock()){
                  //尝试获取锁成功
                  //从数据库查询数据
                  result = getDataFromDB();
                  //将查询的结果放入缓存中
                  setDataToCache(res);
              }else { //获取锁失败
                  //查询缓存
                  res = getDataFromCache();
                  if (res.isEmpty()){
                      //没抢到锁,也没查到缓存
                      Thread.sleep(10);
                      return getData03();//重试
                  }
              }
          }
          return res;
      }
      

缓存击穿解决方案

  • 分析问题的角度

    • 事前:Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃。
    • 事中:本地 ehcache 缓存 + Hystrix 限流+降级,避免MySQL被打死。
    • 事后:Redis 持久化 RDB+AOF,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
  • 缓存击穿解决方案: 最终的目标: 尽量少的线程构建缓存(甚至是一个) + 数据一致性 + 较少的潜在问题

    • 使用互斥锁: 只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据就可以了

      单机环境: synchronized / lock, 分布式环境: 分布式锁

      //分布式锁,感觉还差点意思,不太懂, 后面再看 redlock, redisson
      String get(String key) {  
          String value = redis.get(key);  
          if (value  == null) {  
              if (redis.setnx(key_mutex, "1")) {  
                  // 3 min timeout to avoid mutex holder crash  
                  redis.expire(key_mutex, 3 * 60);
      			value = db.get(key);  
                  redis.set(key, value);  
                  redis.delete(key_mutex);  
              } else {  
                  //其他线程休息50毫秒后重试  
                  Thread.sleep(50);  
                  get(key);  
              }  
          }  
      }  
      
    • 提起使用互斥锁: 在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。伪代码如下:

      //没看懂啥意思
      v = memcache.get(key);  
      if (v == null) {  
         if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {  
             value = db.get(key);  
             memcache.set(key, value);  
             memcache.delete(key_mutex);  
         } else {  
             sleep(50);  
             retry();  
         }  
      } else {  
         if (v.timeout <= now()) {  
             if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {  
                 // extend the timeout for other threads  
                 v.timeout += 3 * 60 * 1000;  
                 memcache.set(key, v, KEY_TIMEOUT * 2);  
                 // load the latest value from db  
                 v = db.get(key);  
                 v.timeout = KEY_TIMEOUT;  
                 memcache.set(key, value, KEY_TIMEOUT * 2);  
                 memcache.delete(key_mutex);  
             } else {  
                 sleep(50);  
                 retry();  
             }  
         }  
      }  
      
    • 永不过期

      从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。

      从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期

      img

      • 缺点: 唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。

      String get(final String key) {  
         V v = redis.get(key);  
         String value = v.getValue();  
         long timeout = v.getTimeout();  
         if (v.timeout <= System.currentTimeMillis()) {  
             // 异步更新后台异常执行  
             threadPool.execute(new Runnable() {  
                 public void run() {  
                     String keyMutex = "mutex:" + key;  
                     if (redis.setnx(keyMutex, "1")) {  
                         // 3 min timeout to avoid mutex holder crash  
                         redis.expire(keyMutex, 3 * 60);  
                         String dbValue = db.get(key);  
                         redis.set(key, dbValue);  
                         redis.delete(keyMutex);  
                     }  
                 }  
             });  
         }  
         return value;  
      }   
      

  • 方案对比

    简单分布式锁:

    优点

    1、思路简单

    2、保证一致性

    缺点

    1、代码复杂度增大

    2、存在死锁的风险

    3、存在线程池阻塞的风险


    不过期

    优点

    1、异步构建缓存,不会阻塞线程池

    缺点

    1、不保证一致性。

    2、代码复杂度增大(每个value都要维护一个timekey)。

    3、占用一定的内存空间(每个value都要维护一个timekey)。


setnx

数据一致性

  • 参考文献

    如何保障mysql和redis之间的数据一致性?

    深入理解缓存之缓存和数据库的一致性

  • 加入缓存的架构

    先查询缓存,命中则直接返回,未命中则去 DB 中查,并将结果加入 Redis 缓存.

    存在的问题:(涉及数据更新操作的时候会出现问题)

    • 线程 A 执行**更新(写)**操作, 先删除了 Redis 中的缓存, 还没执行写库 MySQL 的删除操作, 此时 线程 B 执行读操作,从 缓存中读取失败,从数据库中读取并加入缓存, 然后返回结果. 此时线程 A 执行 MySQL 的数据删除操作,但是 Redis 缓存中存在者脏数据.
    • 线程 A 执行更新(写)操作,如果先 update MySQL 库,然后删除缓存, 删除缓存失败了,则会导致数据不一致的情况.
  • 解决方案: 如果需要强一致性的话,还是建议使用 直接访问数据库 不采用 缓存.

    延时双删:

    • 业务流程:

      img

    • 延时双删的步骤:

      先删除 cache, 在写 db , 休眠 500 ms(根据具体业务来定) , 再次删除 cache

      双删: 是为了防止在写 db 的过程中,发生读操作,将 脏数据 读入 cache 后不 删除.

      休眠的时间: 取决于一次 读操作的耗时,要确保在开始写 db 之前的 读操作完成,否则还是不能删除 cache 中的脏数据

    • 改进: 上面的第二次淘汰缓存是“为了保证缓存一致”而做的操作,而不是“业务要求”,所以其实无需等待,用一个异步的timer,或者利用消息总线异步的来做这个事情即可.

      image-20200813160916142

5. 持久化

介绍

RDB

  • 创建RDB:在执行bgsave或save命令创建一个新的rdb文件时,redis会对数据库中的所有的键进行检查,已经过期的键不会被加入新的rdb文件中。因此过期键对创建RDB没有影响。
  • 载入RDB:载入rdb文件时,会区分载入的这个redis服务器是主服务器还是从服务器。如果是主服务器,会检查所有的键,仅载入未过期的键;如果是从服务器,则无论是否过期都不载入,因为服务器会从主服务器上同步数据。

AOF

  • AOF写入:当一个键过期,但是键还没被惰性删除或定期删除,则AOF仍会将其写入到AOF文件中。当redis程序触发到删除该过期键的时候,会同时向AOF发送一条删除键的追加命令,就类似于正常的键被客户端通过del删除一样。

  • AOF载入:在载入AOF文件时,过期的键不会被载入,redis会检查键是否有过期,避免载入过期的键。

复制

  • 当服务器运行在复制模式,从服务器过期键的删除由主服务器进行控制:

    1)当主服务器要删除过期键,会显式向从服务器发送一条del指令,要求从服务器删除相应的键。

    2)当客户端读取到从服务器的过期键,从服务器不会删除键,而是当作正常的键返回。从服务器只有接到主服务器del指令才会删除键,

    其自身不会删除键

  • 因此,redis的机制是通过主服务器控制从服务器的过期键,统一管理,统一删除。

  • 主从结构中,从服务器不检查键是否过期,客户端对从服务器键的操作无论键是否过期,只要从服务器中有该键,就正常操作;主服务器会通过懒惰删除或定期删除来校验键,并且在删除过期键的时候,通知所有的从数据库同步删除,以确保主从结构的数据一致性

RDB

  • 创建

    save 和 bgsave 两个命令手动生成 rdb 文件

    • save 会阻塞进程, 当执行 save 期间, 服务器会拒绝执行其他命令请求

    • bgsave: 通过子进程进行创建 rdb 文件的工作.bgsave 执行期间可以执行大部分 redis 客户端请求, 除了下面的内容:

      • save:redis子进程在处理bgsave命令期间,会拒绝客户端发来的save命令,目的是为了避免父进程和子进程同时在创建rdb文件,也避免产生竞争条件。
      • bgsave:同样会被拒绝,同save。
      • bgwriteaof:bgsave执行期间,bgwriteaof命令会被延迟到 bgsave 命令执行完毕后才会执行; bgwriteaof 执行期间,bgsave命令会直接被服务器拒绝。这两个命令都是由子进程进行,拒绝同时进行是为了考虑性能,并发处理两个大量写入磁盘的子进程是应当被避免的。
    • bgsave: Redis进程执行 fork 操作创建子进程,RDB持久化过程由子 进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短

      bgsave 流程:

      image-20200814090741593

      1. 执行 bgsave 命令, Redis 父进程判断当前是否存在正在执行的子进程(RDB/AOF),如果存在 bgsave 命令则直接返回

      2)父进程执行 fork 操作创建子进程, fork 操作父进程会阻塞.

      1. 父进程 fork 完成后,bgsave 返回 background saving started 信息,不在阻塞 父进程,可以响应客户端的请求

      4)子进程创建 RDB 文件,根据父进程内存生成的临时快照文件,完成后对原有文件进行原子替换

      5)子进程完成创建 RDB 之后, 发送信号给 父进程表示完成, 父进程更新统计信息.


      image-20200814142244418


  • 载入

    • redis服务器载入rdb文件期间,会一直处于阻塞状态,直到载入完毕

    img

  • 自动保存: 通过配置文件中的save选项,让数据库每隔一段时间执行一次 bgsave 命令

    save n m

    表示的含义是,下列情况发生一种就执行bgsave:

    redis服务器 n 秒内至少执行 m 次修改

  • RDB 文件分析

    • 文件结构:

      img

      REDIS : 用于判断载入的文件是否是 rdb 文件

      db_version: 记录 rdb 版本. 第六版: 0006

      databses: 包含 0 个或者任意个数据库 以及数据库中的键值对数据

      EOF: 表示 rdb 正文结束,键值对载入完成

      check_sum: 用来检测 rdb 文件是否有出错或者损坏. 前 4 部分计算得到的结果,等服务器载入数据后,将载入的内容计算校验和与 check_sum 比较.

    • databases 结构

      image-20200813170123133

      key_value_pairs: 保存数据库键值对数据,包含键值对的过期时间。

      TYPE: 记录键值对的类型

      EXPIRETIME_MS: 告诉程序下一个是 unix 的毫秒时间, 用于表示其键值对的过期时间

      ms: 表示后面键值对的过期时间

  • 优缺点

    优点:

    • RDB是一个紧凑压缩的二进制文件,代表Redis在某个时间点上的数据快照。非常适用于备份,全量复制等场景。
    • Redis加载RDB恢复数据远远快于AOF的方式

    缺点:

    • 没办法做到**实时持久化/秒级持久化.(因为bgsave每次运行都要执行fork操作创建子进程,属于重量级操作,频繁执行成本过高。)**
    • fork 时候,内存中的数据被 克隆了一份, 大致 2 倍 的膨胀性能.

AOF

  • 参考文献

    《Redis设计与实现》读书笔记(十五) ——Redis AOF持久化原理与实现

    Redis 开发与运维_第五章持久化

  • 通过保存 redis 服务器写操作的命令来记录数据库的变化。即aof不保存键值对数据,而是保存每一个写操作的语句。流程如下图所示:

    image-20200814085955989

  • 写入

    当aof持久化打开后,每执行一个写命令,redis会以文本协议格式将其追加到服务器aof_buf缓冲区的末尾。

    • 文本协议格式的好处: 兼容性好, 具有可读性

    只允许在文件末尾追加内容,不允许改写文件。

    结构体如下:

    struct redisServer{
    	sds aof_buf;//aof缓冲区
    }
    

    工作流程图:

    image-20200814102611893


    AOF 流程步骤:

    • 1)所有的写入命令会追加到 aof_buf (缓冲区) 中

      写入缓冲区而不直接写入硬盘的好处:

      • 可以根据需求合理配置 何时 刷新到磁盘
      • 实时刷回磁盘, 降低系统的性能

    • 2)AOF 缓冲区根据对应的策略来决定何时向硬盘做同步操作

      由 appendfsync 参数控制:

      • always: 命令写入 aof_buf 后调用系统的 fsync 将缓存中的内容写入 aof 文件, fsync 完成后 返回
      • everysec: 命令写入 aof_buf 后调用系统的 write 操作, write 完成后返回. fsync 由专门线程每秒调用一次完成
      • no: 命令写入 aof_buf 后调用系统的 write 操作,不对 AOF 文件做 fsync 处理. 由 操作系统来负责持久化.通常同步周期最长为 30 秒.

      write操作会触发延迟写(delayed write)机制, 同步硬盘操作依赖于系统调度机制

      fsync针对单个文件操作(比如AOF文件),做强制硬盘同步,fsync将阻塞直到写入硬盘完成后返回,保证了数据持久化


    • 3)随着 AOF 文件的不断变大,需要对 AOF 文件进行重写,达到压缩的目的 (RDB 会使用 LZF 算法对生成的 RDB 文件进行压缩处理)

      重写后 AOF 文件变小:

      • 过期数据不在写入
      • 将命令合并(对同一个键进行多个操作)

      触发方式:

      • 手动触发: 调用 bgrewriteaof 命令
      • 自动触发: 配置 auto-aof-rewrite-min-size和auto-aof-rewrite-percentage参数. 当前文件大小达到 xxx 并且文件体积增大是上次重写的 xxx 比例时 触发 bgrewriteaof.

      重写流程:

      1. 执行 AOF 重写, 如果当前正在执行 AOF 重写, 则请求不执行,如果正在执行 bgsave 操作, 等待 bgsave 完成后在执行

      2)父进程 fork 一个子进程, 开销等同于 bgsave 的过程 (阻塞)

      3.1) 主进程 fork 完成后,继续响应其他命令. 所有修改命令依然写入AOF缓冲区并根据appendfsync策略同步到硬盘,保证 原有AOF机制正确性。

      3.2) fork 使用了写时 复制技术, 子进程只能 共享 fork 操作时的内存数据. 此时 父进程依然响应命令, 因此使用了 AOF 重写缓冲区保存这部分新数据,防止新 AOF 丢失这部分数据. 当在执行aof重写期间,redis完成一个客户端的写请求后,会同时将这个命令发送给aof缓冲区与aof重写缓冲区

      1. 子进程根据内存快照, 将 按照命令合并规则写入到新的 AOF 文件

      5.1) 新的 AOF 写入完成, 子进程发送行号给父进程

      5.2)父进程把 AOF 重写缓冲区的数据写入到新的 AOF 文件 (阻塞)

      5.3)使用新的 AOF 文件替换 老文件,完成 AOF 重写 (阻塞)


    • 4)当 Redis 重启时,加载 AOF 文件进行数据恢复

      1. aof 持久化开启,则优先使用 aof 文件

      2. aof 关闭或者 aof 文件不存在,加载 rdb 文件

      3. 加载 rdb/aof 文件成功后, Redis 启动成功

      4. 加载 aof/rbd 文件错误, 启动失败并打印日志

      aof 的文件的载入:

      • **创建一个不带网络链接的伪客户端(fake client)。由于redis命令只能在客户端执行,**而载入的时候是逐行读取aof文件,不需要网络链接。因此使用伪客户端可以正常执行aof文件的命令。
      • 从 aof 取出一条命令,然后在伪客户端中执行, 直到所有命令处理完毕

RDB VS AOF

  • 参考文献

    面对Redis持久化连环Call,你还顶得住吗?

    详谈Redis持久化机制

  • 具体如何使用?

    待补充:

    一般来说,如果想达到足以媲美 PostgreSQL 的数据安全性, 你应该同时使用两种持久化功能。

    • 如果你非常关心你的数据,但仍然可以承受数分钟以内的数据丢失, 那么你可以只使用 RDB 持久化。
    • 有很多用户单独使用AOF,但是我们并不鼓励这样,因为时常进行RDB快照非常方便于数据库备份,启动速度也较之快,还避免了AOF引擎的bug。

    注意:基于这些原因,将来我们可能会统一AOF和RDB为一种单一的持久化模型(长远计划)。

AOF 追加阻塞

  • 当开启AOF持久化时,常用的同步硬盘的策略是everysec.

  • 这种方式的流程:

    image-20200814105946970

    存在的问题:

    • everysec配置最多可能丢失2秒数据,不是1秒。

      //aof.c
      server.unixtime - server.aof_flush_postponed_start < 2 不满足,(此时 server.unixtime - server.aof_flush_postponed_start >= 2)
      server.aof_delayed_fsync++;// 记录 AOF 的 write 操作被推迟了多少次
      redisLog(REDIS_NOTICE,"Asynchronous AOF fsync is taking too long (disk is busy?). Writing the AOF buffer without waiting for fsync to complete, this may slow down Redis.");
      //异步aof fsync 操作 耗时太久. 不等待 fsync 完成一直写 aof_buf,这可能会降低 redis 的速度
      
    • 如果系统fsync缓慢,将会导致Redis主线程阻塞影响效率。 因为需要阻塞等待 fsync 操作完成.

    • fsync 缓慢的原因: 系统硬盘负载.

      ​ 1) 此时可能还有的操作就是 aof 重写,通过开启配置 no-appendfsync-onrewrite,默认关闭。表示**在AOF重写期间不做fsync操作**。

      ​ 2) 不要和其他高硬盘负载的服务部署在一起


fork

子进程开销

  • 子进程负责生成 RDB 文件 以及 AOF 文件的重写. 开销在于 CPU, 内存, 硬盘 三大方面

  • CPU

    CPU消耗分析: 子进程负责把进程内的数据分批写入文件,这个过程属于CPU密集操作,通常子进程对单核CPU利用率接近90%.

    建议:

    • 不要和其他CPU密集型服务部署在一起,造成CPU过度竞争。
    • 不要做绑定单核CPU操作。由于子进程非常消耗CPU,会和父进程产生单核资源竞争
  • 内存

    内存消耗分析: 子进程通过fork操作产生,占用内存大小等同于父进程,理论上需要两倍的内存来完成持久化操作,但Linux有写时复制机制(copy-on-write)。

    优化:

    • 尽量保证同一时刻只有一个子进程在工作。
    • 避免在大量写入时做子进程重写操作,这样将导致父进程维护大量页副本,造成内存消耗。
  • 硬盘

    硬盘开销分析: 子进程主要职责是把AOF或者RDB文件写入硬盘持久化。势必造成硬盘写入压力

    优化:

    • 不要和其他高硬盘负载的服务部署在一起
    • AOF重写时会消耗大量硬盘IO,可以开启配置 no-appendfsync-onrewrite,默认关闭。表示**在AOF重写期间不做fsync操作**。

Redis 阻塞的可能原因

内在原因

  • API或数据结构使用不合理。

  • CPU饱和的问题。

  • 持久化相关的阻塞

    • fork 操作阻塞

      fork操作发生在RDB和AOF重写时,Redis主线程调用fork操作产生共享内存的子进程,由子进程完成持久化文件重写工作。如果fork操作本身耗时过长,必然会导致主线程的阻塞。

    • AOF 刷盘阻塞

      当我们开启AOF持久化功能时,文件刷盘的方式一般采用每秒一次,后台线程每秒对AOF文件做fsync操作。当硬盘压力过大时,fsync操作需要等待,直到写入完成。如果主线程发现距离上一次的fsync成功超过2秒,为了数据安全性它会阻塞直到后台线程执行fsync操作完成。这种阻塞行为主要是硬盘压力引起.

    • HugePage写操作阻塞

      子进程在执行重写期间利用Linux写时复制技术降低内存开销,因此只有写操作时Redis才复制要修改的内存页。对于开启Transparent HugePages的操作系统,每次写命令引起的复制内存页单位由4K变为2MB,放大了512倍,会拖慢写操作的执行时间,导致大量写操作慢查询。

外在原因

  • CPU 竞争

    进程竞争

    绑定 CPU

  • 内存交换

  • 网络问题


6. Others

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值