先给结论:
这个项目为了保证线性一致性,没有收到
Get就直接查本地 KV,而是先把Get也作为一条命令提交到 Raft。等这条Get日志被提交、应用后,才读取本地 KV 并返回结果。
这种方案简单可靠,但每次读都要经过一次 Raft 共识,性能较低。
一、为什么不能直接读本地KV
假设集群有三个节点:
S1:旧Leader S2:新Leader S3:Follower发生网络分区后,S1可能还以为自己是 Leader,但S2、S3已经选举出新 Leader。
新 Leader 完成写入:
Put("x", "200")此时:
S2:x = 200 S3:x = 200 S1:x = 100如果客户端向旧 LeaderS1发起Get("x"),直接读取本地数据会得到100。
但写入200已经成功返回,后续读取却读到了旧值,这就违反线性一致性。
所以:
“节点认为自己是 Leader”不等于“这个节点现在仍然拥有多数派支持”。
二、本项目的线性一致读方案
本项目采用“读请求也进入 Raft 日志”的方式:
客户端发送Get ↓ Leader把Get作为Op写入Raft ↓ Get日志复制到多数节点 ↓ Get日志被提交 ↓ Apply线程按顺序应用到这个位置 ↓ 通知Get RPC线程 ↓ RPC线程读取本地KV ↓ 返回查询结果Get请求被包装成:
Op op; op.Operation = "Get"; op.Key = args->key(); op.ClientId = args->clientid(); op.RequestId = args->requestid();然后调用:
m_raftNode->Start(op, &raftIndex, &term, &isLeader);如果当前节点不是 Leader:
if (!isLeader) { reply->set_err(ErrWrongLeader); return; }客户端就会换节点重试。
三、为什么把Get写入Raft就能避免旧读
假设日志顺序是:
index=8:Put("x", "100") index=9:Append("x", "A") index=10:Get("x")状态机必须按照日志顺序应用:
先执行 index=8 再执行 index=9 最后到达 index=10当Get对应的index=10已经应用时,可以确定:
index <= 10 的已提交写操作都已经应用到了本地KV这条Get日志相当于一个读屏障 Read Barrier。
因此读取结果至少包含排在它前面的所有已提交写操作。
例如:
Put("x", "100") 已成功返回 Get("x") 随后开始Get经过 Raft 后,一定不能越过前面已提交的Put,所以不能读到Put之前的旧值。
四、timeOutPop()在等什么
调用Start()后,只代表 Raft Leader接受了日志:
m_raftNode->Start(op, &raftIndex, &term, &isLeader);不代表该日志已经提交。因此 RPC线程还要等待:
chForRaftIndex->timeOutPop( CONSENSUS_TIMEOUT, &raftCommitOp );它等待 Apply线程通知:
这个日志位置上的命令已经提交并应用了。
流程是:
Get RPC线程 Raft Apply线程 | | | Start(Get) | |----------------------------->| | | 复制并提交 | timeOutPop()阻塞等待 | | | 收到ApplyMsg |<------ raftCommitOp ----------| | 读取KV并返回 |五、没有超时时怎么处理
图片下半部分是:
if (raftCommitOp.ClientId == op.ClientId && raftCommitOp.RequestId == op.RequestId) { std::string value; bool exist = false; ExecuteGetOpOnKVDB(op, &value, &exist); if (exist) { reply->set_err(OK); reply->set_value(value); } else { reply->set_err(ErrNoKey); reply->set_value(""); } } else { reply->set_err(ErrWrongLeader); }必须检查:
ClientId是否相同 RequestId是否相同原因是 Raft 领导者可能发生变化。
旧 Leader可能认为当前请求位于:
index = 10但它还没有提交就失去领导权。新 Leader可能用其他命令覆盖index=10。
RPC线程虽然等到了index=10的 Apply消息,但不一定是自己的请求,因此不能只检查日志下标。
必须确认:
raftCommitOp.ClientId == op.ClientId raftCommitOp.RequestId == op.RequestId如果不一致,就让客户端重试。
六、KV究竟在哪里读取
真正读取跳表的代码是:
void KvServer::ExecuteGetOpOnKVDB( Op op, std::string* value, bool* exist ) { m_mtx.lock(); *value = ""; *exist = false; if (m_skipList.search_element(op.Key, *value)) { *exist = true; } m_lastRequestId[op.ClientId] = op.RequestId; m_mtx.unlock(); }互斥锁保证读取时不会和另一个写操作交叉修改。
在这个实现中,可以把两个时间点区分开:
Get日志应用:建立读屏障,保证之前的写已经应用。
锁内读取 KV:真正确定返回值,可看作实际线性化点。
如果在读屏障后,又有一个并发写先应用,Get读到更新后的值也是合法的,因为读和这个写的执行时间发生了重叠。
七、超时分支
代码是:
if (!chForRaftIndex->timeOutPop(...)) { bool isLeader = false; m_raftNode->GetState(&term, &isLeader); if (ifRequestDuplicate(op.ClientId, op.RequestId) && isLeader) { ExecuteGetOpOnKVDB(op, &value, &exist); // 返回查询结果 } else { reply->set_err(ErrWrongLeader); } }timeOutPop()返回false表示等待超时。
但超时不代表 Get 一定没有执行,可能是:
Get已经提交 Get已经执行 Apply通知到达较晚 RPC线程先发生超时所以代码检查去重表:
ifRequestDuplicate(op.ClientId, op.RequestId)如果去重表中已经记录了这个请求,说明该请求以前执行过,可以再次读取。
否则没有证据证明这条 Get 已经通过 Raft建立读屏障,只能返回:
ErrWrongLeader客户端使用相同的ClientId + RequestId换节点重试。
八、isLeader检查需要特别注意
代码通过:
m_raftNode->GetState(&term, &isLeader);检查自己是否仍然是 Leader。
但严格来说:
只检查本地
isLeader,不能单独证明当前节点仍然得到多数派支持。
一个网络隔离的旧 Leader在收到更高任期消息之前,仍可能认为自己是 Leader。
因此,不能写成:
if (isLeader) { 直接读取本地KV; // 对新Get不安全 }图片中的代码还要求:
ifRequestDuplicate(...) && isLeader即只允许已经执行过的 Get 重试读取。不过更清晰、稳妥的实现是:
等待超时后直接返回可重试错误或者重新执行一次完整的 Raft读屏障,而不是只依赖本地isLeader。
九、 Get需要去重吗
Get不会修改业务 KV,因此重复执行不会像Append那样产生重复写入。
但是重复执行可能返回不同结果:
第一次Get:x = 100 中间执行Put:x = 200 重试Get:x = 200在单个请求从调用到最终响应的整个时间区间内,这通常仍可以找到合法的线性化点。
但如果要求重复请求必须返回完全相同的结果,去重表就不能只保存:
ClientId -> LastRequestId还要缓存原始响应:
struct ClientRecord { int lastRequestId; std::string lastValue; Err lastError; };重复请求直接返回第一次查询结果,不重新读取。
十、生产系统常见的三种方案
方案一:Get写入Raft日志
也就是本项目的方案:
Get -> Raft日志 -> 多数派提交 -> 应用 -> 本地读优点:
实现简单 容易证明线性一致性 读写具有统一顺序缺点:
每次读都要复制日志 延迟高 Raft日志增长快方案二:ReadIndex
生产系统更常用:
Leader向多数节点确认自己仍然是Leader 获取安全的commitIndex作为readIndex 等待lastApplied >= readIndex 读取本地KV它不需要把每个Get写入日志,但仍然确认了当前 Leader的有效性。
方案三:Leader Lease
Leader在租约有效期内直接读取本地数据,性能最高,但依赖时钟和租约条件,实现与正确性证明更加复杂。