TinyKV: 基于 Raft 的 KV 存储系统
面试口述模板(先背这个)
30 秒版本(开场)
我做的是 TinyKV,核心是把请求通过 Multi-Raft 做强一致复制,底层用 Badger 做持久化;在其上实现了 region split、成员变更、snapshot、调度器均衡,以及基于 MVCC + Percolator 的分布式事务。 我重点解决过 split/recover 和 conf change 下的稳定性问题,保证复杂故障场景下请求不超时、节点重启可恢复。
3 分钟版本(展开)
- 先讲架构分层:Server → Raftstore → Raft → Storage/MVCC → Scheduler。
- 再讲一致性主线:提案、复制、提交、应用、回调。
- 再讲复杂场景:leader transfer、conf change、split、snapshot、重启恢复。
- 最后讲你做过的问题闭环:bug 现象、根因、修复、验证。
深挖答题顺序(避免答散)
- 先讲目标不变量(线性一致性、epoch 单调、日志顺序)。
- 再讲关键结构(
proposal、Progress、RegionEpoch)。 - 再讲异常路径(proposal dropped、stale/tombstone、snapshot/recover)。
- 最后讲验证手段(高压 + 不可靠网络 + 重启/分区回归)。
架构总览
TinyKV 是一个教学级分布式 KV 存储系统,对标 TiDB 生态的 TiKV + PD,包含四大核心模块:
1 | Client (TinySQL) |
第一部分:存储引擎基础 (Project 1)
1.1 Badger 引擎
- LSM-Tree 架构的 KV 存储(类似 RocksDB)
- Column Family 通过 key 前缀模拟:
${cf}_${key} - 三个 CF:
CfDefault(用户数据),CfLock(事务锁),CfWrite(提交记录)
1.2 Storage 接口设计
1 | type Storage interface { |
1.3 面试要点
- 为什么用 Column Family? 逻辑隔离不同类型的数据(锁、值、写入记录),扫描效率更高
- Badger vs RocksDB? Badger 是 Go 原生,LSM-Tree with value log separation
第二部分:Raft 共识算法 (Project 2A)
2.1 核心数据结构
1 | type Raft struct { |
2.2 Leader 选举算法
流程: 1. Follower 选举超时 → 变为 Candidate 2. Term++, 投票给自己 3. 发送 RequestVote{Term, LastIndex, LogTerm} 给所有节点 4. 收到多数票 → 变为 Leader 5. Leader 立即追加一个 no-op entry(防止提交前任 term 的 entry)
投票规则: - 每个 term 只能投一票 - 候选人的日志必须 >= 投票者(比较 LastLogTerm,相同则比 LastLogIndex) - 收到更高 term 的消息 → 变回 Follower
随机化超时:
randomizedTimeout = electionTimeout + rand(0, electionTimeout)
- 避免活锁(多个节点同时发起选举)
2.3 日志复制算法
Leader 维护每个 Follower 的进度: 1
2
3
4type Progress struct {
Match uint64 // 已确认复制到的最大 index
Next uint64 // 下次发送的起始 index
}
AppendEntries 流程: 1. Leader 发送 {prevLogIndex, prevLogTerm, Entries[], Commit} 2. Follower 检查:prevLogIndex 处的 term 是否 == prevLogTerm 3. 匹配 → 追加日志,更新 committed 4. 不匹配 → 拒绝,Leader 递减 Next 重试(日志回溯)
Commit 规则(关键!): 1
2
3
4
5
6func maybeCommit():
1. 收集所有节点的 Match 值
2. 排序找中位数 N(多数派复制到的位置)
3. 只有 term[N] == currentTerm 时才提交
// 不能提交前任 term 的 entry!
// 这是 Raft 与 Paxos 的关键区别
2.4 RaftLog 结构
1 | snapshot/first.....applied....committed....stabled.....last |
关键函数: - unstableEntries(): stabled
之后的 entries(需要持久化) - nextEnts(): applied+1 到
committed 的 entries(需要应用到状态机) - maybeCompact():
丢弃已被 snapshot 覆盖的 entries
2.5 RawNode 接口 - 上层应用驱动模式
1 | // 应用层循环: |
Ready 结构: 1
2
3
4
5
6
7
8type Ready struct {
SoftState *SoftState // Leader/State 变化
HardState pb.HardState // Term/Vote/Commit 变化
Entries []pb.Entry // 待持久化的日志
CommittedEntries []pb.Entry // 待应用的已提交日志
Snapshot pb.Snapshot // 待安装的快照
Messages []pb.Message // 待发送的消息
}
2.6 面试高频问题
Q: 为什么 Leader 当选后要追加 no-op entry? A: 防止”幽灵日志”问题。新 Leader 可能有前任 term 的未提交 entry,但 Raft 只能通过当前 term 的 entry 来间接提交前任 term 的 entry。no-op 确保了当前 term 有 entry 可以推进 commit。
Q: Raft 的日志一致性是如何保证的? A: 通过 Log Matching Property:(1) 如果两个节点日志中相同 index 处 term 相同,则该 entry 相同;(2) 如果两个节点日志中相同 index 处 term 相同,则该 index 之前的所有 entry 都相同。AppendEntries 的 prevLogTerm 检查保证了这一点。
Q: 为什么不能直接提交前任 term 的 entry? A: 见 Raft 论文 Figure 8。如果直接提交前任 term 的 entry,可能出现已提交的 entry 被覆盖的情况,违反 State Machine Safety。
第三部分:Raft KV 层 (Project 2B)
3.1 整体架构
1 | Client → gRPC → RaftstoreRouter → router.send(regionID, MsgRaftCmd) |
3.2 关键数据持久化
两个 Badger 实例:
| 引擎 | 存储内容 | Key 格式 |
|---|---|---|
| RaftDB | Raft 日志 + RaftLocalState | RaftLogKey(regionID, index) |
| KVDB | 用户数据 + RegionLocalState + RaftApplyState | 用户 key / ApplyStateKey(regionID) |
三个关键元数据: 1
2
3RaftLocalState: {HardState{Term, Vote, Commit}, LastIndex, LastTerm}
RaftApplyState: {AppliedIndex, TruncatedState{Index, Term}}
RegionLocalState: {Region{Id, StartKey, EndKey, Peers[], RegionEpoch}, State}
3.3 Proposal 匹配机制
1 | type proposal struct { |
Proposal 提交时: 记录 (nextIndex, currentTerm, callback) Entry 应用时: 按 (term, index) 匹配 proposal,命中则回调
Stale 检测: - entry.Term > proposal.term → 旧 proposal 被新 leader 覆盖 → ErrStaleCommand - entry.Index > proposal.index → 同理
3.4 面试要点
Q: 读操作也需要走 Raft 吗? A: 在 TinyKV 中是的(ReadIndex 优化未实现)。Get 请求也作为 entry 提交到 Raft,确保线性一致性。真实系统中通常用 ReadIndex 或 LeaseRead 优化。
Q: 为什么 raftWorker 是单线程的? A: 所有 peer 的消息在同一个 goroutine 中处理,避免了锁竞争。通过 channel 实现无锁消息传递。
第四部分:快照机制 (Project 2C)
4.1 快照触发条件
1 | 定期 tick → onRaftGCLogTick() |
4.2 快照生成与发送
1 | Leader 检测到 follower.Next < firstIndex (日志已截断) |
4.3 快照应用流程
1 | Follower 收到 MsgSnapshot |
4.4 面试要点
Q: 为什么快照生成是异步的? A: 快照需要扫描整个
region 数据,可能很慢。异步生成避免阻塞 Raft 主流程。通过
ErrSnapshotTemporarilyUnavailable 让 Raft 下次重试。
第五部分:Multi-Raft (Project 3A/3B)
5.1 Leader Transfer
1 | 1. Leader 收到 MsgTransferLeader{Target} |
关键区别: Leader Transfer 不走 Raft 日志,直接调用
RawNode.TransferLeader()
5.2 Configuration Change (成员变更)
1 | // 提议配置变更 |
安全性: 一次只变更一个节点(非 Joint Consensus),通过 PendingConfIndex 防止并发变更
5.3 Region Split
1 | SplitCheckTick → 扫描 region 大小 → 超过阈值找 split key |
5.4 RegionEpoch 机制
1 | type RegionEpoch struct { |
用途: 检测过期请求。客户端请求的 epoch 必须与 region 当前 epoch 匹配。
5.5 面试要点
Q: 为什么不用 Joint Consensus? A: TinyKV 简化了实现,一次只变更一个节点。Joint Consensus 允许一次变更多个节点但实现复杂。单步变更的安全性由 “一次最多一个变更” 保证。
Q: Split 后新 Region 的副本如何创建? A: Split 的
followers 在 apply split entry 时本地创建新 peer。如果 follower
不在(比如网络分区),Leader 会发送 heartbeat,store 发现未知 region
后通过 replicatePeer() 创建空 peer,然后通过 snapshot
同步数据。
第六部分:Scheduler 负载均衡 (Project 3C)
6.1 processRegionHeartbeat
1 | func processRegionHeartbeat(region) error: |
6.2 Balance Region 调度器
1 | Schedule(cluster): |
6.3 面试要点
**Q: 为什么需要容忍度检查 (2*regionSize)?** A: 防止振荡。如果差距太小,移动后可能马上需要反向移动,导致无限循环。
Q: Scheduler 如何保证调度安全? A: 通过 RegionEpoch 检查。Operator 创建时记录 epoch,执行时如果 epoch 不匹配(region 已被 split 或配置变更),operator 会被取消。
第七部分:分布式事务 (Project 4)
7.1 MVCC 多版本并发控制
三个 Column Family 的协作:
| CF | Key 格式 | Value | 用途 |
|---|---|---|---|
| CfDefault | EncodeKey(userKey, startTS) |
实际数据 | 存储所有版本的值 |
| CfLock | userKey(原始key) |
Lock{Primary, Ts, Ttl, Kind} | 事务锁(每个key最多一把) |
| CfWrite | EncodeKey(userKey, commitTS) |
Write{StartTS, Kind} | 提交记录 |
Key 编码(核心!): 1
2
3
4
5
6func EncodeKey(key []byte, ts uint64) []byte {
encodedKey := codec.EncodeBytes(key) // 保序编码
newKey := append(encodedKey, make([]byte, 8)...)
binary.BigEndian.PutUint64(newKey[len(encodedKey):], ^ts) // 取反!
return newKey
}^ts 使
时间戳降序排列(最新版本排在最前面) -
Seek(EncodeKey(key, startTS)) 直接定位到 <= startTS
的最新版本
7.2 Percolator 两阶段提交
Phase 1: Prewrite(上锁 + 写数据)
1
2
3
4
5对每个 mutation:
1. 检查写冲突: MostRecentWrite(key).commitTs > startTs → WriteConflict
2. 检查锁冲突: GetLock(key) 存在 → KeyLocked
3. PutValue(key, value) → CfDefault[key+startTs] = value
4. PutLock(key, Lock{Primary, startTs, TTL, Kind}) → CfLock[key] = lock
Phase 2: Commit(提交 + 解锁) 1
2
3
4
5
6
7
8对每个 key:
1. GetLock(key) 检查锁
2. lock == nil:
- 检查 CurrentWrite: 已提交 → 幂等返回; 已回滚 → 错误
- 无 write 记录 → prewrite 丢失,skip (no-op)
3. lock.Ts != startTs → 别的事务的锁,错误
4. PutWrite(key, commitTs, Write{startTs, kind}) → CfWrite[key+commitTs]
5. DeleteLock(key) → 删除 CfLock[key]
7.3 读操作 (KvGet)
1 | 1. GetLock(key): 如果 lock.Ts < readTs → 被阻塞(返回 Locked 错误) |
7.4 扫描操作 (KvScan)
1 | type Scanner struct { |
7.5 事务清理机制
CheckTxnStatus(锁超时检测): 1
2
3
4
5
6
71. 检查 primary key 的 write 和 lock
2. 已提交 → 返回 commitTs
3. 已回滚 → 返回 NoAction
4. lock 存在 → 检查 TTL 是否过期
- 过期: 清除锁+值, 写入 rollback, 返回 TTLExpireRollback
- 未过期: 返回 NoAction
5. 锁不存在且无 write → 写入 rollback (防止迟到的 prewrite)
BatchRollback(批量回滚): 1
21. 检查每个 key 是否已被提交 → 如果是,拒绝回滚
2. DeleteLock + DeleteValue + PutWrite(Rollback)
ResolveLock(批量解决锁): 1
2
31. 扫描 CfLock 找到所有 lock.Ts == startTs 的锁
2. commitVersion == 0 → 全部 rollback
3. commitVersion > 0 → 全部 commit
7.6 Latch 机制
1 | type Latches struct { |
- 内存级互斥锁,防止同一进程内对同一 key 的并发操作
- 与 MVCC 锁不同:Latch 是本地的短期锁;MVCC Lock 是持久化的分布式锁
7.7 面试高频问题
Q: Percolator 的 Primary Key 有什么作用? A: Primary Key 是事务的”真相之源”。commit 只需写 primary key 的 write 记录,其他 key(secondary)可以异步提交。检查事务状态时只需检查 primary key 的 lock/write 即可确定整个事务的状态。
Q: 为什么 CfWrite 的 key 用 commitTS 而不是 startTS? A: 读操作需要找到 <= readTS 的最新 commit。如果用 startTS 编码,无法高效定位最新的已提交版本。用 commitTS 可以直接 seek 到 <= readTS 的第一个 write 记录。
Q: Snapshot Isolation vs Serializable? A: SI 允许 Write Skew 异常(两个事务基于同一快照各写不同 key,但语义上冲突)。TinyKV 实现的是 SI,不是 Serializable。
Q: 如果 Client 在 Prewrite 成功但 Commit 之前崩溃怎么办? A: 其他事务遇到锁时会调用 CheckTxnStatus 检查 primary key。如果锁的 TTL 过期,会主动回滚。如果 primary 已经被提交了,会继续完成 commit。
Q: CfLock 的 key 为什么不需要带时间戳? A: 每个 key 同时只能被一个事务锁定。锁内部已经包含了 startTS,所以不需要在 key 中编码时间戳。
第八部分:系统设计面试题
8.1 完整的写请求路径
1 | 1. Client → gRPC → Server.KvPrewrite() |
8.2 Region 分裂流程
1 | Region [a, z) 超过大小阈值 |
8.3 故障恢复
1 | 节点重启: |
8.4 线性一致性保证
1 | 写入: Raft 保证多数派确认 → 写入不会丢失 |
第九部分:关键代码文件索引
| 模块 | 文件 | 核心内容 |
|---|---|---|
| Raft | raft/raft.go |
选举、日志复制、心跳、快照 |
| Raft | raft/log.go |
RaftLog 管理、unstable/committed/applied |
| Raft | raft/rawnode.go |
Ready 接口、Advance、ProposeConfChange |
| Raftstore | kv/raftstore/peer_msg_handler.go |
消息处理、entry 应用、split/confchange |
| Raftstore | kv/raftstore/peer_storage.go |
持久化、快照生成与应用 |
| Raftstore | kv/raftstore/raft_worker.go |
单线程消息循环 |
| MVCC | kv/transaction/mvcc/transaction.go |
MvccTxn、EncodeKey、GetValue |
| MVCC | kv/transaction/mvcc/scanner.go |
范围扫描 |
| Server | kv/server/server.go |
KvGet/KvPrewrite/KvCommit/KvScan |
| Scheduler | scheduler/server/cluster.go |
processRegionHeartbeat |
| Scheduler | scheduler/server/schedulers/balance_region.go |
Balance Region 调度 |



