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来确定:
注:默认为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