一、面试官:请你先整体介绍一下这个项目的持久化架构。
应聘者:
这个项目采用的是"全量快照 Snapshot + 增量操作日志 AOF"的组合架构。
配置支持四种模式:
none
snapshot
aof
both
默认配置是 both:
cpp
persist_file = kvstore.data
persist_aof_file = kvstore.aof
其中:
kvstore.data保存某一时刻内存中的完整数据;kvstore.aof保存全量快照之后发生的写操作;- 服务启动时,先加载 Snapshot,再按顺序回放 AOF;
SAVE成功生成新的全量快照后,会清空旧的 AOF。
整体恢复流程是:
cpp
kvstore.data
|
v
恢复上一次完整状态
|
v
kvstore.aof
|
v
回放快照之后的 SET/MOD/DEL 操作
|
v
得到最终内存状态
项目中有四套 KV 引擎:
- Array
- Red-Black Tree
- Hash Table
- Skiplist
持久化记录中会带有 engine 字段,因此同一个 key 在不同引擎中可以分别恢复。
相关核心代码主要在:
cpp
include/persist.h
src/persist.c
src/kvstore.c
二、面试官:全量快照文件的整体结构是什么?
应聘者:
全量快照是二进制文件,整体格式如下:
cpp
[File Header]
[Record Header][Key Bytes][Value Bytes]
[Record Header][Key Bytes][Value Bytes]
[Record Header][Key Bytes][Value Bytes]
...
文件头定义是:
cpp
typedef struct persist_file_header_s {
char magic[4];
uint32_t version;
uint32_t engine_mask;
uint32_t record_count;
} persist_file_header_t;
当前字段含义如下:
| 字段 | 当前含义 |
|---|---|
magic |
文件魔数,固定为 "KVS1" |
version |
文件格式版本,当前为 1 |
engine_mask |
当前文件中包含哪些引擎 |
record_count |
后面总共有多少条 KV 记录 |
当前环境下这个结构通常是 16 字节:
cpp
4 bytes magic
4 bytes version
4 bytes engine_mask
4 bytes record_count
engine_mask 使用位掩码:
cpp
PERSIST_ENGINE_ARRAY = 1 << 0
PERSIST_ENGINE_RBTREE = 1 << 1
PERSIST_ENGINE_HASH = 1 << 2
PERSIST_ENGINE_SKIPLIST = 1 << 3
例如:
cpp
engine_mask = ARRAY | HASH
表示这个快照中包含 Array 和 Hash 两种引擎的数据。
三、面试官:快照的记录头如何设计?
应聘者:
每条 KV 前面有一个记录头:
cpp
typedef struct persist_record_header_s {
uint32_t engine;
uint32_t key_len;
uint32_t value_len;
} persist_record_header_t;
通常占 12 字节。
后面紧跟:
cpp
[key_len 个字节的 key]
[value_len 个字节的 value]
所以一条完整记录是:
cpp
[engine][key_len][value_len][key bytes][value bytes]
例如:
SET name Tom
假设:
cpp
engine = ARRAY
key_len = 4
value_len = 3
key = "name"
value = "Tom"
文件中逻辑上就是:
cpp
01 00 00 00
04 00 00 00
03 00 00 00
n a m e
T o m
这里不依赖分隔符,而是依赖长度字段解析,因此 value 中可以包含空格、标点和二进制字节。
不过当前项目的大部分引擎内部仍然以 C 字符串形式保存数据,例如 Array、Hash、RBTree 使用 strlen 计算长度,所以从整个系统设计上看,更准确地说是"长度前缀的字符串 KV",还不是完全意义上的任意二进制 KV。
四、面试官:为什么记录中还要保存 engine?文件头不是已经有 engine_mask 了吗?
应聘者:
两者作用不同。
engine_mask 只表示这个文件包含哪些引擎,是一种整体能力声明。
而每条记录的 engine 表示这条具体数据应该恢复到哪个容器中。
例如:
cpp
Record 1: engine = ARRAY
Record 2: engine = HASH
Record 3: engine = SKIPLIST
加载时,持久化层根据记录头的 engine 字段,把数据分发给不同的 set 接口:
cpp
kvs_array_set(...)
kvs_rbtree_set(...)
kvs_hash_set(...)
kvs_skiplist_set(...)
这样一个快照文件可以同时保存四种数据结构。
同时,这也意味着不同引擎之间的 key 是不同命名空间:
cpp
ARRAY: user = Tom
HASH: user = Jerry
它们可以同时存在,互不覆盖。
五、面试官:全量快照具体是怎么生成的?
应聘者:
persist_save_all() 的流程大致如下。
第一步,统计四套引擎当前有效记录数量:
cpp
array_count
rbtree_count
hash_count
skiplist_count
然后计算:
record_count = 四个引擎记录数之和
并生成 engine_mask。
第二步,在内存中构造完整快照:
persist_buf_t
它是一个动态扩容缓冲区,先追加:
File Header
然后依次遍历:
Array
RBTree
Hash
Skiplist
把每条记录追加到内存缓冲区。
第三步,写入临时文件:
cpp
kvstore.data.tmp
项目使用 io_uring 写文件,并把数据分块,每块最大约 1 MB。
第四步,执行 fsync,确保文件数据刷到磁盘。
第五步,通过:
cpp
rename(tmp_path, path)
把临时文件替换成正式快照文件。
这种方式可以避免直接覆盖旧快照。即使中途写失败,旧的正式快照仍然保留,失败时会删除临时文件。
整体流程是:
cpp
遍历内存数据
|
v
构造内存 buffer
|
v
写 kvstore.data.tmp
|
v
fsync
|
v
rename(tmp, data)
六、面试官:四种数据结构分别是如何遍历并写入快照的?
应聘者:
Array 是顺序扫描固定数组:
cpp
for i = 0 ... KVS_ARRAY_SIZE
遇到 key 和 value 都不为空的槽位,就写一条记录。
红黑树采用中序遍历:
cpp
left
current
right
这样写出来的记录按照 key 的顺序排列。恢复时并不依赖这个顺序,只是中序遍历天然比较稳定。
Hash Table 遍历所有桶,然后遍历每个桶的链表:
cpp
bucket 0 -> node -> node
bucket 1 -> node
...
Skiplist 只遍历第 0 层:
cpp
header->forward[0]
因为第 0 层包含所有节点,没必要把跳表的多层索引结构持久化下来。加载时重新调用 Skiplist 的插入逻辑,让它重新建立随机层级。
这是一个比较合理的设计:文件只保存逻辑数据,不保存内存指针、红黑树颜色、跳表层级等运行时结构。
七、面试官:项目里所谓的"增量快照"是什么?它和全量快照有什么区别?
应聘者:
严格来说,项目中的"增量快照"更准确应该叫"增量操作日志"或 AOF。
它不保存某个时间点的全部最终数据,而是保存发生过的写操作:
cpp
SET
MOD
DEL
全量快照保存的是:
当前最终状态
AOF 保存的是:
状态是如何变化的
两者对比如下:
| 类型 | 保存内容 | 优点 | 缺点 |
|---|---|---|---|
| Snapshot | 当前全部 KV | 恢复快,文件结构简单 | 保存时需要遍历全部数据 |
| AOF | 每次写操作 | 追加简单,实时性更好 | 文件会不断变大,恢复需要回放 |
项目的组合模式是:
Snapshot + Snapshot 之后的 AOF
这样既能减少 AOF 恢复时间,也能避免每次写请求都重写整个快照。
八、面试官:AOF 文件的格式是什么?
应聘者:
AOF 没有一个单独的总文件头,而是由多个连续的操作帧组成。
每一条操作的头定义为:
cpp
typedef struct persist_increment_header_s {
char magic[4];
uint32_t version;
uint32_t op;
uint32_t engine;
uint32_t key_len;
uint32_t value_len;
} persist_increment_header_t;
当前通常是 24 字节。
一条 AOF 记录格式是:
cpp
[Increment Header][Key Bytes][Value Bytes]
字段含义:
| 字段 | 含义 |
|---|---|
magic |
"AOF1" |
version |
AOF 格式版本,当前为 1 |
op |
操作类型,SET/MOD/DEL |
engine |
目标引擎 |
key_len |
key 长度 |
value_len |
value 长度 |
操作枚举是:
cpp
PERSIST_OP_SET = 1
PERSIST_OP_MOD = 2
PERSIST_OP_DEL = 3
对于 DEL:
value_len = 0
逻辑格式例如:
cpp
[AOF1][version=1][op=SET][engine=HASH]
[key_len][value_len]
[key]
[value]
九、面试官:一次写请求是如何进入 AOF 的?
应聘者:
以普通客户端执行:
RSET name Tom
为例。
首先,命令解析和路由模块识别出:
command = RSET
engine = RBTREE
operation = SET
然后调用:
cpp
kvs_rbtree_set(...)
如果内存操作成功,代码会判断当前是否启用了:
aof
both
如果启用了,就调用:
cpp
persist_append_increment(...)
追加一条 AOF 记录。
整体顺序是:
解析命令
|
v
修改内存数据
|
v
追加 AOF
|
v
主从复制
|
v
返回客户端
代码中明确要求:只有内存操作成功后,才会追加 AOF。
如果 AOF 写失败,当前代码会给客户端返回:
cpp
persist failed
这里有一个面试时需要主动说明的工程问题:当前实现已经完成了内存修改,之后 AOF 才失败,因此可能出现"内存成功、持久化失败"的状态。生产实现通常会进一步设计失败策略,例如:
- 写 AOF 成功后再提交内存变更;
- 或者持久化失败时让实例进入错误状态;
- 或者明确允许短暂的内存与磁盘不一致。
十、面试官:服务重启时如何恢复?
应聘者:
启动流程在 main() 中大致是:
第一步,加载配置并确定持久化模式。
第二步,初始化四套内存引擎:
cpp
init_kvengine();
第三步,如果是 Snapshot 或 Both:
cpp
persist_load_all(persist_file);
这个函数会:
- 打开快照文件;
- 通过
fstat获取文件大小; - 使用
mmap映射到内存; - 校验魔数和版本;
- 重新初始化四套全局容器;
- 根据
record_count逐条解析; - 根据
engine调用对应引擎的set; - 校验最终指针是否刚好走到文件尾。
第四步,如果是 AOF 或 Both:
cpp
persist_load_increment(persist_incr_file);
它同样使用 mmap,然后从文件头开始循环读取 AOF 帧:
校验 AOF magic
校验 version
读取 op
读取 engine
读取 key/value
回放操作
最终状态以 AOF 的后写入为准。
例如快照中有:
cpp
TeacherData0 = King0
之后 AOF 中有:
cpp
RMOD TeacherData0 Queen0
恢复流程是:
先恢复 King0
再回放 MOD
最终得到 Queen0
十一、面试官:为什么 AOF 回放时 SET 和 MOD 都允许覆盖或补救?
应聘者:
因为日志记录的是历史操作,而不是当前容器一定要满足的严格状态。
例如正常运行时,某次命令可能是:
SET key value
但恢复时由于快照已经包含了这个 key,再回放 SET 就会遇到"key 已存在"。
因此回放逻辑中:
- SET 先尝试
set; - 如果发现已存在,再尝试
mod; - MOD 先尝试
mod; - 如果不存在,再尝试
set; - DEL 如果目标不存在,可以视为幂等成功。
这保证了 AOF 回放的目标是恢复最终状态,而不是机械复现每一次操作当时的返回值。
这和 Redis AOF 重放中的"最终状态恢复"思路比较接近。
十二、面试官:删除操作如何持久化?快照里会记录删除吗?
应聘者:
删除只会记录在 AOF 中:
cpp
op = DEL
key = 被删除的 key
value_len = 0
全量快照只遍历当前仍然存在的数据,所以已经删除的 key 不会写入快照。
例如:
SET a 1
DEL a
如果在删除后执行 SAVE,那么新的快照里不会有 a。
如果还没有执行 SAVE,服务发生重启,则依靠:
旧快照中可能存在 a
AOF 中有 DEL a
恢复后执行删除,最终内存中也不会有 a。
十三、面试官:SAVE 命令具体做了什么?
应聘者:
SAVE 执行:
1. 生成新的全量快照
2. 快照成功后清空 AOF
3. 返回 OK
逻辑上是:
cpp
ret = persist_save_all(persist_file);
if (ret == 0) {
ret = persist_clear_increment(persist_incr_file);
}
只有快照成功后,才清空 AOF。
这是因为:
新快照成功之前,旧 AOF 仍然是恢复所需要的增量信息。
如果先清空 AOF,再发现快照写失败,就可能丢失快照之后的数据。
十四、面试官:清空 AOF 时是怎么做的?安全吗?
应聘者:
当前实现通过:
cpp
open(path, O_CREAT | O_TRUNC | O_WRONLY, 0644);
close(fd);
把 AOF 截断为 0 字节。
从流程上看:
新快照已经成功
旧 AOF 不再需要
截断 AOF
但是从更严格的生产可靠性角度,还有改进空间:
- 截断操作本身没有显式
fsync; - 没有使用新的临时文件加 rename 的方式清理;
- 没有保存快照版本与 AOF 起始位置之间的元数据;
- 如果
SAVE和新的写请求并发,可能需要明确一致性边界。
当前项目是单进程事件循环模型,实际并发程度相对有限,但如果扩展到多线程或后台 SAVE,就必须增加锁、版本号或日志切换机制。
十五、面试官:快照生成期间如果客户端继续写数据,会发生什么?
应聘者:
当前实现没有看到针对快照遍历的显式锁,也没有 copy-on-write 机制。
因此严格来说,快照生成期间如果内存数据被修改,存在一致性风险:
- 统计记录数量时看到的是一个状态;
- 遍历写数据时可能已经是另一个状态;
- 可能导致
record_count和实际记录数不一致; - 或者快照反映的是不同时间点的数据组合。
在当前项目中,由于网络模型是事件驱动的,SAVE 命令处理期间通常不会被同一个事件循环中的其他命令打断,因此实际运行时可以近似看成串行执行。
但如果面试官问生产级方案,我会说可以采用:
- 写锁阻塞写请求;
- 短暂冻结写入,复制数据后释放锁;
- Copy-on-write;
- 后台线程生成快照,同时用 AOF 记录快照期间的新写操作;
- 使用快照序列号和 AOF offset 做一致性边界。
十六、面试官:这个项目的持久化有哪些校验机制?
应聘者:
主要包括以下几类。
第一,魔数校验:
cpp
Snapshot: KVS1
AOF: AOF1
防止把其他文件当成持久化文件读取。
第二,版本校验:
cpp
version == PERSIST_VERSION
version == PERSIST_INCR_VERSION
格式升级时可以拒绝不兼容文件。
第三,引擎掩码校验:
cpp
engine_mask & ~PERSIST_ENGINE_ALL
如果出现未知引擎标志,则认为文件非法。
第四,长度边界校验。
解析记录前会检查:
剩余文件空间 >= 记录头大小
剩余文件空间 >= key_len + value_len
避免越界读取。
第五,记录数量校验。
快照根据 record_count 读取指定数量记录,最后还检查:
p == end
如果文件后面还有多余字节,也会认为格式异常。
第六,重复 key 校验。
快照恢复时使用严格的 set 逻辑,如果同一个引擎内出现重复 key,插入失败,就认为文件异常。
十七、面试官:如果机器在写 AOF 的过程中突然宕机,重启时会怎样?
应聘者:
当前代码对 AOF 使用:
cpp
fwrite(...)
fflush(...)
但没有对每条 AOF 执行 fsync。
所以 fflush 只代表 C 标准库缓冲区刷到内核,并不一定代表已经落盘。
如果宕机发生在一条 AOF 记录写了一半时,文件末尾可能是不完整帧。
当前回放逻辑发现:
剩余空间不足以读取完整 header
或 key/value 不完整
就会返回失败。
当前实现没有"忽略最后半条记录"或"自动截断到最后一个完整帧"的逻辑。
生产级 AOF 通常会这样处理:
- 每条日志增加 checksum;
- 记录完整长度;
- 回放时遇到末尾不完整帧,截断尾部;
- 中间出现损坏则报错;
- 通过
fsync策略控制可靠性:- always
- everysec
- no
这是当前项目非常值得在面试中主动讲出的改进点。
十八、面试官:当前文件格式有什么兼容性问题?
应聘者:
当前结构体是直接通过:
cpp
fwrite(&header, sizeof(header), 1, fp)
原样写入文件的。
这样实现简单,但有几个兼容性风险:
- 字节序问题
当前使用本机字节序。不同大小端机器之间可能无法直接读取。
- 结构体对齐和 padding
虽然当前结构体大概率是 16、12、24 字节,但不同编译器、ABI 或编译选项可能有布局差异。
- 长度和数据类型限制
key/value 长度使用 uint32_t,理论上最大约 4 GB,但实际内存和接口还会受 int、字符串 API 等限制。
- 二进制数据支持不完整
文件格式本身支持长度,但多个引擎使用 strlen 和 '\0' 结尾,因此 value 中间包含零字节时可能被截断。
- 缺少 checksum
魔数和长度只能判断基本格式,不能发现内容被静默修改。
如果要做生产级格式,我会使用:
固定字节序
固定字段宽度
明确 header_size
明确 record_size
checksum
文件序列号
快照时间戳
AOF 起始 offset
并且不要直接持久化 C 结构体,而是逐字段编码。
十九、面试官:为什么加载时使用 mmap?有什么优缺点?
应聘者:
加载流程使用:
cpp
mmap(..., PROT_READ, MAP_PRIVATE, fd, 0)
优点:
- 不需要额外申请一块同样大小的读缓冲区;
- 操作系统按页加载;
- 可以直接用指针做顺序解析;
- 对较大的顺序读取比较方便。
缺点:
- 映射文件过大时仍然会消耗虚拟地址空间;
- 文件损坏时需要非常严格的边界检查;
- 32 位环境可映射空间有限;
- 如果文件特别大,恢复过程仍然需要给每条 KV 分配内存;
- 不能天然解决恢复期间的原子性问题。
当前实现是把整个文件映射,然后逐条解析;不是一次性把所有 KV 都复制到另一个大缓冲区。
二十、面试官:Snapshot 和 AOF 哪一个恢复更快?
应聘者:
一般情况下 Snapshot 恢复更快。
因为 Snapshot 中每条记录就是最终状态,只需要:
读取记录
调用对应引擎 set
AOF 需要:
读取每条操作
判断 SET/MOD/DEL
执行操作
必要时做 SET/MOD 的补救
而且 AOF 文件会包含历史冗余操作,例如:
SET a 1
MOD a 2
MOD a 3
DEL a
SET a 4
最终只需要:
a = 4
但恢复时可能要依次执行全部操作。
所以项目通过 SAVE 做 AOF 裁剪:
生成新的完整 Snapshot
清空旧 AOF
这相当于周期性 compaction。
二十一、面试官:这个项目的持久化和主从复制有什么关系?
应聘者:
这是两个相对独立的机制。
持久化解决的是:
进程重启后数据不丢失
主从复制解决的是:
多个实例之间同步数据
当前写命令成功后,大致会:
1. 修改本机内存
2. 写本机 AOF
3. 如果当前是 master,同步给 slave
从库启动时:
1. 先加载本地 Snapshot/AOF
2. 再根据复制配置从 master 同步
执行全量同步前,从库会清空本地旧数据,并清空自己的增量文件,避免主库已经删除的数据残留在从库。
这里也要注意:持久化成功并不等价于复制成功,复制成功也不等价于持久化成功。生产设计中通常还会分别维护:
AOF offset
replication offset
snapshot sequence
二十二、面试官:当前实现中,你认为持久化部分最值得改进的地方有哪些?
应聘者:
我会重点改进以下几个方面。
第一,AOF 完整性。
当前 AOF 只有 magic、长度和版本,没有 checksum,也没有处理尾部半条记录。
改进:
增加 crc32/checksum
记录完整 frame length
启动时自动截断不完整尾帧
第二,AOF 落盘策略。
当前主要是 fflush,没有逐条或周期性 fsync。
改进为可配置策略:
cpp
always
everysec
no
第三,快照一致性。
当前快照生成和写请求之间没有明确版本边界。
改进:
快照序列号 + AOF offset
读写锁
后台快照
copy-on-write
第四,文件格式跨平台兼容。
当前直接序列化 C 结构体。
改成显式编码:
固定 little endian 或 big endian
固定 header size
固定 record size
第五,启动错误处理。
当前启动阶段调用:
cpp
persist_load_all(...)
persist_load_increment(...)
但返回值没有被严格检查。
如果快照损坏或 AOF 回放失败,服务可能仍然继续启动。
生产系统应该:
- 快照损坏时停止启动;
- AOF 尾部损坏时尝试修复;
- 中间损坏时报警并停止;
- 给出明确日志和恢复建议。
第六,内存占用。
全量快照会先把所有数据拼成一个完整 buffer,再写文件,峰值内存可能接近:
原始内存数据 + 一份完整快照 buffer
改进方式是流式写入,或者分块生成快照。
第七,SAVE 期间的写入策略。
需要明确:
SAVE 是否阻塞写请求
快照期间新写请求是否写入 AOF
快照完成后从哪个 offset 开始保留 AOF
二十三、面试官:请你用一句话总结这个持久化设计。
应聘者:
这个项目采用了一个面向内存 KV 的二进制持久化方案:
用带魔数、版本、引擎标识和长度字段的二进制 Snapshot 保存完整状态,
再用带操作类型和长度信息的二进制 AOF 记录增量写操作;
启动时先恢复 Snapshot,再顺序回放 AOF,
通过 SAVE 重新生成快照并清理 AOF,从而在恢复速度和写入开销之间取得平衡。
不过当前版本更偏向课程项目或原型实现,已经具备完整的基本闭环,但在 AOF fsync、checksum、并发快照一致性、跨平台编码、启动错误处理和尾部损坏修复方面,还可以继续增强。
你还应该主动记住的几个"容易被追问"的事实
-
当前不是严格意义上的"增量快照",而是增量操作日志 AOF。
-
Snapshot 文件头通常是 16 字节,普通记录头通常是 12 字节,AOF 操作头通常是 24 字节。
-
Snapshot 写入使用临时文件、
io_uring、fsync、rename,可靠性高于当前 AOF 追加流程。 -
AOF 写入使用
fwrite + fflush,当前没有显式fsync。 -
Snapshot 加载前会重新初始化四套全局数据结构。
-
AOF 回放时:
- SET 可以退化为 MOD;
- MOD 可以退化为 SET;
- DEL 具有一定幂等性。
-
Snapshot 只保存当前存在的数据,删除操作依靠 AOF 记录。
-
Skiplist 只保存第 0 层数据,索引层重新构建。
-
红黑树只保存 key/value,不保存颜色、指针等内存结构。
-
当前启动代码对加载错误的处理还不够严格,这是一个重要风险点。
-
当前使用
strlen的地方说明系统对字符串数据支持较好,但对包含\0的真正二进制 value 支持不完整。 -
当前快照整个构造到内存 buffer,数据量大时会有额外内存峰值。
-
engine_mask是文件级信息,记录头中的engine是记录级信息。 -
Snapshot 加载后,AOF 中较新的值会覆盖 Snapshot 中的旧值。
-
SAVE的正确顺序必须是:先写成功 Snapshot再清理 AOf
1. 面试官:这个项目的持久化语义是什么?是强持久化还是最终持久化?
应聘者:
当前实现更接近"写成功后尽力持久化",不是严格意义上的强持久化。
写请求流程是:
先修改内存
再追加 AOF
最后返回客户端
AOF 目前使用:
cpp
fwrite()
fflush()
fclose()
但是没有显式调用 fsync(),所以 fflush() 只能保证 C 库缓冲区刷新到内核,不能完全保证数据已经落到物理磁盘。
因此可能出现:
客户端已经收到成功
机器突然断电
最新 AOF 还没有真正落盘
生产系统一般会把持久化策略做成可配置:
cpp
always 每次写都 fsync
everysec 每秒 fsync
no 交给操作系统
当前 Snapshot 的可靠性相对更高,因为它会写临时文件、调用 fsync,最后再 rename。
2. 面试官:如果内存修改成功,但 AOF 写失败怎么办?
应聘者:
当前代码的实际顺序是:
内存操作成功
AOF 写入失败
返回 persist failed
这意味着可能出现:
内存中已经有新值
磁盘中没有对应日志
这是当前实现需要改进的地方。
可以有几种处理策略:
- 先写 AOF,成功后再修改内存;
- AOF 写失败后,将实例置为只读或错误状态;
- 允许内存暂时领先,但通过后台重试;
- 使用事务日志和提交标记。
在实际 KV 存储中,常见做法是:
生成日志
写入日志
根据 fsync 策略确认
提交内存状态
返回客户端
但要注意,先写日志再修改内存也会带来日志写成功、内存操作失败的问题,因此通常还要保证内存操作本身是可靠的,或者使用事务状态。
3. 面试官:快照生成过程中如果发生宕机,旧快照会不会被破坏?
应聘者:
当前 Snapshot 生成流程是:
写入 kvstore.data.tmp
fsync 临时文件
rename 临时文件为 kvstore.data
所以如果宕机发生在临时文件写入期间,正式的 kvstore.data 通常不会被半成品覆盖,旧快照仍然存在。
这是临时文件加原子替换的作用。
但是严格来说,生产级实现还会增加:
cpp
fsync(tmp_file)
rename(tmp_file, data_file)
fsync(parent_directory)
最后一次目录 fsync 是为了保证 rename 操作本身在断电后也能持久保存。
4. 面试官:Snapshot 加载失败后,当前内存数据是否能保持不变?
应聘者:
对于 persist_load_all(),当前实现会先调用:
cpp
persist_reinit_all_globals()
把四套全局引擎清空并重新初始化,然后再逐条加载快照。
如果快照在中途损坏,可能出现:
旧数据已经被清空
新快照只恢复了一部分
最后函数返回失败
也就是说,全局加载不是事务性的,失败后可能留下部分恢复状态。
这是当前实现的一个重要风险。
更好的方式是:
1. 创建临时的四套引擎
2. 将快照全部加载到临时引擎
3. 全部成功后,一次性替换全局引擎
4. 失败则销毁临时引擎,保留旧数据
类似当前 persist_load_array() 采用的临时实例替换思路。
5. 面试官:启动时 Snapshot 或 AOF 损坏,服务会怎么处理?
应聘者:
当前启动代码调用了:
cpp
persist_load_all(persist_file);
persist_load_increment(persist_incr_file);
但返回值没有被严格检查。
因此存在一种情况:
快照加载失败
服务仍然继续启动
这可能导致服务以空数据或部分数据继续对外提供服务。
生产环境中应该区分:
- 快照不存在:第一次启动,可以正常运行;
- 快照格式不兼容:停止启动;
- 快照中间损坏:停止启动或切换备份;
- AOF 尾部半条记录:自动修复;
- AOF 中间损坏:报警并停止启动。
启动恢复应该类似:
cpp
if (persist_load_all(snapshot) < 0) {
log_error("snapshot corrupted");
return -1;
}
if (persist_load_increment(aof) < 0) {
log_error("aof corrupted");
return -1;
}
6. 面试官:为什么 AOF 每条记录都带 magic?
应聘者:
因为 AOF 没有统一的文件头,而是由多个操作帧连续组成:
cpp
[AOF Header][Key][Value]
[AOF Header][Key][Value]
...
每条记录有独立 magic:
AOF1
这样可以在解析每一帧时检查当前位置是否确实位于一条合法记录的开始位置。
它可以帮助发现:
- 文件类型错误;
- 文件版本错误;
- 文件中间出现损坏;
- 记录边界错位。
但是 magic 本身不能保证内容没有被修改,所以还应该增加:
cpp
frame_length
checksum
7. 面试官:如何判断 AOF 最后一条记录是否写完整?
应聘者:
当前实现通过剩余文件长度做基本判断:
剩余空间 >= increment_header
剩余空间 >= key_len + value_len
如果不满足,就返回失败。
但是当前没有自动修复逻辑。
更完善的 AOF 恢复流程是:
1. 从文件头开始顺序扫描
2. 记录每个完整帧的结束 offset
3. 如果最后一帧不完整
4. 截断到最后一个完整帧
5. 继续启动
如果是中间帧损坏,则不能简单截断,因为后面的数据也无法确认,应该报警或停止恢复。
8. 面试官:当前 AOF 是否支持幂等恢复?
应聘者:
部分支持。
回放逻辑中:
- SET 如果发现 key 已存在,会尝试 MOD;
- MOD 如果发现 key 不存在,会尝试 SET;
- DEL 如果 key 不存在,可以视为成功。
因此重复回放同一批操作时,最终状态通常能够保持一致。
例如:
cpp
SET a 1
SET a 2
最终是:
a = 2
不过它不是严格意义上的事务日志,也没有操作 ID、序列号或去重机制。
如果相同 AOF 被重复加载两次,对于 SET/MOD 通常问题不大,但涉及更复杂操作时,可能会产生副作用。因此更完善的日志需要:
sequence number
operation id
snapshot offset
9. 面试官:为什么快照只保存逻辑数据,不直接保存红黑树和跳表的内部结构?
应聘者:
因为内存中的结构包含大量不能直接持久化的运行时信息:
指针
malloc 地址
红黑树颜色
nil 哨兵指针
跳表 forward 指针
哈希桶地址
这些数据只在当前进程地址空间有效,重启后全部失效。
所以快照只保存:
engine
key
value
恢复时重新调用各个引擎的插入接口:
cpp
kvs_rbtree_set(...)
kvs_hash_set(...)
kvs_skiplist_set(...)
让数据结构重新构建自己的索引和内部元数据。
例如 Skiplist 只保存第 0 层的数据,重新加载时再随机生成其他层级。
这是"逻辑序列化",相比"内存镜像"更容易升级和跨进程恢复。
10. 面试官:为什么快照中要记录 record_count?直接读到 EOF 不行吗?
应聘者:
记录 record_count 有几个作用:
- 可以明确知道应该读取多少条记录;
- 可以检测记录数量是否异常;
- 可以快速发现文件截断;
- 可以避免把文件尾部垃圾数据当成正常记录。
当前加载逻辑会读取:
record_count 条记录
最后还检查:
解析指针是否刚好等于文件尾
如果没有读完,说明文件被截断;
如果读完后还有多余字节,说明文件中可能存在垃圾或格式错误。
不过当前实现没有把实际读取数量和 engine_mask 中各引擎的数量分别记录下来,校验能力还可以继续增强。
11. 面试官:engine_mask 和记录头中的 engine 是否会互相校验?
应聘者:
当前实现主要校验了文件级的 engine_mask 是否包含未知位:
cpp
file_header.engine_mask & ~PERSIST_ENGINE_ALL
但逐条记录时,没有充分校验:
record.engine 是否出现在 file_header.engine_mask 中
更严格的检查应该是:
cpp
if ((file_header.engine_mask & record_header.engine) == 0) {
return -1;
}
同时,记录级 engine 应该只能是单个枚举值,而不能是多个位组合。
例如:
cpp
ARRAY | HASH
适合作为文件级 mask,但不适合作为单条记录的 engine。
12. 面试官:这个二进制文件格式能否跨机器使用?
应聘者:
当前不能完全保证。
因为代码直接把 C 结构体写入文件:
cpp
fwrite(&header, sizeof(header), 1, fp);
这依赖于:
- 本机字节序;
- 编译器结构体布局;
- ABI 对齐规则;
- 字段大小;
- 编译参数。
更通用的方案是显式编码:
cpp
magic
version
header_size
engine_mask
record_count
每个整数统一使用规定的字节序,例如 little-endian:
cpp
write_u32_le(...)
read_u32_le(...)
不要依赖:
cpp
sizeof(struct)
这样不同平台、不同编译器之间也能正确解析。
13. 面试官:这个项目是真正支持二进制 value 吗?
应聘者:
文件格式本身采用:
cpp
key_len + value_len
理论上可以保存包含空格和标点的数据。
但是引擎和命令层很多地方仍然使用:
cpp
strlen()
并且在加载时补充:
'\0'
因此它对普通字符串支持较好,但对 value 中间包含 \0 的真正二进制数据支持不完整。
如果要支持二进制 KV,需要保证整个链路都使用显式长度:
协议解析使用长度
引擎存储使用长度
比较使用长度
持久化使用长度
返回客户端也使用长度
不能在核心逻辑中依赖 strlen()。
14. 面试官:当前快照保存会不会占用很多额外内存?
应聘者:
会。
persist_save_all() 会先把完整快照拼到一个动态 buffer 中,然后再写入文件。
因此保存期间的内存大致是:
原始内存数据
+
完整 Snapshot buffer
+
临时 key/value 相关开销
数据量大时峰值内存会明显增加。
当前代码中的优势是可以提前知道完整 buffer,并统一使用 io_uring 写出。
但如果数据规模继续增大,更合适的是流式方案:
先写文件头占位
逐条遍历并写入
最后回填 record_count
或者:
分块构造
分块写入
这样不用一次性保存整个快照。
15. 面试官:项目为什么使用 io_uring?真的提高性能了吗?
应聘者:
项目使用 io_uring 来执行 Snapshot 写入,并且最后用 io_uring 提交 fsync。
理论优势是:
- 减少系统调用;
- 支持异步 I/O;
- 可以批量提交多个请求;
- 更适合大文件写入。
但当前实现实际上是:
提交一次 write
等待一次完成
再提交下一次
因此整体仍然是串行写入,并没有充分利用 io_uring 的并发队列能力。
它更像是把写入接口替换成了 io_uring,不能简单说已经实现了高并发异步持久化。
如果要真正利用 io_uring,可以:
一次提交多个 write
通过 user_data 标记 offset
批量等待完成
统一处理 CQE
不过由于快照文件是顺序写,实际收益还需要基准测试验证。
16. 面试官:SAVE 是否应该阻塞客户端写请求?
应聘者:
当前项目是事件驱动模型,SAVE 在命令处理过程中同步执行,因此执行期间当前事件循环不会继续处理后续命令。
这可以简化一致性问题,但会带来:
快照越大,SAVE 阻塞时间越长
其他客户端延迟升高
生产系统通常会考虑后台生成快照:
主线程继续处理请求
后台线程或子进程生成快照
新增写操作继续进入 AOF
但后台 Snapshot 需要解决一致性边界:
快照开始时对应哪个 AOF offset?
快照期间新写入的日志从哪里开始保留?
快照成功后清理哪一部分 AOF?
因此后台快照通常需要:
snapshot sequence
AOF offset
锁或 copy-on-write
17. 面试官:如果 SAVE 成功后、清空 AOF 前进程崩溃,会不会有问题?
应聘者:
如果新的 Snapshot 已经完整落盘,但 AOF 还没有清空,重启时会:
先加载新 Snapshot
再重复回放旧 AOF
当前回放逻辑对 SET/MOD 设计了覆盖和补救处理,因此很多普通 KV 操作仍然能恢复到正确最终状态。
但从设计上看,重复回放并不是最优方案,因为:
- 会增加恢复时间;
- 依赖操作的幂等性;
- 复杂操作可能不能安全重复执行。
更好的方案是让 Snapshot 记录对应的 AOF offset:
cpp
snapshot_applied_aof_offset = N
恢复时只回放 offset 大于 N 的日志。
18. 面试官:如何测试持久化模块是否可靠?
应聘者:
测试应该分为功能、故障、性能和兼容性四类。
功能测试:
SET 后重启
MOD 后重启
DEL 后重启
Snapshot + AOF 恢复
AOF 覆盖 Snapshot 旧值
四种引擎分别恢复
边界测试:
空 key
超长 key/value
空 value
中文
空格和标点
重复 key
不存在 key 的 MOD/DEL
故障测试:
截断 Snapshot
截断 AOF
修改 magic
修改 version
修改 key_len/value_len
插入未知 engine
写入多余尾部字节
SAVE 中途杀进程
AOF 写入过程中杀进程
性能测试:
Snapshot 文件大小
SAVE 耗时
启动恢复耗时
AOF 回放 QPS
不同数据量下的内存峰值
当前仓库中的 test/testcase_persist.c 已经覆盖了:
- Snapshot 写入;
- AOF 写入;
- 重启恢复;
- Snapshot 旧值被 AOF 覆盖。
但故障注入、checksum、半条 AOF 自动修复等测试还可以继续补充。
最值得你在面试中主动指出的三个问题
如果面试官问:"你认为当前实现还有什么不足?"建议优先回答这三个:
- AOF 可靠性还不够强
cpp
只有 fflush,没有 fsync;
没有 checksum;
尾部半条记录只能报错,不能自动修复。
-
全局快照恢复不是事务性的
加载前会清空全局引擎;
中途失败可能留下部分恢复状态。 -
启动阶段没有严格处理加载错误
cpp
persist_load_all()
persist_load_increment()
的返回值没有完全决定进程是否继续启动,可能导致服务以不完整数据运行。
最后可以这样总结:
当前项目已经形成了完整的 Snapshot + AOF 持久化闭环,核心设计是清晰的;但它更接近一个可运行的原型版本。若向生产级演进,重点应放在 AOF 落盘策略、损坏恢复、快照一致性、跨平台文件格式以及恢复失败时的原子性上。