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

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
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
相关文章
|
5天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
16 2
|
2月前
|
canal 缓存 NoSQL
Redis缓存与数据库如何保证一致性?同步删除+延时双删+异步监听+多重保障方案
根据对一致性的要求程度,提出多种解决方案:同步删除、同步删除+可靠消息、延时双删、异步监听+可靠消息、多重保障方案
Redis缓存与数据库如何保证一致性?同步删除+延时双删+异步监听+多重保障方案
|
3月前
|
JavaScript 前端开发
【Vue面试题二十五】、你了解axios的原理吗?有看过它的源码吗?
这篇文章主要讨论了axios的使用、原理以及源码分析。 文章中首先回顾了axios的基本用法,包括发送请求、请求拦截器和响应拦截器的使用,以及如何取消请求。接着,作者实现了一个简易版的axios,包括构造函数、请求方法、拦截器的实现等。最后,文章对axios的源码进行了分析,包括目录结构、核心文件axios.js的内容,以及axios实例化过程中的配置合并、拦截器的使用等。
【Vue面试题二十五】、你了解axios的原理吗?有看过它的源码吗?
|
7天前
|
SQL 缓存 监控
大厂面试高频:4 大性能优化策略(数据库、SQL、JVM等)
本文详细解析了数据库、缓存、异步处理和Web性能优化四大策略,系统性能优化必知必备,大厂面试高频。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:4 大性能优化策略(数据库、SQL、JVM等)
|
3月前
|
JavaScript 前端开发
【Vue面试题二十七】、你了解axios的原理吗?有看过它的源码吗?
文章讨论了Vue项目目录结构的设计原则和实践,强调了项目结构清晰的重要性,提出了包括语义一致性、单一入口/出口、就近原则、公共文件的绝对路径引用等原则,并展示了单页面和多页面Vue项目的目录结构示例。
|
9天前
|
消息中间件 NoSQL Redis
【赵渝强老师】Redis的消息发布与订阅
本文介绍了Redis实现消息队列的两种场景:发布者订阅者模式和生产者消费者模式。其中,发布者订阅者模式通过channel频道进行解耦,订阅者监听特定channel的消息,当发布者向该channel发送消息时,所有订阅者都能接收到消息。文章还提供了相关操作命令及示例代码,展示了如何使用Redis实现消息的发布与订阅。
|
2月前
|
设计模式 Java 关系型数据库
【Java笔记+踩坑汇总】Java基础+JavaWeb+SSM+SpringBoot+SpringCloud+瑞吉外卖/谷粒商城/学成在线+设计模式+面试题汇总+性能调优/架构设计+源码解析
本文是“Java学习路线”专栏的导航文章,目标是为Java初学者和初中高级工程师提供一套完整的Java学习路线。
410 37
|
1月前
|
存储 缓存 API
LangChain-18 Caching 将回答内容进行缓存 可在内存中或数据库中持久化缓存
LangChain-18 Caching 将回答内容进行缓存 可在内存中或数据库中持久化缓存
42 6
|
1月前
|
SQL 分布式计算 NoSQL
大数据-42 Redis 功能扩展 发布/订阅模式 事务相关的内容 Redis弱事务
大数据-42 Redis 功能扩展 发布/订阅模式 事务相关的内容 Redis弱事务
25 2
|
2月前
|
存储 关系型数据库 MySQL
【Java面试题汇总】MySQL数据库篇(2023版)
聚簇索引和非聚簇索引、索引的底层数据结构、B树和B+树、MySQL为什么不用红黑树而用B+树、数据库引擎有哪些、InnoDB的MVCC、乐观锁和悲观锁、ACID、事务隔离级别、MySQL主从同步、MySQL调优
【Java面试题汇总】MySQL数据库篇(2023版)