内容提要
本文介绍基于Raft实现键值数据库:服务端提供Get、Put、Append接口,客户端Clerk与Server交互;请求携带时间戳uuid保证幂等,经requestCh管道处理,由startAgreement向集群复制日志,等待提交后通过applyCh应用命令,并用history去重,最终返回结果。
延伸解读
幂等设计:用时间戳 UUID 避免重复执行
文章用 time.Now().UnixNano() 作为请求 UUID,并在 Server 端维护 history 映射记录已处理的 UUID。Put 和 Append 在执行前会检查该 UUID 是否已存在,若存在则跳过,从而保证 Clerk 重试时不会重复写入或追加。Get 操作本身幂等,因此未做去重。这种设计简单直接,但依赖时间戳在单个 Clerk 内不重复,文章未讨论多 Clerk 或时钟回拨等边界情况。
请求处理链路:从 Clerk 到 Raft 再到 applyCh
Clerk 的请求先进入 Server 的 requestCh 管道,由后台 goroutine 取出并调用 startAgreement 尝试复制日志。若当前节点不是 leader 或复制失败,立即通过 setReply 返回 ErrWrongLeader;若成功,则等待 applyCh 收到已提交的命令,执行后通过 setReply 将结果返回给 Clerk。整个链路中,setReply 是请求完成的唯一出口,确保回复与日志提交状态一致。
startAgreement 的等待策略与超时处理
startAgreement 在调用 rf.Start 后先休眠两个心跳周期,再通过反复提交 CmdGetIdx 并比较返回的任期和索引来判断日志是否已提交。若任期变化则返回 ErrWrongLeader;若索引达到期望值则成功;若 10 秒内未达成则超时返回错误。这种轮询方式实现简单,但依赖固定心跳间隔和超时阈值,文章未讨论网络分区或 leader 频繁切换时的性能影响。
applyCh 处理与状态机执行
handleApplyMsg 从 applyCh 接收已提交的命令,根据操作类型执行:Get 直接读取 store,若键不存在返回 ErrNoKey;Put 和 Append 先检查 history 中的 UUID,未重复才修改 store 并记录 UUID。该函数在独立 goroutine 中循环处理,与 requestCh 的处理 goroutine 分离,避免阻塞请求接收。文章未涉及快照或日志压缩,因此 history 会随请求数量增长。
Q&A
Lab3 的 KV 服务端需要实现哪些接口?
服务端需要实现三个接口:Get(key string)、Put(key, value string)、Append(key, value string)。如果 key 不存在,Append 等同于 Put。
为什么 Clerk 的请求要带 uuid?
因为 Clerk 可能会重复请求,Server 需要做到幂等。uuid 用时间戳(time.Now().UnixNano())标识请求,服务端通过 history 记录已处理的 uuid 来去重。
服务端收到 Clerk 请求后是怎么处理的?
Get 和 PutAppend 请求都被放入 requestCh 管道。后台 goroutine 监听该管道,调用 startAgreement 向集群复制日志,等待提交后通过 applyCh 应用命令,最后用 setReply 返回结果。
startAgreement 如何确认日志已被集群复制?
调用 rf.Start 提交命令后,先等两个心跳,然后循环调用 rf.Start(raft.CmdGetIdx{}) 检查:若任期变化则返回 ErrWrongLeader;若 commitIndex 达到期望值则复制成功;最多等待 10 秒,超时返回 ErrWrongLeader。
handleApplyMsg 中如何根据操作类型执行命令?
对于 GetOp,从 store 读取 key,不存在则返回 ErrNoKey;对于 PutOp 和 AppendOp,先检查 history 中是否已有该 uuid,没有则执行写入或追加并记录 uuid,避免重复执行。
如果 startAgreement 失败,服务端如何响应 Clerk?
如果 startAgreement 返回错误(如 ErrWrongLeader),服务端调用 request.setReply(ErrWrongLeader, "") 将错误返回给 Clerk;只有共识成功后才通过 setReply 返回实际结果。