在保证线性一致性的情况下如何读Kv
先给结论这个项目为了保证线性一致性没有收到Get就直接查本地 KV而是先把Get也作为一条命令提交到 Raft。等这条Get日志被提交、应用后才读取本地 KV 并返回结果。这种方案简单可靠但每次读都要经过一次 Raft 共识性能较低。一、为什么不能直接读本地KV假设集群有三个节点S1旧Leader S2新Leader S3Follower发生网络分区后S1可能还以为自己是 Leader但S2、S3已经选举出新 Leader。新 Leader 完成写入Put(x, 200)此时S2x 200 S3x 200 S1x 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);如果当前节点不是 Leaderif (!isLeader) { reply-set_err(ErrWrongLeader); return; }客户端就会换节点重试。三、为什么把Get写入Raft就能避免旧读假设日志顺序是index8Put(x, 100) index9Append(x, A) index10Get(x)状态机必须按照日志顺序应用先执行 index8 再执行 index9 最后到达 index10当Get对应的index10已经应用时可以确定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可能用其他命令覆盖index10。RPC线程虽然等到了index10的 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那样产生重复写入。但是重复执行可能返回不同结果第一次Getx 100 中间执行Putx 200 重试Getx 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 LeaseLeader在租约有效期内直接读取本地数据性能最高但依赖时钟和租约条件实现与正确性证明更加复杂。