Redis源码、面试指南(4)单机数据库、持久化、通知与订阅(中)

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: Redis源码、面试指南(4)单机数据库、持久化、通知与订阅

Redis源码、面试指南(4)单机数据库、持久化、通知与订阅(上):https://developer.aliyun.com/article/1508231

持久化

为了避免因服务器宕机或错误造成数据严重丢失的问题,Redis提供了两种持久化(即将数据保存至磁盘)的方式,分别是RDB和AOF。

RDB持久化

RDB持久化是将当前数据库状态生成快照,即一个二进制文件。通过该文件可以还原数据库状态。有两个命令可以生成RDB文件,一个是SAVE,另一个是BGSAVE。其实后者跟前者的区别主要在于BackGround,即后台保存。


·使用SAVE时,当前服务器进程阻塞,直到RDB文件完全生成;


·使用BGSAVE时,当前进程派生一个子进程完成RDB文件的生成,原服务器进程照常工作。


注:子进程与父进程写时复制,所以字典dict的负载因子此时才为5,尽量减少此时rehash的可能性。

现在结合源码分析一下,RDB持久化的源码文件为rdb.c,主要函数为rdbSave(char *filename),并且SAVE和BGSAVE命令底层都是通过它实现的,我们先来看看代码:

/*  
 * 将数据库保存到磁盘上。
 * 保存成功返回 REDIS_OK ,出错/失败返回 REDIS_ERR 。
 */
int rdbSave(char *filename) {
    dictIterator *di = NULL;
    dictEntry *de;
    char tmpfile[256];
    char magic[10];
    int j;
    long long now = mstime();
    FILE *fp;
    rio rdb;
    uint64_t cksum;

    // 创建临时文件
    snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
    fp = fopen(tmpfile,"w");
    if (!fp) {
        redisLog(REDIS_WARNING, "Failed opening .rdb for saving: %s",
            strerror(errno));
        return REDIS_ERR;
    }

    // 初始化 I/O
    rioInitWithFile(&rdb,fp);

    // 设置校验和函数
    if (server.rdb_checksum)
        rdb.update_cksum = rioGenericUpdateChecksum;

    // 写入 RDB 版本号
    snprintf(magic,sizeof(magic),"REDIS%04d",REDIS_RDB_VERSION);
    if (rdbWriteRaw(&rdb,magic,9) == -1) goto werr;

    // 遍历所有数据库
    for (j = 0; j < server.dbnum; j++) {

        // 指向数据库
        redisDb *db = server.db+j;
        // 指向数据库键空间
        dict *d = db->dict;
        // 跳过空数据库
        if (dictSize(d) == 0) continue;
        // 创建键空间迭代器
        di = dictGetSafeIterator(d);
        if (!di) {
            fclose(fp);
            return REDIS_ERR;
        }

         // 写入 DB 选择器符号 该符号后接数据库ID
        if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_SELECTDB) == -1) goto werr;
        if (rdbSaveLen(&rdb,j) == -1) goto werr;

        // 遍历数据库,并写入每个键值对的数据
         
        while((de = dictNext(di)) != NULL) {
            sds keystr = dictGetKey(de);
            robj key, *o = dictGetVal(de);
            long long expire;

            // 根据 keystr ,在栈中创建一个 key 对象
            initStaticStringObject(key,keystr);
            // 获取键的过期时间
            expire = getExpire(db,&key);
            // 保存键值对数据
            if (rdbSaveKeyValuePair(&rdb,&key,o,expire,now) == -1) goto werr;
        }
        dictReleaseIterator(di);
    }
    di = NULL; /* So that we don't release it again on error. */

    /*  写入 EOF 代码
     */
    if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_EOF) == -1) goto werr;

     // CRC64 校验和。
     // 如果校验和功能已关闭,那么 rdb.cksum 将为 0
     // 在这种情况下, RDB 载入时会跳过校验和检查。
    cksum = rdb.cksum;
    memrev64ifbe(&cksum);
    rioWrite(&rdb,&cksum,8);

    // 冲洗缓存,确保数据已写入磁盘
    if (fflush(fp) == EOF) goto werr;
    if (fsync(fileno(fp)) == -1) goto werr;
    if (fclose(fp) == EOF) goto werr;

    // 使用 RENAME ,原子性地对临时文件进行改名,覆盖原来的 RDB 文件。
    
    if (rename(tmpfile,filename) == -1) {
        redisLog(REDIS_WARNING,"Error moving temp DB file on the final destination: %s", strerror(errno));
        unlink(tmpfile);
        return REDIS_ERR;
    }

    // 写入完成,打印日志
    redisLog(REDIS_NOTICE,"DB saved on disk");
    // 清零数据库脏状态
    server.dirty = 0;
    // 记录最后一次完成 SAVE 的时间
    server.lastsave = time(NULL);
    // 记录最后一次执行 SAVE 的状态
    server.lastbgsave_status = REDIS_OK;
    return REDIS_OK;

werr:
    // 关闭文件
    fclose(fp);
    // 删除文件
    unlink(tmpfile);
    redisLog(REDIS_WARNING,"Write error saving DB on disk: %s", strerror(errno));
    if (di) dictReleaseIterator(di);
    return REDIS_ERR;
}

SAVE命令底层几乎就是上述函数,BGSAVE的实现只是需要fork()一个子进程:

int rdbSaveBackground(char *filename) {
    pid_t childpid;
    long long start;

    // 如果 BGSAVE 已经在执行,那么出错
    if (server.rdb_child_pid != -1) return REDIS_ERR;
    // 记录 BGSAVE 执行前的数据库被修改次数
    server.dirty_before_bgsave = server.dirty;
    // 最近一次尝试执行 BGSAVE 的时间
    server.lastbgsave_try = time(NULL);
    // fork() 开始前的时间,记录 fork() 返回耗时用
    start = ustime();
    if ((childpid = fork()) == 0) {
        int retval;
        /* Child */
        // 关闭网络连接 fd
        closeListeningSockets(0);
        // 设置进程的标题,方便识别
        redisSetProcTitle("redis-rdb-bgsave");
        // 执行保存操作
        retval = rdbSave(filename);
        // 打印 copy-on-write 时使用的内存数
        if (retval == REDIS_OK) {
            size_t private_dirty = zmalloc_get_private_dirty()
            if (private_dirty) {
                redisLog(REDIS_NOTICE,
                    "RDB: %zu MB of memory used by copy-on-write",
                    private_dirty/(1024*1024));
            }
        }
        // 向父进程发送信号
        exitFromChild((retval == REDIS_OK) ? 0 : 1);
    } else {
        /* Parent */
        // 计算 fork() 执行的时间
        server.stat_fork_time = ustime()-start;
        // 如果 fork() 出错,那么报告错误
        if (childpid == -1) {
            server.lastbgsave_status = REDIS_ERR;
            redisLog(REDIS_WARNING,"Can't save in background: fork: %s",
                strerror(errno));
            return REDIS_ERR;
        }
        // 打印 BGSAVE 开始的日志
        redisLog(REDIS_NOTICE,"Background saving started by pid %d",childpid);
        // 记录数据库开始 BGSAVE 的时间
        server.rdb_save_time_start = time(NULL);
        // 记录负责执行 BGSAVE 的子进程 ID
        server.rdb_child_pid = childpid;
        // 关闭自动 rehash
        updateDictResizePolicy();
        return REDIS_OK;
    }
    return REDIS_OK; /* unreached */
}

值得一提的是,服务器自动间隔性保存机制即是基于BGSAVE实现的,可以对Redis服务器进行配置,在redis.conf文件中有默认配置

save 900 1
save 300 10
save 60 10000

这表示只要满足上述三个条件任意一个,就执行BGSAVE。

例如save 900 1:900秒内对数据库进行了至少1次修改;

但其实用户还可以自定义保存条件,这主要由结构体redis.h/saveparam决定的:

// 服务器的保存条件(BGSAVE 自动执行的条件)
struct saveparam {
    // 多少秒之内
    time_t seconds;
    // 发生多少次修改
    int changes;
};

上述两个因素,其实是根据redisSever两个属性计算得来的,dirty计数器及lastsave属性。

·dirty计数器记录距离上次保存之后,对数据库进行了多少次修改(写入删除更新等),每进行一次修改,dirty属性值就加1;

·lastsave属性是Unix时间戳,记录上次保存的时间;

一个完整的RDB文件包含的部分如下:

·REDIS是Redis RDB文件的标识;

·db_version是指RDB文件的版本号,这里的版本号是指Redis保存RDB文件的方式根据Redis版本可能是不同的;

·database包含0或任意个数据库的键值对数据;

·EOF长度1字节,标志正文结束;

·check_sum是8字节的校验和;

关于RDB文件结构,这里只做简单的介绍,如果有需要了解的地方可翻阅黄健宏老师的《Redis设计与实现》,或是留言。如果想分析RDB文件可以使用od -c或是od -x命令来打印RDB文件内容。

AOF持久化

AOF是Append Only File的简称。RDB是保存服务器快照,而AOF方式则是保存服务器执行的命令来“记录”服务器状态。简单解释一下这两者的区别:

假设有一个version-1.0版本的数据库,依次执行了SET DEL SADD三个指令,那么:

·RDB方式会保存执行完三个指令之后的数据库状态,设为version1.1;

·AOF方式会**保存执行的三个指令!**如果数据库想恢复version1.1的状态,那么只需依次执行保存的三个指令即可。

AOF文件是纯文本格式,可以直接打开查阅,例如:

具体实现时,AOF主要分为三个步骤:命令追加、文件写入、文件同步

第一步,每当服务器执行完命令之后,就会将命令以AOF协议的格式写入redisServer的aof_buf缓冲区中。

何时保存该缓冲区内容到文件,即是第二步文件写入(写入AOF)及文件同步(保存AOF文件),这由服务器配置appendfsync来确定:

9b1ded9a80b80c54b7d207bf05e9e0be.png

注:默认为everysec。

注:现代操作系统为了提高文件写入效率,当用户调用write函数时,操作系统将数据暂存至内存缓冲区,等待缓冲区满或是指定时限而不是立即写入文件。为了强制写入数据,提供了fsync及fdatasync函数接口。

上述三种同步方式将直接决定AOF的效率和安全

·若是always:最安全,效率最慢;

·若是everysec:效率够快,丢失也仅1s的数据;

·若是no:效率最高,安全性较差;

AOF文件的载入过程可以由下图简单表现:


你可能会注意到:随着服务器的运行,AOF文件中保存的内容将会越来越大,这很可能会造成影响

为了解决这个问题,Redis提供了AOF重写功能,一起来看看。

考虑这样一个情况,服务器执行了以下命令:


AOF中写入了六条命令,但其实···只需要一条命令!

RPUSH list "c" "D" "E" "F" "G"

AOF重写的实现原理即是:首先从数据库读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令。其源码可以参阅aof.c/int rewriteAppendOnlyFile(char *filename);


为了提供重写效率,Redis提供了后台重写的功能,但有个问题需要解决即重写过程中,服务器可能又进行了更新,此时Redis服务器工作流程如下:

即会将客户端的命令追加到子进程重写缓冲区

顺带一提,Redis服务器更“青睐”AOF载入数据,如果服务器重启时开启了AOF载入,那么会首选AOF方式。


事件

Redis服务器是一个事件驱动程序,需要处理两类事件:

·文件事件:客户端通过套接字与服务器连接的,客户端命令就是一种文件事件;

·时间事件:服务器的一些操作需要在给定时间点执行,如serverCron;

文件事件

Redis基于Reactor模式开发了自己的网络(抽象的网络)事件处理器,称为文件事件处理器

·基于IO多路复用监听多个套接字,并根据套接字执行的任务来关联合适的事件处理器;

文件事件处理器由四部分构成:套接字、多路复用、分派器、处理器

值得注意的是,IO多路复用程序会将就绪套接字放进一个队列中,而后从队列头传递一个事件给分派器,只有当上一个套接字事件处理完毕之后,才继续传送下一个套接字

Redis的IO多路复用程序是通过包装常见的select、epoll、evport、kqueue这些函数库来实现的(参考源码:ae_select/epoll/kqueue/export.c),并且都基于上述函数库实现了相同的API,所以Redis的IO复用程序的底层是可以互换的。

在编译时会自动选择系统中**性能最高(是否支持更合理)**的函数库来作为其底层实现:

// ae.c
// 选择当前系统最优的IO复用方式
// 根据性能降序排列 
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif

 注:之所以如此排序是因为 Redis **会优先选择时间复杂度为O(1)**的 I/O 多路复用函数作为底层实现,包括 Solaries 10 中的 evport、Linux 中的 epoll 和 macOS/FreeBSD 中的kqueue,上述的这些函数都使用了内核内部的结构,并且能够服务几十万的文件描述符。

但是如果当前编译环境没有上述函数,select函数是作为 POSIX 标准中的系统调用,在不同版本的操作系统上都会实现,所以将其作为保底方案。

Redis源码、面试指南(4)单机数据库、持久化、通知与订阅(下):https://developer.aliyun.com/article/1508245

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
1月前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
71 2
|
29天前
|
架构师 数据库
大厂面试高频:数据库乐观锁的实现原理、以及应用场景
数据库乐观锁是必知必会的技术栈,也是大厂面试高频,十分重要,本文解析数据库乐观锁。关注【mikechen的互联网架构】,10年+BAT架构经验分享。
大厂面试高频:数据库乐观锁的实现原理、以及应用场景
|
1月前
|
SQL 缓存 监控
大厂面试高频:4 大性能优化策略(数据库、SQL、JVM等)
本文详细解析了数据库、缓存、异步处理和Web性能优化四大策略,系统性能优化必知必备,大厂面试高频。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:4 大性能优化策略(数据库、SQL、JVM等)
|
15天前
|
存储 缓存 Java
Spring面试必问:手写Spring IoC 循环依赖底层源码剖析
在Spring框架中,IoC(Inversion of Control,控制反转)是一个核心概念,它允许容器管理对象的生命周期和依赖关系。然而,在实际应用中,我们可能会遇到对象间的循环依赖问题。本文将深入探讨Spring如何解决IoC中的循环依赖问题,并通过手写源码的方式,让你对其底层原理有一个全新的认识。
38 2
|
1月前
|
关系型数据库 MySQL Linux
在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤,并与使用 RPM 包安装进行了对比
本文介绍了在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤,并与使用 RPM 包安装进行了对比。通过具体案例,读者可以了解如何准备环境、下载源码、编译安装、配置服务及登录 MySQL。编译源码安装虽然复杂,但提供了更高的定制性和灵活性,适用于需要高度定制的场景。
99 3
|
1月前
|
PHP 数据库 数据安全/隐私保护
布谷直播源码部署服务器关于数据库配置的详细说明
布谷直播系统源码搭建部署时数据库配置明细!
|
1月前
|
关系型数据库 MySQL Linux
在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤,包括准备工作、下载源码、编译安装、配置 MySQL 服务、登录设置等。
本文介绍了在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤,包括准备工作、下载源码、编译安装、配置 MySQL 服务、登录设置等。同时,文章还对比了编译源码安装与使用 RPM 包安装的优缺点,帮助读者根据需求选择最合适的方法。通过具体案例,展示了编译源码安装的灵活性和定制性。
133 2
|
2月前
|
关系型数据库 MySQL Linux
在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤
本文介绍了在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤,包括准备工作、下载源码、编译安装、配置服务等,并与使用 RPM 包安装进行了对比,帮助读者根据需求选择合适的方法。编译源码安装虽然复杂,但提供了更高的定制性和灵活性。
282 2
|
2月前
|
关系型数据库 MySQL Linux
在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤
【10月更文挑战第7天】本文介绍了在 CentOS 7 中通过编译源码方式安装 MySQL 数据库的详细步骤,包括准备工作、下载源码、编译安装、配置 MySQL 服务、登录设置等。同时,文章还对比了编译源码安装与使用 RPM 包安装的优缺点,帮助读者根据自身需求选择合适的方法。
62 3
|
2月前
|
缓存 NoSQL 关系型数据库
单机版Redis
【10月更文挑战第3天】
41 0