贴一个raft可视化网站,方便直观学习:Raft Consensus Algorithm

CAP:

**CAP:**一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。

BASE原则是CAP原则的折中,C,A,P三个都要,但是不保证每个原则的实现都是100%

BA:基本可用(Basically Available) S:软状态(Soft State) E:最终一致性(Eventual Consistency) CAP原则是三选二;

选择策略:

  • 当一套系统在发生故障后,客户端的任何请求都被卡死或者超时,但是,系统的每个节点总是会返回一致的数据,则这套系统就是 CP 系统,经典的比如 Zookeeper。

  • 如果一套系统发生分区故障后,客户端依然可以访问系统,为了高可用,每个节点只能用本地数据,导致读取数据不一致,那么这套系统就是 AP 系统,经典的比如 Eureka。

    mysql数据集群与redis集群,由于mysql和redis的数据复制都是采用的异步复制,所以mysql数据集群与redis集群都属于AP类型,在集群中获取数据时,会存在数据不一致的情况。

raft

相比于Paxos,一个易于理解的共识算法(共识即实现复制状态机:),并将其分解为以下三个子问题,主要有两钟RPC:Vote RPC和AppendEntries RPC

三个特征

  1. strong leader:日志条目仅从leader流向其它服务器。这简化了被复制日志的管理并且使得Raft更加容易被理解。

  2. leader election:Raft使用随机计时器来选举leader。这只在任何一致性算法都需要的心跳检测中增加了少量机制,同时简单且快速的解决冲突。

  3. membership changes:Raft用于改变集群中服务器集合的机制使用了一种新的联合的一致性方法,其中两个不同配置的多数在过渡期间是重叠的。这允许集群在配置改变时继续正常工作。

领导者选举

leader follow candicate

  1. 每个follow只有一张选票,先来先得原则
  2. follow什么时候会投票:requestVote rpc的任期号大于自己,否则requestVote rpc最新日志不比自己旧(日志索引+日志任期号 确定一个日志entry)
  3. 只有candicate的日志和大多数的follow一样新时,即包含之前任期提交过的日志才会能当选leader
  4. 什么时候当选leader,超过一半选票(为什么节点为奇数—在发生网络分区的时候…)
1
2
3
4
5
6
7
8
9
10
11
12
struct RequestVote
{
int term; // 当前任期号
int candidateId; // 自己的id
int lastLogIndex; // 自己最后一个日志索引
int lastLogTerm; // 自己最后一个日志的任期号
};
struct ResponseVote
{
int term; // 自己当前任期号
bool voteGranted; // 是否投票
};

当同一个选举未出现leader?

当多个节点同时选举时就可能出现没有leader,raft引入随机超时选举机制来避免了活锁问题

leader只比较任期号和日志索引是否就能选举成功?

日志复制

  • 提交:执行日志指令到状态机,只有日志复制到多数节点,并且是当期的日志才能提交
  • 一致性检查:在appendentries rpc中放入前一个日志索引和任期,follow找不到该日志则拒绝,leader发送前一个,直到相同。因此将通过一致性检查来确保状态一致:

日志复制过程:考虑论文中特殊的情况

image-20250424112442389

这时候S2,S3,S5都有可能成为leader:

  • 当S2为leader,S1的日志3不会被删除,但是随着S2添加新的日志,会覆盖S1的日志3
  • 当S1为leader,全部应用S1日志
  • 当S5为leader,删除S1的日志2,3

日志特性:

  • 如果不同日志中的两个条目有着相同的索引值和任期,则它们存储着相同的指令。
  • 如果不同日志中的两个条目有着相同的索引值和任期,则该日志之前的所有条目也都是完全相同的。
  1. 一个leader只会在特定任期内的某一索引值下最多只会创建一个条目,并且日志条目在日志中的位置是永远不会改变的
  2. 初始化时的空状态满足日志匹配的特性(Log Matching Property),并且每当扩展日志时,一致性检查都会维持日志匹配的特性。
    因此,每当AppendEntries返回成功时,通过新的条目leader就知道follower的日志与leader自己的是完全一致的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct AppendEntriesRequest
{
int term; // 任期号
int leader; // 自己id
int preLogIndex; // 前一个日志索引
int preLogTerm; // 前一个日志任期号
repeated entries; //
int leaderCommit; // 最新已提交日志号
};
struct AppendEntriesResponse
{
int term; // 任期号
bool success; // 是否复制成功
};

安全性

  1. 选举限制:即leader选举中一定包含之前任期的所有被提交日志条,最后日志任期号大的日志更,否则日志索引更大的日志更
  2. 提交限制只有当前任期的日志才能提交,满足这个条件才会提交之前任期的日志;倘若leader当前任期为7,没有任期7的日志,提交了任期4的日志,大多数完成提交,而有一个恢复的包含任期5、6却不包含4的机器当选leader后就会删掉follower中已提交的4的日志,造成一致性失败
  3. 宕机处理:follower或candidate崩溃,发送给他们的requestVote和appendEntries和无限重复
  4. 时间限制:广播时间 << 选举超时时间 << 平均故障时间

日志压缩

快照是最简单的压缩方法。
在快照中,完整的当前系统状态以快照的形式写入稳定的存储中,然后在这个点位之前的整个日志会被丢弃。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct InstallSnapshotArgs {             
int term; 1. 如果term < currentTerm,立即回复。
int leaderId; 2. 如果是第一个块(offset为0),创建新的快照文件。
int lastIncludedIndex; 3. 在指定的offset处将数据写入快照文件。
int lastIncludedTerm; 4. 如果done为false,回复并等待更多数据块。
int offset; 5. 如果done为true,保存快照文件,并丢弃任何现有的或部分的小于该索引的快照。
std::vector<uint8_t> data; 6. 如果现有日志条目与快照的last included条目有相同的索引和任期,保留之后的日志并回复。
bool done; 7. 否则,丢弃整个日志。
}; 8. 使用快照内容重置状态机,并加载集群配置。

struct InstallSnapshotReply {
int term;
};

通常,这个快照将包含目前还不在接受者日志中的新信息。
这种情况下,follower将丢弃它全部的日志;其全部被快照所取代,并且被丢弃的日志中可能有着与快照相冲突的但还未提交的条目。
相反,如果follower接受到的快照是它当前日志的前面一部分(由于重传或者出错了),则被快照所覆盖的日志条目将会被删除但是快照后面的条目依然是有效的并且必须被保留。

这种快照的方式背离了Raft的强leader原则,因为follower可以在leader不知情的情况下生成快照。

虽然由一个leader有助于避免在达成一致时产生决策冲突,但生成快照时是已经达成了一致的,所以不会有决策冲突。

数据依然是仅由leader流向follower,但follower现在可以重新组织它们的数据。

集群成员变更

改变集群配置,进行平滑变更,需要防止新旧集群发生脑裂

一、联合一致:
Raft用于改变集群中服务器集合的机制使用了一种新的联合的一致性方法,其中两个不同配置的多数在过渡期间是重叠的。
这允许集群在配置改变时继续正常工作

  1. 配置以日志条目的形式向follow发送
  2. 一旦follow接收就开始应用新的配置,进入联合一致状态
  3. leader提交c_join,发起c_new条目达到大多数即完成变更

我归纳为两种情况:

  1. leader未提交c_join时宕机:选出的leader不具有c_join,则视为变更失败
  2. 选出的leader具有c_join,则接下来只要让c_new提交即可变更成功

三个问题:

  1. 新的服务器可能在初始化时没有存储任何的日志条目。

如果在这种状态下被加入到集群,它可能需要花费很长一段时间才能赶上,在这段时间内都无法提交新的日志条目。
为了避免可用性的差距,Raft在配置变更前引入了一个额外的阶段,新的服务器以无投票权成员(non-voting members)的身份加入集群
(leader复制日志条目给它们,但它们不被认为是大多数的一份子)。
一旦新的服务器能够追上集群中的其它机器,就可以向上述那般执行配置变更。

  1. 集群的leader可能不是新配置中的一员。

在这种情况下,一旦Cnew日志条目被提交,leader将会退下(返回到follower状态)。
这意味着存在一段时间(在提交Cnew时),其中leader管理者一个不包含自己的集群;它复制着日志条目但不把它自己算作大多数中的一员。
当Cnew被提交时将会发生leader的切换,因为这是新配置可以进行独立操作的第一个点位(总是可以在Cnew中选择出一个leader)。
在此之前,只有来自Cold的服务器才有可能被选举为leader。

  1. 被移除的服务器(不在Cnew中)可能会中断集群

为了避免这一问题,服务器将会在它们认为当前leader存在时忽略掉RequestVote RPC。
特别的,如果一个服务器在当前leader最小的选举超时时间内接收到一个RequestVote RPC,它将不会更新它的任期或者发起投票。
这不会影响正常的选举,即每一个服务器在开始一轮选举之前至少等待一个最小的选举超时时间。
然而,它有助于避免移除服务器时的混乱:如果一个leader能够提供集群中的心跳,则它将不会被一个更大的任期编号给取代。

二、单节点变更:

使用更加简单的单节点集群成员变更,每次只变更一个节点,这样新旧配置集群一定有重合,就可以防止脑裂

缺点:

  1. 更换一个节点时需要两部,增减
  2. 偶数个集群降低了高可用性
  3. 当发送网络分区时可能无leader
  4. 集群配置日志可能出现不一致现象,比如四个节点的集群增加一个,可能出现把已提交的日志覆盖的问题

image-20250426125555287

优化:

  1. 老配置的节点提交时只需要考虑老配置的大多数,如果a,b,c和d;ab,ac,bc是新老配置的最小交集,因此老配置ab,ac,bc可以算作变更时的大多数
  2. 新leader必须提交一条no-op,才能开始单节点成员变更

客户端交互

强一致性读:

线性一致性;必须有leader处理请求且leader有效;读取的日志必须已提交

优化:

  1. no-op:leader必须掌握已提交日志条目的最新信息,Raft通过在leader开始其任期时,让每一个leader提交一个空白的_no-op_条目来处理这一问题
  2. lease(租约):距离上次心跳包的时间还未达到选举超时下界则直接返回。依赖心跳机制来提供一种租约的形式,但这将会依赖于时钟的安全性(假设时间误差是有限的)。
  3. ReadIndex:leader记录当前commitIndex为readIndex,follower请求readIndex并应用到自身,这样follower就可以返回数据了

性能

  1. 合理设置超时时间
  2. batch:一个日志包含多个命令,批量复制,节省网络开销
  3. pipeline:leader不用等follower回复就继续发送下一个日志
  4. multi-raft:将数据分组,独立的raft同步

no-op

一个节点当选leader后立即发送一个自己当前任期的空日志体的rpc

复制状态机

共同出初始状态+共同输入=共同输出状态

不同的副本采用不同的存储方式可以达到不同的需求

  1. 数据量非常小:集群信息,配置文件,分布式锁:basic paxos;chubby
  2. 数据量大但可以拆分为不相干的个部分:大规模存储系统;GFS、HDFS;multi paxos,raft
  3. 数据量大且数据存在关联:数据分片到多个状态机,状态机通过两个阶段来提交;oceanbase、TiDB;改造raft

Paxos比较

raft是一种具有长生命周期的强leader模型,日志只能由leader流向follower,而领导选举的日志顺序复制保证了leader的日志是完整的不具有日志空洞;paxos能完美地处理日志空洞的情况

问题:实际中大多是是并发场景,多个连接;当后一个日志比前一个日志先到就会拒绝,多个拒绝造成系统延迟,效率低下

ParallelRaft:解决raft日志空洞

两个限制:

  1. log复制的顺序性:如果follower接收一个日志,则已接收之前所有日志——》乱序确认:任何log持久化后立即返回,无需等待前序日志
  2. log提交的顺序写:一旦一个节点提交一个日志,则已提交之前所有日志——》乱序提交:一旦收到大多数,即可提交,无需等待前序日志提交

parallelraft:

乱序提交

为每一个log引入look behind buffer,保存前N个修改的LBA(逻辑块地址)——》解决因为跳过的空洞包含对相同数据的修改

提交的前提是:空洞个数不大于N;并且N个look behind buffer中不包含对当前数据的修改

乱序确认

存储引擎

主要存储的是:数据文件和索引文件

数据文件组织形式:索引组织表、堆组织表、哈希组织表

比如索引组织表的节点既存放索引也存放数据

主要影响存储引擎性能的是索引文件的组织形式,因此存储引擎存储结构讲的就是索引文件组织形式

分类:

In-place update structure:原地更新,直接覆盖旧文本;B树;只记录最新结果,读性能更优,写入代价大

Out-of-place update structure:异地更新,存储到新的位置,需要整合(compaction);LSM树

存储结构特性:

  1. 存储结构要适合磁盘存储:磁盘IO尽量小,粒度——》大
  2. 存储结构要支持并发:增删改影响要小,粒度——》小

B树

B树:以页为单位组织,例如innoDB页大小为16K,高扇出,低高度,但是增删改可能造成分裂或合并SMO。

SMO的存在导致并发修改时不仅需要对当前的节点进行加锁,也需要把可能受到SMO操作影响,可能分裂合并的节点也进行加锁

存储引擎和事务的并发操作的不同:

时间:我先对一个事务加locks,在事务中对数据修改时加latchs,修改后释放,等事务完成再释放locks

对象:latchs对一页进行加锁,locks对一行数据加锁

Locks Latchs
隔离级别 用户事务 线程
保护对象 库中数据 内存中数据结构
持续时间 整个事务周期 临界区代码前后
类型 共享、互斥 读、写
死锁机制 监测并解决 避免死锁出现

mysql5.7针对SMO操作阻塞问题引入了SX Latch

S Latch SX Latch X Latch
S Latch 兼容
SX Latch 兼容 不兼容
X Latch

并发读操作:同5.7之前的并发操作一样,index S –> 路径所有节点加Page S –> 释放所有非叶子节点的S –> 释放叶子节点的S

image-20250519162757221

并发写操作:当Page会触发SMO时,这时上的就是SX Latch而不是X Latch,也就是整颗树是可以读的,而不再是不能操作

image-20250519162843264

变种:

  • b树:一个磁盘块存储键值,指针和数据;遍历数据需要中序遍历,不同磁盘块随机IO造成性能低下

  • b+树:数据按顺序大小放在叶子节点,组成双向链表,非叶子节点只存索引,减少了树高度,区分索引和数据有助于扫描全表的顺序io,优化磁盘存储,减少io

  • b*树,b-link树:在分裂时,b树空间利用率为1/2,b*树会增加到右边兄弟节点,当两个都是要分裂时分裂成三个,空间利用率2/3;

    b-link树增加:1.非叶子节点也有指向右兄弟节点的指针;2.分裂模式同b*做法;3.记录当前节点最大key值

    分裂操作:b-link分裂时不需要锁定父节点,分裂的子节点通过右兄弟指针连接

    自底向上加锁:延迟更新,记录需要更新的父节点,异步更新

image-20250519164638548
  • cow-b树:写实复制,直接把需要加X Latch的节点都复制一遍
  • 惰性b树:为每个页设置一个更新buffer,读取时新页和buffer进行合并返回最新数据,应用场景:mongodb

LSM树

不同于B树,LSM的修改完全不用Latchs;

LSM树是分层存储的,最先在内存写入数据,当内存写满后再写入磁盘;这样一层一层重复直到磁盘也满了,就对磁盘数据进行整合;

读取时最上层的数据最新,找不到数据就往下一层查找

应用:leveldb,rocksdb

rocksDB的LSM实现:

  1. put数据首先WAL,写入log中实现落盘,再写入active memtable
  2. active memtable写满后再写入immutable memtable;两种结构都是跳表
  3. immutable到底一定数量写入L0层,L0层可能包含重复数据
  4. L0层满后进行major merge,把L0和L1进行合并,整合为固定大小、不可变的数据块SST;SST指有序字符串表,由索引文件和数据文件组成,
  5. 布隆过滤器用来筛选每一层是否包含需要的数据,设置多个哈希函数降低误报率
image-20250519152831065

并发控制机制

  1. memtable落盘:区分active和immutable避免了单个memtable在落盘时无法写操作的问题
  2. compaction策略:
    1. 使用Tiering合并策略:每一个中多个缓存层,合并时合并这些缓存层再写入下一层
    2. 流水线技术:将compaction分为读取、合并、写入以流水线形式写入,类似cpu流水线执行
    3. 复用组件:在合并时识别不变的部分并保留
    4. cache丢失:合并后刷新cache或者机器学习预测回填

Raft实现

实现领导选举日志复制持久化键值数据库

共识模块cm实现raft算法的核心

集群中每个节点都有一个唯一的id,peerIds记录着其它节点的id,server进行rpc通信

mu互斥锁,并发模式下限制对cm状态的改变

1
2
3
4
5
6
7
8
9
10
11
12
13
type ConsensusModule struct {
mu sync.Mutex
id int
peerIds []int
server *Server

currentTerm int
votedFor int
log []LogEntry

state CMState
electionResetEvent time.Time
}

leader election

为什么ticker设置为10ms

选举超时时间在150~300ms,设置为10ms可以更快的响应和及时退出,runElectionTimer会出现多个go并发的情况,而只需要一个存在

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func (cm *ConsensusModule) runElectionTimer() {
timeoutDuration := cm.electionTimeout()
cm.mu.Lock()
termStarted := cm.currentTerm
cm.mu.Unlock()
)

ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
<-ticker.C // 阻塞等待,直到ticker.C通道接收到一个时间信号, 当时间到达时,ticker.C通道会接收到一个时间信号,然后执行for循环中的代码
cm.mu.Lock()
if cm.state != Candidate && cm.state != Follower {
cm.mu.Unlock()
return
}
if termStarted != cm.currentTerm {
cm.mu.Unlock()
return
}
if elapsed := time.Since(cm.electionResetEvent); elapsed >= timeoutDuration {
cm.startElection()
cm.mu.Unlock()
return
}
cm.mu.Unlock()
}
}

进入竞选状态,而doElection做的是开始向其他节点发送rpc请求,参数设置即RequestVote所需的四个参数,创建一个子线程来进行节点间通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (int i = 0; i < m_peers.size(); i++)
{
if (i == m_me) continue;
int lastLogIndex = -1, lastLogTerm = -1;
getLastLogIndexAndTerm(&lastLogIndex, &lastLogTerm); // 获取最后一个log的term和下标

std::shared_ptr<raftRpcProctoc::RequestVoteArgs> requestVoteArgs =
std::make_shared<raftRpcProctoc::RequestVoteArgs>();
requestVoteArgs->set_term(m_currentTerm);
requestVoteArgs->set_candidateid(m_me);
requestVoteArgs->set_lastlogindex(lastLogIndex);
requestVoteArgs->set_lastlogterm(lastLogTerm);
auto requestVoteReply = std::make_shared<raftRpcProctoc::RequestVoteReply>();
std::thread t(&Raft::sendRequestVote, this, i, requestVoteArgs, requestVoteReply,
votedNum); // 创建新线程并执行b函数,并传递参数
t.detach();
}

在子线程中执行sendRequestVote,利用rpc通信屏屏蔽通信细节,就好像在本地调用一样获取投票结果回应,然后进行判断

  1. 是否对方Term大,是则退出选举状态,重置状态
  2. 此时投票人应该已经修改Term和candidate一样,voteNum++
  3. 投票达到大多数,成为leader
  4. 开始进行日志复制
  5. 持久化操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
bool ok = m_peers[server]->RequestVote(args.get(), reply.get());
...
std::lock_guad<std::mutex> lokc(m_mtx);
if (reply->term() > m_currenTerm)
{
m_status = Follower;
m_currenTerm = reply->term();
m_voteFor = -1;
persisit();
return true;
}
*voteNum = *votedNum + 1;
if (*votedNum >= m_peers.size() / 2 + 1)
{
*votedNum = 0;
m_status = Leader;
int lastLogIndex = getLastLogIndex();
for (int i = 0; i < m_nextIndex.size(); i++)
{
m_nextIndex[i] = lastLogIndex + 1; // 有效下标从1开始,因此要+1
m_matchIndex[i] = 0; // 每换一个领导都是从0开始,见fig2
}
std::thread t(&Raft::doHeartBeat, this); // 马上向其他节点宣告自己就是leader
t.detach();

persist();
}

log replication

心跳机制跟日志复制一样,只是没有日志实体

实现日志复制我们主要需要这些成员信息,m_matchIndex和m_nextIndex在可视化中可以明显的看到是每个LogEntry右小角小黑点和箭头,所以在日志复制中这两个成员变量很关键,同时随通信进行,收到reply后m_nextIndex和m_matchIndex前进,如果要进行日志复制优化就是实现快速查找m_nextIndex

image-20241109210221949

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int m_currenTerm;		// 任期号
std::vector<raftRpcProctoc::LogEntry> m_logs; // 日志体
int m_commitIndex; // 最后一个提交索引
int m_lastApplied; // 最后一个

std::vector<int> m_nextIndex; // 每个节点需要进行日志复制的index
std::vector<int> m_matchIndex // 每个节点已经完成日志复制的index

std::shared_ptr<LockQueue<ApplyMsg>> applyChan; // client从这里取日志(2B),client与raft通信的接口
// ApplyMsgQueue chan ApplyMsg // raft内部使用的chan,applyChan是用于和服务层交互,最后好像没用上

// 心跳超时,用于leader
std::chrono::_V2::system_clock::time_point m_lastResetHearBeatTime;

// 储存了快照中的最后一个日志的Index和Term
int m_lastSnapshotIncludeIndex;
int m_lastSnapshotIncludeTerm;

上面当选leader后创建子线程调用doHeartBeat,同doElection一样,这里的参数稍微复杂,因为实现了日志压缩模块,即将一部分日志体压缩成快照了,需要一个appendNums,match为0,nextIndex为lastLogIndex的下一个

  1. 判断如果nextIndex<=快照,直接发送快照
  2. 否则根据快照index获取m_logs日志项
  3. 创建线程调用sendAppendEntries
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
auto appendNums = std::make_shared<int>(1); // 正确返回的节点的数量
for (int i = 0; i < m_peers.size(); ++i)
{
// 日志压缩加入后要判断是发送快照还是发送AE
if (m_nextIndex[i] <= m_lastSnapshotIncludeIndex)
{
std::thread t(&Raft::leaderSendSnapShot, this, i); // 创建新线程并执行b函数,并传递参数
t.detach();
continue;
}

int preLogIndex = -1;
int PreLogTerm = -1;
getPreLogInfo(i, &preLogIndex, &PreLogTerm);
std::shared_ptr<raft::raftRpcProctoc::AppendEntriesArgs> appendEntriesArgs =
std::make_shared<raftRpcProctoc::AppendEntriesArgs>();
appendEntriesArgs->set_term(m_currenTerm);
appendEntriesArgs->set_leaderid(pm_me);
appendEntriesArgs->set_prelogindex(preLogIndex);
appendEntriesArgs->set_prelogterm(PreLogTerm);
appendEntriesArgs->clear_entries();
appendEntriesArgs->set_leadercommit(m_commitIndex);
if (preLogIndex != m_lastSnapshotIncludeIndex)
{
for (int j = getSlicesIndexFromLogIndex(preLogIndex) + 1; j < m_logs.size(); ++j)
{
raftRpcProctoc::LogEntry *sendEntryPtr = appendEntriesArgs->add_entries();
*sendEntryPtr = m_logs[j];
}
}
else
{
for (const auto &item : m_logs)
{
raftRpcProctoc::LogEntry *sendEntryPtr = appendEntriesArgs->add_entries();
*sendEntryPtr = item;
}
}
int lastLogIndex = getLastLogIndex();
// leader对每个节点发送的日志长短不一,但是都保证从prevIndex发送直到最后
myAssert(appendEntriesArgs->prevlogindex() + appendEntriesArgs->entries_size() == lastLogIndex,
format("appendEntriesArgs.PrevLogIndex{%d}+len(appendEntriesArgs.Entries){%d} != lastLogIndex{%d}",
appendEntriesArgs->prevlogindex(), appendEntriesArgs->entries_size(), lastLogIndex));
// 构造返回值
const std::shared_ptr<raftRpcProctoc::AppendEntriesReply> appendEntriesReply =
std::make_shared<raftRpcProctoc::AppendEntriesReply>();
appendEntriesReply->set_appstate(Disconnected);

std::thread t(&Raft::sendAppendEntries, this, i, appendEntriesArgs, appendEntriesReply,
appendNums); // 创建新线程并执行b函数,并传递参数
t.detach();
}
m_lastResetHearBeatTime = now(); // leader发送心跳,就不是随机时间了

而后sendAppendEntries也是类似,远程调用,进行判断,这里的reply比上面原理中多出两个类型,用来快速调整nextIndex和标识节点网络状态,AppState没什么必要,收不到回复就一直重发就好了

int32 UpdateNextIndex=3;

int32 AppState=4;

  1. 还是首先判断对方Term,大则说明自己已经过期,回到follower
  2. 判断受否复制成功,否,则什么也不做
  3. 判断有无UpdateNextIndex,有,说明不匹配,更新nextIndex
  4. appendNums++,当大多数复制成功,则提交
  5. 当提交的日志属于currentTerm才可以更新m_commitIndex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool ok = m_peers[server]->AppendEntries((args.get(), reply.get()));
if (!reply->success())
{
if (reply->updatenextindex() != -100) m_nextIndex[server] = reply->updatenextindex(); // 失败是不更新mathIndex的
else
{
*appendNums = *appendNums + 1;
m_matchIndex[server] = std::max(m_matchIndex[server], args->prevlogindex() + args->entries_size());
m_nextIndex[server] = m_matchIndex[server] + 1;
int lastLogIndex = getLastLogIndex();

if (*appendNums >= 1 + m_peers.size() / 2)
*appendNums = 0;
if (args->entries_size() > 0 && args->entries(args->entries_size() - 1).logterm() == m_currentTerm)
m_commitIndex = std::max(m_commitIndex, args->prevlogindex() + args->entries_size());
}
}

重写rpc方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 重写rpc方法
void Raft::RequestVote(google::protobuf::RpcController *controller, const ::raftRpcProctoc::RequestVoteArgs *request,
::raftRpcProctoc::RequestVoteReply *response, ::google::protobuf::Closure *done)
{
RequestVote(request, response);
done->Run();
}

void Raft::AppendEntries(google::protobuf::RpcController *controller,
const ::raftRpcProctoc::AppendEntriesArgs *request,
::raftRpcProctoc::AppendEntriesReply *response, ::google::protobuf::Closure *done)
{
AppendEntries1(request, response);
done->Run();
}
void Raft::InstallSnapshot(google::protobuf::RpcController *controller,
const ::raftRpcProctoc::InstallSnapshotRequest *request,
::raftRpcProctoc::InstallSnapshotResponse *response, ::google::protobuf::Closure *done)
{
InstallSnapshot(request, response);

done->Run();
}

rpc方法调用

1
2
3
4
5
6
7
8
9
10
11
12
13
bool raftRpcUtil::AppendEntries(raftRpcProctoc::AppendEntriesArgs *args, raftRpcProctoc::AppendEntriesReply *response)
{
MprpcController controller;
stub_->AppendEntries(&controller, args, response, nullptr);
return !controller.Failed();
}

bool raftRpcUtil::RequestVote(raftRpcProctoc::RequestVoteArgs *args, raftRpcProctoc::RequestVoteReply *response)
{
MprpcController controller;
stub_->RequestVote(&controller, args, response, nullptr);
return !controller.Failed();
}

raft是一种共识算法,目的实现分布式一致性,为实现目的采用了强领导模型,因此构建raft的关键模块是随机的选举计时器

follower and candidate始终运行一个runElectionTimer协程,electionTimeout为150~300ms,这还使用10毫秒的ticker负责判断状态,以快速响应该节点的状态变化,同一时间可能运行多个runElectionTimer协程,通过term判断旧的退出

leader运行一个协程,每50ms执行leaderSendheartbeats

6.284

MapReducec

用户指定一个用于处理k/v对并生成中间态k/v对集合的映射(map)函数,以及一个用于合并所有具有相同中间态key的中间态value值的归约(reduce)函数。

工作模型:

1、用户编写Map函数,其获得一个输入的k/v对并生成一个中间态的k/v对。
2、MapReduce库对所有的k/v对进行分组,使得所有有着相同中间态key值的k/v对的value值组合在一起,然后将它们传递给Reduce函数。

3、用户编写Reduce函数,其接收一个中间态的key值和与该键对应的一组value值的集合。它会将这些value值进行统一的合并以形成一个可能更小的value值集合。这允许我们得以处理那些无法被完整放入内存的,过大的列表集合。

例子:

URL访问频率计数:map函数处理网页请求的处理日志,并且输出<URL,1>的键值对。mapreduce进行排序分类成一个个list。reduce函数累加所有具有相同URL键值对的value值,并且输出一个<URL,总访问数>的键值对。

实现:

1、将文件分割成M份

2、master将M份任务通过调度分配到多个Map worker

3、多个Map work用自定义的Map函数生成键值对的形式,并且放入缓存中,这里生成R个临时文件对应后面R个work

4、缓存中的kv被周期性的写入通过分区函数划分的R个磁盘区域,告诉master位置,master通知reduce worker位置

5、reduce worker知道这些位置,通过rpc读取数据,读取了所有数据后进行排序,这样相同key的kv被分组在一起

6、调用reduce函数进行数据处理和输出

7、最终唤醒用户返回数据

image-20250522125955300

数据结构:

master存储了对应的任务状态(闲置的,运行中,或者已完成),以及worker机器的id(针对非空闲的任务)。

对于每个已完成的map任务,master存储了由map任务生成的R个中间态文件区域的位置和大小。

容错:

心跳+重置状态:

1、master会周期性的ping每一个worker。如果在一定的时间内没有接收到来自某一worker的响应,master将会将worker标记为有故障(failed)。所有由该worker完成的map任务将会被重置回初始状态,因此这些map任务能被其它worker去调度执行。

2、当一个map任务在worker A上被首次执行,不久后又被worker B执行(因为worker A发生了故障),所有执行reduce任务的worker将会被通知需要重新执行。所有还没有从worker A处读取(完整)数据的reduce任务将改为从worker B处读取数据。

前言

分布式目的和挑战:

  • 高性能计算
  • 提供容错
  • 天然原因:银行之间转账
  • 安全:将代码分散到不同计算机,限制出错域

挑战:

  • 因为系统中存在很多部分,这些部分又在并发执行,你会遇到并发编程和各种复杂交互所带来的问题,以及时间依赖的问题(比如同步,异步)。这让分布式系统变得很难。
  • 另一个导致分布式系统很难的原因是,分布式系统有多个组成部分,再加上计算机网络,你会会遇到一些意想不到的故障。如果你只有一台计算机,那么它通常要么是工作,要么是故障或者没电,总的来说,要么是在工作,要么是没有工作。而由多台计算机组成的分布式系统,可能会有一部分组件在工作,而另一部分组件停止运行,或者这些计算机都在正常运行,但是网络中断了或者不稳定。所以,局部错误也是分布式系统很难的原因。
  • 最后一个导致分布式系统很难的原因是,人们设计分布式系统的根本原因通常是为了获得更高的性能,比如说一千台计算机或者一千个磁盘臂达到的性能。但是实际上一千台机器到底有多少性能是一个棘手的问题,这里有很多难点。所以通常需要倍加小心地设计才能让系统实际达到你期望的性能。

可扩展性:

我们希望构建了一个系统,并且只要增加计算机的数量,系统就能相应提高性能或者吞吐量。

但是会瓶颈转移,当反向代理扩展到一定水平就需要去扩展数据库。现实中这很难实现无限扩展,需要一些架构设计来将这个可扩展性无限推进下去。

可用性:

指系统的容错性:1、系统能屏蔽特定范围内故障的错误,能够在出错时继续运行;2、具备自我恢复性,将数据放入磁盘,在修复后能完全正确的重新运行

实现容错的两个工具:

  1. 非易失存储,但是频繁的更新非易失存储是代价很高的操作
  2. 复制,本质是实现复制状态机

一致性:

  • 强一致性:可以保证get得到的是put写入的最新的数据;需要做大量的通信,比如同时读取所有副本,并使用最新的数据

  • 弱一致性:系统不会做出类似的保证,通过get看到的可能仍然是一个旧数据。

人们常常会使用弱一致系统,你只需要更新最近的数据副本,并且只需要从最近的副本获取数据。

GFS

设计难点:

多副本设计中,C1,C2写请求,倘若没有没有做任何事情来保障两台服务器以相同的顺序处理这2个请求,会发生数据不一致情况

在这里,Master节点用来管理文件和Chunk的信息,而Chunk服务器用来存储实际的数据

更进一步,我们看一下GFS的一致性以及GFS是如何处理故障。为了了解这些,我们需要知道Master节点内保存的数据内容,这里我们关心的主要是两个表单:

  • 第一个是文件名到Chunk ID或者Chunk Handle数组的对应。这个表单告诉你,文件对应了哪些Chunk。但是只有Chunk ID是做不了太多事情的,所以有了第二个表单。
  • 第二个表单记录了Chunk ID到Chunk数据的对应关系。这里的数据又包括了:
    • 每个Chunk存储在哪些服务器上,所以这部分是Chunk服务器的列表
    • 每个Chunk当前的版本号,所以Master节点必须记住每个Chunk对应的版本号。
    • 所有对于Chunk的写操作都必须在主Chunk(Primary Chunk)上顺序处理,主Chunk是Chunk的多个副本之一。所以,Master节点必须记住哪个Chunk服务器持有主Chunk。
    • 并且,主Chunk只能在特定的租约时间内担任主Chunk,所以,Master节点要记住主Chunk的租约过期时间。

这里在磁盘中维护log而不是数据库的原因是,数据库本质上来说是某种B树(b-tree)或者hash table,相比之下,追加log会非常的高效,因为你可以将最近的多个log记录一次性的写入磁盘。因为这些数据都是向同一个地址追加,这样只需要等待磁盘的磁碟旋转一次。而对于B树来说,每一份数据都需要在磁盘中随机找个位置写入。所以使用Log可以使得磁盘写入更快一些。

Read:

1、客户端(或者应用程序)将文件名和偏移量发送给Master。

2、Master节点将Chunk Handle(也就是ID,记为H)和服务器列表发送给客户端。

3、客户端挑选最近的一个服务器,发送读请求,并且客户端会缓存Chunk和服务器的对应关系

4、将Chunk Handle和偏移量发送给那个Chunk服务器,服务器找到对应的文件,读取数据并返回

读请求刚好越界:客户端会进行分割,发送多个读请求,接收到buffer中组合

Write:

1、写操作以追加的方式进行

2、客户端向master申请最后一个chunk。master去寻找主副本,若找不到,Master节点需要能够在Chunk的多个副本中识别出最新的。每个Chunk可能同时有多个副本,最新的副本是指,副本中保存的版本号与Master中记录的Chunk的版本号一致。

3、当客户端想要对文件进行追加,但是又不知道文件尾的Chunk对应的Primary在哪时,Master会等所有存储了最新Chunk版本的服务器集合完成,然后挑选一个作为Primary,其他的作为Secondary。之后,Master会增加版本号,并将版本号写入磁盘,这样就算故障了也不会丢失这个数据。

4、Primary和Secondary服务器都会将版本号存储在本地的磁盘中,向Master报告本地保存的Chunk的实际版本号

5、Master管理着版本号,目的:将实际更新Chunk的能力转移给Primary服务器。如果Master节点故障重启,还是可以在相同的Primary和Secondary服务器上继续更新Chunk

primary有着租约时间代表有效的管理能力时间,例如60秒后失效,master得重新选取primary

6、Primary和second服务器将数据写入临时位置,当所有second回复再写入文件

7、所有的Secondary都有相同的版本号。版本号只会在Master指定一个新Primary时才会改变。

GFS一致性要求:

1、需要让Primary来探测重复的请求

2、Secondary必须回应,并且能被移除

3、两阶段提交

4、当一个Primary崩溃了,一个Secondary会接任成为新的Primary,新的Primary上任时,需要显式的与Secondary进行同步,以确保操作历史的结尾是相同的。

5、系统要么需要将所有的读请求都发送给Primary,对于Secondary需要一个租约系统


FT VM

复制操作:

  • 状态转移:Primary将自己完整状态,比如说内存中的内容,拷贝并发送给Backup。
  • 复制状态机:通常来说,如果有两台计算机,如果它们从相同的状态开始,并且它们以相同的顺序,在相同的时间,看到了相同的输入,那么它们会一直互为副本,并且一直保持一致。

随机操作在复制状态机会怎么处理?

Backup不会执行这些指令,而是在应该执行指令的地方,等着Primary告诉它,正确的答案是什么,并将监听到的答案返回给软件。

VMware FT 独特之处在于其机器级的全状态复制,使其能够对任意软件提供无修改的容错能力,而大多数现代系统采用更高效但依赖应用逻辑参与的应用级复制方案。

在 VMware FT 这样的系统中,“机器级复制”包括:

类型 复制内容说明
CPU 状态 所有寄存器、程序计数器(PC)、条件码、状态寄存器等
内存状态 所有 RAM 的内容 —— 包括应用程序的堆、栈、代码段、全局变量
设备状态 虚拟设备(如网卡、磁盘控制器等)的寄存器状态和 I/O 队列
非确定性事件记录 中断、时钟读取、随机数、DMA 操作、I/O 完成等事件发生的顺序和时间点
指令执行顺序 确保所有指令执行的顺序保持一致,哪怕有中断插入

确认性重放

目的:使备份虚拟机(Backup VM)能精确重现主虚拟机(Primary VM)的执行,从而达到两者在逻辑上的“锁步同步”。

原理:将主 VM 执行过程中的所有非确定性事件和输入(如中断、时钟读取、网络包等)都记录为日志,并将这些日志实时传送到备份 VM。

实现:

主 VM 执行时记录日志

  • 所有输入:网络包、磁盘读取、用户输入等;
  • 所有非确定性事件:如 CPU 时间戳读取、虚拟中断、DMA、随机数等;
  • 日志由 hypervisor 拦截生成。

备 VM 实时回放日志

  • 按主 VM 的顺序重放所有输入和事件;
  • 确保在精确指令位置重放中断等事件,防止状态偏差。

硬件支持

  • 使用 Intel/AMD 的性能计数器帮助记录中断触发的具体指令点。

无需“epoch”机制

  • 与旧系统不同,VMware 的实现能精确到单个事件/指令,无需将多个事件“批量处理”。

FT协议

若主 VM 故障,备 VM 接管后,其状态应与主 VM 最后一次“对外输出”时的状态一致。

主 VM 不得发送任何对外输出,直到备 VM 确认收到与该输出操作相关的日志条目。

实现:

primary进行控制输出,1、生成一个“日志条目”,记录该输出操作;2、等待备 VM 回传“ACK 确认”;3、然后再发送这个输出。

确认性重放通过精确记录和重放所有非确定性事件,使主备 VM 执行保持一致;

FT 协议通过“输出延迟直到日志确认”的机制,确保即使主 VM 故障也不会造成对外状态不一致。


一致性

强一致性模型:

模型 描述 典型系统
线性一致性(Linearizability) 最严格的强一致性模型;读写看起来发生在一个“全局时间点”上,保持真实时间顺序 ZooKeeper、Etcd、Raft
顺序一致性(Sequential Consistency) 稍弱于线性一致性;全局操作有序,但顺序不一定符合真实时间 多处理器系统、部分共享内存系统

线性一致性必须与真实时间顺序符合,所以是最强的模型,顺序一致性联想到内存模型的顺序一致性模型,也就是说所有进程看到一样的顺序操作即可,即使有多种顺序操作可能发生,每一种都是合理的,但所有人只能同时看到一种

弱一致性模型:

模型 描述 典型系统
最终一致性(Eventual Consistency) 最常见的弱一致性模型;最终所有副本趋于一致,但过程不保证顺序 Amazon Dynamo、Cassandra
因果一致性(Causal Consistency) 保证有因果关系的操作顺序一致,无关操作顺序不做要求 COPS、Orleans
会话一致性(Session Consistency) 每个客户端看到自己写过的值,别人的修改可能延迟可见 DynamoDB 的 Session consistency
弱一致性(Weak Consistency) 完全不保证每次读到什么值,只在某些“同步点”上有一致性 写优化存储系统

特征:

  • 性能更好,适合高可用、大规模系统;

  • 写入不会立即被所有副本看到,可能出现读旧值;

  • 适合容错性更强但一致性要求没那么高的场景,如社交网络、电商系统中的推荐信息。


raft

在以前,因为都是主master节点协调管理,有单点故障;而多台的话为了防止脑裂,只有:1构建永远不会故障的网络;2、人工解决问题;

过半票决的出现解决了脑裂问题,所以说为什么服务器数量要是奇数,这样的网络分区不是对称的,同时如果系统有 2 * F + 1 个服务器,那么系统最多可以接受F个服务器出现故障,仍然可以正常工作。

为什么Raft系统这么关注Log,Log究竟起了什么作用?

1、Log是Leader用来对操作排序的一种手段。

2、Log是用来存放临时操作的地方。

3、Leader需要能够向Follower重传丢失的Log消息。所以,Leader也需要一个地方来存放客户端请求的拷贝。即使对那些已经commit的请求,为了能够向丢失了相应操作的副本重传,也需要存储在Leader的Log中。

4、Log也会被用来持久化存储操作,服务器可以依赖这些操作来恢复状态。

客户端如何请求?应用层接口是怎么样的?

在每个副本中,raft和kv之间有两个接口:

1、start函数,客户端向kv请求,kv转发到raft层,马上返回,这是因为请求不一定commit,start会返回index和term

2、raft层通过go channle发送给kv,然后kv可以返回给客户端了

为什么要有leader?

通常情况下,如果服务器不出现故障,有一个Leader的存在,会使得整个系统更加高效。

对于一个无Leader的系统,通常需要一轮消息来确认一个临时的Leader,之后第二轮消息才能确认请求。

我的理解是多个客户端请求时必须有一个临时leader来协调,不然每个副本都可以复制log,到底该应用谁的?存在被覆盖或者顺序不一致这些问题

选举定时器

1、避免分割投票,使用随机化去掉节点之间的同步性

2、下限应该为心跳间隔的几倍

3、上限应该考虑系统故障的频繁性

4、不同节点的选举定时器的超时时间差(S2和S3之间)必须要足够长,使得第一个开始选举的节点能够完成一轮选举。这里至少需要大于发送一条RPC所需要的往返(Round-Trip)时间。

哪些数据需要持久化?

Log条目,Term,votedfor

Zookeeper

介绍

ZooKeeper 是一个分布式协调系统,为多节点分布式应用中的进程提供一致性、协调服务。

“ZooKeeper 是关键基础设施的一部分,目标是提供一个简单且高性能的内核,使客户端能基于它构建更复杂的协调原语。”

  • ZooKeeper 是中间件底层的一部分(critical infrastructure);
  • 核心要小而快,让更复杂的功能由客户端组合。

“它结合了组播、共享寄存器和分布式锁服务的元素,并通过复制的集中式服务(即多副本)实现。”

特性:

  • Wait-free:客户端操作不会被阻塞(尤其是读操作); ZooKeeper 选择使用 非阻塞的 wait-free 数据对象(比如 znodes),这些对象像文件系统一样组织。
  • Watch机制:类似“缓存失效通知”,客户端能在数据变动时收到事件通知;
  • 接口简单但功能强大,适合构建更高层服务。
  • FIFO:同一客户端操作按顺序执行

保证:“ZooKeeper 保证客户端请求 FIFO 顺序执行,并对所有写操作提供线性一致性。”

性能:写由 Leader 控制,读操作可以由 Follower 本地直接处理,提升并发性能

目的:为分布式系统提供各种协调服务,传统做法是:为每种功能单独开发服务(比如 Amazon SQS 专注于消息队列,Chubby 是锁服务)。例如配置管理(Configuration)组成员管理、领导者选举(Leader Election)锁(Locks) 实现互斥访问。

并且提供API,使得应用开发者可基于这个 API 自定义所需的协调逻辑,这样 ZooKeeper 的内核就不需要频繁修改,更灵活、适配性更强。

实现:

  • 使用 Zab(ZooKeeper Atomic Broadcast)协议 实现写操作的线性一致性;
  • 使用 流水线处理架构(pipelined architecture) 支持高吞吐并发;
  • 支持异步操作,客户端可以并发提交多个请求,避免初始化延迟;
  • 读操作在本地处理,不需要用 Zab 排序,并借助客户端缓存 + Watch 增强性能。

与 Chubby 的对比:

ZooKeeper Chubby
缓存机制 客户端自己缓存 + Watch 通知 服务端主动管理缓存,更新时会阻塞其他客户端
阻塞操作 避免阻塞操作(更轻量) 有锁、open/close 等阻塞操作
对故障客户端的处理 Watch 机制完全避免慢客户端影响系统 使用 Lease 限制影响,但仍可能被阻塞

znode

znode 是 ZooKeeper 数据树中的一个节点。每个 znode 都有唯一的路径(类似文件路径),比如 /app/config。ZooKeeper 中的 znode 分为两大类:

类型 说明
Regular znode(常规节点) 客户端创建,直到主动删除之前一直存在
Ephemeral znode(临时节点) 会话断开时自动删除,适合表示临时存在性(如进程状态)

创建 znode 时还可以设置 sequential 标志,会自动在 znode 名字后附加一个递增序号,用于实现有序性(如队列、选主等)。

znode 能干什么?

  1. 存储配置信息和状态数据
    由于 znode 可以存储小量数据,很多分布式系统把配置信息和元数据放到 ZooKeeper 的 znode 上,实现配置管理和共享。
  2. 协调分布式应用
    ZooKeeper 作为分布式协调服务,利用 znode 实现:
    • 分布式锁:创建临时顺序节点,排队获取锁。
    • 选举领导者:利用顺序节点选出 leader 节点。
    • 服务发现:服务启动时在 ZooKeeper 上创建临时节点,客户端通过读取节点判断服务是否在线。
  3. 事件监听(Watch)
    客户端可以对 znode 设置监听(watch),当节点数据或子节点发生变化时,客户端会收到通知,从而实现实时响应。
  4. 保存元数据和协调状态
    ZooKeeper 的强一致性保证让 znode 成为分布式应用之间同步状态和数据的可靠媒介。

Watch 机制(事件触发通知)

  • 客户端在读取数据时可附带 watch=true
  • 一旦该 znode 被修改,ZooKeeper 会异步通知客户端
  • 特性:
    • Watch 是一次性的(触发后即失效);
    • 通知的是“发生了变化”,不会告诉你具体改了什么
    • Session 事件(如连接丢失)也会通过 Watch 回调告知,提示 Watch 事件可能延迟。

客户端连接zookeeper后启动一个session会话,会话可在多个 ZooKeeper 服务器之间迁移,临时节点和 Watch 都是 基于 Session 生命周期管理的

API

方法 功能与说明
create(path, data, flags) 创建一个 znode,写入数据 data[],并返回 znode 的路径名。通过 flags 参数选择节点类型(常规/临时/顺序
delete(path, version) 删除指定路径的 znode,若其版本号与 version 匹配(用于实现条件删除
exists(path, watch) 查询某 znode 是否存在,若 watch=true 则在其状态变动时触发通知
getData(path, watch) 获取指定 znode 的数据和元数据(如版本号),支持设置 watch
setData(path, data, version) 修改 znode 数据,若其当前版本号与传入 version 匹配;-1 表示忽略版本检查
getChildren(path, watch) 获取某个 znode 的子节点列表,支持 watch
sync(path) 等待所有 pending 的更新操作传播到当前客户端连接的服务器;path 参数暂时未使用

zookeeper顺序性保证

保证类型 含义 作用
A-Linearizability(异步线性一致性) 所有修改 ZooKeeper 状态的操作是原子、有序的(全局顺序) 类似标准线性一致性(客户端一次只能有一个正在执行的操作),但允许客户端同时发起多个操作,支持更高效的异步处理
FIFO Client Order 来自同一个客户端的所有请求按发送顺序执行 保证单客户端观察一致性,支持异步并发调用

写请求保证了全局一致性,但是读请求在副本进行,但是提供了sync()来读取最新状态

考虑一个场景,一个新上线的leader需要更新多个配置项,我的理解是:

leader做了这些事:1、删除ready znode;2、更新这些配置项;3、创建ready znode

这样客户端看到ready znode时就说明 Leader 的所有配置更新都已经完成;如果新leader宕机也不会让客户端使用这些不完全更新的配置;

并且一个进程在读取旧ready znode时也进行监听,watch机制让该进程知道新leader删除了ready znode

ZooKeeper 通过 异步线性一致性 + 客户端 FIFO 顺序 构建了高效而强一致的协调服务,支持异步调用、快速批量操作、watch 通知顺序保证和 sync 强制同步读,是满足分布式一致性与性能需求的重要基础设施。