在保证线性一致性的情况下如何读Kv

先给结论:

这个项目为了保证线性一致性,没有收到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在租约有效期内直接读取本地数据,性能最高,但依赖时钟和租约条件,实现与正确性证明更加复杂。