KVStore 持久化实战:快照 + 写前日志(WAL)双保险机制

张开发
2026/4/14 19:33:56 15 分钟阅读

分享文章

KVStore 持久化实战:快照 + 写前日志(WAL)双保险机制
一、为什么需要持久化KV 引擎的数据存在内存中进程崩溃或重启后数据就丢了进程崩溃 / 重启 │ ▼ ┌─────────────────────────────────────┐ │ 内存数据丢失 │ │ │ │ SET name zhangsan │ │ SET age 25 │ │ SET city nanjing │ │ │ │ ❌ 全部消失 │ └─────────────────────────────────────┘持久化的目标把内存数据保存到磁盘重启后能恢复。二、两种持久化策略KV 引擎通常有两种持久化方式方式原理优点缺点快照RDB/Snapshot定期把全部数据一次性写入磁盘恢复快宕机会丢数据日志WAL/Journal每次写操作先记日志再执行不丢数据日志文件会很大恢复慢业界常用方案两者结合兼顾性能和数据安全。三、KVStore 项目的持久化架构┌─────────────────────────────────────────────────────────┐ │ KVStore 持久化架构 │ │ │ │ 写操作流程 │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ SET a 1 │───→│ 写 journal │───→│ 执行操作 │ │ │ │ DEL b │───→│ 追加日志 │───→│ 内存操作 │ │ │ │ MOD c 2 │───→│ fflushfsync│───→│ 更新树/表 │ │ │ └──────────┘ └──────────────┘ └──────────────┘ │ │ │ │ 启动恢复流程 │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 加载 RDB │───→│ 回放 Journal │───→│ 数据完整恢复│ │ │ │ 批量导入 │ │ 增量补操作 │ │ │ │ │ └──────────┘ └──────────────┘ └──────────────┘ │ └──────────────────────────────────────────────────────────┘核心文件snapshot.txt — 快照文件全量数据 kvs_journal.log — 增量日志写操作记录四、快照持久化Snapshot / RDB4.1 核心思想定期把内存中的所有数据一次性全部写入磁盘文件。内存数据结构 磁盘文件 ┌────────────────┐ ┌──────────────────┐ │ Array: [a:1] │ │ SET a 1 │ │ Hash: {b:2} │ ──────→ │ HSET b 2 │ │ Rbtree: {c:3} │ │ RSET c 3 │ │ Skiplist: {...}│ │ SSET ... │ └────────────────┘ └──────────────────┘4.2 实现代码快照保存遍历所有引擎intkvs_snapshot_save(void){// 1. 打开临时文件写透避免崩溃产生不完整文件FILE*fpfopen(snapshot.rdb.tmp,w);if(!fp){perror(open);return-1;}// 2. 遍历 Array 引擎#ifENABLE_ARRAYfor(inti0;iglobal_array.total;i)if(global_array.table[i].key)fprintf(fp,SET %s %s\n,global_array.table[i].key,global_array.table[i].value);#endif// 3. 遍历 Rbtree 引擎递归中序遍历#ifENABLE_RBTREEsave_rbtree_node(global_rbtree.root,global_rbtree,fp);#endif// 4. 遍历 Hash 引擎遍历桶 链表#ifENABLE_HASHfor(inti0;iglobal_hash.max_slots;i){hashnode_t*nodeglobal_hash.nodes[i];while(node){fprintf(fp,HSET %s %s\n,node-key,node-value);nodenode-next;}}#endif// 5. 遍历 Skiplist 引擎按层遍历#ifENABLE_SKIPTABLEfor(intiglobal_skiplist.level;i0;i--){kvs_skiplist_item_t*curglobal_skiplist.header;while(cur-forward[i]){fprintf(fp,SSET %s %s\n,cur-forward[i]-key,cur-forward[i]-value);curcur-forward[i];}}#endif// 6. 强制刷盘 原子重命名fflush(fp);// 确保写入磁盘fsync(fileno(fp));// 强制刷到磁盘操作系统缓存 → 磁盘fclose(fp);rename(snapshot.rdb.tmp,snapshot.txt);// 原子替换// 7. 清空增量日志journal_clear();return0;}快照恢复逐行解析命令intkvs_snapshot_load(void){FILE*fpfopen(snapshot.txt,r);if(!fp)return-1;charline[1024];while(fgets(line,sizeof(line),fp)){// 按空格分割tokens[0]命令 tokens[1]key tokens[2]valuechar*tokens[3]{0};intcountkvs_split_token(line,tokens);if(count3)continue;// 跳过空行和注释char*cmdtokens[0];char*keytokens[1];char*valuetokens[2];// 根据命令类型回放对应的引擎操作#ifENABLE_ARRAYif(strcmp(cmd,SET)0)kvs_array_set(global_array,key,value);#endif#ifENABLE_HASHif(strcmp(cmd,HSET)0)kvs_hash_set(global_hash,key,value);#endif#ifENABLE_RBTREEif(strcmp(cmd,RSET)0)kvs_rbtree_set(global_rbtree,key,value);#endif#ifENABLE_SKIPTABLEif(strcmp(cmd,SSET)0)kvs_skiplist_set(global_skiplist,key,value);#endif}fclose(fp);return0;}4.3 快照格式SET name zhangsan SET age 25 HSET score math 95 RSET id 2024001 SSET class cs101优点恢复速度快批量导入比一条条执行快文件格式人类可读方便调试缺点宕机时会丢失最后一次快照之后的数据快照期间如果数据量很大会产生 IO 压力五、增量日志WAL / Journal5.1 核心思想每次写操作先把操作记录写到日志文件再执行内存操作。写操作流程 ┌──────────────────────────────────────────────────────┐ │ 1. journal_append(SET, name, zhangsan) │ │ ↓ │ │ 2. 追加到日志文件 (journal_fp) │ │ SET name zhangsan\n │ │ ↓ │ │ 3. fflush() fsync() ← 确保刷到磁盘 │ │ ↓ │ │ 4. 执行 kvs_array_set() → 内存操作 │ └──────────────────────────────────────────────────────┘ 崩溃场景日志已刷盘数据未写入 snapshot 重启恢复读取日志 → 重放所有操作 → 数据完整恢复 ✅5.2 日志追加journal_appendvoidjournal_append(constchar*op,constchar*key,constchar*value){// 保证日志文件已打开if(!journal_fp){journal_init();}if(!key||key[0]\0)return;// 写操作类型决定日志格式if(value){// SET / HSET / RSET / SSET → 有 valuefprintf(journal_fp,%s %s %s\n,op,key,value);}else{// DEL / HDEL / RDEL / SDEL → 无 valuefprintf(journal_fp,%s %s\n,op,key);}// 关键必须 flush fsync否则操作系统缓存丢失会丢数据fflush(journal_fp);fsync(fileno(journal_fp));}为什么必须fsync应用程序 → write() → 操作系统页缓存 → 磁盘 write() → 只写到页缓存返回成功可能丢数据 fflush() → 从 C 库缓冲区刷到页缓存可能丢数据 fsync() → 从页缓存强制刷到磁盘 ✅不丢数据5.3 日志回放journal_replayintjournal_replay(void){FILE*fpfopen(KVS_JOURNAL_PATH,r);if(!fp)return-1;charop[16],key[256],value[512];while(!feof(fp)){// 读取操作类型if(fscanf(fp,%15s,op)!1)break;// 解析命令字 → 命令枚举intcmdKVS_CMD_START;for(cmdKVS_CMD_START;cmdKVS_CMD_COUNT;cmd){if(strcmp(op,command[cmd])0)break;}// 根据命令类型读取参数并回放switch(cmd){#ifENABLE_ARRAYcaseKVS_CMD_SET:// SET key valuefscanf(fp,%255s %511s,key,value);kvs_array_set(global_array,key,value);break;caseKVS_CMD_MOD:// MOD key valuefscanf(fp,%255s %511s,key,value);kvs_array_mod(global_array,key,value);break;caseKVS_CMD_DEL:// DEL keyfscanf(fp,%255s,key);kvs_array_del(global_array,key);break;#endif#ifENABLE_HASHcaseKVS_CMD_HSET:fscanf(fp,%255s %511s,key,value);kvs_hash_set(global_hash,key,value);break;caseKVS_CMD_HDEL:fscanf(fp,%255s,key);kvs_hash_del(global_hash,key);break;// ...#endif// ... 其他引擎类似 ...}}fclose(fp);return0;}5.4 日志清空snapshot 保存后voidjournal_clear(void){if(journal_fp){fclose(journal_fp);journal_fpNULL;}// w 模式打开 → 文件被截断为空journal_fpfopen(KVS_JOURNAL_PATH,w);if(!journal_fp){perror(journal_clear fopen);}}注意快照保存后要清空日志否则重启恢复会重复执行日志中的操作。六、完整启动恢复流程进程启动 │ ▼ ┌─────────────────────────────────────────┐ │ 1. journal_replay() │ │ 读取 journal.log │ │ 回放所有写操作 │ │ 补上快照之后的数据 │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 2. 如果 snapshot.txt 存在 │ │ kvs_snapshot_load() │ │ 批量加载全量数据 │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 3. 服务就绪接收请求 │ │ 写操作 → journal_append → 内存操作 │ └─────────────────────────────────────────┘为什么先回放日志journal 记录的是快照之后的所有操作如果先加载快照再回放日志数据就是最新的七、API 接口KVStore 服务端支持以下持久化命令命令说明FUSAVE全量快照保存Force Snapshot SaveFULOAD全量快照加载Force Snapshot LoadINLOAD增量日志回放Journal Replay使用示例# 客户端连接 telnet localhost 2000 # 设置数据 SET name zhangsan OK SET age 25 OK # 手动触发快照保存 FUSAVE OK # 删除数据 DEL name OK # 客户端退出 EXIT八、代码结构kvstore/ ├── persistence.h # 持久化接口定义 ├── persistence.c # 持久化实现 │ ├── kvs_snapshot_save() # 快照保存 │ ├── kvs_snapshot_load() # 快照加载 │ ├── journal_init() # 日志初始化 │ ├── journal_append() # 日志追加 │ ├── journal_replay() # 日志回放 │ └── journal_clear() # 日志清空 └── kvstore.c # 服务端 协议处理persistence.h 关键定义#ifndefPERSISTENCE_H#definePERSISTENCE_H#defineKVS_JOURNAL_PATHkvs_journal.logintkvs_snapshot_save(void);// 快照保存intkvs_snapshot_load(void);// 快照加载voidjournal_init(void);// 日志初始化voidjournal_append(constchar*op,constchar*key,constchar*value);intjournal_replay(void);// 日志回放voidjournal_clear(void);// 清空日志#endif九、容灾场景分析场景 1进程正常退出后重启正常退出 → FUSAVE快照 → journal_clear() → 退出 重启 → journal_replay()日志空 → kvs_snapshot_load() → 完整恢复 ✅场景 2进程崩溃未执行 FUSAVE写入若干数据 → 进程崩溃 重启 → journal_replay()日志有数据 → kvs_snapshot_load()旧快照 → 数据恢复 ✅场景 3快照保存中崩溃FUSAVE 开始 → 写 snapshot.rdb.tmp → 崩溃只写了一半 重启 → snapshot.txt 是上一次完整快照 journal 是期间所有操作 → 完整恢复 ✅场景 4journal 文件损坏journal_replay() 解析失败 → 跳过损坏行继续解析下一行 → 部分数据恢复尽力而为十、性能与安全权衡10.1 写放大问题每次写操作都要写日志fprintffflushfsync执行内存操作fsync是最慢的操作每次都 fsync 会严重影响性能。10.2 优化策略优化说明批量 fsync每 N 次写操作或每 T 秒才 fsync 一次异步写日志日志写到缓冲区异步刷盘丢数据风险写时复制COWfork 子进程做快照父进程不受影响10.3 KVStore 的权衡当前实现每次写都同步 fsync数据最安全但性能较低。更优方案供扩展// 异步日志优化typedefstruct{charbuf[65536];// 批量缓冲区intlen;time_tlast_sync;// 上次刷盘时间}async_journal_t;voidjournal_append_async(constchar*op,constchar*key,constchar*value){// 追加到缓冲区intnsnprintf(journal-bufjournal-len,remaining,%s %s %s\n,op,key,value);journal-lenn;// 缓冲区满 或 超过 1 秒 → 刷盘if(journal-len60000||time(NULL)-journal-last_sync1){fwrite(journal-buf,1,journal-len,journal_fp);fflush(journal_fp);fsync(fileno(journal_fp));journal-len0;journal-last_synctime(NULL);}}十一、与 Redis 持久化对比特性KVStoreRedis快照格式文本命令格式二进制 RDB 格式日志格式文本命令格式AOF类似 WAL快照触发手动 FUSAVE定时 / BGSAVE日志回放服务端启动时服务端启动时AOF 刷盘策略无只有同步always / everysec / no十二、完整示例启动服务器./kvstore2000# listen port : 2000客户端测试# 连接telnet localhost2000# 写入数据SET name zhangsan OK SET age25OK HSET score math95OK HSET score english88OK# 修改数据MOD age26OK# 触发快照保存FUSAVE OK# 删除数据DEL name OK# 退出EXIT重启验证数据持久化# 重启服务器./kvstore2000# 客户端验证telnet localhost2000# 数据已恢复age26 是 journal 回放的name 被删除GET age26GET name NO EXIST HGET score math95十三、总结组件职责触发时机快照Snapshot全量数据一次性保存手动 FUSAVE日志Journal增量操作每次写都记录每次 SET/DEL/MOD回放Replay恢复数据解析日志重做操作服务启动时清空Clear快照后清空旧日志避免重复FUSAVE 后核心保证数据不丢失每次写操作都先落日志再执行内存操作可恢复journal_replay()snapshot_load()组合恢复原子性保存快照用rename()原子替换崩溃不产生垃圾文件写在最后持久化是 KV 引擎的必备能力。本文的快照 WAL 双保险机制用最简单的方式实现了数据可靠性。实际生产环境可以根据性能要求选择不同的刷盘策略always / everysec / no。

更多文章