前言
在正式介绍本文的内容分之前,我们先引用一下大佬的介绍:Raft算法的目标之一是实现 线性一致性(Linearizability) 的语义。线性一致性的实现不仅与Raft算法本身有关,还与整个系统的实现(即状态机)有关。即便Raft算法本身保证了其日志的故障容错有序共识,但是在通过Raft算法实现系统时,仍会存在系统整体线性一致性语义等问题。因此《CONSENSUS: BRIDGING THEORY AND PRACTICE》的“Chapter 6 Client interaction”,专门介绍了实现系统时客户端与系统交互的相关问题。需要实现基于Raft算法的读者应详细阅读该章节中介绍的问题与解决方案。
在还没想好怎么介绍readIndex,先看看一致性吧~
共识和一致性
一致性在程序员的世界里太常见了,最平常的是读写一致,即我写入的数据,在没有被 ‘我’ 改变的情况下,我读到的数据应该和写入的数据保持一致,这种情况时常出现在并发场景下的数据读写问题中。不过这里的一致主要是不同时间下对同一个主体的数据一致。然而在分布式场景下,一致性是一个系统需要达成的目标。因为分布式系统引入了多个节点,节点规模越大,宕机、网络时延、网络分区就会成为常态,任何一个问题都可能导致节点之间的数据不一致,因此Paxos 和 Raft 准确来讲是用来解决一致性问题的共识算法,用于分布式场景,而非”缓存一致性“这种单机场景,所以很多文章也就简称”Paxos是分布式系统中的一致性算法“。
而共识一般是指即多个提议者达成共识的过程,例如Paxos,Raft 就是共识算法,paxos 是一种共识理论,分布式系统是他的场景,一致性是他的目标。即表明使用 Raft或者 paxos 的系统并不代表该系统都是线性一致的(Linearizability 即强一致)。类似于paxos、raft等共识算法只能提供基础,如果要实现线性一致还需要在算法之上做出更多的努力。
本文主要是学习raft的如何读取到一致的数据, 首先看看类似提议方式 LogRead 进行一个读请求,因为raft是日志有序的,因此读取的数据一定是一致的。那为什么没有采用这种方式呢?
优点:实现简单,只需要依赖raft已有的提报机制;
缺点:延迟高、吞吐量低,不仅需要一轮共识所需的开销,又有将这条Raft日志落盘的开销。
那为了优化只读请求的性能,就要想办法绕过Raft算法完整的日志机制。raft的特点是所有的读写请求都会走到leader,因而只要leader是合法的,那么一定是能保证只读操作的线性一致性的。不合法情况我们在前面已经他按逃过,这里就不再详细介绍了。为解决这一问题,《CONSENSUS: BRIDGING THEORY AND PRACTICE》给出了两个方案:Read Index和Lease Read,其中readIndex便是本文着重要介绍机制。etcd/raft对于这两种方式都做了相应的实现,可以通过配置readOnly.option设置。
ReadIndex
由于只读请求并没有需要写入的数据,因此并不需要将其写入Raft日志,而只需要关注收到请求时leader的commit index。只要在该commit index被应用到状态机后执行读操作,就能保证其线性一致性。因此使用了ReadIndex的leader在收到只读请求时,会按如下方式处理:
- 记录当前的commit index,作为read index;
- 向集群中的所有节点广播一次心跳,如果收到了数量达到quorum的心跳响应,leader可以得知当收到该只读请求时,其一定是集群的合法leader;
- 继续执行,直到leader本地的apply index大于等于之前记录的read index。此时可以保证只读操作的线性一致性;
- 让状态机执行只读操作,并将结果返回给客户端。
了解了leader的处理方式,接下来以一个只读请求的视角,看看这个请求会经历哪些流程,当然这个实现还是基于etcd/raft。
一个只读请求的一生
- 默认所有的只读请求都会调用Node下的ReadIndex方法;
// ReadIndex request a read state. The read state will be set in the ready.
// Read state has a read index. Once the application advances further than the read
// index, any linearizable read requests issued before the read request can be
// processed safely. The read state will have the same rctx attached.
// Note that request can be lost without notice, therefore it is user's job
// to ensure read index retries.
ReadIndex(ctx context.Context, rctx []byte) error
在这个方法中,传入了一个唯一的ID,以byte数组的形式传入以对应每一个请求。
2. 将这个请求以消息的形式传入raft
func (n *node) ReadIndex(ctx context.Context, rctx []byte) error {
return n.step(ctx, pb.Message{Type: pb.MsgReadIndex, Entries: []pb.Entry{{Data: rctx}}})
}
与正常消息提报一样,依据不同的节点状态进入不同的处理函数,如果当前节点是follower:
func stepFollower(r *raft, m pb.Message) error {
switch m.Type {
...
case pb.MsgReadIndex:
if r.lead == None {
r.logger.Infof("%x no leader at term %d; dropping index reading msg", r.id, r.Term)
return nil
}
m.To = r.lead
// 转发给主节点
r.send(m)
case pb.MsgReadIndexResp:
if len(m.Entries) != 1 {
r.logger.Errorf("%x invalid format of MsgReadIndexResp from %x, entries count: %d", r.id, m.From, len(m.Entries))
return nil
}
r.readStates = append(r.readStates, ReadState{Index: m.Index, RequestCtx: m.Entries[0].Data})
}
}
如上所述,如果是follower,就将这个消息转发给主节点。如果是leader,则进入stepLeader
func stepLeader(r *raft, m pb.Message) error {
// These message types do not require any progress for m.From.
switch m.Type {
...
case pb.MsgReadIndex:
// 如果是单节点集群则直接返回,报告当前commitIndex
if r.prs.IsSingleton() {
if resp := r.responseToReadIndexReq(m, r.raftLog.committed); resp.To != None {
r.send(resp)
}
return nil
}
// 如果当前主节点还没有提报过日志,即当前主节点的CommitIndex不是最新的,需要等提报一次在进再响应读请求
if !r.committedEntryInCurrentTerm() {
r.pendingReadIndexMessages = append(r.pendingReadIndexMessages, m)
return nil
}
// 处理具体读请求
sendMsgReadIndexResponse(r, m)
return nil
}
}
func sendMsgReadIndexResponse(r *raft, m pb.Message) {
// thinking: use an internally defined context instead of the user given context.
// We can express this in terms of the term and index instead of a user-supplied value.
// This would allow multiple reads to piggyback on the same message.
switch r.readOnly.option {
// If more than the local vote is needed, go through a full broadcast.
case ReadOnlySafe:
// 将这个请求加入队列,并保存详细到map中,主要是关联读请求和Index的对应关系
r.readOnly.addRequest(r.raftLog.committed, m)
// The local node automatically acks the request.
r.readOnly.recvAck(r.id, m.Entries[0].Data)
// 心跳广播,这里继续传入唯一的ID
r.bcastHeartbeatWithCtx(m.Entries[0].Data)
case ReadOnlyLeaseBased:
if resp := r.responseToReadIndexReq(m, r.raftLog.committed); resp.To != None {
r.send(resp)
}
}
}
因此针对Leader的处理有三种情况:
1. 单节点集群
2. 主节点还未提交
3. 正常
接下来, 对于正常情况,会考虑readOnly的option设置,在readIndex中主要做三件事:保存请求(加入队列)、响应以及广播心跳。
// addRequest adds a read only request into readonly struct.
// `index` is the commit index of the raft state machine when it received
// the read only request.
// `m` is the original read only request message from the local or remote node.
func (ro *readOnly) addRequest(index uint64, m pb.Message) {
s := string(m.Entries[0].Data)
if _, ok := ro.pendingReadIndex[s]; ok {
return
}
ro.pendingReadIndex[s] = &readIndexStatus{index: index, req: m, acks: make(map[uint64]bool)}
ro.readIndexQueue = append(ro.readIndexQueue, s)
}
// recvAck notifies the readonly struct that the raft state machine received
// an acknowledgment of the heartbeat that attached with the read only request
// context.
func (ro *readOnly) recvAck(id uint64, context []byte) map[uint64]bool {
rs, ok := ro.pendingReadIndex[string(context)]
if !ok {
return nil
}
rs.acks[id] = true
return rs.acks
}
心跳消息会包含leader的commit index以及这个请求的ID(rctx),follower收到心跳消息后,首先将commit Index与主节点同步,然后回复心跳消息,也包含这个ID的数据,方便主节点在处理心跳响应时,能够顺便处理readIndex请求。
func (r *raft) handleHeartbeat(m pb.Message) {
r.raftLog.commitTo(m.Commit)
r.send(pb.Message{To: m.From, Type: pb.MsgHeartbeatResp, Context: m.Context})
}
// 主节点的响应
func stepLeader(r *raft, m pb.Message) error {
// These message types do not require any progress for m.From.
switch m.Type {
case pb.MsgHeartbeatResp:
...
// 如果不是ReadOnlySafe 或者ID内容为空,就不用处理readIndex请求,直接返回
if r.readOnly.option != ReadOnlySafe || len(m.Context) == 0 {
return nil
}
// 如果响应超过大多数,则表明当前的主节点是合法的,且可以返回在这个ID之前的所有读请求,因为所有的读请求都是用队列有序的保存
if r.prs.Voters.VoteResult(r.readOnly.recvAck(m.From, m.Context)) != quorum.VoteWon {
return nil
}
// 处理之前保存的读请求,拿到当前ID的之前的所有请求消息,并移除队列和Map中的数据;
rss := r.readOnly.advance(m)
for _, rs := range rss {
// 将这个读请求返回给应用层,表明当前readIndex已经同步,可以读取数据
if resp := r.responseToReadIndexReq(rs.req, rs.index); resp.To != None {
r.send(resp)
}
}
...
}
}
应用层需要做几件事:
1. 将请求传入raft
2. 监听raft的返回,如果请求ID返回,等待当前节点应用到Index
3. 告知具体的服务层,可以读取数据了
Lease Read
本来之前没有关注Lease Read,主要是项目中没有使用。借这个机会一起来看看。前面说道,readIndex需要主节点向其他从节点发送心跳消息,主要是查看当前主节点是否合法。在【raft】学习六中了解了raft选举优化的几种措施,其中谈到Check Quorum 和 Lease leader,保在一定时间内不发生选举即不产生新的leader,也就保证主节点在一定时间内是合法的,这样就可以省去了广播心跳的开销,其commit index可以直接作为read index。
但是只要有时间间隔,便会存在失效的时间片段,即有一个时间片段可能会存在多个leader,导致读取数据不一致/读到旧的数据,这个就可以根据项目具体情况来取舍了。
注意: 这里的主节点合法和选取时主节点有效,针对的象是不一样的,有一定的区别。在选举时,如果设置了lease Leader,则当follower节点收到选取请求,如果主节点还在合法的时间内,那么这个选举请求将会被拒绝;但lease read 是针对主节点自己来看,如果设置了这个时间区间,主节点认为自己在这个时间范围内,自己是合法的,超过这个时间内会进行一次check quorum。所以这两个还是存在一定区别,学习raft的朋友注意区分。
总结
本文主要了解了一下一致性的一些基本知识,以及跟随etcd/raft的代码逻辑,探讨了一个只读请求需要经历哪些操作,主要探讨了两种方式:readIndex和lease read。