线性一致性需要满足的要求:
1.任何一次读都能读到某个数据的最近一次写的数据。即每次都是直接读最新的数据
2.系统中的所有进程,看到的操作顺序,都与全局时钟下的顺序一致。一旦某个请求在时刻a读到了版本为b的某个值,那么时刻a以后的任何读请求都必须能够读到这个版本或者更新版本的值。
etcd线性一致性读:通过ReadIndex来实现线性一致性读。线性一致性读就是相当于整个集群只有一个副本,一旦一个客户端读到了某个数据,那么后续所有的客户端都可以读到该数据,因为每个日志都有一个索引,且这个索引是单调递增的,每个读请求都对应一个ReadIndex,这个值是读请求到来的时候集群最新的commitedIndex即集群已达成共识的最新的数据,所以只要当appliedIndex>=ReadIndex的时候就可以去bolt数据库查询了,后续就一定不会读到版本比ReadIndex还旧的数据了。
个人笔记:
1:要求读最新数据。一旦请求被放行,那么他就可以去读最新的数据,而不是被限制只能读某个版本之前的数据
2:数据一旦发布,就必须对所有的请求可见
etcd里面是get请求对应的是range函数
etcdserver.EtcdServer.Range #就两步:1:先阻塞,直到其他线程通知他可以读;2:去数据库读取最新数据
if !serilizable{ #serilizable表示直接读leader,!serilizable表示ReadIndex即线性一致性读
etcdserver.EtcdServer.linearizableReadNotify #执行等待,直到appliedIndex>=ReadIndex后才去数据库读数据
s.readwaitc <- struct{}{} #发消息到readwatic chan 来通知linearizableReadLoop函数有人需要ReadIndex
#ReadIndex:就是leader首先确认自己此刻是不是leader,因为有可能网络分区等原因导致leader实际不是leader
#如果是,那么当前leader的commitedIndex就是此读请求对应的ReadIndex
another thread 1{ #当过半节点都承认leader节点有效地时候,
#thread1 会通过写chan来通知上层程序leader当前是有效的,即leader的commitedIndex是有效地
#后续上层程序只需要等待appliedIndex>=confirmIndex即可
#confirmIndex即请求到来时leader的commitedIndex
etcdserver.EtcdServer.linearizableReadLoop #一个死循环,获取此刻最新的commitedIndex,通过chan接受上层发来的ReadIndex请求,
#然后也通过chan把处理结果返回给发请求的线程
for{ #就是一个死循环,监听某个chan,如果收到上层发来请求就唤醒,
#然后创建一个chan,发给其他线程,然后等待其他线程发回结果
idutil.Generator.Next #为每个请求生成一个唯一reqid,后续用来检索
case <- s.readwaitc #阻塞,直到从readwaitc收到其他线程发来的ReadIndex的请求
oldnotifier:=etcdserver.notifier #保存当前的notifier
etcdserver.newNotifier #创建一个新的notifier,后续到来的读请求都会阻塞在这个新的notifier上,
#然后这个新notifier对应的读请求会在下一次循环时处理
#之所以这样是因为他是串行处理,当前notifier没处理完就一直阻塞在这里
#但是又不能阻止上层继续发来读请求,所以直接在阻塞之前就创建一个新的notifier
#然后当他睡眠的时候就把所有的请求都挂到这个notifier下面,当当前的notifer收到结果
#后就会结束本次循环,然后下次循环就把这个新的notifer拿来用,然后再创一个新的,如此往复
etcdserver.EtcdServer.requestCurrentIndex #获取此刻commitedIndex的值并保存到一个叫confirmIndex的变量中
#注意,一个notifer下面会挂一大串请求,但是他这里只需要请求一次就行
#因为请求等待的readIndex不会大于此刻的commitedIndex
#所以当applied>commitedIndex时表示所有readIndex<commitedIndex的
#读请求的一致性要求都可以满足
#从而他会一次性唤醒所有readIndex<此刻commmitedIndex的读请求
etcdserver.EtcdServer.sendReadIndex #获取最新commitedIndex
raft.node.ReadIndex
raft.node.step(pb.MsgReadIndex,reqid) #构造一个MsgReadIndex消息
#前面生成的reqid作为数据部分放在消息的data字段中,然后处理
raft.node.stepWithWaitOption
case n.recvc <- m #把前面构造的的MsgReadIndex消息发到recvc,
#然后让他去走一遍stepLeader或者stepFollower里对应的流程(根据节点角色决定)
#这个recvc就是专门收发其他节点发来的消息,当然也可以自己发给自己
#读取实践中有三种方式:1:Log(每次读也写一条日志)
#2:readIndex:就录一个commitedIndex,直到appliedIndex>=记录的commitedIndex
#3:直接从本地读,不经过leader
#log方式太慢了;readIndex还是需要一轮广播;直接本地读,不安全
return nil
another thread 2{ #node.recvc收到etcdserver发来的ReadIndex请求即发来的MsgReadIdnex消息
raft.node.run
for{
case m := <-n.recvc #etcdserver发来的MsgReadIndex消息
raft.raft.Step
raft.stepLeader
case pb.MsgReadIndex #处理思想就是:走一遍heartbeat流程。
#如果heartbeat流程中有过半节点拥护当前节点,那么当前节点就是有效地leader
#那么此leader当前的commitedIndex就是此请求对应的ReadIndex
#即后面说的变量confirmIndex
#对MsgReadIndex消息的处理流程如下:
#1:用一个map acks保存所有节点对该ReadIndex的投票情况,map的key是节点id
#2:发送MsgHeartbeat消息给所有节点
#3:收到一个MsgHeartbeatResp时不但要标记该节点x是活跃的,
#还要同时令acks[x]=true即认为该节点是赞同当前leader和ReadIndex的
if !raft.raft.committedEntryInCurrentTerm #如果当前leader在任期内还没有提交过日志,
#那么就直接挂起这个ReadIndex,然后直接返回
#因为在处理完一个ReadIndex时会同时唤醒所有index
#在他之前的所有ReadIndex请求,
#所以这里可以安心挂起,因为后续的ReadIndex会唤醒它
#本文后面会解释
append(r.pendingReadIndexMessages) #挂起就是把请求放到一个pending数据,然后直接不管这个请求了
return
raft.sendMsgReadIndexResponse #发送heartbeat消息给所有peer节点
#两步:1:leader自己给自己投一票;2:发消息给follower
case ReadOnlySafe: #ReadOnlySafe表示ReadIndex读
raft.readOnly.addRequest(r.raftLog.committed, m) #MsgReadIndex消息的数据字段包含了本次ReadIndex的reqid
#即reqid对应的这个ReadIndex请求正在等待commttedIndex的当前值
#当ReadIndex处理完毕后那么保存的这个commitedIndex值
#就是confirmIndex
raft.readOnly.recvAck(r.id, m.Entries[0].Data) #消息的Data字段实际就是ReadIndex对应的reqid,r.id表示leader本身
#这里就是leader默认是投自己一票
ro:=pendingReadIndex[reqid] #获取reqid对应的投票信息
ro.acks[id]=true #对于reqid对应的这个ReadIndex,leader肯定是表示支持的
#只有活跃节点才会放到这个map acks中,
#如果后续检测到这个map中有过半节点数
#那么就认为reqid对应的ReadIndex被批准了,
#就可以通过chan来通知上层可以去数据库读数据了
r.bcastHeartbeatWithCtx #广播heartbeat消息给所有peer节点
#follower节点对heartbeat消息的处理很简单,
#就简单返回本身的commitedIndex给leader
case ReadOnlyLeaseBased #ReadOnlyLeaseBased表示LeaseRead即副本读,这里忽略
}
}//another threa 2
another thread 3{ #thread 3处理MsgHeartbetaResp消息,即follower发回来的响应
#如果有过半节点承认reqid对应的ReadIndex就通知上层读当前leader是有效的,
#reqid对应的ReadIndex请求可以结束等待了
raft.node.run
for{
case m := <-n.recvc #peer节点发来的MsgHeartBeatResp
raft.raft.Step
raft.stepLeader
case pb.MsgHeartbeatRes #对MsgHeartBeatResp的处理主要包括三步:
#1:标记该节点x是近期活跃的
#2:标记reqid对应的acks[x]=true
#3:如果过半,则写chan来通知上层可以结束对该reqid对应的ReadIndex的等待了
progress.RecentActive=true #1:标记该节点是近期活跃
if pr.Match < r.raftLog.lastIndex #follower节点会把自己的commitedIndex告知leader节点,
#此处发现follower节点落后了,所以发送MsgApp通知他追赶
raft.raft.sendAppend
raft.readOnly.recvAck(perrId,reqid) #2:标记reqid.acks[perrId]=true即该peer节点支持leader
quorum.JointConfig.VoteResult #3计算投票结果,看reqid对应的map acks中是否有过半节点,
#如果有则通知上层linearizableReadLoop reqid对应的读请求可以结束等待了
#就是一个count,看是否过半
rss=raft.readOnly.advance(m.Index) #从等待队列移除所有index在m.Index之前的所有pendingRequest,并返回这些pendingRequest
#我们前面把reqid和一个commitedIndex(假设值为x)绑定在一起
#当x可以结束等待时,那些commitedIndex小于x的ReadIndex请求肯定可以结束等待了
raft.raft.responseToReadIndexReq #根据rss中的请求构造MsgReadIndexResp消息,
#这个MsgReadIndexResp消息中包含了reqid对应的ReadIndex值即当时的commitedIndex值
#如果消息来源是follower,则把MsgReadIndexResp消息发给follower,
#follower再把该req放到readState中(readState用来保存当前已经批准的的读请求)
#然后会把readState中的元素发到指定的r.readStateC,
#linearizableReadLoop 每次循环就是在等待这个readStateC
#我们通过前面的步骤已经确定了该req对应的读请求所等待的commitedIndex值,
#因为客户端如果请求的是follower节点,follower节点会把请求转发给follower,
#leader会把批准的ReadIndex值放到这个MsgReadIndexResp中(假设用变量confirmIndex表示),
#这样后续当follower节点发现本机appliedIndex>=confirmIndex时
#就可以遍历readState中的所有读请求,
#凡是req.confirmIndex<=appliedIndex的读请求都可以解除阻塞
#如果消息来源是leader自己,一样的把他加到leader节点自己的readState数组
#在node.run在下一次循环中会检测到readState不为空,然后就触发case rd<-r.Ready()
raft.raft.send(pb.MsgReadIndexResp)#消息目的地设置为leader自己,然后发给自己
}
}//another thread 3
another thread 4{ #上面已经把批准的ReadIndex请求放到readState了,然后readState不为空会被node Ready()检测到
#然后raftnode.run会把readState最后一个元素发到指定的r.readStateC以激活下一步
raft.raftNode.run
for{
select
case rd := <-r.Ready() #peer节点发来的MsgHeartBeatResp
if len(rd.ReadStates) != 0 { #我们在thread 3里面append了readStates,所以这次肯定不为空
case r.readStateC <- rd.ReadStates[len(rd.ReadStates)-1] #发送最新的readState到指定chan,激活相关线程,
#因为他是不断循环的,只要readState不为空,那么就会继续ready,
#继续处理,直到为空
}
}//another thread 4
for { #这是一个死循环,等待其他线程处理完MsgReadIndex消息并通过填充readStateC chan来解除死循环
#这个for循环是上面那个requestCurrentIndex函数里的
#requestCurrentIndex函数会阻塞,直到node.run中把readState中的chan发给他来唤醒它
select
case rs := <-s.r.readStateC #阻塞在readStateC上,其他线程处理完MsgReadIndex消息后,
#会在上面的thread4中把readstate中的响应填充到readStateC来通知这里结束等待
#整个etcdserver.EtcdServer.linearizableReadNotify有三种结束等待的方式:
#1:超时或者error结束等待;2:readStateC;3:notifier
#一个notifier对象可能对应1批ReadIndex请求,只要这一批有一个请求完成了,
#那么他完成时会通知本批次所有请求都结束等待
return rs.ReadIndex #到达这里说明该请求已经被批准了,此处返回结果
case <-firstCommitInTermNotifier #收到了当前任期第一次提交发来的通知。即当客户端发来ReadIndex的时候本leader才刚获得leader资格
#在他的这个任期内集群还没有发生过commited事件,所以必须等待,假设旧leader提交到了x+3然后崩溃
#然后新leader当选,因为此时集群变了比如旧leader崩溃了,导致没有过半节点到达x+3,
#那么新leader就不能从x+3开始提交
#需要重新确定commited,这是一个不断尝试的过程,也就是说这是一个不断变化的过程,
#所以在新leader确定commited之前
#不能读取,也就是新leader第一次提交之前发出的sendReadIndex操作需要在新leader第一次提交之后重新尝试
etcdserver.EtcdServer.sendReadIndex
time.Timer.Reset #重置定时器
case <-retryTimer.C:
etcdserver.EtcdServer.sendReadIndex
time.Timer.Reset
case <-leaderChangedNotifier #如果leader变了,则放弃所有读请求,并返回错误
return
case <-errorTimer.C #超时,返回错误
return
}
//当requestCurrentIndex返回后,就可以获取此刻得appliedIndex
etcdserver.EtcdServer.getAppliedIndex #获取当前的appliedIndex
if appliedIndex<confirmIndex #如果还没有apply到confirmIndex(即读请求到来时的有效commitedIndex值)就继续等待
case <- wait.timelist.Wait(confirmIndex) #继续阻塞,直到etcdserver.EtcdServer.applyAll线程
#在完成一此apply操作后主动唤醒所有appliedIndex之前的读请求
#这个Wait会创建一个chan,applyAll唤醒它时调用close(ch)来填充这个chan,来结束阻塞
etcdserver.notifier.notify #当属于同一个notifier的一批请求中的某个被批准的时候
#会唤醒所有在等待这个oldnotifier的读请求
close(oldnotifier chan) #close(chan)会唤醒所有等待这个chan的线程
}//another thread 1
}
<- s.readNotifier #等待readNotifier发来通知。当linearizableReadLoop发现可以进行ReadIndex 读取的时候就会close这个readNotifier来唤醒
}//end:if !serilizable
etcdserver.EtcdServer.doSerialize #Lease Read指直接从从bbolt数据库读取数据
#而ReadIndex读则相当于在LeaseRead之前增加了一个wait操作,直到appliedIndex>=commitedIndex
#线性读要求读最新数据,这里就直接去数据库读了,我是这样理解的:只要请求还在服务器上,那么他们就还算是同一批请求,还没有分出先后
#因为etcd这里时可以有多个请求都在执行doSerialize的,而他们之间是没有先后的,谁都可以先读完
#所以我才认为只有当客户端收到了一个响应,这个请求才算先于后续客户端发出的请求,就是说这个先后还是在客户端这里定义的。
#当然,这是我瞎猜的
#如果是从不同的节点读也没关系,因为所有请求最终都会统一走一遍leader
txn.Range #doSerialize就是LeaseRead,当ReadIndex读请求被放行以后就执行LeaseRead
#这个Lease Read就是调用txn.Range来读取
#txn.Range就是走一遍STM(软件事务内存)中的读事务,至于STM就在下一篇笔记了
一点随想与杂记(不一定对,可能是错的,因为我也没怎么搞懂到底先后是什么意思,暂时不想搞懂了,说不定哪天就灵光一现了):
http://blog.mrcroxx.com/posts/
v3.1 中利用 batch 来提高写事务的吞吐量,所有的写请求会按固定周期 commit 到 boltDB。当上层向底层 boltdb 层发起读写事务时,都会申请一个事务锁(如以下代码片段),该事务锁的粒度较粗,所有的读写都将受限。对于较小的读事务,该锁仅仅降低了事务的吞吐量,而对于相对较大的读事务(后文会有详细解释),则可能阻塞读、写,甚至 member 心跳都有可能出现超时。
https://maimai.cn/article/detail?fid=1338198277\&efid=1QlHCPNjVaVznBt7QlxjHw
https://keys961.github.io/2020/11/06/etcd-raft-7/
Raft 算法就可以保障 CAP 中的 C 和 P,但无法保障 A:
commitedIndex一定是大于appliedIndex
对于同一个key,a先读b后读,b看到的数据版本必须大于等于a读到的版本,一旦一个值被读到,那么后续所有的都必须能读到这个值,也就是说b不能读到旧值.b读到一个值,这个值是不是旧的,肯定需要一个比较对象,这个比较对象坑定是在b读请求之前完成。!!!这个谁先谁后肯定是看时间,因为可能a在节点1上读,b在节点2上读,那么这个先后肯定说的是全局逻辑时钟下的先后。在全局逻辑时钟下,假设a在时刻x处完成读取,b后读,在x+1时刻完成读取,因为客户端只能读取applied的数据,appliedIndex又是单调递增的,所以时刻x处的appliedIndex必定小于时刻x+1处的appliedIndex,而某一时刻,appliedIndex必定小于commitedIndex,所以时刻x处的appliedIndex必定小于时刻x+1处的commited,所以我们当一个读请求到来的时候,我们记下此刻的commitedIndex值k,然后等到appliedIndex>=k的时候再去读就可以保证这个请求不会读到旧值,也就是说这个请求读完成的时间定格在记下commitedIndex的那一刻。我们回到etcd代码,整个etcd程序只有一个线程在运行linearizableReadLoop,也就是说所有请求都会经过linearizableReadLoop这个函数,而linearizableReadLoop这个函数内部又是死循环,每次循环处理一个请求,并且是处理完一个请求才处理下一个请求,并没有开启其他的线程,这样所有请求在linearizableReadLoop中就都是串行处理的,也就是说linearizableReadLoop函数里就给所有的请求都排了一个先后了,所以linearizableReadLoop函数里循环的id(即当前是linearizableReadLoop的第几次循环)就可以看作leader上的逻辑时钟(所以当客户端从follower节点读取的时候也能使用同一个逻辑时钟,所以follower节点的读请求也会通过leader节点来获取commitedIndex),linearizableReadLoop在每次循环中调用requestCurrentIndex函数,这个函数会把当前请求和当前的commitedIndex绑定在一起,也就是说记录下了这个读请求完成的时刻,只要记录下了这个请求对应的时刻,我们就可以任意处理这个请求了,把它发送到其他chan就可以不用管了,然后继续处理下一个请求,因为下一个请求必定是下一次循环了,所以下一次循环中通过requestCurrentIndex获取的commiteIndex必定要大于等于本次循环中获取的commitedIndex,也就是这里保证了顺序。另外一点是,etcd中的chan基本都是容量为1,也就是说本次处理如果没有完毕,那么当上游线程再次准备填充chan的时候就会阻塞,这里也有意无意的提供了一个先后顺序(所以etcd中如果是发送一个数据,可以用chan,也可以通过定时轮询来获取最新数据,但是如果是多个数据,那么一般就是放到数组,然后通过定时轮询来获取新数据,往往不用chan,比如readState和entry)。再举个例子,客户端先后发送a,b两个请求读取同一个key,如果在在收到a请求响应之前就发送了b请求,那么a、b请求就没有先后,他们可能返回任意结果,比如a先b后或者b先a后,但是如果收到了a的请求结果,在此之后再发送b请求,那么b请求必不会读到比a版本还旧的数据,因为b读取时的appliedIndex必定大于等于a读取时的appliedIndex。!!补充说明:只要这一批请求都还在服务器中,那么他们就没有先后,如果客户端已经收到了某个请求的响应,那么该请求就一定是先于此时服务器中的所有请求的。现在的疑点就是这个先后到底是哪里定义的,应该是客户端看到的结果先后吧。在服务器上没有先后的意思这样的,我在etcd代码里看到的是一旦一个ReadIndex请求被唤醒,他就把这个请求丢去LeaseRead,就不管了,也就是说可能有多个请求同时LeaseRead,而LeaseRead完成顺序不一定和唤醒顺序相同
下面是网上文章的摘抄,不记得是网址了:
1.1 Log Read
Raft算法通过Raft算法实现线性一致性读最简单的方法就是让读请求也通过Raft算法的日志机制实现。即将读请求也作为一条普通的Raft日志,在应用该日志时将读取的状态返回给客户端。这种方法被称为Log Read。
Log Read的实现非常简单,其仅依赖Raft算法已有的机制。但显然,Log Read算法的延迟、吞吐量都很低。因为其既有达成一轮共识所需的开销,又有将这条Raft日志落盘的开销。因此,为了优化只读请求的性能,就要想办法绕过Raft算法完整的日志机制。然而,直接绕过日志机制存在一致性问题,因为Raft算法是基于quorum确认的算法,因此即使日志被提交,也无法保证所有节点都能反映该应用了该日志后的结果。
在Raft算法中,所有的日志写入操作都需要通过leader节点进行。只有leader确认一条日志复制到了quorum数量的节点上,才能确认日志被提交。因此,只要仅通过leader读取数据,那么一定是能保证只读操作的线性一致性的。然而,在一些情况下,leader可能无法及时发现其已经不是合法的leader。这一问题在介绍Raft选举算法的Check Quorum优化是讨论过这一问题。当网络分区出现时,处于小分区的leader可能无法得知集群中已经选举出了新的leader。如果此时原leader还在为客户端提供只读请求的服务,可能会出现stale read的问题。为了解决这一问题,《CONSENSUS: BRIDGING THEORY AND PRACTICE》给出了两个方案:Read Index和Lease Read。
1.2 ReadIndex
显然,只读请求并没有需要写入的数据,因此并不需要将其写入Raft日志,而只需要关注收到请求时leader的commit index。只要在该commit index被应用到状态机后执行读操作,就能保证其线性一致性。因此使用了ReadIndex的leader在收到只读请求时,会按如下方式处理:
记录当前的commit index,作为read index。
向集群中的所有节点广播一次心跳,如果收到了数量达到quorum的心跳响应,leader可以得知当收到该只读请求时,其一定是集群的合法leader。
继续执行,直到leader本地的apply index大于等于之前记录的read index。此时可以保证只读操作的线性一致性。
让状态机执行只读操作,并将结果返回给客户端。
可以看出,ReadIndex的方法只需要一轮心跳广播,既不需要落盘,且其网络开销也很小。ReadIndex方法对吞吐量的提升十分显著,但由于其仍需要一轮心跳广播,其对延迟的优化并不明显。
需要注意的是,实现ReadIndex时需要注意一个特殊情况。当新leader刚刚当选时,其commit index可能并不是此时集群的commit index。因此,需要等到新leader至少提交了一条日志时,才能保证其commit index能反映集群此时的commit index。幸运的是,新leader当选时为了提交非本term的日志,会提交一条空日志。因此,leader只需要等待该日志提交就能开始提供ReadIndex服务,而无需再提交额外的空日志。
通过ReadIndex机制,还能实现follower read。当follower收到只读请求后,可以给leader发送一条获取read index的消息,当leader通过心跳广播确认自己是合法的leader后,将其记录的read index返回给follower,follower等到自己的apply index大于等于其收到的read index后,即可以安全地提供满足线性一致性的只读服务。
1.3 Lease Read
ReadIndex虽然提升了只读请求的吞吐量,但是由于其还需要一轮心跳广播,因此只读请求延迟的优化并不明显。而Lease Read在损失了一定的安全性的前提下,进一步地优化了延迟。
Lease Read同样是为了确认当前的leader为合法的leader,但是其实通过心跳与时钟来检查自身合法性的。当leader的heartbeat timeout超时时,其需要向所有节点广播心跳消息。设心跳广播前的时间戳为startstartstart,当leader收到了至少quorum数量的节点的响应时,该leader可以认为其lease的有效期为[start,start+electiontimeout/clockdriftbound)[start, start + election \ timeout / clock\ drift\ bound) [ start , start + election** timeout/clock** drift bound)。因为如果在startstartstart时发送的心跳获得了至少quorum数量节点的响应,那么至少要在election timeout后,集群才会选举出新的leader。但是,由于不同节点的cpu时钟可能有不同程度的漂移,这会导致在一个很小的时间窗口内,即使leader认为其持有lease,但集群已经选举出了新的leader。这与Raft选举优化Leader Lease存在同样的问题。因此,一些系统在实现Lease Read时缩小了leader持有lease的时间,选择了一个略小于election timeout的时间,以减小时钟漂移带来的影响。
当leader持有lease时,leader认为此时其为合法的leader,因此可以直接将其commit index作为read index。后续的处理流程与ReadIndex相同。
etcd有两种读模式:
ReadIndex:将读请求发送到Leader,Leader收到请求,记录下当前的committed index,然后向其他节点发送心跳,通过收到大多数节点的响应,来确认自己还是Leader,等待当前committed index指向的日志Entry,等到applied index > 该committed index,就读取数据响应给客户端。
LeaseRead:使用lease机制,保证Leader的有效性,Leader在处理读操作时无需向Follower发送心跳确认自己的Leader身份,等applied index > 该committed index后可以直接响应数据给客户