一、背景
内存数据库的持久化
KV 存储是一种内存数据库,那么,如何在最小的性能损失下将数据引擎中的数据从内存中保存到磁盘上,就是这类数据库必须直面的一个问题。
相关阅读:这篇博客介绍了数据变更日志的持久化(AOF)
博客内容
本博客包含了 vemory 项目全量持久化部分的设计思路、难点剖析,以及部分的 C 源码展示,我们今天的主题是快照式的全量数据持久化
二、全量持久化
上面有谈到,全量持久化的本质是数据引擎中数据快照,触发保存的时候需要全量遍历各个数据引擎中的键值对,然后我们将键值对编码成适合保存的格式之后,进行落盘。
2.1. 同步保存和异步保存
在 KV 存储的架构设计中,全量快照的实现通常分为两种经典模式:**同步阻塞的SAVE**与 **异步非阻塞的BGSAVE**。
以下是这两种模式的完整执行流程对比:
a. 同步SAVE:简单的阻塞快照
当执行SAVE命令时,打开FILE* fp-> 将数据按照存储格式编码到fp->fflush->fsync,在 SAVE 期间,服务器会进入完全阻塞状态,拒绝任何客户端请求。
intkvs_rdb_save(void){chartmp_path[KVS_CONFIG_PATH_LEN+8];FILE*fp;// ...fp=fopen(tmp_path,"wb");if(!fp)return-1;if(rdb_encode_all_engines(rdb_file_sink,fp)!=0){fclose(fp);unlink(tmp_path);return-1;}if(fflush(fp)!=0||fsync(fileno(fp))!=0){fclose(fp);unlink(tmp_path);return-1;}fclose(fp);if(rename(tmp_path,RDB_FILE)!=0){unlink(tmp_path);return-1;}// 至此,保存成功return0;}同步阻塞。当内存数据量很大时,遍历和fwrite会耗费大量时间,导致服务直接卡死(QPS 归零)。
b. 子进程BGSAVE:工业级快照方案
为了解决主线程阻塞问题,我参考实现了工业界普遍采用的BGSAVE:利用操作系统内核的COW(Copy-on-Write,写时复制)机制,将上面同步阻塞的逻辑丢给子进程。
主线程fork完之后立刻回归事件循环,继续响应处理客户端命令。
intkvs_rdb_bgsave(void){//...pid=fork();if(pid<0){// 返回 -1:创建失败return-1;}elseif(pid==0){// 返回 0:子进程写 RDBrdb_child_close_inherited_fds();//这是一个坑,子进程会继承epoll fdif(kvs_rdb_save()!=0)_exit(1);_exit(0);}else{// 返回子进程 PID:父进程登记并立即返回g_rdb_child_pid=pid;return0;2.2. 难点剖析
在全量持久化的设计和实现中,主要的难点来源是fork子进程:写时复制(COW)机制
当父进程调fork创建子进程的时候,子进程获取了一份父进程页表的拷贝,但是在写入数据发生之前,父子进程各自页表均指向同一块物理内存。所谓的写时复制,子进程读取数据并不触发复制。而父进程在完成了fork之后,将快照的读取和刷盘这些任务完全抛给了子进程,可以继续响应客户端——如果客户端发送了SET这类修改指令,父进程这时候才复制了物理内存,而这个机制的好处就在于,子进程读的那一份是fork刚刚完成后的物理内存,完全不受到后来修改的影响。