diff --git a/2023/08/24/raft-basic-algorithm-ch3/index.html b/2023/08/24/raft-basic-algorithm-ch3/index.html index 539fade..8c88fca 100644 --- a/2023/08/24/raft-basic-algorithm-ch3/index.html +++ b/2023/08/24/raft-basic-algorithm-ch3/index.html @@ -23,17 +23,20 @@ raftpractice 大论文阅读-第三章 Raft 算法基础 - ERaftBooks - + - + + + - + + @@ -103,7 +106,19 @@

raftpractice 大论文阅读-第三章 Raft 算法基础

这一章我们来介绍 Raft 算法,我们将 Raft 设计得尽可能易于理解,第一部分我们将介绍我们让算法易于理解的设计方法。剩下的部分我们描述了算法本身,并包括了我们为了让算法更容易理解做出来的设计的示例。

-

为了容易理解而设计的算法

Raft 概览

+

为了容易理解而设计的算法

Raft 概览

Raft 算法管理了上一章我们介绍的复制状态机的日志。下面这个图我们以简洁的方式给出了算法的参考。

+
+ +

还有 Raft 算法的关键属性如下,图中的其余元素我们将在论文剩下的部分来讨论。

+
+ +

Raft 首先选举一个服务节点作为 Leader,然后给这个 Leader 管理复制日志的权限。Leader 接受来自客户端的请求并生成日志条目,把它们复制给其他的服务器,并告诉服务什么时候可以安全的将日志应用到状态机中。

+

集群中拥有一个 Leader 简化了复制日志的管理,例如,Leader 可以不用咨询其他的服务器自己决定日志里面追加新条目的位置,数据以简单的方式从 Leader 流向其他服务器。Leader 可能会故障或者与其他服务器网络断开,在这种情况下会选举出新的 Leader。

+

考虑到集群的领导方式,Raft 将一致性问题分为三个相对独立的子问题,这些子问题我们将在下面的章节进行详细的讨论。

+ +
许可协议

raftpractice 大论文阅读-第二章 设计 Raft 算法的动机

一、通过复制状态机来实现系统的容错共识算法通常伴随着复制状态机出现,通过共识算法,我们实现一组拥有相同状态的计算机,我们通常把这一组拥有相同状态的计算机称为副本集。在某些机器宕机的情况下,这些计算机仍然能够继续之前的工作进行操作。 复制状态机通常通过复制日志来实现,如下图: diff --git a/search.json b/search.json index b45529c..da8a754 100644 --- a/search.json +++ b/search.json @@ -1 +1 @@ -[{"title":"raftpractice 大论文阅读-第三章 Raft 算法基础","path":"/2023/08/24/raft-basic-algorithm-ch3/","content":"这一章我们来介绍 Raft 算法,我们将 Raft 设计得尽可能易于理解,第一部分我们将介绍我们让算法易于理解的设计方法。剩下的部分我们描述了算法本身,并包括了我们为了让算法更容易理解做出来的设计的示例。 为了容易理解而设计的算法Raft 概览"},{"title":"raftpractice 大论文阅读-第二章 设计 Raft 算法的动机","path":"/2023/08/21/raftpractice-ch2/","content":"一、通过复制状态机来实现系统的容错共识算法通常伴随着复制状态机出现,通过共识算法,我们实现一组拥有相同状态的计算机,我们通常把这一组拥有相同状态的计算机称为副本集。在某些机器宕机的情况下,这些计算机仍然能够继续之前的工作进行操作。 复制状态机通常通过复制日志来实现,如下图: 每个服务节点都存储着一个日志结构,它里面包含了多个对状态机操作的命令序列,它们按顺序在状态机里面执行。每个服务器里面都存储着相同顺序的命令序列,使得每个状态机执行相同序列的命令。由于状态是确定的,每个状态机都计算得到相同的状态,并且有着相同的操作输出序列。 共识算法的工作就是要保证复制日志的一致性,服务节点上的共识算法模块在服务器收到来自客户端的命令请求后,会把它追加到自己的日志中。并且和其他服务节点上的共识算法模块进行网络通信,确保每个日志都有相同顺序的相同请求。即使某些服务器故障,一旦命令请求被正确的复制了,它们就被称为已经提交的日志。每个服务节点的状态机按日志顺序处理提交的命令,并将命令输出返回给客户端。这样,服务器就像是一个单一的,高可用的机器(永不故障的单机)。 一个实际系统的共识算法通常具有以下属性: 它们确保在所有非拜占庭(non-Byzantine)条件下的安全性,包括网络延迟、分区、数据包丢失、重复请求和重新排序。 只要任何大多数服务器都可以运行并且可以相互通信以及与客户端通信,系统就是可用的。例如,典型的 5 台服务器组成的集群可用容忍人一两台服务器故障。假定服务器因停止而发生故障,它们稍后可能会从稳定的存储(持久化存储设备)上的状态恢复并重新加入集群。 它们不依赖时间来确保日志的一致性:在时钟错误和极端消息延迟的情况下可能会发生可用性问题。也就是说,它们是在异步的模型下保持安全性,其中消息和处理程序一任意的速度进行。 通常,一旦集群中的大多数成员相应了一轮远程过程调用(RPC),命令就可以完成返回客户端。少数缓慢的服务器并不影响系统整体的性能。 二、常用的使用复制状态机场景复制状态机是使分布式系统具有容错的通用构建模块,它们可以以多种方式被使用,这一部分我们来讨论典型的使用案例。 模式 1对于大多数的部署模式,一般都是由 3 台或者 5 台服务器组成一个复制状态机。然后其他服务器可以使用这个状态机来协调它们的活动,如下图所示: 我们工业上常见用来做分布式协调的应用例如 Etcd 和 Zookeeper 都是这种应用模式。 这些系统通常使用复制状态机进行分布式系统的成员组,配置,还有锁的管理。 举一个更具体一点的示例:复制状态机可以提供一个容错的工作队列,而其他的服务器可以使用这个复制状态进行协调分配工作给它们自己。 模式 2还有一种常见的部署模式如下图所示: 在这种模式下一台服务器充当 Leader 管理者剩下的服务器。Leader 将其关键的数据存储在共识系统中。如果它宕机了,其他的备用服务器会重新竞选领导者的位置,成功之后,我们可以使用共识系统中的数据继续的运行。 许多大型的存储系统都有单个集群 Leader, 例如 GFS, HDFS, 和 RAMCloud。 模式 3共识协议也通常用于拥有超大量数据的系统的复制,如下图所示: 例如 Magastore, Spanner 和 Scatter 它们存储着超大量的数据,无法存储在单个服务器分组里面。它们将数据分区存储到多个不同的复制状态机(多个服务器组)里面,跨越多个分区的使用两阶段提交 (2PC)来保证数据一致性。 三、Paxos 存在的问题在过去的十年间,Leslie Lamport 的 Paxos 协议几乎成了分布式共识的代名词:它是大学课程里面最常教授的协议,大多数共识算法的实现都以它为起点。Paxos 首先定义了一个能够就单个决策达成一致的协议,例如单个日志条目的复制。我们将这个子集称为 single-decree(单法令) Paxos。 Paxos 还结合该协议的多个实例来促进一系列决策,例如日志(Multi-Paxos)。 Paxos确保了安全性和有效性(假设使用了足够的故障检测器来避免提议者的活动锁定,它最终会达成共识) 并证明了它的正确性。Multi-Paxos在正常情况下是有效的,并且 Paxos 支持更改集群成员身份。 不幸的是,Paxos 有两个显著的缺点。 Paxos 难以理解第一个缺点是 Paxos 很难理解,完整的解释是出了名的难懂。 缺少构建实践的基础Paxos 的第二个问题是,它并没有为构建实践实际的分布式系统提供良好的基础。"},{"title":"动手学习分布式-Multi-分布式事务初探 (Golang eraftkv 版)","path":"/2023/08/19/distributed-tx/","content":"事务介绍在介绍分布式事务之前,我们先来通过一个例子看看事务是什么? 开始讨论我们系统中可能发生的事情之前,我们要重新说一下事务的定义。事务是对数据库的一系列操作,这些操作满足ACID的属性。 我们看到上图的例子,假设我们现在实现的分布式存储系统存储了银行账户数据。 T1表示储蓄用户的账户为Y,他有10块钱,然后他给X转账1块钱,那么对应的数据操作就是对x + 1,对Y - 1 。用户提交这个转账后,系统就开始修改数据库中的值了。 T2表示银行对账人员,她需要统计用户X, Y的账户总和。如果T2在T1开始且还没有操作的时候执行x’, y’值得获取,那么能拿到20块的总和,这是符合预期的。 但是,因为两个用户使用系统的时候,他们访问的顺序是随机的,我们无法保证,一旦T2在T1执行add(x, 1)之后读取x’, y’的值。我们将得到X + Y = 21,统计莫名的多出了一块钱(似乎银行亏1块钱也没啥问题),如果这笔转账金额很大呢,比如一个小目标1个亿,那就是绝对不能容忍的错误了。 这时候就需要我们的事务保障了。 ACID atomic原子性。数据库管理系统保证事务是原子的,事务要么执行其所有操作,要么不执行任何操作。 cosistent一致性。这个表示数据库是一致的。应用程序访问的有关数据的所有查询都将返回正确的结果。 isolated隔离性。数据库管理系统提供了事务在系统中单独运行的假象。他们看不到并发事务的影响。这等同于事务的执行是以串行的顺序的。但是为了更好的性能,数据库管理系统必须交错并发的执行事务操作。 durable持久性。在系统崩溃和重启之后,提交事务的所有更改都必须是持久化的。数据库管理系统可以使用日志记录或者影子页面来确保所有的更改都是持久化的。 两阶段提交 在一个分布式系统中,数据被分割存储在不同的机器上。例如我们eraft中将数据按哈希值分布到不同的bucket,然后有不同的机器去负责这个bucket数据的存取。这个时候,事务处理就更复杂了。单节点我们可以通过锁保证事务正确性,但是分布式场景就不一样的,我们把上述转账示例带入分布式场景下: 两阶段提交账户数据存储在S1, S2两台不同的机器上。T1在S1上执行+1操作,X现在等于11。当T1执行到对Y减1操作的时候,服务S2奔溃掉了。那么这时候这个操作返回用户失败,但是S1上的账户已经脏了,这时候对账人员去对账也会得到错误的数据。 面对这种场景分布式系统是如何去解决的呢? 这个时候就需要一个节点作为事务协调者 Transaction Coordinator,来协调事务的执行了,S1,S2负责执行事务,他们被称为事务参与者 Participants。 我们首先概览以下两阶段提交是如何工作的 首先在我们的图中,假定TC, S1, S2都位于不同的服务器。TC是事务执行的协调者。S1, S2是持有数据的服务节点。 事务协调器TC会给S1发消息告诉它要对X进行+1操作,给服务器S2发消息告诉它对Y进行-1操作。后面会有一系列的消息来确认,要么S1, S2都成功执行了相应的,要么两个服务器都没有执行操作,不会出现非原子操作的状态,这就是两阶段提交的大致流程。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-Multi-Raft设计与实现 (Golang eraftkv 版)","path":"/2023/08/19/multi-raft/","content":"设计思考在上一章中,我们应用Raft实现了一个单分组的Raft KV集群。客户端写请求到Leader,Leader把操作复制到Follower节点。当Leader挂了,会按我们第四章描述的Raft算法库,进行一轮新的选举,选出新的Leader后继续提供服务。这样我们就有了一个高可用的集群。 我们通过单分组的KV集群实现了高可用,以及分区容忍性,但是分布式系统还有一个可扩展的特性,也就是我们可以通过堆机器的方式来让系统实现更高的吞吐量。我们第五章实现的是单分组集群,只有单个Leader节点承担客户端的写入。单分组集群系统的吞吐量上线取决于单机的性能。那么我们该如何实现分布式可扩展性呢? 接下来我们将介绍Multi-Raft实现,它可以解决单分组集群的可扩展性问题。它的思路是这样的:既然单组有上限,那么我们可不可以用多组Raft KV集群来实现扩展呢?我们需要有一个配置中心来管理多个分组服务器的地址信息。有了多个分组之后,我们就要考虑怎么样把用户的请求均衡地分发到相应的分组服务器上了。我们可以使用哈希算法来解决这个问题,对用户的key做哈希计算,然后映射到不同的服务分组上,这样可以保证流量的均衡。 整合一下上面的思路,我们可以得到如下的系统架构图a: 在第一章开篇,我们介绍了系统的整体架构并且带大家体验了系统的运行。之后,我们讲解了Go语言的基础知识、Raft算法库以及应用Raft算法库的示例。相信大家现在对这个系统已经有了一个更加深入的理解。 首先,客户端启动之后,会从配置服务器(ConfigServer)拉取集群的分组信息,以及分组负责的数据桶(Bucket)信息到本地。客户端发送请求的时候会计算key的哈希值,找到相应的桶以及负责这个桶的服务器分组(ShardServer)地址信息,然后将操作发送到对应的服务器分组进行处理。这里ConfigServer服务器分组和ShardServer服务器分组都是高可用的,多个ShardServer实现了系统的可扩展性。 配置服务器实现分析配置服务的实现在eraft/configserver目录下。根据上面的架构图,我们大概可以知道配置服务器需要存储哪些信息:(1)每个服务分组的服务器地址信息;(2)服务分组负责的数据桶信息。这个结构定义如下: 1type Config struct { Version int Buckets [common.NBuckets]int Groups map[int][]string } Version表示当前配置的版本号。Buckets存储了分组负责的桶信息。NBuckets是一个常量,表示系统中最大的桶数量。这里我们默认是10。对于大规模的分布式系统, Buckets值可以被设置为很大。 我们看到下面的配置示例:配置版本号为6 ,10个桶和1个分组服务。 我们设置127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090这三台服务器组成分组1。Buckets数组中0-4号桶都是分组1负责的。5-9号桶为0,表示当前没有分组负责这些桶的数据写入。 1{"Version":6,"Buckets":[1,1,1,1,1,0,0,0,0,0],"Groups":{"1":["127.0.0.1:6088","127.0.0.1:6089","127.0.0.1:6090"]}} eraft中把上述配置信息存储到了leveldb中。下面是几个操作的接口,Join操作 是把一个分组的服务器加入到集群配置中;Leave操作是删除某些分组的服务器配置信息;Move操作是将某个桶分配给相应的分组负责;Query操作是查询配置信息。这些操作是通过修改Config这个结构,并将数据持久化到leveldb中实现。操作的代码实现逻辑在eraft/configserver/config_stm.go里面。 12345678910type ConfigStm interface { Join(groups map[int][]string) error Leave(gids []int) error Move(bucket_id, gid int) error Query(num int) (Config, error)} 配置服务器的核心逻辑在config_server.go里面,可以看到和我们第四章实现的单分组kv极其类似。这里只是把Put, Get操作给改成了对配置的Join、Leave、Move、Query操作。 每一次操作都经过一次共识,保证三个服务节点都有一致的配置。这样Leader配置节点挂掉后,集群中仍然可以从新的Leader配置服务器中获取配置信息。 分片服务器实现分析首先,我们看到bucket定义如下: 123456789101112131415// a bucket is a logical partition in a distributed system // it has a unique id, a pointer to db engine, and status//type Bucket struct { ID int KvDB storage_eng.KvStore Status buketStatus} 桶具有一个唯一标识的ID。 前面我们介绍配置服务器的时,介绍过一个服务分组负责一部分桶的数据。在配置服务器中,1·桶关联的ID值就是配置数组的索引号。 同时,桶还关联了一个KvStore的接口。我们写数据的时候会传入当前服务器持有的数据存储引擎,下面是对桶中数据的操作,有Get, Put, Append。 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061// // get encode key data from engine//func (bu *Bucket) Get(key string) (string, error) { encodeKey := strconv.Itoa(bu.ID) + SPLIT + key v, err := bu.KvDB.Get(encodeKey) if err != nil { return "", err } return v, nil}//// put key, value data to db engine//func (bu *Bucket) Put(key, value string) error { encodeKey := strconv.Itoa(bu.ID) + SPLIT + key return bu.KvDB.Put(encodeKey, value)}//// appned data to engine//func (bu *Bucket) Append(key, value string) error { oldV, err := bu.Get(key) if err != nil { return err } return bu.Put(key, oldV+value)} 接下来我们来看看ShardServer定义的结构体 1234567891011121314151617181920212223242526272829303132333435363738type ShardKV struct { mu sync.RWMutex dead int32 rf *raftcore.Raft applyCh chan *pb.ApplyMsg gid_ int cvCli *configserver.CfgCli lastApplied int lastConfig configserver.Config curConfig configserver.Config stm map[int]*Bucket dbEng storage_eng.KvStore notifyChans map[int]chan *pb.CommandResponse stopApplyCh chan interface{} pb.UnimplementedRaftServiceServer} 这个结构和我们应用Raft实现单组KvServer的结构特别类似。这里的状态机是map[int]*Bucket类型的,代表当前服务器的桶的数据。分片服务器需要和配置服务器交互,知道自己负责哪些分片的数据。cvCli定义了到配置服务器的客户端。lastConfig,curConfig分别记录了上一个版本以及当前版本的集群配置表。服务器知道这个表之后就知道自己负责哪些分片的数据。当集群拓扑变更后,配置表会变化,分片服务器能第一时间感知到变化,并且应用新的配置表。其他结构就和我们之前介绍单组KvServer一样了。 我们看看ShardServer构造流程和单组KvServer的区别。首先, Server启动的时候我们初始化了两个引擎,一个用来存储Raft日志的logDbEng,另一个用来存储实际数据的newdbEng。cvCli是到配置服务器分组的连接客户。,我们调用MakeCfgSvrClient构造到配置服务器的客户端,传入配置服务器分组的地址列表。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566//// MakeShardKVServer make a new shard kv server// peerMaps: init peer map in the raft group// nodeId: the peer's nodeId in the raft group// gid: the node's raft group id// configServerAddr: config server addr (leader addr, need to optimized into config server peer map)//func MakeShardKVServer(peerMaps map[int]string, nodeId int, gid int, configServerAddrs string) *ShardKV { ...\tlogDbEng := storage_eng.EngineFactory("leveldb", "./log_data/shard_svr/group_"+strconv.Itoa(gid)+"/node_"+strconv.Itoa(nodeId))\tnewRf := raftcore.MakeRaft(clientEnds, nodeId, logDbEng, newApplyCh, 500, 1500)\tnewdbEng := storage_eng.EngineFactory("leveldb", "./data/group_"+strconv.Itoa(gid)+"/node_"+strconv.Itoa(nodeId)) shardKv := &ShardKV{ ... cvCli: configserver.MakeCfgSvrClient(common.UN_UNSED_TID, strings.Split(configServerAddrs, ",")), lastApplied: 0, curConfig: configserver.DefaultConfig(), lastConfig: configserver.DefaultConfig(), stm: make(map[int]*Bucket), ...\t} shardKv.initStm(shardKv.dbEng)\tshardKv.curConfig = *shardKv.cvCli.Query(-1)\tshardKv.lastConfig = *shardKv.cvCli.Query(-1) ... go shardKv.ConfigAction() return shardKv} 我们initStm函数初始化了状态机里面的每个Bucket。之后,调用cvCli.Query(-1)查询当前最新的配置缓存到本地的curConfig,lastConfig,初始启动,这两个配置是一样的。 这里有一个执行任务为ConfigAction的Goruntine,我们来看看它干了啥。 核心逻辑如下,下面的逻辑是一个循环执行的,时间间隔是1s。 首先我们通过cvCli.Query尝试查询下一个配置版本信息,如果当前集群没有配置变更,返回nil,我们continue进入下一轮循环,啥也不干。 如果有新的配置变更,比如加入了新的服务器分组,我们就会对比新的配置和当前配置的版本信息。如果匹配上,当前节点作为Leader需要把这个配置变化信息发送到这个服务器分组,让大家都知道新的配置变化。分组服务里面的每个服务器配置都要是一致的,这里我们通过Propose提交一个OpType_OpConfigChange的提案。 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950 if _, isLeader := s.rf.GetState(); isLeader {...nextConfig := s.cvCli.Query(int64(curConfVersion) + 1)if nextConfig == nil { continue}nextCfBytes, _ := json.Marshal(nextConfig)curCfBytes, _ := json.Marshal(s.curConfig)raftcore.PrintDebugLog("next config -> " + string(nextCfBytes))raftcore.PrintDebugLog("cur config -> " + string(curCfBytes))if nextConfig.Version == curConfVersion+1 { req := &pb.CommandRequest{} nextCfBytes, _ := json.Marshal(nextConfig) raftcore.PrintDebugLog("can perform next conf -> " + string(nextCfBytes)) req.Context = nextCfBytes req.OpType = pb.OpType_OpConfigChange reqBytes, _ := json.Marshal(req) idx, _, isLeader := s.rf.Propose(reqBytes) if !isLeader { return } ...}} 最后我们看看分组中的服务器是怎么Apply这个日志的 123456789nextConfig := &configserver.Config{}json.Unmarshal(req.Context, nextConfig)if nextConfig.Version == s.curConfig.Version+1 { ...\ts.lastConfig = s.curConfig\ts.curConfig = *nextConfig\tcfBytes, _ := json.Marshal(s.curConfig)\traftcore.PrintDebugLog("applied config to server -> " + string(cfBytes))} 我们会更新Server的lastConfig和curConfig配置信息。 客户端实现分析当客户端写入一个Key到系统中时,我们首先需要知道Key属于那个分组服务器负责。在构造客户端的时候,我们会先将最新的配置信息缓存到本地。 1234567// make a kv cilent//func MakeKvClient(csAddrs string) *KvClient {\t...\tkvCli.config = kvCli.csCli.Query(-1)\treturn kvCli} 客户端中我们提供了Get(key string)和Put(key, value string)的接口,它们都是调用公用的Command方法去访问我们的分组服务器。 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950//// Command// do user normal command//func (kvCli *KvClient) Command(req *pb.CommandRequest) (string, error) {\tbucket_id := common.Key2BucketID(req.Key)\tgid := kvCli.config.Buckets[bucket_id]\tif gid == 0 { return "", errors.New("there is no shard in charge of this bucket, please join the server group before")\t}\tif servers, ok := kvCli.config.Groups[gid]; ok { for _, svrAddr := range servers { if kvCli.GetConnFromCache(svrAddr) == nil { kvCli.rpcCli = raftcore.MakeRaftClientEnd(svrAddr, common.UN_UNSED_TID) } else { kvCli.rpcCli = kvCli.GetConnFromCache(svrAddr) } resp, err := (*kvCli.rpcCli.GetRaftServiceCli()).DoCommand(context.Background(), req) if err != nil { // node down raftcore.PrintDebugLog("there is a node down is cluster, but we can continue with outher node") continue } switch resp.ErrCode { case common.ErrCodeNoErr: kvCli.commandId++ return resp.Value, nil case common.ErrCodeWrongGroup: kvCli.config = kvCli.csCli.Query(-1) return "", errors.New("WrongGroup") case common.ErrCodeWrongLeader: kvCli.rpcCli = raftcore.MakeRaftClientEnd(servers[resp.LeaderId], common.UN_UNSED_TID) resp, err := (*kvCli.rpcCli.GetRaftServiceCli()).DoCommand(context.Background(), req) if err != nil { fmt.Printf("err %s", err.Error()) panic(err) } if resp.ErrCode == common.ErrCodeNoErr { kvCli.commandId++ return resp.Value, nil } default: return "", errors.New("unknow code") } }\t} else { return "", errors.New("please join the server group first")\t}\treturn "", errors.New("unknow code")} 1.首先我们会使用Key2BucketID函数对Key做CRC32运算,得到它应该被分配到桶的ID;2.然后 从本地缓存的kvCli.config配置里面找到负责这个bucket id数据的服务器分组;3.拿到服务器分组之后,我们会向第一个服务器发送DoCommand RPC;4.如果这个服务器不是Leader,它会返回Leader的ID。然后客户端会重新发DoCommand RPC给Leader节点。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-基于Raft库,实现简单的分布式KV系统 (Golang eraftkv 版)","path":"/2023/08/19/kv-distributed-systems/","content":"系统架构概览上一章中,我们已经介绍了我们构建好的Raft库,它在eraft/raftcore目录下。现在我们要使用这个库来构建一个高可用的分布式KV存储系统。 让我们回到上图,这个图在我们一开始介绍Raft的时候讲到过,我们这一章就要实现这样一个系统。 对外接口定义第一步我们来定义系统与客户端的交互接口,客户端可以发送Put和Get操作将KV数据写到系统中。我们需要定义这两个操作的RPC。我们将它们合并到一个RPC请求里面,定义如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061// // client op type //enum OpType { OpPut = 0; OpAppend = 1; OpGet = 2;}//// client command request//message CommandRequest { string key = 1; string value = 2; OpType op_type = 3; int64 client_id = 4; int64 command_id = 5; bytes context = 6;}//// client command response//message CommandResponse { string value = 1; int64 leader_id = 2; int64 err_code = 3;}rpc DoCommand (CommandRequest) returns (CommandResponse) {} 其中OpType定义了我们支持的操作类型:put,Append,get。 客户端请求的内容定义在了CommandRequest中。CommandRequest中有我们的key, value以及操作类型,还有客户端id以及命令id,还有一个字节类型的context (context可以存放一些我们需要附加的不确定的内容)。 响应CommandResponse包括了返回值value ,leader_id字段(告诉我们当前Leader是哪个节点的。因为最开始客户端发送请求时,不知道那个节点被选成Leader 。我们通过一次试探请求拿到Leader节点的id,然后再次发送请求给Leader节点。),以及错误码err_code(记录可能出现的错误)。 客户端通过DoCommand RPC请求到我们的分布式KV系统。那我们的系统是怎么处理请求的呢?首先,我们来看看服务端的结构的定义。 mu是一把读写锁,用来对可能出现并发冲突的操作加锁。dead表示当前服务节点的状态,是不是存活。Rf比较重要,这个是只想我们之前构建的Raft结构的指针。applyCh是一个通道,用来从我们的算法库中返回已经apply的日志。我们的server拿到这个日之后需要apply到实际的状态机中。stm就是我们的状态机了,我们一会儿介绍。notifyChans是一个存储客户端响应的map,其中值是一个通道。KvServer在应用日志到状态机操作完之后,会给客户端发送响应到这个通道中。stopApplyCh用来停止我们的Apply操作。 服务端核心实现分析123456789101112131415type KvServer struct { mu sync.RWMutex dead int32 Rf *raftcore.Raft applyCh chan *pb.ApplyMsg lastApplied int stm StateMachine notifyChans map[int]chan *pb.CommandResponse stopApplyCh chan interface{} pb.UnimplementedRaftServiceServer} 有了这个结构之后,我们要如何应用Raft算法库实现图中高可用的kv分布式系统呢? 1.首先我们要构造到每个server的rpc客户端;2.然后,构造applyCh通道,以及构造日志存储结构;3.之后,调用MakeRaft构造我们的Raft算法库核心结构;4.最后,启动Apply Goruntine,从通道中监听在经过Raft算法库之后返回的消息。 123456789101112131415161718192021222324252627282930313233343536373839func MakeKvServer(nodeId int) *KvServer {clientEnds := []*raftcore.RaftClientEnd{}for id, addr := range PeersMap { newEnd := raftcore.MakeRaftClientEnd(addr, uint64(id)) clientEnds = append(clientEnds, newEnd)}newApplyCh := make(chan *pb.ApplyMsg)logDbEng, err := storage_eng.MakeLevelDBKvStore("./data/kv_server" + "/node_" + strconv.Itoa(nodeId))if err != nil { raftcore.PrintDebugLog("boot storage engine err!") panic(err)}// 构造 Raft 结构,传入 clientEnds,当前节点 id, 日志存储的 db, apply 通道,心跳超时时间,和选举超时时间// 由于是测试,为了方便观察选举的日志,我们设置的时间是 1s 和 3s, 你可以设置的更短newRf := raftcore.MakeRaft(clientEnds, nodeId, logDbEng, newApplyCh, 1000, 3000)kvSvr := &KvServer{Rf: newRf, applyCh: newApplyCh, dead: 0, lastApplied: 0, stm: NewMemKV(), notifyChans: make(map[int]chan *pb.CommandResponse)}kvSvr.stopApplyCh = make(chan interface{})// 启动 apply Goruntinego kvSvr.ApplingToStm(kvSvr.stopApplyCh)return kvSvr} 客户端命令到来之后,最开始调用的是DoCommand函数,我们来看看这个函数做了哪些工作: 首先,Docommand函数调用Marshal序列化了我们的CommandResponse到reqBytes的字节数组中,然后调用Raft库的Propose接口,把提案提交到我们的算法库中。Raft算法中只有Leader可以处理提案。如果节点不是Leader我们会直接返回给客户端ErrCodeWrongLeader的错误码。之后就是从getNotifyChan拿到当前日志id对应的apply通知通道。只有这条日志通知到了,下面select才会继续往下走,拿到值放到cmdResp.Value中,当然如果操作超过了ErrCodeExecTimeout时间也会生成错误码,响应客户端执行超超时。 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061func (s *KvServer) DoCommand(ctx context.Context, req *pb.CommandRequest) (*pb.CommandResponse, error) {raftcore.PrintDebugLog(fmt.Sprintf("do cmd %s", req.String()))cmdResp := &pb.CommandResponse{}if req != nil { reqBytes, err := json.Marshal(req) if err != nil { return nil, err } idx, _, isLeader := s.Rf.Propose(reqBytes) if !isLeader { cmdResp.ErrCode = common.ErrCodeWrongLeader return cmdResp, nil } s.mu.Lock() ch := s.getNotifyChan(idx) s.mu.Unlock() select { case res := <-ch: cmdResp.Value = res.Value case <-time.After(ExecCmdTimeout): cmdResp.ErrCode = common.ErrCodeExecTimeout cmdResp.Value = "exec cmd timeout" } go func() { s.mu.Lock() delete(s.notifyChans, idx) s.mu.Unlock() }()}return cmdResp, nil} 最后我们来看看Apply Goruntine干的事情: 它等待s.applyCh通道中apply消息的到来。这个applyCh我们在Raft库中提到过,它用来通知应用层日志已经提交,应用层可以把日志应用到状态机了。当applyCh中appliedMsg到来之后,我们更新了KvServer的lastApplied号,然后根据客户端的操作类型对我们的状态机做不同的操作,做完之后把响应放到notifyChan中,也就是DoCommand等待的那个通道,至此整个请求处理的流程已经结束。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960func (s *KvServer) ApplingToStm(done <-chan interface{}) { for !s.IsKilled() { select { case <-done: return case appliedMsg := <-s.applyCh: req := &pb.CommandRequest{} if err := json.Unmarshal(appliedMsg.Command, req); err != nil { raftcore.PrintDebugLog("Unmarshal CommandRequest err") continue } s.lastApplied = int(appliedMsg.CommandIndex) var value string switch req.OpType { case pb.OpType_OpPut: s.stm.Put(req.Key, req.Value) case pb.OpType_OpAppend: s.stm.Append(req.Key, req.Value) case pb.OpType_OpGet: value, _ = s.stm.Get(req.Key) } cmdResp := &pb.CommandResponse{} cmdResp.Value = value ch := s.getNotifyChan(int(appliedMsg.CommandIndex)) ch <- cmdResp } } } 客户端实现介绍客户端实现就比较简单了,主要是构造Get和Put的CommandRequest调用DoCommand发送到服务端,逻辑实现在cmd/kvcli/kvcli.go里面。 我们总结一下: 客户端请求到来之后, KvServer首先会调用Propose提交日志到Raft中算法库。Raft算法库经过共识之后提交这条日志,并通知applyCh,KvServer会在Apply Goruntine中将applyCh的消息解码,然后将操作应用到自己的状态机中,最后把结果写到通知客户端的notifyChan中,在DoCommand中响应结果给客户端。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-构建Raft库 (Golang eraftkv 版)","path":"/2023/08/19/build-raft-lib/","content":"核心数据结构设计我们上一章节讲了Raft算法的主要内容,现在我们要代码实现它们了。首先我们需要抽象出我们需要的数据结构。先来梳理一下可能用到的数据结构,首先节点之间需要互相访问,那我们需要定义访问其他节点的网络客户端,这里面要包含节点的id,地址,还有rpc的客户端,整个结构我们抽象为RaftClientEnd,主要数据内容如下: 123456type RaftClientEnd struct { id uint64 addr string raftServiceCli *raftpb.RaftServiceClient // grpc 客户端} 节点状态我们之前描述的有三种,定义如下: 123456789 const (NodeRoleFollower NodeRole = iotaNodeRoleCandidateNodeRoleLeader ) 我们要完成选举操作的话需要两个超时时间,这里我们使用Golang time库里面的Timer实现,它可以定时的给一个通道发送消息,我们可以用它来实现选举超时和心跳超时。 12electionTimer *time.TimerheartbeatTimer *time.Timer 除此之外我们还需要记录当前节点的id,当前的任期号,为谁投票,获得票数的统计,已经提交的日志索引号,最后apply到状态机的日志号,以及节点如果是Leader的话需要记录到其他节点复制最新匹配的日志号,这写数据结构定义如下: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647type Raft struct { mu sync.RWMutex peers []*RaftClientEnd // rpc 客户端 me_ int // 自己的 id dead int32 // 节点的状态 applyCh chan *pb.ApplyMsg // apply 协程通道,协程模型中会讲到 applyCond *sync.Cond // apply 流程控制的信号量 replicatorCond []*sync.Cond // 复制操作控制的信号量 role NodeRole // 节点当前的状态 curTerm int64 // 当前的任期 votedFor int64 // 为谁投票 grantedVotes int // 已经获得的票数 logs *RaftLog // 日志信息 commitIdx int64 // 已经提交的最大的日志 id lastApplied int64 // 已经 apply 的最大日志的 id nextIdx []int // 到其他节点下一个匹配的日志 id 信息 matchIdx []int // 到其他节点当前匹配的日志 id 信息 leaderId int64 // 集群中当前 Leader 节点的 id electionTimer *time.Timer // 选举超时定时器 heartbeatTimer *time.Timer // 心跳超时定时器 heartBeatTimeout uint64 // 心跳超时时间 baseElecTimeout uint64 // 选举超时时间} 首先系统启动的时候需要构造Raft这个结构体,这个流程是在MakeRaft里面实现的,它主要是初始化与一些变量,两个定时器,并启动相关的协程,我们这里对每个对端节点复制有Replicator协程,触发两个超时时间有Tick协程,应用已经提交的日志有Applier协程。 协程模型 上图展示了我们raftcore里面的协程模型,当应用层提案(propose)到来之后主协程会在本地追加日志,然后发送BroadcastAppend,然后到其他节点复制日志的协程的等待就会被唤醒,开始进行一轮的的复制,replicateOneRound成功复制半数节点日志之后会触发commit, rf.applyCond.Signal()会唤醒等待做Apply操作的Applier协程。 另外Tick协程会监听Timer两个超时的C通道信号,一旦心跳超时并且当前节点的状态是Leader,就会调用BroadcastHeartbeat发送心跳,发心跳的时候也会replicateOneRound就和上述一样,如果成功复制半数节点日志之后会触发commit, rf.applyCond.Signal()就会唤醒等待做Apply操作的Applier协程。 Applier协程apply完消息之后会把ApplyMsg消息写入rf.applyCh通知应用层,应用层的协程和以监听这个通道,如果有ApplyMsg到来就把它应用到状态机。 Rpc定义eraft的rpc定义文件在pbs目录下的raftbasic.proto文件中,主要的消息如下: Entry 这个是一个日志条目信息表示,和我们之前描述的一样,它有任期号term,索引号index,以及操作的序列化数据data我们用一个字节流来存储,日志条目有两种类型一种是Normal正常日志,另一种是ConfChange配置变更的日志: 12345678910111213141516171819202122enum EntryType { EntryNormal = 0; EntryConfChange = 1;}message Entry { EntryType entry_type = 1; uint64 term = 2; int64 index = 3; bytes data = 4;} RequestVote相关下面是请求投票RPC的定义,基本和论文里面保持一致:请求投票里面有候选人的任期号,它的id以及它最后一条日志的索引以及任期号信息。响应里面有个任期号,这个用来给候选人在选举失败的时候更新自己的任期,还有一个vote_granted表示这个请求投票操作是否被对端节点接受。 123456789101112131415161718192021222324252627282930message RequestVoteRequest { int64 term = 1; int64 candidate_id = 2; int64 last_log_index = 3; int64 last_log_term = 4;}message RequestVoteResponse { int64 term = 1; bool vote_granted = 2;}service RaftService { //... rpc RequestVote (RequestVoteRequest) returns (RequestVoteResponse) {}} AppendEntries相关日志追加操作的定义如下,基本也和论文里面一致: 请求里面有Leader的任期,id (用来告诉follower,这样client访问了follower之后可以被告知leader节点是哪个), prev_log_index表示消息里面将要同步的第一条日志前一条日志的的索引信息,prev_log_term是它的任期信息,leader_commit则是leader的commit号(可以用来周知follower节点当前的commit进度),entries表示日志条目信息。 响应里面term用来告诉leader是否出新的任期的消息,可以用来更新leader的任期号,success表示日志追加操作是否成功,conflict_index用来记录冲突日志的索引号,conflict_term用来记录冲突日志的任期号。 123456789101112131415161718192021222324252627282930313233343536373839message AppendEntriesRequest { int64 term = 1; int64 leader_id = 2; int64 prev_log_index = 3; int64 prev_log_term = 4; int64 leader_commit = 5; repeated Entry entries = 6;}message AppendEntriesResponse { int64 term = 1; bool success = 2; int64 conflict_index = 3; int64 conflict_term = 4;}service RaftService { //... rpc AppendEntries (AppendEntriesRequest) returns (AppendEntriesResponse) {}} Leader选举实现分析Raft官网提供了一个算法的动态演示动画,我们先来直观感受下Leader选举的流程,然后结合代码介绍这个流程。 待续。。 首先Raft算法中有两个超时时间用来控制着Leader选举的流程,首先是选举超时,这个是Candidate等待变成Leader的时间跨度,如果在这个时间内还没被选成Leader,这个超时定时器会被重置。我们前面也介绍过,这个超时时间设置一般在150ms到300ms之间。 启动的时候,所有节点的选举超市时间都被设置到150~300ms之间的随机值,那么大概率有一个节点会率先达到超市时间,如图A,B,C节点的C先达到超时时间,它从Follower变成Candidate,随后开始新任期的选举,它会给自己投一票,然后向集群中的其他节点发送RequestVoteRequest rpc请求它们的投票。 1.eraft代码中在启动,也就是应用层调用MakeRaft函数的时候会传入baseElectionTimeOutMs和heartbeatTimeOutMs, 这里心跳超市时间是固定的,选举超时时间我们使用MakeAnRandomElectionTimeout构造生成了一个随机超市时间。 12345678func MakeRaft(peers []*RaftClientEnd, me int, newdbEng storage_eng.KvStore, applyCh chan *pb.ApplyMsg, heartbeatTimeOutMs uint64, baseElectionTimeOutMs uint64) *Raft {...heartbeatTimer: time.NewTimer(time.Millisecond * time.Duration(heartbeatTimeOutMs)),electionTimer: time.NewTimer(time.Millisecond * time.Duration(MakeAnRandomElectionTimeout(int(baseElectionTimeOutMs)))),... 2.达到选举超时后,节点C首先把自己状态改成Candidate,然后增加自己的任期号,开始选举。 12345678910111213141516//// Tick raft heart, this ticket trigger raft main flow running//func (rf *Raft) Tick() { for !rf.IsKilled() { select { case <-rf.electionTimer.C: { rf.SwitchRaftNodeRole(NodeRoleCandidate) rf.IncrCurrentTerm() rf.Election() rf.electionTimer.Reset(time.Millisecond * time.Duration(MakeAnRandomElectionTimeout(int(rf.baseElecTimeout)))) } ... }} 3.下面这段代码就是发起选举的核心逻辑了,首先节点IncrGrantedVotes给自己投一票,然后把votedFor设置成自己,之后构造RequestVoteRequest rpc请求,带上自己的任期号,CandidateId也就是自己的id,最后一个日志条目的索引还有任期号,然后把当前Raft状态持久化,向集群中的其他节点并行的发送,RequestVote请求 123456789101112131415161718192021222324252627282930//// Election make a new election//func (rf *Raft) Election() {\tfmt.Printf("%d start election ", rf.me_)\trf.IncrGrantedVotes()\trf.votedFor = int64(rf.me_)\tvoteReq := &pb.RequestVoteRequest{ Term: rf.curTerm, CandidateId: int64(rf.me_), LastLogIndex: int64(rf.logs.GetLast().Index), LastLogTerm: int64(rf.logs.GetLast().Term),\t}\trf.PersistRaftState()\tfor _, peer := range rf.peers { if int(peer.id) == rf.me_ { continue } go func(peer *RaftClientEnd) { PrintDebugLog(fmt.Sprintf("send request vote to %s %s ", peer.addr, voteReq.String())) requestVoteResp, err := (*peer.raftServiceCli).RequestVote(context.Background(), voteReq) if err != nil { PrintDebugLog(fmt.Sprintf("send request vote to %s failed %v ", peer.addr, err.Error())) } ... }(peer)\t}} 如果A,B收到请求的时候还没有发出投票(因为它们还没达到选举超时时间),它们就会给候选人节点C投票,同时重设自己的选举超时定时器。 4.eraft处理投票请求的细节如下,我们结合图中的例子分析下面的逻辑,假设A节点正在处理来自C的投票请求,那么首先C的 任期号大于A的,代码中1的if分支不会执行,在2这里, A节点发现来自C的请求投票消息的任期号大于自己的,它会调用SwitchRaftNodeRole变成Follower节点,在回C消息之前,代码中3号位置A调用electionTimer.Reset重设了自己的选举超时定时器。 12345678910111213141516171819202122232425262728//// HandleRequestVote handle request vote from other node//func (rf *Raft) HandleRequestVote(req *pb.RequestVoteRequest, resp *pb.RequestVoteResponse) {\trf.mu.Lock()\tdefer rf.mu.Unlock()\tdefer rf.PersistRaftState() // 1\tif req.Term < rf.curTerm || (req.Term == rf.curTerm && rf.votedFor != -1 && rf.votedFor != req.CandidateId) { resp.Term, resp.VoteGranted = rf.curTerm, false return\t} // 2 if req.Term > rf.curTerm { rf.SwitchRaftNodeRole(NodeRoleFollower) rf.curTerm, rf.votedFor = req.Term, -1\t} ... rf.votedFor = req.CandidateId // 3\trf.electionTimer.Reset(time.Millisecond * time.Duration(MakeAnRandomElectionTimeout(int(rf.baseElecTimeout))))\tresp.Term, resp.VoteGranted = rf.curTerm, true} C收到半数票以上,也就是A, B节点的任意一个的投票加上自己那一张选票,它就变成了Leader,然后停止自己的选举超时定时器。 5.C统计票数处理请求投票的响应如下,注意:这段代码加了锁,应为这里涉及到多个Goruntine去修改rf中的非原子变量,如果不加锁可能会导致逻辑错误。代码中1处,如果收到投票的响应VoteGranted是true。C就会调用IncrGrantedVotes递增自己拥有的票书,然后if rf.grantedVotes > len(rf.peers)/2判断是否拿到了半数以上票,如果是的调用SwitchRaftNodeRole切换自己的状态为Leader,之后BroadcastHeartbeat广播心跳消息,并重新设置自己的得票数grantedVotes为0。 1234567891011121314151617181920212223242526if requestVoteResp != nil { rf.mu.Lock() defer rf.mu.Unlock() PrintDebugLog(fmt.Sprintf("send request vote to %s recive -> %s, curterm %d, req term %d", peer.addr, requestVoteResp.String(), rf.curTerm, voteReq.Term)) if rf.curTerm == voteReq.Term && rf.role == NodeRoleCandidate { // 1 if requestVoteResp.VoteGranted { // success granted the votes PrintDebugLog("I grant vote") rf.IncrGrantedVotes() if rf.grantedVotes > len(rf.peers)/2 { PrintDebugLog(fmt.Sprintf("node %d get majority votes int term %d ", rf.me_, rf.curTerm)) rf.SwitchRaftNodeRole(NodeRoleLeader) rf.BroadcastHeartbeat() rf.grantedVotes = 0 } // 2 } else if requestVoteResp.Term > rf.curTerm { // request vote reject rf.SwitchRaftNodeRole(NodeRoleFollower) rf.curTerm, rf.votedFor = requestVoteResp.Term, -1 rf.PersistRaftState() } } } 我们知道A,B在前面处理投票请求的时候只是重设的超时定时器,那么万一再一次超时定时器到达,会不会重新出发选举,然后陷入选举循环呢?答案是不会的,我们前面只介绍了选举超时时间,还有一个心跳超时时间,这个超时时间比选举超市时间短,一般是选举超时时间的1/3。也就是说在A, B还没到达选举超时时间之前,这个心跳超市时间会先出发,如果是Leader节点的话,它会给集群中其他节点发送心跳包,其他节点(A, B)接受到心跳包之后,又会重设自己的选举超时定时器。也就是说,只要Leader C一直正常运心发送心跳包,那么A,B节点不可能触发选举,只有当Leader C挂了。A,B节点才会开始下一轮选举。 日志复制实现分析经过上述的选举流程,我们现在就有一个拥有主节点和多个从节点的系统了,主节点会不断的给从节点发送心跳消息。现在我们要开始考虑处理客户端请求了,如果客户端发送一个操作过来,我们这个系统是如何处理的呢? 首先,Raft规定只有Leader节点能处理请求写入,客户端发送请求首先会到达Leader节点。 在eraft库中用户请求到来和raft交互的入口函数是Propose,这个函数首先会查询当前节点状态,只有Leader节点才能处理提案(propose),之后会把用户操作的序列化之后的[]byte调用Append追加到自己的日志中,之后BroadcastAppend将日志内容发送给集群中的Follower节点。 123456789101112131415//// Propose the interface to the appplication propose a operation//func (rf *Raft) Propose(payload []byte) (int, int, bool) {\trf.mu.Lock()\tdefer rf.mu.Unlock()\tif rf.role != NodeRoleLeader { return -1, -1, false\t}\tnewLog := rf.Append(payload)\trf.BroadcastAppend()\treturn int(newLog.Index), int(newLog.Term), true} 如上图中,绿色的表示客户端节点,它发送SET 5的请求过来,A作为当前集群中的Leader节点首先会把这个SET 5操作封装成一个日志条目写入到自己的日志存储结构中,然后在下一次给从节点发送心跳消息的时候带上这个日志发送给Follower节点。 在eraft实现中,我们专门有一组Goruntine做日志复制相关的事情,用户提案到达Leader之后调用BroadcastAppend会唤醒做日志复制操作的Goruntine, replicatorCond这个信号量用来完成Goruntine之间的同步操作。 12345678func (rf *Raft) BroadcastAppend() {\tfor _, peer := range rf.peers { if peer.id == uint64(rf.me_) { continue } rf.replicatorCond[peer.id].Signal()\t}} 复制操作的Goruntine执行的任务函数是Replicator,当BroadcastAppend中通过Signal函数唤醒信号量,rf.replicatorCond[].Wait()就会停止阻塞,继续往下执行,调用replicateOneRound进行数据复制。 1234567891011121314//// Replicator manager duplicate run//func (rf *Raft) Replicator(peer *RaftClientEnd) {\trf.replicatorCond[peer.id].L.Lock()\tdefer rf.replicatorCond[peer.id].L.Unlock()\tfor !rf.IsKilled() { PrintDebugLog("peer id wait for replicating...") for !(rf.role == NodeRoleLeader && rf.matchIdx[peer.id] < int(rf.logs.GetLast().Index)) { rf.replicatorCond[peer.id].Wait() } rf.replicateOneRound(peer)\t}} replicateOneRound就会把日志打包到一个AppendEntriesRequest中发送到Follower节点了。 Follower收到追加请求后会把日志条目追加到自己的日志存储结构中,然后给Leader发送成功追加的响应。Leader统计到集群半数节点(包括自己)日志追加成功之后,它会把这条日志状态设置为已经提交(committed),然后将操作结果发送给客户端,如下图所示,A设置SET 5 之后这个日志提交的信息会在下一次给Follower发送的心跳包中带过去,Follower收到日志也会更新自己的日志提交状态。 日志提交之后,Apply协程会收到通知,开始将已经提交的日志apply到状态机中,日志的成功Apply之后给客户端发送成功写入的响应包。 对应eraft实现中,日志提交之后Leader节点会调用advanceCommitIndexForLeader函数。它会计算当前日志提交的索引号,然后和之前已经提交的commitIdx进行对比,如果更大,就会更新commitIdx,同时调用rf.applyCond.Signal()唤醒做Apply操作的Goruntine。Applier函数是Apply Goruntine运行的任务函数,它会Wait applyCond这个信号量,如果被唤醒,它会拷贝初节点中已经提交的日志,打包成ApplyMsg发送到applyCh通道通知应用层,应用层拿到apply消息之后会更新状态机并回包给客户端。 123456789101112131415161718192021222324252627282930313233343536373839404142434445func (rf *Raft) advanceCommitIndexForLeader() {\tsort.Ints(rf.matchIdx)\tn := len(rf.matchIdx)\tnewCommitIndex := rf.matchIdx[n-(n/2+1)]\tif newCommitIndex > int(rf.commitIdx) { if rf.MatchLog(rf.curTerm, int64(newCommitIndex)) { PrintDebugLog(fmt.Sprintf("peer %d advance commit index %d at term %d", rf.me_, rf.commitIdx, rf.curTerm)) rf.commitIdx = int64(newCommitIndex) rf.applyCond.Signal() }\t}}//// Applier() Write the commited message to the applyCh channel// and update lastApplied//func (rf *Raft) Applier() {\tfor !rf.IsKilled() { rf.mu.Lock() for rf.lastApplied >= rf.commitIdx { PrintDebugLog("applier ...") rf.applyCond.Wait() } firstIndex, commitIndex, lastApplied := rf.logs.GetFirst().Index, rf.commitIdx, rf.lastApplied entries := make([]*pb.Entry, commitIndex-lastApplied) copy(entries, rf.logs.GetRange(lastApplied+1-int64(firstIndex), commitIndex+1-int64(firstIndex))) rf.mu.Unlock() PrintDebugLog(fmt.Sprintf("%d, applies entries %d-%d in term %d", rf.me_, rf.lastApplied, commitIndex, rf.curTerm)) for _, entry := range entries { rf.applyCh <- &pb.ApplyMsg{ CommandValid: true, Command: entry.Data, CommandTerm: int64(entry.Term), CommandIndex: int64(entry.Index), } } rf.mu.Lock() rf.lastApplied = int64(Max(int(rf.lastApplied), int(commitIndex))) rf.mu.Unlock()\t}} Raft快照实现分析下面是日志快照的RPC定义 1234567891011121314message InstallSnapshotRequest { int64 term = 1; int64 leader_id = 2; int64 last_included_index = 3; int64 last_included_term = 4; bytes data = 5;}message InstallSnapshotResponse { int64 term = 1; }rpc Snapshot (InstallSnapshotRequest) returns (InstallSnapshotResponse) {} InstallSnapshotRequest中term代表当前发送快照的Leader的任期,Follower将它与自己的任期号来决定是否要接收这个快照。leader_id是当前leader的id,这样客户端访问到Follower节点之后也能快速知道Leader信息。last_included_index和last_included_term还有data可以参见我们第三章图中的介绍,它们记录了打完快照之后第一条日志的索引号和任期号,以及状态机序列化之后的数据。 什么时间点Raft会打快照呢? 我们知道日志条目过多了,我们就需要打快照。在eraft中就是计算当前level中的日志条目s.Rf.GetLogCount()来打快照的,打快照的入口函数是takeSnapshot(index int),传入了当前applied日志的id,然后将状态机的数据序列化,调用Raft层的Snapshot函数。 这个函数通过EraseBeforeWithDel做了删除日志的操作,然后PersisSnapshot将快照中状态数据缓存到了存储引擎中。 123456789101112131415161718192021//// take a snapshot//func (rf *Raft) Snapshot(index int, snapshot []byte) {\trf.mu.Lock()\tdefer rf.mu.Unlock()\trf.isSnapshoting = true\tsnapshotIndex := rf.logs.GetFirstLogId()\tif index <= int(snapshotIndex) { rf.isSnapshoting = false PrintDebugLog("reject snapshot, current snapshotIndex is larger in cur term") return\t}\trf.logs.EraseBeforeWithDel(int64(index) - int64(snapshotIndex))\trf.logs.SetEntFirstData([]byte{}) // 第一个操作日志号设为空\tPrintDebugLog(fmt.Sprintf("del log entry before idx %d", index))\trf.isSnapshoting = false\trf.logs.PersisSnapshot(snapshot)} 什么时间点Leader会发送快照呢? 在复制的时候我们会判断到peer的prevLogIndex,如果比当前日志的第一条索引号还小,就说明Leader已经把这条日志打到快照中了,这里我们就要构造InstallSnapshotRequest调用Snapshot RPC将快照数据发送给Followr节点,在收到成功响应之后,我们会更新rf.matchIdx,rf.nextId为LastIncludedIndex和LastIncludedIndex + 1,更新到Follower节点复制进度。 123456789101112131415161718192021222324252627282930313233343536373839if prevLogIndex < uint64(rf.logs.GetFirst().Index) {\tfirstLog := rf.logs.GetFirst()\tsnapShotReq := &pb.InstallSnapshotRequest{ Term: rf.curTerm, LeaderId: int64(rf.me_), LastIncludedIndex: firstLog.Index, LastIncludedTerm: int64(firstLog.Term), Data: rf.ReadSnapshot(),\t}\trf.mu.RUnlock()\tPrintDebugLog(fmt.Sprintf("send snapshot to %s with %s ", peer.addr, snapShotReq.String()))\tsnapShotResp, err := (*peer.raftServiceCli).Snapshot(context.Background(), snapShotReq)\tif err != nil { PrintDebugLog(fmt.Sprintf("send snapshot to %s failed %v ", peer.addr, err.Error()))\t}\trf.mu.Lock()\tPrintDebugLog(fmt.Sprintf("send snapshot to %s with resp %s ", peer.addr, snapShotResp.String()))\tif snapShotResp != nil { if rf.role == NodeRoleLeader && rf.curTerm == snapShotReq.Term { if snapShotResp.Term > rf.curTerm { rf.SwitchRaftNodeRole(NodeRoleFollower) rf.curTerm = snapShotResp.Term rf.votedFor = -1 rf.PersistRaftState() } else { PrintDebugLog(fmt.Sprintf("set peer %d matchIdx %d ", peer.id, snapShotReq.LastIncludedIndex)) rf.matchIdx[peer.id] = int(snapShotReq.LastIncludedIndex) rf.nextIdx[peer.id] = int(snapShotReq.LastIncludedIndex) + 1 } }\t}\trf.mu.Unlock()} Follower这边操作就比较简单了,它会调用HandleInstallSnapshot处理快照数据,并把快照数据构造pb.ApplyMsg写到rf.applyCh,最后负责日志Apply的Goruntine会调用CondInstallSnapshot安装快照,最后在restoreSnapshot会将快照的data数据解析,让后写入自己的状态机。 Raft如何应对脑裂在第三章中我们,介绍了脑裂的场景,并且分过多数派选举协议可以避免脑裂的场景,使得分布式系统在网络分区的情况下也能保持正确性。 Raft是一种多数派选举的协议,现在我们就来看看它是如何应对脑裂的。 我们看到上图中的场景,一个五节点的系统被分成了两个区,C,D,E为一个区,A,B为一个区。这时候两个分区中都选出了各自的Leader,但是注意B是任期1的Leader,C是任期2 Leader。大家可能会有疑问为什么一定有一个任期更高的Leader,这其实也是多数派选举决定的,分区后,肯定会出现一个多数节点所在的分区,如果这个分区还没有Leader,那么肯定会触发选举。 之后如下图所示,C,D,E所在分区被写入了SET 8的操作,由于C,D,E有三个节点,超过5个节点的半数以上,所以SET 8这个操作被提交了。然后A,B分区被写入SET 3操作,但是由于它们是少数派,只有两个节点,所以SET 3这个操作写入它们的日志之后并不能被提交。 最后网络恢复了,B,A收到来自C的更高任期的心跳会秒变Follower并且会将之前没有提交的日志擦除,将Leader C发过来的新日志(带有SET 8操作)写到自己的日志中,这样整个系统仍然是一致的。"},{"title":"动手学习分布式-Raft论文解读 (Golang eraftkv 版)","path":"/2023/08/19/raftbasic/","content":"raft概览这一小节我们不深入Raft算法细节,而是带着大家概览一下Raft算法在一个实际的应用系统中的应用。 我们看到上图的系统,这是一个使用Raft算法实现的一个分布式KV系统。我们这个系统的设计目标是保证集群中所有节点状态一致,也就是每个节点中KV表(这里使用通俗的“表”的概念描述,实际这些数据会存储到一个存储引擎里面)里面的数据状态最终是一致的。 先不考虑故障的场景,我们来看看系统在正常的情况下是怎么运行的。 我们来分析一下Put操作经过这个系统的流程,首先客户端会将Put请求发送给当前Raft集群中的Leader节点对应的K/V应用层。这个操作会被Leader包装成一个操作给Raft层,Raft对这个Put请求生成一条日志存储到自己的日志序列中,同时会把这的操作日志,复制给集群中的Follower节点,当集群中的半数以上节点都复制这个日志并返回响应之后,Leader会提交这条日志,并应用这条日志,写入数据到KV表,并通知应用层。这个操作成功执行,这时候K/V层会响应客户端,同时Leader会把Commit信息在下一次复制请求带给Follwer,Follower也会应用这条日志,写入数据到KV表中,最终集群中所有节点的状态一致,整个系统的运行的时序下图所示。 这就是应用Raft实现一个能保证一致性状态系统的例子,乍一看,像是很简单。但是当你深入到算法细节里面的时候,这个系统就简单了。例如日志复制的时候会有很多约束条件来保证提交日志的一致性,以及故障的时候如何正确的选出下一个leader?多次故障之后,日志状态一致性如何能够安全的保证?当然,这些细节也是我们后续分析的重点,我们会结合具体代码,尽量简单,让你系统的理解raft在处理这些问题时候的解决办法。 分布式系统中的脑裂在我们介绍Raft算法之前我们先来看一下分布式系统的脑裂的问题,脑裂字面上是大脑裂开的意思,大脑是人体的控制中心,如果裂开了,那么整个系统就会出现紊乱。 对应到我们分布式系统里面,一般就是集群中的节点由于网络故障或者其他故障被划分成不同的分区,这时候系统中不同分区由于无法通信会出现状态不一致的情况,如果系统没有考虑处理这种情况。那么当网络再恢复的时候,系统也就没法再保证正确性了。 我们看到上图,这就是分布式系统出现网络分区的情形。系统里面有A-E五个节点,由于故障, A,B节点和C,D,E节点被划分到了各自的网络分区里面。绿色的圆形代表两个客户端,如果它们向不同的分区节点写入数据,那么系统能保证分区恢复后状态一致吗?Raft算法解决了这个问题,在后续下一节讨论怎么解决的。 多数派协议Raft论文中提到的半数票决(Majority Vote) ,也叫做多数派协议。是解决脑裂问题的关键,首先我们来解释一下半数票决是怎么做的,假设分布式系统中有2*f + 1个服务器,系统在做决策的时候需要系统中半数节点以上投票同意,也就是必须要f + 1个服务器都要活着,系统才能正常工作。那么这个系统最多可以接受f个服务器出现故障。 Raft正是应用了半数票决来解决脑裂问题,假设我们有奇数个节点(3,5…2n+1)个节点组成的分布式系统,其中一旦出现网络分区,那么必然会有一个分区存在半数节点以上的,那么过半票决这个策略就能正常运行,这样系统就不会因此不可用,多数派票决正是解决脑裂问题的关键。 Raft的日志结构前面我们概览了整个Raft算法的流程,请求经过系统最开始就要写Raft算法层的日志了,那这个日志的结构是什么样的呢?接下来我们就来看看Raft日志的结构: 上图表示一个raft节点日志的结构,日志主要用来记录用户的操作,我们会对这些操作进行编号。图中每一条(1~8)日志都有独立的编号log index。然后日志中还有任期号,如图中1-3号日志为任期1的日志。这个任期号是用来表示这个日志的选举状态的,我们后面解释它的作用。 然后每个日志都有一个操作,这个操作是对状态机的操作,如1号日志我们的操作是把x设置成3。 Raft的状态转换Raft协议的工作模式是一个Leader和多个Follower节点的模式。在Raft协议中,每个节点都维护了一个状态机。该状态机有3中状态:Leader、Follower和Candidate。系统起来后的任意一个时间点,集群中的任何节点都处于这三个状态中的一个。 每个节点一启动就会进入Follower状态,然后当选举超时时间到达后,它会转换成Candidate状态,这时候就开始选举了,当它获得半数节点以上的选票之后,Candidate状态的节点会转变成Leader。或者当Candidate状态的节点发现了一个新的leader或者收到新任期的消息,它会变成Follower, Leader发现更高任期的消息也会变成Follower,系统正常运行中会一直在这三种状态之间转换。 Leader选举在说明Leader选举流程之前,我们先来解释下Raft协议中与选举相关的两个超时时间:选举超时(election timeout)时间和心跳超时(heartbeat timeout)时间。当Follower节点在选举超时时间之内没有收到来自Leader的心跳消息之后,就会切换成Candidate开始新一轮的选举。选举超时时间一般设置为150ms ~ 300ms的随机数,这里随机的目的是为了避免节点同时发起选票到时有相同票数的节点,从而选举失败重新选举的情况,增加这个时间的随机性有利于更快的选出Leader。心跳超时时间则是指Leader向Follower节点发送的心跳消息间隔时间。我们来梳理一下选举的流程1.集群初始化,所有节点都会变成Follower状态。2.经过一段时间后(选举超时时间到达)Follower还没收到来自Leader的心跳消息,那么它会开始切换为Candidate开始发起选举。3.变成Candidate之后节点的任期号也会增加,同时给自己投一票,然后并行的向集群中的其他节点发送请求投票(RequestVoteRPC)消息。4.Candidate状态的节点赢得了半数以上选票成为Leader节点,之后散播心跳给集群中其他节点,成功选举成Leader以上是大致的流程,但是有两个细节点需要注意:在等待投票的过程中,Candidate可能会收到来自另外一个节点成为了Leader之后发送的心跳消息,如果这个消息中Leader的任期号 (term)大于Candidate当前记录的任期号,Candidate会认为这个Leader是合法的,它会装换为Follower节点。如果这个心跳消息的任期号小于Candidate当前的任期号,Candidate将会拒绝这个消息,继续保持当前状态另一种可能的结果是Candidate即没有赢得选举也没有输:也就是集群中多个Follower节点同时成为了Candidate,这种情况叫做选票分裂,没有任何Candidate节点获得大多数选票,当这种情况发生的时候,每个Candidate会重新设置一个随机的选举超时时间,然后继续选举,由于选举时间是随机的,下一轮选举很大概率会有一个节点获得多数选票会成为新的Leader。 日志复制我们假设集群中有A,B,C三个节点,其中节点A为Leader,此时客户端发送了一个操作到集群:1.当收到客户端请求,节点A将会更新操作记录到本地的Log中。2.节点A会向集群中其他节点发送AppendEntries消息,消息中记录了Leader节点最近收到的客户端提交请求的日志信息(还没有同步给Follower的部分)。3.B,C收到来自A的AppendEntries消息时,会将操作记录到本地的Log中,并返回通知Leader成功追加日志的消息。4.当A收到半数节点以上成功追加日志响应消息时,会认为集群中有半数节点完成了日志同步操作,它会将日志提交的committed号更新。5.Leader向客户端返回响应,并且在下一次发送AppendEntires消息的时候把commit号通知给Follower6.Follower收到消息之后,也会更新自己本地的commit号。注意:上述流程是假设正常的情形下的流程,如果Follower宕机了或者运行很慢或者Leader发过来的消息某次丢失了, Leader上记录了到某个Follower同步的日志进度,如果追加请求没成功,会不停的重新发送消息,直到所有Follower都存储了所有的日志条目。 日志合并和快照发送Raft论文在第7章介绍了日志压缩合并相关要点,按我们前面介绍的Raft复制相关内容,只要客户端有新的操作过来,就会写我们的日志文件,并且Leader同步给Follower之后,集群中所有节点的日志量都会随着操作的变多一直增长。eraft中使用leveldb存储了Raft日志条目,如果日志量不断增长,那么我们引擎去访问日志的耗时就会不断增长,如果有节点挂了重新加入集群,我们需要给它追加大量的日志,这个操作会非常消耗IO资源,影响系统性能。 那么如何解决这个问题呢,首先我们再分析下日志结构: 我们看到这里的操作,都是对x,y的操作,每次日志提交完,我们都会应用日志到状态机中。这里我们会发现其实我们并没有必要存储每一个日志条目,我们只关心一致的状态,也就是已经提交的日志让状态机最终到达一种什么样的状态。那么在图中,我们就可以把1,2,3,4,5 号日志的操作之后状态机记录下来,也就是x = 0, y = 9,并且记录这个状态之后第一条日制的信息,然后1~5号日志可以被安全的删除了。 以上的整个操作在Raft里面被叫做快照(snapshot),Raft会定期的打快照吧历史的状态记录下来。当集群中有某个节点挂了,并且日志完全无法找回之后,集群中Leader节点首先会发送快照给这个挂掉之后新加入的节点,并且用一个InstallSnapshot的RPC发送快照数据给它,对端安装快照数据之后,会继续同步增量的日志,这样新的节点能快速的恢复状态。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-Go语言基础知识 (Golang eraftkv 版)","path":"/2023/08/19/1-2-gobasic/","content":"Go语言优点首先Go是一门开源的语言,他出生名门Google,社区有强有力的顶级技术人员支撑。它最开始的设计者是Rob Pike,Robert Griesemer,Ken Thompson。 你可以在这个页面找到他们的资料 https://golang.design/history/ 其中Ken Thompson是UNIX系统的发明者。 Go语言对于初学者很友好,很容易在短时间内快速上手,你可以通过这个网站上提供的代码示例快速的入门Go语言:https://gobyexample.com。 作为多核时代的语言,Go语言在设计之初就对并发编程有很多内置的支持,提供的及其健壮的相关标准库。利用它,我们可以快速的编写出高并发的应用程序。 最后,国内外很多大厂都在使用Go语言,它有一个强大的社区,全世界优秀的技术人员开发了丰富的工具生态,有很多脚手架帮你快速的构建应用程序。 切片切片是一个数组的一段,它基于数组构建,并提供了更多丰富的数据操作功能,提供开发者灵活和便利的操作数组结构。 在Go语言内部,切片只是对底层数组数据结构的引用。接下来我们将熟悉如何创建和使用切片,并了解它底层是怎么工作的。 使用字面量列表创建切片这种方式类似c++里面的初始化列表 12var s = []int{3, 5, 7, 9, 11, 13, 17} 这种方式创建切片的时候,它首先会创建一个数组,然后返回对该数组切片的引用。 从已有数组创建切片 12// Obtaining a slice from an array `a` a[low:high] 这里我们对a数组进行切割得到切片,这个操作得到的结果是索引[low, high)的元素,生成的切片包括索引低,但是不包括索引高之间的所有数组元素。你可以运行下面的示例,理解这个切片操作 12345678910111213141516171819202122package mainimport "fmt"func main() { var a = [5]string{"Alpha", "Beta", "Gamma", "Delta", "Epsilon"} // Creating a slice from the array var s []string = a[1:4] fmt.Println("Array a = ", a) fmt.Println("Slice s = ", s) } // output Array a = [Alpha Beta Gamma Delta Epsilon] Slice s = [Beta Gamma Delta] 修改切片中的元素由于切片是引用类型,它指向了底层的数组。所以当我们使用切片引用去修改数组中对应的元素的时候,引用相同数组的其他切片对象也会看到这个修改结果。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566package mainimport "fmt"func main() {\ta := [7]string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}\tslice1 := a[1:]\tslice2 := a[3:]\tfmt.Println("------- Before Modifications -------")\tfmt.Println("a = ", a)\tfmt.Println("slice1 = ", slice1)\tfmt.Println("slice2 = ", slice2)\tslice1[0] = "TUE"\tslice1[1] = "WED"\tslice1[2] = "THU"\tslice2[1] = "FRIDAY"\tfmt.Println(" -------- After Modifications --------")\tfmt.Println("a = ", a)\tfmt.Println("slice1 = ", slice1)\tfmt.Println("slice2 = ", slice2)}// Output------- Before Modifications -------a = [Mon Tue Wed Thu Fri Sat Sun]slice1 = [Tue Wed Thu Fri Sat Sun]slice2 = [Thu Fri Sat Sun]type: post-------- After Modifications --------a = [Mon TUE WED THU FRIDAY Sat Sun]slice1 = [TUE WED THU FRIDAY Sat Sun]slice2 = [THU FRIDAY Sat Sun] 例如上面这个示例,slice2的修改操作在slice1中是能被看到的, slice1的修改在slice2也能被看到。 切片底层结构一个切片由三个部分组成,如图 1.一个指向底层数组的指针Ptr2.切片所包含数组段的长度Len3.切片的容量Cap 我们看一个具体的切片结构的底层示例: 12var a = [6]int{10, 20, 30, 40, 50, 60} var s = [1:4] s在Go内部是这样表示的: 一个切片的长度和容量我们是可以通过len(), cap()函数获取的,例如我们可以通过下面的方式获取s的长度和容量。 1234567891011121314package mainimport "fmt"func main() {\ta := [6]int{10, 20, 30, 40, 50, 60}\ts := a[1:4]\tfmt.Printf("s = %v, len = %d, cap = %d ", s, len(s), cap(s))}// outputs = [20 30 40], len = 3, cap = 5 GoruntineGoruntine是由Go运行时所管理的一个轻量级的线程,一个Go程序中的Goroutines在相同的地址空间中运行,因此对共享内存的访问必须同步。我们运行一个简单地示例来看看: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354package main import (\t"fmt"\t"time")func say(s string) {\tfor i := 0; i < 5; i++ { time.Sleep(100 * time.Millisecond) fmt.Println(s)\t}}func main() {\tgo say("world")\tsay("hello")}// outputhelloworldhelloworldhelloworldworldhellohello 我们可以看到主线程中say(“hello”)和goruntine的say(“world”)在交替的输出,它们在同时的运行,在其他语言做这个事情先要创建线程,然后绑定相关的执行函数,而Go语言直接把并发设计到了编译器语言支持层面,用go关键字就可以轻松地创建轻量级的线程。 调度器调度器的设计决策在解释Go语言调度器之前,我们先一个例子: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152func main() { var wg sync.WaitGroup wg.Add(11) for i := 0; i <= 10; i++ { go func(i int) { defer wg.Done() fmt.Printf("loop i is - %d ", i) }(i) } wg.Wait() fmt.Println("Hello, Welcome to Go")}// outputloop i is - 0loop i is - 4loop i is - 1loop i is - 2loop i is - 3loop i is - 8loop i is - 7loop i is - 9loop i is - 5loop i is - 10loop i is - 6Hello, Welcome to Go 这个程序创建了11个Goruntine,对于这个输出结果,我们可能会问: 这11个Goruntine是如何并行运行的?它们运行有没有特别的顺序?要回答这两个问题,我们需要思考: 如何将多个Goruntine分配到我们有限个数的CPU核心的机器上运行的多个OS线程上?为了公平的让这些Goruntine获得CPU资源,这些Goruntine应该以什么的顺序在这多个CPU核心上运行? Go调度器的模型介绍 为了解决上面的调度问题,Go语言设计了图d中的调度器模型:Go语言使用协程调度被称为GMP模型,其中:G:代表一个Goruntine,是我们使用go关键字创建出来的可以并行运行的代码块。M:代表一个操作系统线程P:代表逻辑处理器我们看到上图中由两个P处理核心运行时调度正在调度执行8个Goruntine。图中我们还看到了有两种类型的队列:本地队列(Local Run Queue):存放等待运行的G,这个队列存储的数量有限,一般不能超过256个,当用户新建Goruntine时,如果这个队列满了,Go运行时会将一半的G移动到全局队列中。全局队列(Global Queue):存放等待运行的G,其他的本地队列满了,会移动G过来。 Go调度器的工作流程GMP调度器调度Goruntine执行的大致逻辑如下:1.线程想要调度G执行就必须要先与某个P关联2.然后从P的本地队列中获取G3.如果本地队列中没有可运行的G了,M就会从全局队列拿一批G放到本地的P队列4.如果全局队列也没有可以运行的G的时候,M会随机的从其他的P的本地队列偷一半G任务放到自己(P)的本地队列中。5.拿到可以运行的G之后,M运行G, G执行完成之后,M会运行下一个G,一直重复执行下去。 跟踪Go调度器工作流程Go提供了GODEBUG工具可以跟踪调度器调度过程上述模型实时状态我们使用下面的程序示例来追踪一下Go调度器是如何调度执行程序中的Goruntine的 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152package mainimport (\t"sync"\t"time")func main() {\tvar wg sync.WaitGroup\twg.Add(10)\tfor i := 0; i < 10; i++ { go work(&wg)\t}\twg.Wait()\t// Wait to see the global run queue deplete.\ttime.Sleep(3 * time.Second)}func work(wg *sync.WaitGroup) {\ttime.Sleep(time.Second)\tvar counter int\tfor i := 0; i < 1e10; i++ { counter++\t}\twg.Done()} 代码中创建了十个Goruntine,每个Goruntine都在做循环加counter值的操作。我们编译上述例子 1go build go_demo.go 然后使用GODEBUG工具来分析观察这些Goruntine的调度情况执行命令: 1GOMAXPROCS=2 GODEBUG=schedtrace=1000 ./go_demo 可以得到看到如下的输出,当然机器不一样可能输出会不一样,以下是在我的笔记本上输出的, 我的本子有四个核心,下面的指令我指定了创建两个逻辑处理核心 12345678910111213141516171819202122232425colin@book % GOMAXPROCS=2 GODEBUG=schedtrace=1000 ./go_demoSCHED 0ms: gomaxprocs=2 idleprocs=1 threads=4 spinningthreads=0 idlethreads=1 runqueue=0 [0 0]SCHED 1009ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=0 [8 0]SCHED 2009ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=2 [3 3]SCHED 3016ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=2 [3 3]SCHED 4017ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=7 [0 1]SCHED 5027ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=5 [0 3]SCHED 6031ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=3 [2 3]SCHED 7037ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=5 [1 2]SCHED 8045ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=4 [2 2]SCHED 9052ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=8 [0 0]SCHED 10065ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=4 [0 4]SCHED 11069ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=4 [1 3] 输出信息的含义如下 我们选取第二条分析1009ms:这个是从程序启动到这个trace采集度过的时间gomaxprocs=2:配置的逻辑处理核心,我们启动命令中写的idleprocs=0:空闲逻辑核心的数量threads=4:运行时正在管理的线程数量idlethreads=1:空闲线程的数量,这里有1个空闲,3个正在运行中runqueue=0:全局运行队列中Goruntine的数量[8 0]:表示逻辑核心上本地队列中排队中Goruntine的数量,我们看到有一个核心上面有8个Goruntine,另一个有0个,当然我们看后面的trace后续这两个核心的本地队列上都有任务了 了解更多调度器原理Go语言是开源的,你可以在这个文件里面找到调度器的主要逻辑,https://github.com/golang/go/blob/master/src/runtime/proc.go ,目前最新的代码有6千多行了,值得去读一读,弄懂个大概也是很有收获的。 内存管理内存管理架构概览Go最早的内存分配发源自tcmalloc,它比普通的malloc性能要好,随着Go语言的不断演进,当前的内存管理性能已经非常好了。我们首先通过图e来看Go内存管理架构的概览 图中涉及到的主要结构如下: resident set (常驻集)虚拟内存划分为每个8kb的页面,由一个全局的mheap对象管理 mheap这里管理了Go语言动态存储的数据结构(即编译时无法计算大小的任何数据),这是最大的内存块,也是Go垃圾收集发生的地方。mheap里面有管理了不同结构的页面,主要结构如下 mspanmspan是mheap中管理内存页的最基本结构,它底层结构是一个双向链表,span size class,以及span中的页面数量。和tcmalloc的操作一样,Go将内存页面按大小划分为67个不同类的块,8b ~ 32kb不等,如图所示 mcentralmcentral将相同大小span类组成分组,每个mcentral中包含两个mspan:empty:一个双向的span链表,其中没有空闲的对象或者span缓存在mcache中non-empty:有空闲对象的双链接列表,当mcentral请求新的span的时候,会从non-empty移动到empty list当mcentral没哟任何空闲的span是,它会向mheap请求一个新的运行页面 arena堆内存在分配的虚拟内存中根据需要进行扩大和收缩,当需要更多的内存时,mheap从虚拟内存中拉大小为64MB的内存块出来,这个被叫做arean。 mcachemcache是提供给P(逻辑处理核心)的内存缓存,用来存储小对象(也就是大小<= 32kb)。这有点类似于线程栈,但是它其实是堆的一部分,用于动态数据。mcache中包含了scan和noscan类型所有大小的mspan对象Goroutine们可以从mcache中获取内存,不需要加人任何锁,因为P在同一时刻只能调度一个G,因此这是很高效的, mcache在需要的时候会向mcentral中获取新的span Stack这里是管理堆栈的内存区域,每个Goroutine都有一个堆栈,这里用来存储静态数据,包括函数框架,静态的结构,原语值和指向动态数据机构的指针。内存分配流程概要分配器会按对象大小分配 Tiny对于Tiny (超小, size < 16 B)对象:直接使用mcache的tiny分配器分配大小小于16字节的对象,这是非常高效的。 Small对于Small (小型,size 16B ~ 32KB)对象:大小在16字节到32 k字节的对象分配时,在运行中G的P上的mcache里面获取。在Tiny和Small分配中,如果mspan列表为空,没有页面用来分配了,分配器将从mheap获取一系列页面用于mspan。 Large对于Large (大型,size > 32KB)对象,直接分配在mheap相应大小的类上。如果mheap为空或者没有足够大的页面来分配了,那么它会从操作系统的进程虚拟内存分配一组新的页面过来(至少1MB)。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-体验分布式系统 (Golang eraftkv 版)","path":"/2023/08/19/hello-world/","content":"体验分布式KV存储系统 eraftkv作为系列的开篇,我们将带着大家从顶层体验下分布式系统,我们将忽略系统实现的细节点,直观感受一个分布式系统所具备的能力。本书还是第一个版本,我们还在不断的校对完善中,评论区是放开的,欢迎大家讨论参与本书的优化。 安装go编译环境首先我们需要在电脑上安装好go语言编译器,你可以在 https://go.dev/dl/官网下载对应你系统版本的安装包。 按指示https://go.dev/doc/install安装golang编译环境。 编译构建 eraftkv执行以下命令(确保你的机器上安装了Go语言编译器以及git, make等基础工具)编译很简单,下载代码之后,进入根目录直接 make 123git clone https://github.com/eraftorg/eraft.git -b mit6824_lab cd eraft make 架构概览在我们运行eraftkv之前我们先概览以下它的架构,以便于我们对于接下来运行的程序功能有清晰的认识。 eraftkv作为一个分布式kv存储系统,其中包含的服务角色以及一些概念需要提前给大家介绍一下。 系统中的一些概念 bucket -它是集群做数据管理的逻辑单元,一个分组的ShardServer服务可以负责多个bucket的数据。 config table -集群配置表,它主要维护了集群服务分组与bucket的映射关系,客户端访问集群数据之前需要先到这个表查询要访问bucket所在的服务分组列表。 系统中有三种角色 Client -客户端,它是用户使用我们这个分布式的接入端。 ConfigServer -配置服务器,它是系统的配置管理中心,它存储了集群的路由配置信息。 ShardServer -数据服务器,它是系统中实际存储用户数据的服务器。 请求处理流程看了架构概览,大家可能有觉得概念有些模糊,我们接下来就分析一个具体的请求示例,看看这个分布式系统是如何工作起来的。例如现在客户端来了一个put testkey testvalue的请求: 1.客户端程序启动运行的时候会从ConfigServer获取到最新的路由信息表。 2.put testkey testvalue操作首先会计算testkey的CRC32哈希值对集群中的bucket数取模算到这个key命中了哪个bucket。 3.之后客户端将put请求内容打包成一个rpc请求包,发送到集群配置表中负责这个bucket的ShardServer服务分组。 4.Leader ShardServer服务收到这个rpc请求后并不是直接写入存储引擎,而是构造一个raft提案,提交到raft状态机,当分组中半数节点以上的ShardServer都同意这个操作后(如果你没有接触过分布式一致性算法,这里可以先不用理解细节,我们会在Raft论文解读一章详细解读为什么要这样做),作为Leader的ShardServer服务器才能返回给客户端写入成功。 让系统跑起来,体验它!前面我们构建完eraftkv之后,在eraft目录有一个output文件夹,里面有我们需要运行的bin文件。 12345678 colin@B-M1-0045 eraft % ls -l output total 124120-rwxr-xr-x 1 colin staff 12119296 5 25 20:40 bench_cli-rwxr-xr-x 1 colin staff 12114784 5 25 20:40 cfgcli-rwxr-xr-x 1 colin staff 13578848 5 25 20:40 cfgserver-rwxr-xr-x 1 colin staff 12127328 5 25 20:40 shardcli-rwxr-xr-x 1 colin staff 13600368 5 25 20:40 shardserver 可执行文件介绍1.cfgserverConfigServer的可执行文件,系统的配置管理中心,需要首先启动 2.cfgcliConfigServer的客户端工具,它和ConfigServer交互用来管理集群的配置 3.shardserverShardServer的可执行文件,它负责存储用户的数据 4.shardcliShardServer的客户端工具,用户可以使用它向集群中写入数据 5.bench_cli系统的性能测试工具 启动服务1.启动配置服务器分组 123./cfgserver 0 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090./cfgserver 1 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090./cfgserver 2 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 2.初始化集群配置 1234./cfgcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 join 1 127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090./cfgcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 join 2 127.0.0.1:7088,127.0.0.1:7089,127.0.0.1:7090./cfgcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 move 0-4 1./cfgcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 move 5-9 2 3.启动数据服务器分组 1234567./shardserver 0 1 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090./shardserver 1 1 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090./shardserver 2 1 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090./shardserver 0 2 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:7088,127.0.0.1:7089,127.0.0.1:7090./shardserver 1 2 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:7088,127.0.0.1:7089,127.0.0.1:7090./shardserver 2 2 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090127.0.0.1:7088,127.0.0.1:7089,127.0.0.1:7090 4.读写数据 12./shardcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 put testkey testvalue ./shardcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 get testkey 5.运行基准测试 1./bench_cli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 100 put 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"}] \ No newline at end of file +[{"title":"raftpractice 大论文阅读-第三章 Raft 算法基础","path":"/2023/08/24/raft-basic-algorithm-ch3/","content":"这一章我们来介绍 Raft 算法,我们将 Raft 设计得尽可能易于理解,第一部分我们将介绍我们让算法易于理解的设计方法。剩下的部分我们描述了算法本身,并包括了我们为了让算法更容易理解做出来的设计的示例。 为了容易理解而设计的算法Raft 概览Raft 算法管理了上一章我们介绍的复制状态机的日志。下面这个图我们以简洁的方式给出了算法的参考。 还有 Raft 算法的关键属性如下,图中的其余元素我们将在论文剩下的部分来讨论。 Raft 首先选举一个服务节点作为 Leader,然后给这个 Leader 管理复制日志的权限。Leader 接受来自客户端的请求并生成日志条目,把它们复制给其他的服务器,并告诉服务什么时候可以安全的将日志应用到状态机中。 集群中拥有一个 Leader 简化了复制日志的管理,例如,Leader 可以不用咨询其他的服务器自己决定日志里面追加新条目的位置,数据以简单的方式从 Leader 流向其他服务器。Leader 可能会故障或者与其他服务器网络断开,在这种情况下会选举出新的 Leader。 考虑到集群的领导方式,Raft 将一致性问题分为三个相对独立的子问题,这些子问题我们将在下面的章节进行详细的讨论。 Leader 选举:启动集群或者集群中 Leader 节点故障的时候会选举出一个新的 Leader。"},{"title":"raftpractice 大论文阅读-第二章 设计 Raft 算法的动机","path":"/2023/08/21/raftpractice-ch2/","content":"一、通过复制状态机来实现系统的容错共识算法通常伴随着复制状态机出现,通过共识算法,我们实现一组拥有相同状态的计算机,我们通常把这一组拥有相同状态的计算机称为副本集。在某些机器宕机的情况下,这些计算机仍然能够继续之前的工作进行操作。 复制状态机通常通过复制日志来实现,如下图: 每个服务节点都存储着一个日志结构,它里面包含了多个对状态机操作的命令序列,它们按顺序在状态机里面执行。每个服务器里面都存储着相同顺序的命令序列,使得每个状态机执行相同序列的命令。由于状态是确定的,每个状态机都计算得到相同的状态,并且有着相同的操作输出序列。 共识算法的工作就是要保证复制日志的一致性,服务节点上的共识算法模块在服务器收到来自客户端的命令请求后,会把它追加到自己的日志中。并且和其他服务节点上的共识算法模块进行网络通信,确保每个日志都有相同顺序的相同请求。即使某些服务器故障,一旦命令请求被正确的复制了,它们就被称为已经提交的日志。每个服务节点的状态机按日志顺序处理提交的命令,并将命令输出返回给客户端。这样,服务器就像是一个单一的,高可用的机器(永不故障的单机)。 一个实际系统的共识算法通常具有以下属性: 它们确保在所有非拜占庭(non-Byzantine)条件下的安全性,包括网络延迟、分区、数据包丢失、重复请求和重新排序。 只要任何大多数服务器都可以运行并且可以相互通信以及与客户端通信,系统就是可用的。例如,典型的 5 台服务器组成的集群可用容忍人一两台服务器故障。假定服务器因停止而发生故障,它们稍后可能会从稳定的存储(持久化存储设备)上的状态恢复并重新加入集群。 它们不依赖时间来确保日志的一致性:在时钟错误和极端消息延迟的情况下可能会发生可用性问题。也就是说,它们是在异步的模型下保持安全性,其中消息和处理程序一任意的速度进行。 通常,一旦集群中的大多数成员相应了一轮远程过程调用(RPC),命令就可以完成返回客户端。少数缓慢的服务器并不影响系统整体的性能。 二、常用的使用复制状态机场景复制状态机是使分布式系统具有容错的通用构建模块,它们可以以多种方式被使用,这一部分我们来讨论典型的使用案例。 模式 1对于大多数的部署模式,一般都是由 3 台或者 5 台服务器组成一个复制状态机。然后其他服务器可以使用这个状态机来协调它们的活动,如下图所示: 我们工业上常见用来做分布式协调的应用例如 Etcd 和 Zookeeper 都是这种应用模式。 这些系统通常使用复制状态机进行分布式系统的成员组,配置,还有锁的管理。 举一个更具体一点的示例:复制状态机可以提供一个容错的工作队列,而其他的服务器可以使用这个复制状态进行协调分配工作给它们自己。 模式 2还有一种常见的部署模式如下图所示: 在这种模式下一台服务器充当 Leader 管理者剩下的服务器。Leader 将其关键的数据存储在共识系统中。如果它宕机了,其他的备用服务器会重新竞选领导者的位置,成功之后,我们可以使用共识系统中的数据继续的运行。 许多大型的存储系统都有单个集群 Leader, 例如 GFS, HDFS, 和 RAMCloud。 模式 3共识协议也通常用于拥有超大量数据的系统的复制,如下图所示: 例如 Magastore, Spanner 和 Scatter 它们存储着超大量的数据,无法存储在单个服务器分组里面。它们将数据分区存储到多个不同的复制状态机(多个服务器组)里面,跨越多个分区的使用两阶段提交 (2PC)来保证数据一致性。 三、Paxos 存在的问题在过去的十年间,Leslie Lamport 的 Paxos 协议几乎成了分布式共识的代名词:它是大学课程里面最常教授的协议,大多数共识算法的实现都以它为起点。Paxos 首先定义了一个能够就单个决策达成一致的协议,例如单个日志条目的复制。我们将这个子集称为 single-decree(单法令) Paxos。 Paxos 还结合该协议的多个实例来促进一系列决策,例如日志(Multi-Paxos)。 Paxos确保了安全性和有效性(假设使用了足够的故障检测器来避免提议者的活动锁定,它最终会达成共识) 并证明了它的正确性。Multi-Paxos在正常情况下是有效的,并且 Paxos 支持更改集群成员身份。 不幸的是,Paxos 有两个显著的缺点。 Paxos 难以理解第一个缺点是 Paxos 很难理解,完整的解释是出了名的难懂。 缺少构建实践的基础Paxos 的第二个问题是,它并没有为构建实践实际的分布式系统提供良好的基础。"},{"title":"动手学习分布式-Multi-分布式事务初探 (Golang eraftkv 版)","path":"/2023/08/19/distributed-tx/","content":"事务介绍在介绍分布式事务之前,我们先来通过一个例子看看事务是什么? 开始讨论我们系统中可能发生的事情之前,我们要重新说一下事务的定义。事务是对数据库的一系列操作,这些操作满足ACID的属性。 我们看到上图的例子,假设我们现在实现的分布式存储系统存储了银行账户数据。 T1表示储蓄用户的账户为Y,他有10块钱,然后他给X转账1块钱,那么对应的数据操作就是对x + 1,对Y - 1 。用户提交这个转账后,系统就开始修改数据库中的值了。 T2表示银行对账人员,她需要统计用户X, Y的账户总和。如果T2在T1开始且还没有操作的时候执行x’, y’值得获取,那么能拿到20块的总和,这是符合预期的。 但是,因为两个用户使用系统的时候,他们访问的顺序是随机的,我们无法保证,一旦T2在T1执行add(x, 1)之后读取x’, y’的值。我们将得到X + Y = 21,统计莫名的多出了一块钱(似乎银行亏1块钱也没啥问题),如果这笔转账金额很大呢,比如一个小目标1个亿,那就是绝对不能容忍的错误了。 这时候就需要我们的事务保障了。 ACID atomic原子性。数据库管理系统保证事务是原子的,事务要么执行其所有操作,要么不执行任何操作。 cosistent一致性。这个表示数据库是一致的。应用程序访问的有关数据的所有查询都将返回正确的结果。 isolated隔离性。数据库管理系统提供了事务在系统中单独运行的假象。他们看不到并发事务的影响。这等同于事务的执行是以串行的顺序的。但是为了更好的性能,数据库管理系统必须交错并发的执行事务操作。 durable持久性。在系统崩溃和重启之后,提交事务的所有更改都必须是持久化的。数据库管理系统可以使用日志记录或者影子页面来确保所有的更改都是持久化的。 两阶段提交 在一个分布式系统中,数据被分割存储在不同的机器上。例如我们eraft中将数据按哈希值分布到不同的bucket,然后有不同的机器去负责这个bucket数据的存取。这个时候,事务处理就更复杂了。单节点我们可以通过锁保证事务正确性,但是分布式场景就不一样的,我们把上述转账示例带入分布式场景下: 两阶段提交账户数据存储在S1, S2两台不同的机器上。T1在S1上执行+1操作,X现在等于11。当T1执行到对Y减1操作的时候,服务S2奔溃掉了。那么这时候这个操作返回用户失败,但是S1上的账户已经脏了,这时候对账人员去对账也会得到错误的数据。 面对这种场景分布式系统是如何去解决的呢? 这个时候就需要一个节点作为事务协调者 Transaction Coordinator,来协调事务的执行了,S1,S2负责执行事务,他们被称为事务参与者 Participants。 我们首先概览以下两阶段提交是如何工作的 首先在我们的图中,假定TC, S1, S2都位于不同的服务器。TC是事务执行的协调者。S1, S2是持有数据的服务节点。 事务协调器TC会给S1发消息告诉它要对X进行+1操作,给服务器S2发消息告诉它对Y进行-1操作。后面会有一系列的消息来确认,要么S1, S2都成功执行了相应的,要么两个服务器都没有执行操作,不会出现非原子操作的状态,这就是两阶段提交的大致流程。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-Multi-Raft设计与实现 (Golang eraftkv 版)","path":"/2023/08/19/multi-raft/","content":"设计思考在上一章中,我们应用Raft实现了一个单分组的Raft KV集群。客户端写请求到Leader,Leader把操作复制到Follower节点。当Leader挂了,会按我们第四章描述的Raft算法库,进行一轮新的选举,选出新的Leader后继续提供服务。这样我们就有了一个高可用的集群。 我们通过单分组的KV集群实现了高可用,以及分区容忍性,但是分布式系统还有一个可扩展的特性,也就是我们可以通过堆机器的方式来让系统实现更高的吞吐量。我们第五章实现的是单分组集群,只有单个Leader节点承担客户端的写入。单分组集群系统的吞吐量上线取决于单机的性能。那么我们该如何实现分布式可扩展性呢? 接下来我们将介绍Multi-Raft实现,它可以解决单分组集群的可扩展性问题。它的思路是这样的:既然单组有上限,那么我们可不可以用多组Raft KV集群来实现扩展呢?我们需要有一个配置中心来管理多个分组服务器的地址信息。有了多个分组之后,我们就要考虑怎么样把用户的请求均衡地分发到相应的分组服务器上了。我们可以使用哈希算法来解决这个问题,对用户的key做哈希计算,然后映射到不同的服务分组上,这样可以保证流量的均衡。 整合一下上面的思路,我们可以得到如下的系统架构图a: 在第一章开篇,我们介绍了系统的整体架构并且带大家体验了系统的运行。之后,我们讲解了Go语言的基础知识、Raft算法库以及应用Raft算法库的示例。相信大家现在对这个系统已经有了一个更加深入的理解。 首先,客户端启动之后,会从配置服务器(ConfigServer)拉取集群的分组信息,以及分组负责的数据桶(Bucket)信息到本地。客户端发送请求的时候会计算key的哈希值,找到相应的桶以及负责这个桶的服务器分组(ShardServer)地址信息,然后将操作发送到对应的服务器分组进行处理。这里ConfigServer服务器分组和ShardServer服务器分组都是高可用的,多个ShardServer实现了系统的可扩展性。 配置服务器实现分析配置服务的实现在eraft/configserver目录下。根据上面的架构图,我们大概可以知道配置服务器需要存储哪些信息:(1)每个服务分组的服务器地址信息;(2)服务分组负责的数据桶信息。这个结构定义如下: 1type Config struct { Version int Buckets [common.NBuckets]int Groups map[int][]string } Version表示当前配置的版本号。Buckets存储了分组负责的桶信息。NBuckets是一个常量,表示系统中最大的桶数量。这里我们默认是10。对于大规模的分布式系统, Buckets值可以被设置为很大。 我们看到下面的配置示例:配置版本号为6 ,10个桶和1个分组服务。 我们设置127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090这三台服务器组成分组1。Buckets数组中0-4号桶都是分组1负责的。5-9号桶为0,表示当前没有分组负责这些桶的数据写入。 1{"Version":6,"Buckets":[1,1,1,1,1,0,0,0,0,0],"Groups":{"1":["127.0.0.1:6088","127.0.0.1:6089","127.0.0.1:6090"]}} eraft中把上述配置信息存储到了leveldb中。下面是几个操作的接口,Join操作 是把一个分组的服务器加入到集群配置中;Leave操作是删除某些分组的服务器配置信息;Move操作是将某个桶分配给相应的分组负责;Query操作是查询配置信息。这些操作是通过修改Config这个结构,并将数据持久化到leveldb中实现。操作的代码实现逻辑在eraft/configserver/config_stm.go里面。 12345678910type ConfigStm interface { Join(groups map[int][]string) error Leave(gids []int) error Move(bucket_id, gid int) error Query(num int) (Config, error)} 配置服务器的核心逻辑在config_server.go里面,可以看到和我们第四章实现的单分组kv极其类似。这里只是把Put, Get操作给改成了对配置的Join、Leave、Move、Query操作。 每一次操作都经过一次共识,保证三个服务节点都有一致的配置。这样Leader配置节点挂掉后,集群中仍然可以从新的Leader配置服务器中获取配置信息。 分片服务器实现分析首先,我们看到bucket定义如下: 123456789101112131415// a bucket is a logical partition in a distributed system // it has a unique id, a pointer to db engine, and status//type Bucket struct { ID int KvDB storage_eng.KvStore Status buketStatus} 桶具有一个唯一标识的ID。 前面我们介绍配置服务器的时,介绍过一个服务分组负责一部分桶的数据。在配置服务器中,1·桶关联的ID值就是配置数组的索引号。 同时,桶还关联了一个KvStore的接口。我们写数据的时候会传入当前服务器持有的数据存储引擎,下面是对桶中数据的操作,有Get, Put, Append。 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061// // get encode key data from engine//func (bu *Bucket) Get(key string) (string, error) { encodeKey := strconv.Itoa(bu.ID) + SPLIT + key v, err := bu.KvDB.Get(encodeKey) if err != nil { return "", err } return v, nil}//// put key, value data to db engine//func (bu *Bucket) Put(key, value string) error { encodeKey := strconv.Itoa(bu.ID) + SPLIT + key return bu.KvDB.Put(encodeKey, value)}//// appned data to engine//func (bu *Bucket) Append(key, value string) error { oldV, err := bu.Get(key) if err != nil { return err } return bu.Put(key, oldV+value)} 接下来我们来看看ShardServer定义的结构体 1234567891011121314151617181920212223242526272829303132333435363738type ShardKV struct { mu sync.RWMutex dead int32 rf *raftcore.Raft applyCh chan *pb.ApplyMsg gid_ int cvCli *configserver.CfgCli lastApplied int lastConfig configserver.Config curConfig configserver.Config stm map[int]*Bucket dbEng storage_eng.KvStore notifyChans map[int]chan *pb.CommandResponse stopApplyCh chan interface{} pb.UnimplementedRaftServiceServer} 这个结构和我们应用Raft实现单组KvServer的结构特别类似。这里的状态机是map[int]*Bucket类型的,代表当前服务器的桶的数据。分片服务器需要和配置服务器交互,知道自己负责哪些分片的数据。cvCli定义了到配置服务器的客户端。lastConfig,curConfig分别记录了上一个版本以及当前版本的集群配置表。服务器知道这个表之后就知道自己负责哪些分片的数据。当集群拓扑变更后,配置表会变化,分片服务器能第一时间感知到变化,并且应用新的配置表。其他结构就和我们之前介绍单组KvServer一样了。 我们看看ShardServer构造流程和单组KvServer的区别。首先, Server启动的时候我们初始化了两个引擎,一个用来存储Raft日志的logDbEng,另一个用来存储实际数据的newdbEng。cvCli是到配置服务器分组的连接客户。,我们调用MakeCfgSvrClient构造到配置服务器的客户端,传入配置服务器分组的地址列表。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566//// MakeShardKVServer make a new shard kv server// peerMaps: init peer map in the raft group// nodeId: the peer's nodeId in the raft group// gid: the node's raft group id// configServerAddr: config server addr (leader addr, need to optimized into config server peer map)//func MakeShardKVServer(peerMaps map[int]string, nodeId int, gid int, configServerAddrs string) *ShardKV { ...\tlogDbEng := storage_eng.EngineFactory("leveldb", "./log_data/shard_svr/group_"+strconv.Itoa(gid)+"/node_"+strconv.Itoa(nodeId))\tnewRf := raftcore.MakeRaft(clientEnds, nodeId, logDbEng, newApplyCh, 500, 1500)\tnewdbEng := storage_eng.EngineFactory("leveldb", "./data/group_"+strconv.Itoa(gid)+"/node_"+strconv.Itoa(nodeId)) shardKv := &ShardKV{ ... cvCli: configserver.MakeCfgSvrClient(common.UN_UNSED_TID, strings.Split(configServerAddrs, ",")), lastApplied: 0, curConfig: configserver.DefaultConfig(), lastConfig: configserver.DefaultConfig(), stm: make(map[int]*Bucket), ...\t} shardKv.initStm(shardKv.dbEng)\tshardKv.curConfig = *shardKv.cvCli.Query(-1)\tshardKv.lastConfig = *shardKv.cvCli.Query(-1) ... go shardKv.ConfigAction() return shardKv} 我们initStm函数初始化了状态机里面的每个Bucket。之后,调用cvCli.Query(-1)查询当前最新的配置缓存到本地的curConfig,lastConfig,初始启动,这两个配置是一样的。 这里有一个执行任务为ConfigAction的Goruntine,我们来看看它干了啥。 核心逻辑如下,下面的逻辑是一个循环执行的,时间间隔是1s。 首先我们通过cvCli.Query尝试查询下一个配置版本信息,如果当前集群没有配置变更,返回nil,我们continue进入下一轮循环,啥也不干。 如果有新的配置变更,比如加入了新的服务器分组,我们就会对比新的配置和当前配置的版本信息。如果匹配上,当前节点作为Leader需要把这个配置变化信息发送到这个服务器分组,让大家都知道新的配置变化。分组服务里面的每个服务器配置都要是一致的,这里我们通过Propose提交一个OpType_OpConfigChange的提案。 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950 if _, isLeader := s.rf.GetState(); isLeader {...nextConfig := s.cvCli.Query(int64(curConfVersion) + 1)if nextConfig == nil { continue}nextCfBytes, _ := json.Marshal(nextConfig)curCfBytes, _ := json.Marshal(s.curConfig)raftcore.PrintDebugLog("next config -> " + string(nextCfBytes))raftcore.PrintDebugLog("cur config -> " + string(curCfBytes))if nextConfig.Version == curConfVersion+1 { req := &pb.CommandRequest{} nextCfBytes, _ := json.Marshal(nextConfig) raftcore.PrintDebugLog("can perform next conf -> " + string(nextCfBytes)) req.Context = nextCfBytes req.OpType = pb.OpType_OpConfigChange reqBytes, _ := json.Marshal(req) idx, _, isLeader := s.rf.Propose(reqBytes) if !isLeader { return } ...}} 最后我们看看分组中的服务器是怎么Apply这个日志的 123456789nextConfig := &configserver.Config{}json.Unmarshal(req.Context, nextConfig)if nextConfig.Version == s.curConfig.Version+1 { ...\ts.lastConfig = s.curConfig\ts.curConfig = *nextConfig\tcfBytes, _ := json.Marshal(s.curConfig)\traftcore.PrintDebugLog("applied config to server -> " + string(cfBytes))} 我们会更新Server的lastConfig和curConfig配置信息。 客户端实现分析当客户端写入一个Key到系统中时,我们首先需要知道Key属于那个分组服务器负责。在构造客户端的时候,我们会先将最新的配置信息缓存到本地。 1234567// make a kv cilent//func MakeKvClient(csAddrs string) *KvClient {\t...\tkvCli.config = kvCli.csCli.Query(-1)\treturn kvCli} 客户端中我们提供了Get(key string)和Put(key, value string)的接口,它们都是调用公用的Command方法去访问我们的分组服务器。 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950//// Command// do user normal command//func (kvCli *KvClient) Command(req *pb.CommandRequest) (string, error) {\tbucket_id := common.Key2BucketID(req.Key)\tgid := kvCli.config.Buckets[bucket_id]\tif gid == 0 { return "", errors.New("there is no shard in charge of this bucket, please join the server group before")\t}\tif servers, ok := kvCli.config.Groups[gid]; ok { for _, svrAddr := range servers { if kvCli.GetConnFromCache(svrAddr) == nil { kvCli.rpcCli = raftcore.MakeRaftClientEnd(svrAddr, common.UN_UNSED_TID) } else { kvCli.rpcCli = kvCli.GetConnFromCache(svrAddr) } resp, err := (*kvCli.rpcCli.GetRaftServiceCli()).DoCommand(context.Background(), req) if err != nil { // node down raftcore.PrintDebugLog("there is a node down is cluster, but we can continue with outher node") continue } switch resp.ErrCode { case common.ErrCodeNoErr: kvCli.commandId++ return resp.Value, nil case common.ErrCodeWrongGroup: kvCli.config = kvCli.csCli.Query(-1) return "", errors.New("WrongGroup") case common.ErrCodeWrongLeader: kvCli.rpcCli = raftcore.MakeRaftClientEnd(servers[resp.LeaderId], common.UN_UNSED_TID) resp, err := (*kvCli.rpcCli.GetRaftServiceCli()).DoCommand(context.Background(), req) if err != nil { fmt.Printf("err %s", err.Error()) panic(err) } if resp.ErrCode == common.ErrCodeNoErr { kvCli.commandId++ return resp.Value, nil } default: return "", errors.New("unknow code") } }\t} else { return "", errors.New("please join the server group first")\t}\treturn "", errors.New("unknow code")} 1.首先我们会使用Key2BucketID函数对Key做CRC32运算,得到它应该被分配到桶的ID;2.然后 从本地缓存的kvCli.config配置里面找到负责这个bucket id数据的服务器分组;3.拿到服务器分组之后,我们会向第一个服务器发送DoCommand RPC;4.如果这个服务器不是Leader,它会返回Leader的ID。然后客户端会重新发DoCommand RPC给Leader节点。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-基于Raft库,实现简单的分布式KV系统 (Golang eraftkv 版)","path":"/2023/08/19/kv-distributed-systems/","content":"系统架构概览上一章中,我们已经介绍了我们构建好的Raft库,它在eraft/raftcore目录下。现在我们要使用这个库来构建一个高可用的分布式KV存储系统。 让我们回到上图,这个图在我们一开始介绍Raft的时候讲到过,我们这一章就要实现这样一个系统。 对外接口定义第一步我们来定义系统与客户端的交互接口,客户端可以发送Put和Get操作将KV数据写到系统中。我们需要定义这两个操作的RPC。我们将它们合并到一个RPC请求里面,定义如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061// // client op type //enum OpType { OpPut = 0; OpAppend = 1; OpGet = 2;}//// client command request//message CommandRequest { string key = 1; string value = 2; OpType op_type = 3; int64 client_id = 4; int64 command_id = 5; bytes context = 6;}//// client command response//message CommandResponse { string value = 1; int64 leader_id = 2; int64 err_code = 3;}rpc DoCommand (CommandRequest) returns (CommandResponse) {} 其中OpType定义了我们支持的操作类型:put,Append,get。 客户端请求的内容定义在了CommandRequest中。CommandRequest中有我们的key, value以及操作类型,还有客户端id以及命令id,还有一个字节类型的context (context可以存放一些我们需要附加的不确定的内容)。 响应CommandResponse包括了返回值value ,leader_id字段(告诉我们当前Leader是哪个节点的。因为最开始客户端发送请求时,不知道那个节点被选成Leader 。我们通过一次试探请求拿到Leader节点的id,然后再次发送请求给Leader节点。),以及错误码err_code(记录可能出现的错误)。 客户端通过DoCommand RPC请求到我们的分布式KV系统。那我们的系统是怎么处理请求的呢?首先,我们来看看服务端的结构的定义。 mu是一把读写锁,用来对可能出现并发冲突的操作加锁。dead表示当前服务节点的状态,是不是存活。Rf比较重要,这个是只想我们之前构建的Raft结构的指针。applyCh是一个通道,用来从我们的算法库中返回已经apply的日志。我们的server拿到这个日之后需要apply到实际的状态机中。stm就是我们的状态机了,我们一会儿介绍。notifyChans是一个存储客户端响应的map,其中值是一个通道。KvServer在应用日志到状态机操作完之后,会给客户端发送响应到这个通道中。stopApplyCh用来停止我们的Apply操作。 服务端核心实现分析123456789101112131415type KvServer struct { mu sync.RWMutex dead int32 Rf *raftcore.Raft applyCh chan *pb.ApplyMsg lastApplied int stm StateMachine notifyChans map[int]chan *pb.CommandResponse stopApplyCh chan interface{} pb.UnimplementedRaftServiceServer} 有了这个结构之后,我们要如何应用Raft算法库实现图中高可用的kv分布式系统呢? 1.首先我们要构造到每个server的rpc客户端;2.然后,构造applyCh通道,以及构造日志存储结构;3.之后,调用MakeRaft构造我们的Raft算法库核心结构;4.最后,启动Apply Goruntine,从通道中监听在经过Raft算法库之后返回的消息。 123456789101112131415161718192021222324252627282930313233343536373839func MakeKvServer(nodeId int) *KvServer {clientEnds := []*raftcore.RaftClientEnd{}for id, addr := range PeersMap { newEnd := raftcore.MakeRaftClientEnd(addr, uint64(id)) clientEnds = append(clientEnds, newEnd)}newApplyCh := make(chan *pb.ApplyMsg)logDbEng, err := storage_eng.MakeLevelDBKvStore("./data/kv_server" + "/node_" + strconv.Itoa(nodeId))if err != nil { raftcore.PrintDebugLog("boot storage engine err!") panic(err)}// 构造 Raft 结构,传入 clientEnds,当前节点 id, 日志存储的 db, apply 通道,心跳超时时间,和选举超时时间// 由于是测试,为了方便观察选举的日志,我们设置的时间是 1s 和 3s, 你可以设置的更短newRf := raftcore.MakeRaft(clientEnds, nodeId, logDbEng, newApplyCh, 1000, 3000)kvSvr := &KvServer{Rf: newRf, applyCh: newApplyCh, dead: 0, lastApplied: 0, stm: NewMemKV(), notifyChans: make(map[int]chan *pb.CommandResponse)}kvSvr.stopApplyCh = make(chan interface{})// 启动 apply Goruntinego kvSvr.ApplingToStm(kvSvr.stopApplyCh)return kvSvr} 客户端命令到来之后,最开始调用的是DoCommand函数,我们来看看这个函数做了哪些工作: 首先,Docommand函数调用Marshal序列化了我们的CommandResponse到reqBytes的字节数组中,然后调用Raft库的Propose接口,把提案提交到我们的算法库中。Raft算法中只有Leader可以处理提案。如果节点不是Leader我们会直接返回给客户端ErrCodeWrongLeader的错误码。之后就是从getNotifyChan拿到当前日志id对应的apply通知通道。只有这条日志通知到了,下面select才会继续往下走,拿到值放到cmdResp.Value中,当然如果操作超过了ErrCodeExecTimeout时间也会生成错误码,响应客户端执行超超时。 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061func (s *KvServer) DoCommand(ctx context.Context, req *pb.CommandRequest) (*pb.CommandResponse, error) {raftcore.PrintDebugLog(fmt.Sprintf("do cmd %s", req.String()))cmdResp := &pb.CommandResponse{}if req != nil { reqBytes, err := json.Marshal(req) if err != nil { return nil, err } idx, _, isLeader := s.Rf.Propose(reqBytes) if !isLeader { cmdResp.ErrCode = common.ErrCodeWrongLeader return cmdResp, nil } s.mu.Lock() ch := s.getNotifyChan(idx) s.mu.Unlock() select { case res := <-ch: cmdResp.Value = res.Value case <-time.After(ExecCmdTimeout): cmdResp.ErrCode = common.ErrCodeExecTimeout cmdResp.Value = "exec cmd timeout" } go func() { s.mu.Lock() delete(s.notifyChans, idx) s.mu.Unlock() }()}return cmdResp, nil} 最后我们来看看Apply Goruntine干的事情: 它等待s.applyCh通道中apply消息的到来。这个applyCh我们在Raft库中提到过,它用来通知应用层日志已经提交,应用层可以把日志应用到状态机了。当applyCh中appliedMsg到来之后,我们更新了KvServer的lastApplied号,然后根据客户端的操作类型对我们的状态机做不同的操作,做完之后把响应放到notifyChan中,也就是DoCommand等待的那个通道,至此整个请求处理的流程已经结束。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960func (s *KvServer) ApplingToStm(done <-chan interface{}) { for !s.IsKilled() { select { case <-done: return case appliedMsg := <-s.applyCh: req := &pb.CommandRequest{} if err := json.Unmarshal(appliedMsg.Command, req); err != nil { raftcore.PrintDebugLog("Unmarshal CommandRequest err") continue } s.lastApplied = int(appliedMsg.CommandIndex) var value string switch req.OpType { case pb.OpType_OpPut: s.stm.Put(req.Key, req.Value) case pb.OpType_OpAppend: s.stm.Append(req.Key, req.Value) case pb.OpType_OpGet: value, _ = s.stm.Get(req.Key) } cmdResp := &pb.CommandResponse{} cmdResp.Value = value ch := s.getNotifyChan(int(appliedMsg.CommandIndex)) ch <- cmdResp } } } 客户端实现介绍客户端实现就比较简单了,主要是构造Get和Put的CommandRequest调用DoCommand发送到服务端,逻辑实现在cmd/kvcli/kvcli.go里面。 我们总结一下: 客户端请求到来之后, KvServer首先会调用Propose提交日志到Raft中算法库。Raft算法库经过共识之后提交这条日志,并通知applyCh,KvServer会在Apply Goruntine中将applyCh的消息解码,然后将操作应用到自己的状态机中,最后把结果写到通知客户端的notifyChan中,在DoCommand中响应结果给客户端。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-构建Raft库 (Golang eraftkv 版)","path":"/2023/08/19/build-raft-lib/","content":"核心数据结构设计我们上一章节讲了Raft算法的主要内容,现在我们要代码实现它们了。首先我们需要抽象出我们需要的数据结构。先来梳理一下可能用到的数据结构,首先节点之间需要互相访问,那我们需要定义访问其他节点的网络客户端,这里面要包含节点的id,地址,还有rpc的客户端,整个结构我们抽象为RaftClientEnd,主要数据内容如下: 123456type RaftClientEnd struct { id uint64 addr string raftServiceCli *raftpb.RaftServiceClient // grpc 客户端} 节点状态我们之前描述的有三种,定义如下: 123456789 const (NodeRoleFollower NodeRole = iotaNodeRoleCandidateNodeRoleLeader ) 我们要完成选举操作的话需要两个超时时间,这里我们使用Golang time库里面的Timer实现,它可以定时的给一个通道发送消息,我们可以用它来实现选举超时和心跳超时。 12electionTimer *time.TimerheartbeatTimer *time.Timer 除此之外我们还需要记录当前节点的id,当前的任期号,为谁投票,获得票数的统计,已经提交的日志索引号,最后apply到状态机的日志号,以及节点如果是Leader的话需要记录到其他节点复制最新匹配的日志号,这写数据结构定义如下: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647type Raft struct { mu sync.RWMutex peers []*RaftClientEnd // rpc 客户端 me_ int // 自己的 id dead int32 // 节点的状态 applyCh chan *pb.ApplyMsg // apply 协程通道,协程模型中会讲到 applyCond *sync.Cond // apply 流程控制的信号量 replicatorCond []*sync.Cond // 复制操作控制的信号量 role NodeRole // 节点当前的状态 curTerm int64 // 当前的任期 votedFor int64 // 为谁投票 grantedVotes int // 已经获得的票数 logs *RaftLog // 日志信息 commitIdx int64 // 已经提交的最大的日志 id lastApplied int64 // 已经 apply 的最大日志的 id nextIdx []int // 到其他节点下一个匹配的日志 id 信息 matchIdx []int // 到其他节点当前匹配的日志 id 信息 leaderId int64 // 集群中当前 Leader 节点的 id electionTimer *time.Timer // 选举超时定时器 heartbeatTimer *time.Timer // 心跳超时定时器 heartBeatTimeout uint64 // 心跳超时时间 baseElecTimeout uint64 // 选举超时时间} 首先系统启动的时候需要构造Raft这个结构体,这个流程是在MakeRaft里面实现的,它主要是初始化与一些变量,两个定时器,并启动相关的协程,我们这里对每个对端节点复制有Replicator协程,触发两个超时时间有Tick协程,应用已经提交的日志有Applier协程。 协程模型 上图展示了我们raftcore里面的协程模型,当应用层提案(propose)到来之后主协程会在本地追加日志,然后发送BroadcastAppend,然后到其他节点复制日志的协程的等待就会被唤醒,开始进行一轮的的复制,replicateOneRound成功复制半数节点日志之后会触发commit, rf.applyCond.Signal()会唤醒等待做Apply操作的Applier协程。 另外Tick协程会监听Timer两个超时的C通道信号,一旦心跳超时并且当前节点的状态是Leader,就会调用BroadcastHeartbeat发送心跳,发心跳的时候也会replicateOneRound就和上述一样,如果成功复制半数节点日志之后会触发commit, rf.applyCond.Signal()就会唤醒等待做Apply操作的Applier协程。 Applier协程apply完消息之后会把ApplyMsg消息写入rf.applyCh通知应用层,应用层的协程和以监听这个通道,如果有ApplyMsg到来就把它应用到状态机。 Rpc定义eraft的rpc定义文件在pbs目录下的raftbasic.proto文件中,主要的消息如下: Entry 这个是一个日志条目信息表示,和我们之前描述的一样,它有任期号term,索引号index,以及操作的序列化数据data我们用一个字节流来存储,日志条目有两种类型一种是Normal正常日志,另一种是ConfChange配置变更的日志: 12345678910111213141516171819202122enum EntryType { EntryNormal = 0; EntryConfChange = 1;}message Entry { EntryType entry_type = 1; uint64 term = 2; int64 index = 3; bytes data = 4;} RequestVote相关下面是请求投票RPC的定义,基本和论文里面保持一致:请求投票里面有候选人的任期号,它的id以及它最后一条日志的索引以及任期号信息。响应里面有个任期号,这个用来给候选人在选举失败的时候更新自己的任期,还有一个vote_granted表示这个请求投票操作是否被对端节点接受。 123456789101112131415161718192021222324252627282930message RequestVoteRequest { int64 term = 1; int64 candidate_id = 2; int64 last_log_index = 3; int64 last_log_term = 4;}message RequestVoteResponse { int64 term = 1; bool vote_granted = 2;}service RaftService { //... rpc RequestVote (RequestVoteRequest) returns (RequestVoteResponse) {}} AppendEntries相关日志追加操作的定义如下,基本也和论文里面一致: 请求里面有Leader的任期,id (用来告诉follower,这样client访问了follower之后可以被告知leader节点是哪个), prev_log_index表示消息里面将要同步的第一条日志前一条日志的的索引信息,prev_log_term是它的任期信息,leader_commit则是leader的commit号(可以用来周知follower节点当前的commit进度),entries表示日志条目信息。 响应里面term用来告诉leader是否出新的任期的消息,可以用来更新leader的任期号,success表示日志追加操作是否成功,conflict_index用来记录冲突日志的索引号,conflict_term用来记录冲突日志的任期号。 123456789101112131415161718192021222324252627282930313233343536373839message AppendEntriesRequest { int64 term = 1; int64 leader_id = 2; int64 prev_log_index = 3; int64 prev_log_term = 4; int64 leader_commit = 5; repeated Entry entries = 6;}message AppendEntriesResponse { int64 term = 1; bool success = 2; int64 conflict_index = 3; int64 conflict_term = 4;}service RaftService { //... rpc AppendEntries (AppendEntriesRequest) returns (AppendEntriesResponse) {}} Leader选举实现分析Raft官网提供了一个算法的动态演示动画,我们先来直观感受下Leader选举的流程,然后结合代码介绍这个流程。 待续。。 首先Raft算法中有两个超时时间用来控制着Leader选举的流程,首先是选举超时,这个是Candidate等待变成Leader的时间跨度,如果在这个时间内还没被选成Leader,这个超时定时器会被重置。我们前面也介绍过,这个超时时间设置一般在150ms到300ms之间。 启动的时候,所有节点的选举超市时间都被设置到150~300ms之间的随机值,那么大概率有一个节点会率先达到超市时间,如图A,B,C节点的C先达到超时时间,它从Follower变成Candidate,随后开始新任期的选举,它会给自己投一票,然后向集群中的其他节点发送RequestVoteRequest rpc请求它们的投票。 1.eraft代码中在启动,也就是应用层调用MakeRaft函数的时候会传入baseElectionTimeOutMs和heartbeatTimeOutMs, 这里心跳超市时间是固定的,选举超时时间我们使用MakeAnRandomElectionTimeout构造生成了一个随机超市时间。 12345678func MakeRaft(peers []*RaftClientEnd, me int, newdbEng storage_eng.KvStore, applyCh chan *pb.ApplyMsg, heartbeatTimeOutMs uint64, baseElectionTimeOutMs uint64) *Raft {...heartbeatTimer: time.NewTimer(time.Millisecond * time.Duration(heartbeatTimeOutMs)),electionTimer: time.NewTimer(time.Millisecond * time.Duration(MakeAnRandomElectionTimeout(int(baseElectionTimeOutMs)))),... 2.达到选举超时后,节点C首先把自己状态改成Candidate,然后增加自己的任期号,开始选举。 12345678910111213141516//// Tick raft heart, this ticket trigger raft main flow running//func (rf *Raft) Tick() { for !rf.IsKilled() { select { case <-rf.electionTimer.C: { rf.SwitchRaftNodeRole(NodeRoleCandidate) rf.IncrCurrentTerm() rf.Election() rf.electionTimer.Reset(time.Millisecond * time.Duration(MakeAnRandomElectionTimeout(int(rf.baseElecTimeout)))) } ... }} 3.下面这段代码就是发起选举的核心逻辑了,首先节点IncrGrantedVotes给自己投一票,然后把votedFor设置成自己,之后构造RequestVoteRequest rpc请求,带上自己的任期号,CandidateId也就是自己的id,最后一个日志条目的索引还有任期号,然后把当前Raft状态持久化,向集群中的其他节点并行的发送,RequestVote请求 123456789101112131415161718192021222324252627282930//// Election make a new election//func (rf *Raft) Election() {\tfmt.Printf("%d start election ", rf.me_)\trf.IncrGrantedVotes()\trf.votedFor = int64(rf.me_)\tvoteReq := &pb.RequestVoteRequest{ Term: rf.curTerm, CandidateId: int64(rf.me_), LastLogIndex: int64(rf.logs.GetLast().Index), LastLogTerm: int64(rf.logs.GetLast().Term),\t}\trf.PersistRaftState()\tfor _, peer := range rf.peers { if int(peer.id) == rf.me_ { continue } go func(peer *RaftClientEnd) { PrintDebugLog(fmt.Sprintf("send request vote to %s %s ", peer.addr, voteReq.String())) requestVoteResp, err := (*peer.raftServiceCli).RequestVote(context.Background(), voteReq) if err != nil { PrintDebugLog(fmt.Sprintf("send request vote to %s failed %v ", peer.addr, err.Error())) } ... }(peer)\t}} 如果A,B收到请求的时候还没有发出投票(因为它们还没达到选举超时时间),它们就会给候选人节点C投票,同时重设自己的选举超时定时器。 4.eraft处理投票请求的细节如下,我们结合图中的例子分析下面的逻辑,假设A节点正在处理来自C的投票请求,那么首先C的 任期号大于A的,代码中1的if分支不会执行,在2这里, A节点发现来自C的请求投票消息的任期号大于自己的,它会调用SwitchRaftNodeRole变成Follower节点,在回C消息之前,代码中3号位置A调用electionTimer.Reset重设了自己的选举超时定时器。 12345678910111213141516171819202122232425262728//// HandleRequestVote handle request vote from other node//func (rf *Raft) HandleRequestVote(req *pb.RequestVoteRequest, resp *pb.RequestVoteResponse) {\trf.mu.Lock()\tdefer rf.mu.Unlock()\tdefer rf.PersistRaftState() // 1\tif req.Term < rf.curTerm || (req.Term == rf.curTerm && rf.votedFor != -1 && rf.votedFor != req.CandidateId) { resp.Term, resp.VoteGranted = rf.curTerm, false return\t} // 2 if req.Term > rf.curTerm { rf.SwitchRaftNodeRole(NodeRoleFollower) rf.curTerm, rf.votedFor = req.Term, -1\t} ... rf.votedFor = req.CandidateId // 3\trf.electionTimer.Reset(time.Millisecond * time.Duration(MakeAnRandomElectionTimeout(int(rf.baseElecTimeout))))\tresp.Term, resp.VoteGranted = rf.curTerm, true} C收到半数票以上,也就是A, B节点的任意一个的投票加上自己那一张选票,它就变成了Leader,然后停止自己的选举超时定时器。 5.C统计票数处理请求投票的响应如下,注意:这段代码加了锁,应为这里涉及到多个Goruntine去修改rf中的非原子变量,如果不加锁可能会导致逻辑错误。代码中1处,如果收到投票的响应VoteGranted是true。C就会调用IncrGrantedVotes递增自己拥有的票书,然后if rf.grantedVotes > len(rf.peers)/2判断是否拿到了半数以上票,如果是的调用SwitchRaftNodeRole切换自己的状态为Leader,之后BroadcastHeartbeat广播心跳消息,并重新设置自己的得票数grantedVotes为0。 1234567891011121314151617181920212223242526if requestVoteResp != nil { rf.mu.Lock() defer rf.mu.Unlock() PrintDebugLog(fmt.Sprintf("send request vote to %s recive -> %s, curterm %d, req term %d", peer.addr, requestVoteResp.String(), rf.curTerm, voteReq.Term)) if rf.curTerm == voteReq.Term && rf.role == NodeRoleCandidate { // 1 if requestVoteResp.VoteGranted { // success granted the votes PrintDebugLog("I grant vote") rf.IncrGrantedVotes() if rf.grantedVotes > len(rf.peers)/2 { PrintDebugLog(fmt.Sprintf("node %d get majority votes int term %d ", rf.me_, rf.curTerm)) rf.SwitchRaftNodeRole(NodeRoleLeader) rf.BroadcastHeartbeat() rf.grantedVotes = 0 } // 2 } else if requestVoteResp.Term > rf.curTerm { // request vote reject rf.SwitchRaftNodeRole(NodeRoleFollower) rf.curTerm, rf.votedFor = requestVoteResp.Term, -1 rf.PersistRaftState() } } } 我们知道A,B在前面处理投票请求的时候只是重设的超时定时器,那么万一再一次超时定时器到达,会不会重新出发选举,然后陷入选举循环呢?答案是不会的,我们前面只介绍了选举超时时间,还有一个心跳超时时间,这个超时时间比选举超市时间短,一般是选举超时时间的1/3。也就是说在A, B还没到达选举超时时间之前,这个心跳超市时间会先出发,如果是Leader节点的话,它会给集群中其他节点发送心跳包,其他节点(A, B)接受到心跳包之后,又会重设自己的选举超时定时器。也就是说,只要Leader C一直正常运心发送心跳包,那么A,B节点不可能触发选举,只有当Leader C挂了。A,B节点才会开始下一轮选举。 日志复制实现分析经过上述的选举流程,我们现在就有一个拥有主节点和多个从节点的系统了,主节点会不断的给从节点发送心跳消息。现在我们要开始考虑处理客户端请求了,如果客户端发送一个操作过来,我们这个系统是如何处理的呢? 首先,Raft规定只有Leader节点能处理请求写入,客户端发送请求首先会到达Leader节点。 在eraft库中用户请求到来和raft交互的入口函数是Propose,这个函数首先会查询当前节点状态,只有Leader节点才能处理提案(propose),之后会把用户操作的序列化之后的[]byte调用Append追加到自己的日志中,之后BroadcastAppend将日志内容发送给集群中的Follower节点。 123456789101112131415//// Propose the interface to the appplication propose a operation//func (rf *Raft) Propose(payload []byte) (int, int, bool) {\trf.mu.Lock()\tdefer rf.mu.Unlock()\tif rf.role != NodeRoleLeader { return -1, -1, false\t}\tnewLog := rf.Append(payload)\trf.BroadcastAppend()\treturn int(newLog.Index), int(newLog.Term), true} 如上图中,绿色的表示客户端节点,它发送SET 5的请求过来,A作为当前集群中的Leader节点首先会把这个SET 5操作封装成一个日志条目写入到自己的日志存储结构中,然后在下一次给从节点发送心跳消息的时候带上这个日志发送给Follower节点。 在eraft实现中,我们专门有一组Goruntine做日志复制相关的事情,用户提案到达Leader之后调用BroadcastAppend会唤醒做日志复制操作的Goruntine, replicatorCond这个信号量用来完成Goruntine之间的同步操作。 12345678func (rf *Raft) BroadcastAppend() {\tfor _, peer := range rf.peers { if peer.id == uint64(rf.me_) { continue } rf.replicatorCond[peer.id].Signal()\t}} 复制操作的Goruntine执行的任务函数是Replicator,当BroadcastAppend中通过Signal函数唤醒信号量,rf.replicatorCond[].Wait()就会停止阻塞,继续往下执行,调用replicateOneRound进行数据复制。 1234567891011121314//// Replicator manager duplicate run//func (rf *Raft) Replicator(peer *RaftClientEnd) {\trf.replicatorCond[peer.id].L.Lock()\tdefer rf.replicatorCond[peer.id].L.Unlock()\tfor !rf.IsKilled() { PrintDebugLog("peer id wait for replicating...") for !(rf.role == NodeRoleLeader && rf.matchIdx[peer.id] < int(rf.logs.GetLast().Index)) { rf.replicatorCond[peer.id].Wait() } rf.replicateOneRound(peer)\t}} replicateOneRound就会把日志打包到一个AppendEntriesRequest中发送到Follower节点了。 Follower收到追加请求后会把日志条目追加到自己的日志存储结构中,然后给Leader发送成功追加的响应。Leader统计到集群半数节点(包括自己)日志追加成功之后,它会把这条日志状态设置为已经提交(committed),然后将操作结果发送给客户端,如下图所示,A设置SET 5 之后这个日志提交的信息会在下一次给Follower发送的心跳包中带过去,Follower收到日志也会更新自己的日志提交状态。 日志提交之后,Apply协程会收到通知,开始将已经提交的日志apply到状态机中,日志的成功Apply之后给客户端发送成功写入的响应包。 对应eraft实现中,日志提交之后Leader节点会调用advanceCommitIndexForLeader函数。它会计算当前日志提交的索引号,然后和之前已经提交的commitIdx进行对比,如果更大,就会更新commitIdx,同时调用rf.applyCond.Signal()唤醒做Apply操作的Goruntine。Applier函数是Apply Goruntine运行的任务函数,它会Wait applyCond这个信号量,如果被唤醒,它会拷贝初节点中已经提交的日志,打包成ApplyMsg发送到applyCh通道通知应用层,应用层拿到apply消息之后会更新状态机并回包给客户端。 123456789101112131415161718192021222324252627282930313233343536373839404142434445func (rf *Raft) advanceCommitIndexForLeader() {\tsort.Ints(rf.matchIdx)\tn := len(rf.matchIdx)\tnewCommitIndex := rf.matchIdx[n-(n/2+1)]\tif newCommitIndex > int(rf.commitIdx) { if rf.MatchLog(rf.curTerm, int64(newCommitIndex)) { PrintDebugLog(fmt.Sprintf("peer %d advance commit index %d at term %d", rf.me_, rf.commitIdx, rf.curTerm)) rf.commitIdx = int64(newCommitIndex) rf.applyCond.Signal() }\t}}//// Applier() Write the commited message to the applyCh channel// and update lastApplied//func (rf *Raft) Applier() {\tfor !rf.IsKilled() { rf.mu.Lock() for rf.lastApplied >= rf.commitIdx { PrintDebugLog("applier ...") rf.applyCond.Wait() } firstIndex, commitIndex, lastApplied := rf.logs.GetFirst().Index, rf.commitIdx, rf.lastApplied entries := make([]*pb.Entry, commitIndex-lastApplied) copy(entries, rf.logs.GetRange(lastApplied+1-int64(firstIndex), commitIndex+1-int64(firstIndex))) rf.mu.Unlock() PrintDebugLog(fmt.Sprintf("%d, applies entries %d-%d in term %d", rf.me_, rf.lastApplied, commitIndex, rf.curTerm)) for _, entry := range entries { rf.applyCh <- &pb.ApplyMsg{ CommandValid: true, Command: entry.Data, CommandTerm: int64(entry.Term), CommandIndex: int64(entry.Index), } } rf.mu.Lock() rf.lastApplied = int64(Max(int(rf.lastApplied), int(commitIndex))) rf.mu.Unlock()\t}} Raft快照实现分析下面是日志快照的RPC定义 1234567891011121314message InstallSnapshotRequest { int64 term = 1; int64 leader_id = 2; int64 last_included_index = 3; int64 last_included_term = 4; bytes data = 5;}message InstallSnapshotResponse { int64 term = 1; }rpc Snapshot (InstallSnapshotRequest) returns (InstallSnapshotResponse) {} InstallSnapshotRequest中term代表当前发送快照的Leader的任期,Follower将它与自己的任期号来决定是否要接收这个快照。leader_id是当前leader的id,这样客户端访问到Follower节点之后也能快速知道Leader信息。last_included_index和last_included_term还有data可以参见我们第三章图中的介绍,它们记录了打完快照之后第一条日志的索引号和任期号,以及状态机序列化之后的数据。 什么时间点Raft会打快照呢? 我们知道日志条目过多了,我们就需要打快照。在eraft中就是计算当前level中的日志条目s.Rf.GetLogCount()来打快照的,打快照的入口函数是takeSnapshot(index int),传入了当前applied日志的id,然后将状态机的数据序列化,调用Raft层的Snapshot函数。 这个函数通过EraseBeforeWithDel做了删除日志的操作,然后PersisSnapshot将快照中状态数据缓存到了存储引擎中。 123456789101112131415161718192021//// take a snapshot//func (rf *Raft) Snapshot(index int, snapshot []byte) {\trf.mu.Lock()\tdefer rf.mu.Unlock()\trf.isSnapshoting = true\tsnapshotIndex := rf.logs.GetFirstLogId()\tif index <= int(snapshotIndex) { rf.isSnapshoting = false PrintDebugLog("reject snapshot, current snapshotIndex is larger in cur term") return\t}\trf.logs.EraseBeforeWithDel(int64(index) - int64(snapshotIndex))\trf.logs.SetEntFirstData([]byte{}) // 第一个操作日志号设为空\tPrintDebugLog(fmt.Sprintf("del log entry before idx %d", index))\trf.isSnapshoting = false\trf.logs.PersisSnapshot(snapshot)} 什么时间点Leader会发送快照呢? 在复制的时候我们会判断到peer的prevLogIndex,如果比当前日志的第一条索引号还小,就说明Leader已经把这条日志打到快照中了,这里我们就要构造InstallSnapshotRequest调用Snapshot RPC将快照数据发送给Followr节点,在收到成功响应之后,我们会更新rf.matchIdx,rf.nextId为LastIncludedIndex和LastIncludedIndex + 1,更新到Follower节点复制进度。 123456789101112131415161718192021222324252627282930313233343536373839if prevLogIndex < uint64(rf.logs.GetFirst().Index) {\tfirstLog := rf.logs.GetFirst()\tsnapShotReq := &pb.InstallSnapshotRequest{ Term: rf.curTerm, LeaderId: int64(rf.me_), LastIncludedIndex: firstLog.Index, LastIncludedTerm: int64(firstLog.Term), Data: rf.ReadSnapshot(),\t}\trf.mu.RUnlock()\tPrintDebugLog(fmt.Sprintf("send snapshot to %s with %s ", peer.addr, snapShotReq.String()))\tsnapShotResp, err := (*peer.raftServiceCli).Snapshot(context.Background(), snapShotReq)\tif err != nil { PrintDebugLog(fmt.Sprintf("send snapshot to %s failed %v ", peer.addr, err.Error()))\t}\trf.mu.Lock()\tPrintDebugLog(fmt.Sprintf("send snapshot to %s with resp %s ", peer.addr, snapShotResp.String()))\tif snapShotResp != nil { if rf.role == NodeRoleLeader && rf.curTerm == snapShotReq.Term { if snapShotResp.Term > rf.curTerm { rf.SwitchRaftNodeRole(NodeRoleFollower) rf.curTerm = snapShotResp.Term rf.votedFor = -1 rf.PersistRaftState() } else { PrintDebugLog(fmt.Sprintf("set peer %d matchIdx %d ", peer.id, snapShotReq.LastIncludedIndex)) rf.matchIdx[peer.id] = int(snapShotReq.LastIncludedIndex) rf.nextIdx[peer.id] = int(snapShotReq.LastIncludedIndex) + 1 } }\t}\trf.mu.Unlock()} Follower这边操作就比较简单了,它会调用HandleInstallSnapshot处理快照数据,并把快照数据构造pb.ApplyMsg写到rf.applyCh,最后负责日志Apply的Goruntine会调用CondInstallSnapshot安装快照,最后在restoreSnapshot会将快照的data数据解析,让后写入自己的状态机。 Raft如何应对脑裂在第三章中我们,介绍了脑裂的场景,并且分过多数派选举协议可以避免脑裂的场景,使得分布式系统在网络分区的情况下也能保持正确性。 Raft是一种多数派选举的协议,现在我们就来看看它是如何应对脑裂的。 我们看到上图中的场景,一个五节点的系统被分成了两个区,C,D,E为一个区,A,B为一个区。这时候两个分区中都选出了各自的Leader,但是注意B是任期1的Leader,C是任期2 Leader。大家可能会有疑问为什么一定有一个任期更高的Leader,这其实也是多数派选举决定的,分区后,肯定会出现一个多数节点所在的分区,如果这个分区还没有Leader,那么肯定会触发选举。 之后如下图所示,C,D,E所在分区被写入了SET 8的操作,由于C,D,E有三个节点,超过5个节点的半数以上,所以SET 8这个操作被提交了。然后A,B分区被写入SET 3操作,但是由于它们是少数派,只有两个节点,所以SET 3这个操作写入它们的日志之后并不能被提交。 最后网络恢复了,B,A收到来自C的更高任期的心跳会秒变Follower并且会将之前没有提交的日志擦除,将Leader C发过来的新日志(带有SET 8操作)写到自己的日志中,这样整个系统仍然是一致的。"},{"title":"动手学习分布式-Raft论文解读 (Golang eraftkv 版)","path":"/2023/08/19/raftbasic/","content":"raft概览这一小节我们不深入Raft算法细节,而是带着大家概览一下Raft算法在一个实际的应用系统中的应用。 我们看到上图的系统,这是一个使用Raft算法实现的一个分布式KV系统。我们这个系统的设计目标是保证集群中所有节点状态一致,也就是每个节点中KV表(这里使用通俗的“表”的概念描述,实际这些数据会存储到一个存储引擎里面)里面的数据状态最终是一致的。 先不考虑故障的场景,我们来看看系统在正常的情况下是怎么运行的。 我们来分析一下Put操作经过这个系统的流程,首先客户端会将Put请求发送给当前Raft集群中的Leader节点对应的K/V应用层。这个操作会被Leader包装成一个操作给Raft层,Raft对这个Put请求生成一条日志存储到自己的日志序列中,同时会把这的操作日志,复制给集群中的Follower节点,当集群中的半数以上节点都复制这个日志并返回响应之后,Leader会提交这条日志,并应用这条日志,写入数据到KV表,并通知应用层。这个操作成功执行,这时候K/V层会响应客户端,同时Leader会把Commit信息在下一次复制请求带给Follwer,Follower也会应用这条日志,写入数据到KV表中,最终集群中所有节点的状态一致,整个系统的运行的时序下图所示。 这就是应用Raft实现一个能保证一致性状态系统的例子,乍一看,像是很简单。但是当你深入到算法细节里面的时候,这个系统就简单了。例如日志复制的时候会有很多约束条件来保证提交日志的一致性,以及故障的时候如何正确的选出下一个leader?多次故障之后,日志状态一致性如何能够安全的保证?当然,这些细节也是我们后续分析的重点,我们会结合具体代码,尽量简单,让你系统的理解raft在处理这些问题时候的解决办法。 分布式系统中的脑裂在我们介绍Raft算法之前我们先来看一下分布式系统的脑裂的问题,脑裂字面上是大脑裂开的意思,大脑是人体的控制中心,如果裂开了,那么整个系统就会出现紊乱。 对应到我们分布式系统里面,一般就是集群中的节点由于网络故障或者其他故障被划分成不同的分区,这时候系统中不同分区由于无法通信会出现状态不一致的情况,如果系统没有考虑处理这种情况。那么当网络再恢复的时候,系统也就没法再保证正确性了。 我们看到上图,这就是分布式系统出现网络分区的情形。系统里面有A-E五个节点,由于故障, A,B节点和C,D,E节点被划分到了各自的网络分区里面。绿色的圆形代表两个客户端,如果它们向不同的分区节点写入数据,那么系统能保证分区恢复后状态一致吗?Raft算法解决了这个问题,在后续下一节讨论怎么解决的。 多数派协议Raft论文中提到的半数票决(Majority Vote) ,也叫做多数派协议。是解决脑裂问题的关键,首先我们来解释一下半数票决是怎么做的,假设分布式系统中有2*f + 1个服务器,系统在做决策的时候需要系统中半数节点以上投票同意,也就是必须要f + 1个服务器都要活着,系统才能正常工作。那么这个系统最多可以接受f个服务器出现故障。 Raft正是应用了半数票决来解决脑裂问题,假设我们有奇数个节点(3,5…2n+1)个节点组成的分布式系统,其中一旦出现网络分区,那么必然会有一个分区存在半数节点以上的,那么过半票决这个策略就能正常运行,这样系统就不会因此不可用,多数派票决正是解决脑裂问题的关键。 Raft的日志结构前面我们概览了整个Raft算法的流程,请求经过系统最开始就要写Raft算法层的日志了,那这个日志的结构是什么样的呢?接下来我们就来看看Raft日志的结构: 上图表示一个raft节点日志的结构,日志主要用来记录用户的操作,我们会对这些操作进行编号。图中每一条(1~8)日志都有独立的编号log index。然后日志中还有任期号,如图中1-3号日志为任期1的日志。这个任期号是用来表示这个日志的选举状态的,我们后面解释它的作用。 然后每个日志都有一个操作,这个操作是对状态机的操作,如1号日志我们的操作是把x设置成3。 Raft的状态转换Raft协议的工作模式是一个Leader和多个Follower节点的模式。在Raft协议中,每个节点都维护了一个状态机。该状态机有3中状态:Leader、Follower和Candidate。系统起来后的任意一个时间点,集群中的任何节点都处于这三个状态中的一个。 每个节点一启动就会进入Follower状态,然后当选举超时时间到达后,它会转换成Candidate状态,这时候就开始选举了,当它获得半数节点以上的选票之后,Candidate状态的节点会转变成Leader。或者当Candidate状态的节点发现了一个新的leader或者收到新任期的消息,它会变成Follower, Leader发现更高任期的消息也会变成Follower,系统正常运行中会一直在这三种状态之间转换。 Leader选举在说明Leader选举流程之前,我们先来解释下Raft协议中与选举相关的两个超时时间:选举超时(election timeout)时间和心跳超时(heartbeat timeout)时间。当Follower节点在选举超时时间之内没有收到来自Leader的心跳消息之后,就会切换成Candidate开始新一轮的选举。选举超时时间一般设置为150ms ~ 300ms的随机数,这里随机的目的是为了避免节点同时发起选票到时有相同票数的节点,从而选举失败重新选举的情况,增加这个时间的随机性有利于更快的选出Leader。心跳超时时间则是指Leader向Follower节点发送的心跳消息间隔时间。我们来梳理一下选举的流程1.集群初始化,所有节点都会变成Follower状态。2.经过一段时间后(选举超时时间到达)Follower还没收到来自Leader的心跳消息,那么它会开始切换为Candidate开始发起选举。3.变成Candidate之后节点的任期号也会增加,同时给自己投一票,然后并行的向集群中的其他节点发送请求投票(RequestVoteRPC)消息。4.Candidate状态的节点赢得了半数以上选票成为Leader节点,之后散播心跳给集群中其他节点,成功选举成Leader以上是大致的流程,但是有两个细节点需要注意:在等待投票的过程中,Candidate可能会收到来自另外一个节点成为了Leader之后发送的心跳消息,如果这个消息中Leader的任期号 (term)大于Candidate当前记录的任期号,Candidate会认为这个Leader是合法的,它会装换为Follower节点。如果这个心跳消息的任期号小于Candidate当前的任期号,Candidate将会拒绝这个消息,继续保持当前状态另一种可能的结果是Candidate即没有赢得选举也没有输:也就是集群中多个Follower节点同时成为了Candidate,这种情况叫做选票分裂,没有任何Candidate节点获得大多数选票,当这种情况发生的时候,每个Candidate会重新设置一个随机的选举超时时间,然后继续选举,由于选举时间是随机的,下一轮选举很大概率会有一个节点获得多数选票会成为新的Leader。 日志复制我们假设集群中有A,B,C三个节点,其中节点A为Leader,此时客户端发送了一个操作到集群:1.当收到客户端请求,节点A将会更新操作记录到本地的Log中。2.节点A会向集群中其他节点发送AppendEntries消息,消息中记录了Leader节点最近收到的客户端提交请求的日志信息(还没有同步给Follower的部分)。3.B,C收到来自A的AppendEntries消息时,会将操作记录到本地的Log中,并返回通知Leader成功追加日志的消息。4.当A收到半数节点以上成功追加日志响应消息时,会认为集群中有半数节点完成了日志同步操作,它会将日志提交的committed号更新。5.Leader向客户端返回响应,并且在下一次发送AppendEntires消息的时候把commit号通知给Follower6.Follower收到消息之后,也会更新自己本地的commit号。注意:上述流程是假设正常的情形下的流程,如果Follower宕机了或者运行很慢或者Leader发过来的消息某次丢失了, Leader上记录了到某个Follower同步的日志进度,如果追加请求没成功,会不停的重新发送消息,直到所有Follower都存储了所有的日志条目。 日志合并和快照发送Raft论文在第7章介绍了日志压缩合并相关要点,按我们前面介绍的Raft复制相关内容,只要客户端有新的操作过来,就会写我们的日志文件,并且Leader同步给Follower之后,集群中所有节点的日志量都会随着操作的变多一直增长。eraft中使用leveldb存储了Raft日志条目,如果日志量不断增长,那么我们引擎去访问日志的耗时就会不断增长,如果有节点挂了重新加入集群,我们需要给它追加大量的日志,这个操作会非常消耗IO资源,影响系统性能。 那么如何解决这个问题呢,首先我们再分析下日志结构: 我们看到这里的操作,都是对x,y的操作,每次日志提交完,我们都会应用日志到状态机中。这里我们会发现其实我们并没有必要存储每一个日志条目,我们只关心一致的状态,也就是已经提交的日志让状态机最终到达一种什么样的状态。那么在图中,我们就可以把1,2,3,4,5 号日志的操作之后状态机记录下来,也就是x = 0, y = 9,并且记录这个状态之后第一条日制的信息,然后1~5号日志可以被安全的删除了。 以上的整个操作在Raft里面被叫做快照(snapshot),Raft会定期的打快照吧历史的状态记录下来。当集群中有某个节点挂了,并且日志完全无法找回之后,集群中Leader节点首先会发送快照给这个挂掉之后新加入的节点,并且用一个InstallSnapshot的RPC发送快照数据给它,对端安装快照数据之后,会继续同步增量的日志,这样新的节点能快速的恢复状态。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-Go语言基础知识 (Golang eraftkv 版)","path":"/2023/08/19/1-2-gobasic/","content":"Go语言优点首先Go是一门开源的语言,他出生名门Google,社区有强有力的顶级技术人员支撑。它最开始的设计者是Rob Pike,Robert Griesemer,Ken Thompson。 你可以在这个页面找到他们的资料 https://golang.design/history/ 其中Ken Thompson是UNIX系统的发明者。 Go语言对于初学者很友好,很容易在短时间内快速上手,你可以通过这个网站上提供的代码示例快速的入门Go语言:https://gobyexample.com。 作为多核时代的语言,Go语言在设计之初就对并发编程有很多内置的支持,提供的及其健壮的相关标准库。利用它,我们可以快速的编写出高并发的应用程序。 最后,国内外很多大厂都在使用Go语言,它有一个强大的社区,全世界优秀的技术人员开发了丰富的工具生态,有很多脚手架帮你快速的构建应用程序。 切片切片是一个数组的一段,它基于数组构建,并提供了更多丰富的数据操作功能,提供开发者灵活和便利的操作数组结构。 在Go语言内部,切片只是对底层数组数据结构的引用。接下来我们将熟悉如何创建和使用切片,并了解它底层是怎么工作的。 使用字面量列表创建切片这种方式类似c++里面的初始化列表 12var s = []int{3, 5, 7, 9, 11, 13, 17} 这种方式创建切片的时候,它首先会创建一个数组,然后返回对该数组切片的引用。 从已有数组创建切片 12// Obtaining a slice from an array `a` a[low:high] 这里我们对a数组进行切割得到切片,这个操作得到的结果是索引[low, high)的元素,生成的切片包括索引低,但是不包括索引高之间的所有数组元素。你可以运行下面的示例,理解这个切片操作 12345678910111213141516171819202122package mainimport "fmt"func main() { var a = [5]string{"Alpha", "Beta", "Gamma", "Delta", "Epsilon"} // Creating a slice from the array var s []string = a[1:4] fmt.Println("Array a = ", a) fmt.Println("Slice s = ", s) } // output Array a = [Alpha Beta Gamma Delta Epsilon] Slice s = [Beta Gamma Delta] 修改切片中的元素由于切片是引用类型,它指向了底层的数组。所以当我们使用切片引用去修改数组中对应的元素的时候,引用相同数组的其他切片对象也会看到这个修改结果。 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566package mainimport "fmt"func main() {\ta := [7]string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}\tslice1 := a[1:]\tslice2 := a[3:]\tfmt.Println("------- Before Modifications -------")\tfmt.Println("a = ", a)\tfmt.Println("slice1 = ", slice1)\tfmt.Println("slice2 = ", slice2)\tslice1[0] = "TUE"\tslice1[1] = "WED"\tslice1[2] = "THU"\tslice2[1] = "FRIDAY"\tfmt.Println(" -------- After Modifications --------")\tfmt.Println("a = ", a)\tfmt.Println("slice1 = ", slice1)\tfmt.Println("slice2 = ", slice2)}// Output------- Before Modifications -------a = [Mon Tue Wed Thu Fri Sat Sun]slice1 = [Tue Wed Thu Fri Sat Sun]slice2 = [Thu Fri Sat Sun]type: post-------- After Modifications --------a = [Mon TUE WED THU FRIDAY Sat Sun]slice1 = [TUE WED THU FRIDAY Sat Sun]slice2 = [THU FRIDAY Sat Sun] 例如上面这个示例,slice2的修改操作在slice1中是能被看到的, slice1的修改在slice2也能被看到。 切片底层结构一个切片由三个部分组成,如图 1.一个指向底层数组的指针Ptr2.切片所包含数组段的长度Len3.切片的容量Cap 我们看一个具体的切片结构的底层示例: 12var a = [6]int{10, 20, 30, 40, 50, 60} var s = [1:4] s在Go内部是这样表示的: 一个切片的长度和容量我们是可以通过len(), cap()函数获取的,例如我们可以通过下面的方式获取s的长度和容量。 1234567891011121314package mainimport "fmt"func main() {\ta := [6]int{10, 20, 30, 40, 50, 60}\ts := a[1:4]\tfmt.Printf("s = %v, len = %d, cap = %d ", s, len(s), cap(s))}// outputs = [20 30 40], len = 3, cap = 5 GoruntineGoruntine是由Go运行时所管理的一个轻量级的线程,一个Go程序中的Goroutines在相同的地址空间中运行,因此对共享内存的访问必须同步。我们运行一个简单地示例来看看: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354package main import (\t"fmt"\t"time")func say(s string) {\tfor i := 0; i < 5; i++ { time.Sleep(100 * time.Millisecond) fmt.Println(s)\t}}func main() {\tgo say("world")\tsay("hello")}// outputhelloworldhelloworldhelloworldworldhellohello 我们可以看到主线程中say(“hello”)和goruntine的say(“world”)在交替的输出,它们在同时的运行,在其他语言做这个事情先要创建线程,然后绑定相关的执行函数,而Go语言直接把并发设计到了编译器语言支持层面,用go关键字就可以轻松地创建轻量级的线程。 调度器调度器的设计决策在解释Go语言调度器之前,我们先一个例子: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152func main() { var wg sync.WaitGroup wg.Add(11) for i := 0; i <= 10; i++ { go func(i int) { defer wg.Done() fmt.Printf("loop i is - %d ", i) }(i) } wg.Wait() fmt.Println("Hello, Welcome to Go")}// outputloop i is - 0loop i is - 4loop i is - 1loop i is - 2loop i is - 3loop i is - 8loop i is - 7loop i is - 9loop i is - 5loop i is - 10loop i is - 6Hello, Welcome to Go 这个程序创建了11个Goruntine,对于这个输出结果,我们可能会问: 这11个Goruntine是如何并行运行的?它们运行有没有特别的顺序?要回答这两个问题,我们需要思考: 如何将多个Goruntine分配到我们有限个数的CPU核心的机器上运行的多个OS线程上?为了公平的让这些Goruntine获得CPU资源,这些Goruntine应该以什么的顺序在这多个CPU核心上运行? Go调度器的模型介绍 为了解决上面的调度问题,Go语言设计了图d中的调度器模型:Go语言使用协程调度被称为GMP模型,其中:G:代表一个Goruntine,是我们使用go关键字创建出来的可以并行运行的代码块。M:代表一个操作系统线程P:代表逻辑处理器我们看到上图中由两个P处理核心运行时调度正在调度执行8个Goruntine。图中我们还看到了有两种类型的队列:本地队列(Local Run Queue):存放等待运行的G,这个队列存储的数量有限,一般不能超过256个,当用户新建Goruntine时,如果这个队列满了,Go运行时会将一半的G移动到全局队列中。全局队列(Global Queue):存放等待运行的G,其他的本地队列满了,会移动G过来。 Go调度器的工作流程GMP调度器调度Goruntine执行的大致逻辑如下:1.线程想要调度G执行就必须要先与某个P关联2.然后从P的本地队列中获取G3.如果本地队列中没有可运行的G了,M就会从全局队列拿一批G放到本地的P队列4.如果全局队列也没有可以运行的G的时候,M会随机的从其他的P的本地队列偷一半G任务放到自己(P)的本地队列中。5.拿到可以运行的G之后,M运行G, G执行完成之后,M会运行下一个G,一直重复执行下去。 跟踪Go调度器工作流程Go提供了GODEBUG工具可以跟踪调度器调度过程上述模型实时状态我们使用下面的程序示例来追踪一下Go调度器是如何调度执行程序中的Goruntine的 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152package mainimport (\t"sync"\t"time")func main() {\tvar wg sync.WaitGroup\twg.Add(10)\tfor i := 0; i < 10; i++ { go work(&wg)\t}\twg.Wait()\t// Wait to see the global run queue deplete.\ttime.Sleep(3 * time.Second)}func work(wg *sync.WaitGroup) {\ttime.Sleep(time.Second)\tvar counter int\tfor i := 0; i < 1e10; i++ { counter++\t}\twg.Done()} 代码中创建了十个Goruntine,每个Goruntine都在做循环加counter值的操作。我们编译上述例子 1go build go_demo.go 然后使用GODEBUG工具来分析观察这些Goruntine的调度情况执行命令: 1GOMAXPROCS=2 GODEBUG=schedtrace=1000 ./go_demo 可以得到看到如下的输出,当然机器不一样可能输出会不一样,以下是在我的笔记本上输出的, 我的本子有四个核心,下面的指令我指定了创建两个逻辑处理核心 12345678910111213141516171819202122232425colin@book % GOMAXPROCS=2 GODEBUG=schedtrace=1000 ./go_demoSCHED 0ms: gomaxprocs=2 idleprocs=1 threads=4 spinningthreads=0 idlethreads=1 runqueue=0 [0 0]SCHED 1009ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=0 [8 0]SCHED 2009ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=2 [3 3]SCHED 3016ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=2 [3 3]SCHED 4017ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=7 [0 1]SCHED 5027ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=5 [0 3]SCHED 6031ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=3 [2 3]SCHED 7037ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=5 [1 2]SCHED 8045ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=4 [2 2]SCHED 9052ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=8 [0 0]SCHED 10065ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=4 [0 4]SCHED 11069ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 idlethreads=1 runqueue=4 [1 3] 输出信息的含义如下 我们选取第二条分析1009ms:这个是从程序启动到这个trace采集度过的时间gomaxprocs=2:配置的逻辑处理核心,我们启动命令中写的idleprocs=0:空闲逻辑核心的数量threads=4:运行时正在管理的线程数量idlethreads=1:空闲线程的数量,这里有1个空闲,3个正在运行中runqueue=0:全局运行队列中Goruntine的数量[8 0]:表示逻辑核心上本地队列中排队中Goruntine的数量,我们看到有一个核心上面有8个Goruntine,另一个有0个,当然我们看后面的trace后续这两个核心的本地队列上都有任务了 了解更多调度器原理Go语言是开源的,你可以在这个文件里面找到调度器的主要逻辑,https://github.com/golang/go/blob/master/src/runtime/proc.go ,目前最新的代码有6千多行了,值得去读一读,弄懂个大概也是很有收获的。 内存管理内存管理架构概览Go最早的内存分配发源自tcmalloc,它比普通的malloc性能要好,随着Go语言的不断演进,当前的内存管理性能已经非常好了。我们首先通过图e来看Go内存管理架构的概览 图中涉及到的主要结构如下: resident set (常驻集)虚拟内存划分为每个8kb的页面,由一个全局的mheap对象管理 mheap这里管理了Go语言动态存储的数据结构(即编译时无法计算大小的任何数据),这是最大的内存块,也是Go垃圾收集发生的地方。mheap里面有管理了不同结构的页面,主要结构如下 mspanmspan是mheap中管理内存页的最基本结构,它底层结构是一个双向链表,span size class,以及span中的页面数量。和tcmalloc的操作一样,Go将内存页面按大小划分为67个不同类的块,8b ~ 32kb不等,如图所示 mcentralmcentral将相同大小span类组成分组,每个mcentral中包含两个mspan:empty:一个双向的span链表,其中没有空闲的对象或者span缓存在mcache中non-empty:有空闲对象的双链接列表,当mcentral请求新的span的时候,会从non-empty移动到empty list当mcentral没哟任何空闲的span是,它会向mheap请求一个新的运行页面 arena堆内存在分配的虚拟内存中根据需要进行扩大和收缩,当需要更多的内存时,mheap从虚拟内存中拉大小为64MB的内存块出来,这个被叫做arean。 mcachemcache是提供给P(逻辑处理核心)的内存缓存,用来存储小对象(也就是大小<= 32kb)。这有点类似于线程栈,但是它其实是堆的一部分,用于动态数据。mcache中包含了scan和noscan类型所有大小的mspan对象Goroutine们可以从mcache中获取内存,不需要加人任何锁,因为P在同一时刻只能调度一个G,因此这是很高效的, mcache在需要的时候会向mcentral中获取新的span Stack这里是管理堆栈的内存区域,每个Goroutine都有一个堆栈,这里用来存储静态数据,包括函数框架,静态的结构,原语值和指向动态数据机构的指针。内存分配流程概要分配器会按对象大小分配 Tiny对于Tiny (超小, size < 16 B)对象:直接使用mcache的tiny分配器分配大小小于16字节的对象,这是非常高效的。 Small对于Small (小型,size 16B ~ 32KB)对象:大小在16字节到32 k字节的对象分配时,在运行中G的P上的mcache里面获取。在Tiny和Small分配中,如果mspan列表为空,没有页面用来分配了,分配器将从mheap获取一系列页面用于mspan。 Large对于Large (大型,size > 32KB)对象,直接分配在mheap相应大小的类上。如果mheap为空或者没有足够大的页面来分配了,那么它会从操作系统的进程虚拟内存分配一组新的页面过来(至少1MB)。 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"},{"title":"动手学习分布式-体验分布式系统 (Golang eraftkv 版)","path":"/2023/08/19/hello-world/","content":"体验分布式KV存储系统 eraftkv作为系列的开篇,我们将带着大家从顶层体验下分布式系统,我们将忽略系统实现的细节点,直观感受一个分布式系统所具备的能力。本书还是第一个版本,我们还在不断的校对完善中,评论区是放开的,欢迎大家讨论参与本书的优化。 安装go编译环境首先我们需要在电脑上安装好go语言编译器,你可以在 https://go.dev/dl/官网下载对应你系统版本的安装包。 按指示https://go.dev/doc/install安装golang编译环境。 编译构建 eraftkv执行以下命令(确保你的机器上安装了Go语言编译器以及git, make等基础工具)编译很简单,下载代码之后,进入根目录直接 make 123git clone https://github.com/eraftorg/eraft.git -b mit6824_lab cd eraft make 架构概览在我们运行eraftkv之前我们先概览以下它的架构,以便于我们对于接下来运行的程序功能有清晰的认识。 eraftkv作为一个分布式kv存储系统,其中包含的服务角色以及一些概念需要提前给大家介绍一下。 系统中的一些概念 bucket -它是集群做数据管理的逻辑单元,一个分组的ShardServer服务可以负责多个bucket的数据。 config table -集群配置表,它主要维护了集群服务分组与bucket的映射关系,客户端访问集群数据之前需要先到这个表查询要访问bucket所在的服务分组列表。 系统中有三种角色 Client -客户端,它是用户使用我们这个分布式的接入端。 ConfigServer -配置服务器,它是系统的配置管理中心,它存储了集群的路由配置信息。 ShardServer -数据服务器,它是系统中实际存储用户数据的服务器。 请求处理流程看了架构概览,大家可能有觉得概念有些模糊,我们接下来就分析一个具体的请求示例,看看这个分布式系统是如何工作起来的。例如现在客户端来了一个put testkey testvalue的请求: 1.客户端程序启动运行的时候会从ConfigServer获取到最新的路由信息表。 2.put testkey testvalue操作首先会计算testkey的CRC32哈希值对集群中的bucket数取模算到这个key命中了哪个bucket。 3.之后客户端将put请求内容打包成一个rpc请求包,发送到集群配置表中负责这个bucket的ShardServer服务分组。 4.Leader ShardServer服务收到这个rpc请求后并不是直接写入存储引擎,而是构造一个raft提案,提交到raft状态机,当分组中半数节点以上的ShardServer都同意这个操作后(如果你没有接触过分布式一致性算法,这里可以先不用理解细节,我们会在Raft论文解读一章详细解读为什么要这样做),作为Leader的ShardServer服务器才能返回给客户端写入成功。 让系统跑起来,体验它!前面我们构建完eraftkv之后,在eraft目录有一个output文件夹,里面有我们需要运行的bin文件。 12345678 colin@B-M1-0045 eraft % ls -l output total 124120-rwxr-xr-x 1 colin staff 12119296 5 25 20:40 bench_cli-rwxr-xr-x 1 colin staff 12114784 5 25 20:40 cfgcli-rwxr-xr-x 1 colin staff 13578848 5 25 20:40 cfgserver-rwxr-xr-x 1 colin staff 12127328 5 25 20:40 shardcli-rwxr-xr-x 1 colin staff 13600368 5 25 20:40 shardserver 可执行文件介绍1.cfgserverConfigServer的可执行文件,系统的配置管理中心,需要首先启动 2.cfgcliConfigServer的客户端工具,它和ConfigServer交互用来管理集群的配置 3.shardserverShardServer的可执行文件,它负责存储用户的数据 4.shardcliShardServer的客户端工具,用户可以使用它向集群中写入数据 5.bench_cli系统的性能测试工具 启动服务1.启动配置服务器分组 123./cfgserver 0 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090./cfgserver 1 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090./cfgserver 2 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 2.初始化集群配置 1234./cfgcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 join 1 127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090./cfgcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 join 2 127.0.0.1:7088,127.0.0.1:7089,127.0.0.1:7090./cfgcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 move 0-4 1./cfgcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 move 5-9 2 3.启动数据服务器分组 1234567./shardserver 0 1 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090./shardserver 1 1 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090./shardserver 2 1 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:6088,127.0.0.1:6089,127.0.0.1:6090./shardserver 0 2 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:7088,127.0.0.1:7089,127.0.0.1:7090./shardserver 1 2 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 127.0.0.1:7088,127.0.0.1:7089,127.0.0.1:7090./shardserver 2 2 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090127.0.0.1:7088,127.0.0.1:7089,127.0.0.1:7090 4.读写数据 12./shardcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 put testkey testvalue ./shardcli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 get testkey 5.运行基准测试 1./bench_cli 127.0.0.1:8088,127.0.0.1:8089,127.0.0.1:8090 100 put 捐赠整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。 下载 PDF 版本 本站总访问量次"}] \ No newline at end of file