0. 分布式共识算法Raft

一个更易理解的共识算法(论文原文为In Search of an Understandable Consensus Algorithm)

0.1 什么是分布式共识问题?

  • 分布式环境下系统集群存在很多节点,每个节点都可提出议案,我们需要:
    • 对多个节点提出的议案作裁决并得到一个一致的结论;
    • 让每个节点都感知到最终结论,从而使集群整体状态保持一致
    • 允许一部分节点宕机后集群仍可正常工作,先前通过的议案仍可访问,集群状态仍维持一致

0.2 Raft算法的前世今生

  • 在 Raft 算法提出之前,学术界早已有 Paxos 算法 (莱斯利·兰伯特在1990年提出)来解决分布式共识问题。
  • 到了 2006 年,Google 在两篇经典论文 Bigtable:A Distributed Storage System for Structured DataThe Chubby lock service for loosely-coupled distributed systems 中提及用 Paxos 算法实现了一个分布式锁服务 Chubby,于是 Paxos 算法开始进入工业界领域被广大技术人员熟知。
  • 在分布式共识算法领域,Paxos 算法可以说是宗师级角色,统治该领域十余年,大多数共识算法都是在其基础上进行改进和优化,Raft 算法也不例外。正因如此,Chubby 的作者 Mike Burrows 曾说过:只有一种分布式共识算法,那就是 Paxos,其他共识算法都只是 Paxos 算法的不完整版
  • 即便是大名鼎鼎的Paxos算法,也存在一些问题,Raft 算法的作者 Diego Ongaro 在研究 Paxos 算法时,就深受其复杂性困扰。他觉得 Paxos 算法是一门极其难懂的算法,其工程化实践更是困难重重,原始的 Paxos 算法不经过一番修改很难应用于工程之中,而修改后的 Paxos 算法又很难保证其正确性。他总结出 Paxos 算法有两个大问题:
    1. 非常难于理解,Diego Ongaro花了一年时间才掌握Paxos算法
    2. 没有给工程实现提供一个好的基础,而证明这点的最好论据就是:Paxos 算法自首次提出以来已过去十多年了,开源社区几乎没有一个被广泛认可的工程实现,很多 Paxos 算法的实现都是对其完整版的近似。
  • 正因如此,Diego Ongaro打算开发一门新的共识算法,而这门新的共识算法就是著名的Raft算法

1. Raft概述

  • Raft 是用来管理复制日志(replicated log)的一致性协议。它跟 Paxos 作用相同,效率也相当,但是它的组织结构跟 Paxos 不同.
  • 目的是实现集群的高可用性,让集群中的每个节点都可用,即具备完整的正确的日志.
  • 相比于Paxos,Raft最大的特性就是易于理解(Understandable)。为了达到这个目标,
    Raft主要做了两方面的事情:
    • 问题分解:把共识算法分为三个子问题,分别是领导者选举(leader election)日志
      复制(log replication)安全性(safety).
    • 状态简化:对算法做出一些限制,减少状态数量和可能产生的变动。
    • 而Raft算法的强大也是被证明了的,论文中对43个大学生做了个实验,让他们同时学习Paxos和Raft,结果显示,其中有33个
      人学习Raft的成绩好于学习Paxos的成绩。

2. 复制状态机

  • 在具体介绍Raft之前,我们要先了解一下复制状态机(Replicatedstatemachine)的概念。
  • 相同的初始状态+相同的输入=相同的结束状态
  • 多个节点上,从相同的初始状态开始,执行相同的一串命令,产生相同的最终状态
  • 在Raft中,leader将客户端请求(command)封装到一个个日志实体(log entry)中,再把这些log entries复制到所有follower节点,然后所有节点一起把log应用到自己的状态机(state Machine)上,根据复制状态机的理论,大家的结束状态肯定是一致的。
  • 这样,client无论查询那个节点的状态机,查询到的结果都是一致的
  • 可以说,我们使用共识算法,就是为了实现复制状态机。一个分布式场景下的各节点间,就是通过共识算法来保证命令序列的一致,从而始终保持它们的状态一致,从而实现高可用的。(投票选主是一种特殊的命令)
  • 这里稍微拓展一点,复制状态机的功能可以更加强大。比如数据库两个副本一个采用行存储的数据结构存储数据,另一个采用列存储,只要它们初始数据相同,并持续发给他们相同的命令,那么同一时刻从两个副本中读取到的结果也是一样的,这就是一种HTAP(Hybrid Transaction and Analytical Process,混合事务和分析处理)的实现方法(比如TiDB就是利用这种方式来巧妙的实现自己的HTAP特性的)。

3. 状态简化

  • 在任何时刻,每一个服务器节点都处于leader,follower或candidate这三个状态之一。
  • 相比于Paxos,这一点就极大简化了算法的实现,因为Raft只需考虑状态的切换,而不用像Paxos那样考虑状态之间的共存和互相影响

状态描述:

  1. 所有节点开始的时候都处于Follower状态,此时,第一个认识到集群中没有Leader的节点会把自己变成candidate
  2. 节点处于Candidate状态时,会发生一次或多次选举,最后根据选举结果决定自己时切换回Follower状态还是切换到Leader状态
  3. 如果切换到Leader状态,就会在Leader状态为客户端提供服务
  4. 如果节点在Leader状态的任期结束,或者是节点宕机亦或者其他的问题,就会切换回Follower状态,并开始下一个循环
  • Raft把时间分割成任意长度的任期(term),任期用连续的整数标记。
  • 每一段任期从一次选举开始。在某些情况下,一次选举无法选出leader(比如两个节点收到了相同的票数,如下图\(t_3\)),在这种情况下,这一任期会以没有leader结束;一个新的任期(包含一次新的选举)会很快重新开始。Raft保证在任意一个任期内,最多只有一个leader。

任期的机制可以非常明确地表示集群的状态,而通过任期的比较,也可以确立一台服务器历史的状态

比如我们可以通过查看一台服务器是否具有在\(t_2\)任期内的日志,判断该服务器在\(t_2\)任期内是否宕机

  • Raft算法中服务器节点之间使用RPC进行通信,并且Raft中只有两种主要的RPC:
  • RequestVoteRPC(请求投票):由candidate在选举期间发起。
  • AppendEntriesRPC(追加条目):由leader发起,用来复制日志和提供一种心跳机制。
  • 服务器之间通信的时候会交换当前任期号;如果一个服务器上的当前任期号比其他的小,该服务器会将自己的任期号更新为较大的那个值。
  • 如果一个candidate或者leader发现自己的任期号过期了,它会立即回到follower状态。
  • 如果一个节点接收到一个包含过期的任期号的请求,它会直接拒绝这个请求。

相比其他共识算法十多种的通信类型,Raft算法的精简设计,极大减少了理解和实现的成本

4. 领导者选举

信息解读:

  1. \(S_5\)是一个Leader,它向其它所有server发送心跳消息,来维持自己的地位

  2. 如果一个Server在它的进度条读完之前仍没有收到\(S_5\)的心跳消息的话,该server就会认为系统中没有可用的leader,然后开始选举.

  3. 开始一个选举过程后,follower先增加自己的当前任期号,并转换到candidate状态。然后投票给自己,并且并行地向集群中的其他服务器节点发送投票请求(RequestVote RPC)。

  4. 最终会有三种结果:

    • 它获得超过半数选票赢得了选举-> 成为Leader并开始发送心跳(告知集群中存在Leader),结束投票选举阶段

    • 其他节点赢得了选举->收到新leader的心跳后,如果新leader的任期号不小于自己当前的任期号(任期号大概率相等),那么就从candidate回到follower状态。

    • 一段时间之后没有任何获胜者->每个candidate都在一个自己的随机选举超时时间后增加任期号开始新一轮投票。

  5. 为什么会没有获胜者?

    • 比如有多个follower同时成为candidate,得票太过分散,没有任何一个candidate得票超过半数,进入下一轮选举.
    • “注意:当前选举阶段并没有产生任何Leader”这个结论不需要集群所有节点对此产生共识,而是通过每个candidate都在等待一个随机选举超时时间之后,默认去进入下一个选举阶段。
  6. 论文中给出的随机选举超时时间为 150~300ms,这意味着如果candidate没有收到超过半数的选票,也没有收到新Leader的心跳,那么他就会在150到300毫秒之间随机选择一个时间再次发起选举.

//请求投票RPC Request,由candidate发起
type RequestVoteRequest struct{
    term			int		//自己当前的任期号	所有节点都带有任期号,因为raft的节点要通过任期号来确定自身的状态,以及判断接不接收这个RPC.
    candidateld 	int 	//自己的ID			Follower需要知道自己投票给谁
    lastLogIndex 	int 	//自己最后一个日志号
    lastLogTerm 	int 	//自己最后一个日志的任期
}
//请求投票RPC Response,由Follower回复candidate
type RequestVoteResponse struct{
	term		int		//自己当前任期号
	voteGranted bool 	//自己会不会投票给这个candidate
}

Follower的投票逻辑:

  1. 所有节点开始的时候都处于Follower状态,此时,第一个认识到集群中没有Leader的节点会把自己变成candidate

  2. 他会给自己的任期号加一并发请求投票request给其他follower。

  3. 收到一个requestVoteRequest之后会先校验这个candidate是否符合条件。

    • term是否比自己大?

    • 与Request的后两个字段相关,我们之后再进行讲解

  4. 确认无误后开始投票,没有成为candidate的follower节点,对于同一个任期,会按照先来先得的原则投出自己的选票。

  5. 为什么RequestVoteRPC中要有candidate最后一个日志的信息呢,安全性子问题中会给出进一步的说明。

5. Raft日志复制(重点)

  • Leader被选举出来后,开始为客户端请求提供服务。

  • 客户端怎么知道新leader是哪个节点呢?

    • 非常容易解决,Client仍然向老节点发送请求,此时,会有三种情况
      • 这个节点恰好是Leader
      • 这个节点是Follower,Follower可以通过心跳得知Leader的ID,据此告知Client该找哪个节点
      • 这个节点宕机了,此时Client会向其它任一节点发送请求,重复这个过程
    • 也有一些比如设置第三方节点的做法,但这里不做说明,有兴趣的同学可以去自行了解
  • Leader接收到客户端的指令后,会把指令作为一个新的条目追加到日志中去。

  • 一条日志中需要具有三个信息:

    • 状态机指令
    • leader的任期号(对于检测多个日志副本之间的不一致情况和判定节点状态,都有重要作用)
    • 日志号(日志索引,区分日志的前后关系)

    只有任期号和日志号一起看才能确定一个日志

    • Leader并行发送AppendEntries RPC给follower,让它们复制该条目。当该条目被超过半数的follower复制后,leader就可以在本地执行该指令并把结果返回客户端
    • 我们把本地执行指令,也就是leader应用日志与状态机这一步, 称作提交

  1. 在上图中,最上面的一行代表Leader,其余四行代表Follower,可以观察到,Follower节点的进度不一定是一致的

  2. 但是在这里,只要有三个节点(包括Leader在内)复制到了日志,Leader就可以提交了,在上图中可以提交的日志号到7

  3. 在此过程中,leader或follower随时都有崩溃或缓慢的可能性,Raft必须要在有宕机的情况下继续支持日志复制,并且保证每个副本日志顺序的一致(以保证复制状态机的实现)。具体有三种可能:

    1. 如果有follower因为某些原因没有给leader响应,那么leader会不断地重发追加条目请求(AppendEntries RPC),哪怕leader已经回复了客户端。
    2. 如果有follower崩溃后恢复,这时Raft追加条目的一致性检查生效,保证follower能按顺序恢复崩溃后的缺失的日志。
      • Raft的一致性检查:leader在每一个发往follower的追加条目RPC中,会放入前一个日志条目的索引位置和任期号,如果follower在它的日志中找不到前一个日志,那么它就会拒绝此日志,leader收到follower的拒绝后,会发送前一个日志条目,从而逐渐向前定位到follower第一个缺失的日志。
    3. 如果leader崩溃,那么崩溃的leader可能已经复制了日志到部分follower但还没有提交,而被选出的新leader又可能不具备这些日志,这样就有部分follower中的日志和新leader的日志不相同。

对于上述问题的第三点,我们以上图举例:

  • 可以发现,此时follower中的c和d比leader还多出两个日志,那么为什么leader没有多出的日志还可以当选leader呢?
  • 是因为c和d多出的日志还没有提交,也就不构成多数.
  • 在这七个节点的集群中,leader可以依靠a,b,e和自己的选票当选leader(当然f也可以投票,因为a,b,e,f的任期都小于等于leader)
  • 再看最后一个节点f(宕机的leader节点),它具有2,3任期的日志,别的节点都不具有,这意味着它在这两个任期内担任leader.
  • 但是它在2,3任期内的日志都没有正常复制到大多数节点,也就没有提交.
  • 这时,如果f恢复了,即便它在2,3任期的日志与leader不同,也不会产生冲突,因为Raft在这种情况下,leader通过强制follower复制它的日志来解决不一致的问题,这意味着follower中跟leader冲突的日志条目会被新leader的日志条目覆盖(因为没有提交,所以不违背外部一致性)。
  • 这样图中的c,d,e,f节点中与leader不同的日志,最终都会被覆盖掉.
  • 也有可能当前的leader宕机,这个时候a,c,d是有机会当上leader的,如果c,d当选leader,就可以把多出的日志复制给follower,来使自己多出的日志提交

总结一下:

  • 通过这种机制,leader在当权之后就不需要任何特殊的操作来使日志恢复到一致状态。
  • Leader只需要进行正常的操作,然后日志就能在回复AppendEntries一致性检查失败的时候自动趋于一致。
  • Leader从来不会覆盖或者删除自己的日志条目。(Append-Only)
  • 这样的日志复制机制,就可以保证一致性特性:
    • 只要过半的服务器能正常运行,Raft就能够接受、复制并应用新的日志条目;
    • 在正常情况下,新的日志条目可以在一个RPC来回中被复制给集群中的过半机器;
    • 单个运行慢的follower不会影响整体的性能。
//追加日志RPC Request
type AppendEntriesRequest struct {
    term			int				//自己当前的任期号
    leaderld 		int				//leader(也就是自己)的ID,告诉follower自己是谁
    prevLogIndex 	int 			//前一个日志的日志号		用于进行一致性检查
    prevLogTerm 	int 			//前一个日志的任期号		用于进行一致性检查,只有这两个都与follower中的相同,follower才会认为日志是一致的
        							//如果只有日志号相同,这可能就是上图中f的情况,依旧需要向前回溯
    entries 		[]byte			//当前日志体,也就是命令内容
    leaderCommit 	int				//leader的已提交日志号
}
//追加日志RPC Response
type AppendEntriesResponse struct{
    term				int				// 自己当前任期号
    success 			bool			//如果follower包括前一个日志,则返回true
}

提交详解:

Request:

  1. 对于follower而言,接收到了leader的日志,并不能立即提交,因为这时候还没有确认这个日志是否被复制到了大多数节点。
  2. 只有leader确认了日志被复制到大多数节点后,leader才会提交这个日志,也就是应用到自己的状态机中
  3. 然后leader会在AppendEntries RPC中把这个提交信息告知follower(也就是上面的leaderCommit).
  4. 然后follower就可以把自己复制但未提交的日志设为已提交状态(应用到自己的状态机中).
  5. 对于还在追赶进度的follower来说,若leaderCommit大于自己最后一个日志,这时它的所有日志都是可以提交的

Response:

  • 这个success标志只有在request的term大于等于自己的term,且request通过了一致性检查之后才会返回true,否则都返回false

6. 安全性(重难点)

  • 领导者选举和日志复制两个子问题实际上已经涵盖了共识算法的全程,但这两点还不能完全保证每一个状态机会按照相同的顺序执行相同的命令。
    • 这里非常强调顺序,因为日志中的命令应用到状态机的顺序是一定不能颠倒的(因为状态机要产生相同的最终状态)
    • 但很多共识算法为了提高效率,会允许日志乱序复制到非leader的节点,这样就会在日志中出现很多空洞(如上图所示),造成非常多的边界情况需要处理。
    • 而raft为了避免这些复杂处理,在日志复制阶段就保证了日志是有序且无空洞的.
    • 但raft能生效的前提是leader是正常的,如果leader出现宕机,他的后几个日志的状态就可能出现不正常.
    • 这时就引出了安全性问题(即新leader是否具有这些不正常的日志,以及怎么处理这些日志)
  • 所以Raft通过几个补充规则完善整个算法,使算法可以在各类宕机问题下都不出错。
  • 这些规则包括(不讨论安全性条件的证明过程):
    1. Leader宕机处理:选举限制
    2. Leader宕机处理:新leader是否提交之前任期内的日志条目
    3. Follower和Candidate宕机处理
    4. 时间与可用性限制
  1. Leader宕机处理:选举限制

    • 如果仅仅依靠投票选举子问题中的规则会出现这种情况:如果一个follower落后了leader若干条日志(但没有漏一整个任期),那么下次选举中,按照领导者选举里的规则,它依旧有可能当选leader。它在当选新leader后就永远也无法补上之前缺失的那部分日志,从而造成状态机之间的不一致。

    • 所以需要对领导者选举增加一个限制,保证被选出来的leader一定包含了之前各任期的所有被提交的日志条目。

    • 而raft实现这个限制的方法就是依靠RequestVote RPC中的后两个参数.

      • 回顾一下这两个参数

      • //请求投票RPC Request,由candidate发起
        type RequestVoteRequest struct{
            term			int		//自己当前的任期号	所有节点都带有任期号,因为raft的节点要通过任期号来确定自身的状态,以及判断接不接收这个RPC.
            candidateld 	int 	//自己的ID			Follower需要知道自己投票给谁
            lastLogIndex 	int 	//自己最后一个日志号
            lastLogTerm 	int 	//自己最后一个日志的任期
        }
        
    • RequestVote RPC执行了这样的限制:RPC中包含了candidate的日志信息,如果投票者自己的日志比candidate的还,它会拒绝掉该投票请求.

    • Raft通过比较两份日志中最后一条日志条目的索引值和任期号来定义谁的日志比较新。

      • 如果两份日志最后条目的任期号不同,那么任期号大的日志更“新”。
      • 如果两份日志最后条目的任期号相同,那么日志较长的那个更“新”。
  2. Leader宕机处理:新leader是否提交之前任期内的日志条目

    • 如果某个leader在提交某个日志条目之前崩溃了,以后的leader会试图完成该日志条目的复制
    • 复制,而非提交,不能通过心跳提交老日志。那么为什么不能提交呢,我们以下图为例.
    1. a中,S1是leader
    2. b中,S1崩溃了,S5通过S3S4的选票赢得选举
    3. 到了c中,S5又崩溃了,S1恢复了并赢得选举成为leader,此时日志2已经被复制到了大多数机器上,但还没有被提交(此时日志2是老日志,它不能被立即提交)
    4. 到了d中,S1再次崩溃,S5通过S2S3S4的选票再次选举成功,并将日志3复制到follower上
    5. 在这里我们可以观察到,哪怕S1把日志2复制到了大多数节点,最终还是会被日志3覆盖(即没有在集群中提交日志2)
    6. 在这里我们提出一种假设
    7. 假设S1重新当选leader(c时),在S1S2S3中都把日志2提交了 ,这时候可以认为日志2已经提交了,也可以返回客户端提交成功
    8. 但是这时候S1宕机了,集群重新选举,S5当选,这时raft会通过强制复制把日志2覆盖掉,出现d中的情况,然而在这时,集群中已提交的日志被覆盖了,这是很危险的
    • 所以说Raft永远不会通过计算副本数目的方式来提交之前任期内的日志条目。

    • 只有leader当前任期内的日志条目才通过计算副本数目的方式来提交;

      • 因为可以确认自己当前的任期号是最大的
    • 新leader的提交是危险的,但是复制是安全的,依旧会把老日志复制到所有节点,那么这些老的日志怎么才能被提交呢?

      • 这个新leader会在它的任期内新产生一个日志,在这个日志提交时,老leader任期内的日志也就可以提交了
      • 这是因为一旦当前任期的某个日志条目被提交,那么由于日志匹配特性,之前的所有日志条目也都会被间接地提交。

如果各位仍感觉上述两点不理解的话,可以自己动手,用官方提供的动画Raft Scope来回顾一下这两点.

  1. Follower和Candidate宕机处理

    • Follower和Candidate崩溃后的处理方式比leader崩溃要简单的多,并且两者的处理方式是相同的。
    • 如果follower或candidate崩溃了,那么后续发送给他们的RequestVote RPC和AppendEntriesRPC都会失败。
    • Raft通过无限的重试来处理这种失败。如果崩溃的机器重启了,那么这些RPC就会成功地完成。
    • 如果一个服务器在完成了一个RPC,但是还没有响应的时候崩溃了,那么它重启之后就会再次收到同样的请求。(Raft的RPC都是幂等的)
  2. 时间与可用性限制

    • raft算法整体不依赖客观时间,也就是说,哪怕因为网络或其他因素,造成后发的RPC先到,也不会影响raft的正确性。
      • 这点和Google的分布式数据库spanner的设计思路不同
    • 虽然Raft不依赖客观时间,但是整个系统的一些时间指标还是要满足的,只要整个系统满足下面的时间要求,Raft就可以选举出并维持一个稳定的leader:
      • 广播时间(broadcastTime)<<选举超时时间(electionTimeout)<<平均故障时间(MTBF)
      • 广播时间和平均故障时间是由系统决定的,但是选举超时时间是我们自己选择的。Raft的RPC需要接受并将信息落盘,所以广播时间大约是0.5ms到20ms,取决于存储的技术。因此,选举超时时间可能需要在10ms到500ms之间。大多数服务器的平均故障间隔时间都在几个月甚至更长,因此我们通常不需要过分担忧宕机.

7. 集群成员变更

  • 我们前面讲的领导者选举、日志复制和安全性三个子问题,可以保证raft在集群节点稳定的状态下正常运行,甚至可以容忍一定程度的故障,但很多时候我们需要对集群的配置需要调整,这时需要对raft的配置文件进行改变,改变的过程可能会影响raft的正常运行,我们当然可以停止集群再执行变更,但这必然要停止对外服务一段时间,因此,raft设计了更为方便的方案.

  • 在需要改变集群配置的时候(如增减节点替换宕机的机器或者改变复制的程度),Raft可以进行配置变更自动化。

  • 自动化配置变更机制最大的难点是保证转换过程中不会出现同一任期的两个leader,因为转换期间整个集群可能划分为两个独立的大多数。

    以下图举一个例子

    • 下图为三节点(\(S_1,S_2,S_3\))集群扩容到五节点(\(S_1,S_2,S_3,S_4,S_5\))
    • \(S_1,S_2\)为老配置集群((别忘了还有一个leader呢),\(S_3,S_4,S_5\)为新配置集群
    • 老配置为三节点,\(S_1,S_2\)可以选出一个leader(2/3)
    • 新配置为五节点,\(S_3,S_4,S_5\)可以选出一个leader(3/5)
    • 这时就出现了两个Leader,也就出现了脑裂问题了

那么怎么解决这个问题呢?

  • 所以raft配置采用了一种两阶段的方法。

  • 集群先切换到一个过渡的配置,称之为联合一致(joint consensus) 。(这样我们只需要关注怎样避免在联合一致状态发生脑裂问题就可以了。)

  • 而配置信息作为一个日志体包装为一个普通的AppendEntries RPC,发送给所有的follower。

    • //追加日志RPC Request
      type AppendEntriesRequest struct {
          term			int				//自己当前的任期号
          leaderld 		int				//leader(也就是自己)的ID,告诉follower自己是谁
          prevLogIndex 	int 			//前一个日志的日志号		用于进行一致性检查
          prevLogTerm 	int 			//前一个日志的任期号		用于进行一致性检查,只有这两个都与follower中的相同,follower才会认为日志是一致的
              							//如果只有日志号相同,这可能就是上图中f的情况,依旧需要向前回溯
          entries 		[]byte			//当前日志体,也就是命令内容
          leaderCommit 	int				//leader的已提交日志号
      }
      
    • 第一阶段,leader发起\(C_{old,new}\),使整个集群进入联合一致状态。这时,所有RPC都要在新旧两个配置中都达到大多数才算成功

      • 这个限制是联合一致能够避免脑裂问题的核心点。
    • 第二阶段,leader发起\(C_{new}\),作使整个集群进入新配置状态。这时,所有RPC只要在新配置下能达到大多数就算成功。

  • 一旦某个服务器将该新配置日志条目增加到自己的日志中,他就会用该配置来做出未来所有的决策(服务器总是使用它日志中最新的配置,无论该配置日志是否已经被提交)。

  • 这意味着Leader不用等待\(C_{old,new}\)\(C_{new}\)返回,就会直接使用其中的新规则来作出决策。

  • 我们假设leader可以在集群成员变更任何时候宕机,大概有以下几种可能(也在下图中标注出来了):

    1. leader在\(C_{old,new}\)未提交时宕机
    2. leader在\(C_{old,new}\)已提交但\(C_{new}\)未发起时宕机
    3. leader在\(C_{new}\)已发起时宕机

我们来具体解读一下增加机器时集群成员变更的两个阶段:

  1. 首先我们有\(S_1,S_2,S_3\)三个节点,其中S3是现在任期的leader。
  2. 这时我们增加\(S_4,S_5\)两个节点,raft会先将他们设置为只读,等到他们追上日志进度后,才会开始集群成员变更.
  3. 然后现任leader \(S_3\)发起\(C_{old,new}\),并复制给了\(S_4,S_5\)
  4. 注意,这时的\(S_3,S_4,S_5\)已经进入了联合一致状态,他们的决策要在新旧两个配置中都达到大多数才算成功。
  1. 我们讨论第一种情况,leader在\(C_{old,new}\)未提交时宕机

  • \(S_1,S_2\)超时,开始进行选举,并且以两票可以产生一个老配置的leader。
  • 但是,在联合一致状态下,\(S_3,S_4,S_5\)中的任意节点必须要在老配置(\(S_1,S_2,S_3\))和新配置(\(S_1,S_2,S_3,S_4,S_5\))下都拿到超过半数选票才能当选。
  • 因为\(S_1,S_2\)投票给了他们之中的一个节点,所以在老配置中,超过半数的条件是不满足的.
  • 所以\(S_3,S_4,S_5\)无法选出leader,集群中只能选出\(S_1,S_2\)中的一个leader。
  • 这样集群成员变更就失败了,但不会出现两个leader。

  • 这里其实还有一种可能,就是重新选出的新leader具有\(C_{old,new}\)
    • 比如图中\(S_1,S_3,S_4,S_5\)都复制了\(C_{old,new}\),但还没有提交,这时选出的新leader一定具有\(C_{old,new}\)
  • 但按照安全性限制,这个新leader无法提交\(C_{old,new}\)
  • 可以让它继续发送\(C_{new}\)继续进行集群成员变更。

  • 假设\(S_3\)没有宕机,并且正常复制\(C_{old,new}\),满足了联合一致条件。
  • 比如图中\(S_2,S_3,S_4\)都复制了\(C_{old,new}\).
  • 在这种情况下,leader(S3)的\(C_{old,new}\)日志在新旧两种配置的集群中都超过半数了,\(C_{old,new}\)就可以被提交了。
  • \(S_2 S_3 / S_1 S_2 S_3 =2/3\)
  • \(S_2S_3S_4/S_1S_2S_3S_4S_5 =3/5\)
  • 这时有可能出现第二种leader宕机的情况:leader在\(C_{old,new}\)已提交但\(C_{new}\)未发起时宕机
  • 这时候选举限制安全性规则决定了选出的新leader一定具有\(C_{old,new}\),也就是符合在两种配置集群中都超过半数,已经不存在脑裂的可能了。

  • 这里要说明一下,就像上图一样,集群成员变更的过程中依旧可以对外服务。
  • 联合一致状态下,也是可以正常执行命令的,但也需要在两个配置集群中都达到大多数才能提交

  • \(C_{old,new}\),提交后,leader就会发起\(C_{new}\),这时leader只要满足新配置中的条件,就可以提
    交日志。
    • 比如说图中\(S_3S_4S_5\)复制了\(C_{new}\),\(C_{new}\)就可以提交了,不用再在\(S_1S_2S_3\)中达到大多数了。
    • \(S_3S_4S_5/S_1S_2S_3S_4S_5 =3/5\)
  • 这时有可能出现第三种leader宕机的情况:leader在\(C_{new}\)已发起时宕机
  • 已经复制了\(C_{new}\)的节点会只按新配置选举,
  • 没有复制\(C_{new}\)的节点会按新老配置选举。
  • 有没有复制\(C_{new}\)的节点都有可能当上leader.
  • 但没有复制\(C_{new}\)的节点选举成功也会发\(C_{new}\)

但是这里有一种情况需要特别讨论一下,即缩减节点

  • \(S_1S_2S_3S_4S_5\)缩减为\(S_1S_2S_3\),\(C_{old,new}\)仍需要复制到两个集群中的大多数才能提交,但\(C_{new}\)只需要复制到\(S_1S_2S_3\)中的两个就可以提交了。
  • 这时如果leader \(S_3\)宕机了,\(C_{new}\)会不会被覆盖呢?
  • 不会的,因为处于联合一致状态的节点,也就是只复制了\(C_{old,new}\)没有复制\(C_{new}\)的节点,必须要在两个集群都得到大多数选票才能选举成功。
  • \(S_2S_3\)不会投票给\(S_1S_4S_5\)中的一个,所以\(S_3\)宕了,只有\(S_2\)才能当选,已提交的\(C_{new}\)不会被覆盖。

这里再补充一下(第一种情况),\(C_{old,new}\)的复制满足了在新老配置中都超过半数的条件,但leader宕机,这时leader无法提交\(C_{old,new}\),但继续发\(C_{new}\)的情况。

  • leader在\(C_{old,new}\)未提交时宕机
  • 如图中,leader \(S_3\)复制了\(C_{old,new}\)到了新老配置的大多数节点,满足联合一致,但\(S_3\)未提交\(C_{old,new}\)就宕机了,这时,\(S_1\)当选leader
  • 选出的新leader(\(S_1\))具有\(C_{old,new}\)但这个新leader无法提交\(C_{old,new}\)(安全性限制)
  • 但是可以让它继续发送\(C_{new}\),这时,它把\(C_{new}\)复制到了\(S_1S_4S_5\)节点,构成了新配置集群的大多数,但这时它能提交吗?
  • 并不能,因为他没有\(S_3\)的反馈,\(C_{old,new}\)的提交规则并没有满足,这样提交的\(C_{new}\) ,会把\(C_{old,new}\)也一并提交,这是不安全的
  • 论文中没有给出这个问题的处理方法
  • 但在某些设计中,这里可以强制让\(C_{new}\)按照联合一致规则提交,如果leader满足不了条件,自动退位。

到这里,我们就明确了集群成员变更两阶段的全程,接下来,我们就论文上的这张图进行简单的回顾

  • \(C_{old,new}\)发起但未提交时,raft集群还未进入联合一致状态。这时leader宕机,可以仅靠老配置选出来的新leader。

  • 一旦\(C_{old,new}\)提交,raft集群就进入了联合一致状态,这时leader宕机,选出的新leader也要符合联合一致的选票规则了。

  • \(C_{old,new}\)提交后,leader就可以发起\(C_{new}\),从发起\(C_{new}\)开始,集群就可以仅靠新配置进行选举和日志复制了。

  • 红色箭头说明,如果是缩减集群的情况下,leader可能自身就是缩减的对象,那么它会在\(C_{new}\)复制完成后自动退位,这点我们接下来会进行补充说明。

  • 集群成员变更还有三个补充规则需要说明一下:

    1. 新增节点时,需要等新增的节点完成日志同步再开始集群成员变更
      • 这点是防止集群在新增节点还未同步日志时就进入联合一致状态或新配置状态,影响正常命令日志提交。
    2. 缩减节点时,leader本身可能就是要缩减的节点,这时它会在完成\(C_{new}\)的提交后自动退位。
      • 在发起\(C_{new}\)后,要退出集群的leader就会处在操纵一个不包含它本身的raft集群的状态下。这时它可以发送\(C_{new}\)日志,但是日志计数时不计自身
    3. 为了避免下线的节点超时选举而影响集群运行,服务器会在它确信集群中有leader存在时拒绝RequestVote RPC
      • 因为\(C_{new}\)的新leader不会再发送心跳给要退出的节点,如果这些节点没有及时下线,它们会超时增加任期号后发送RequestVote RPC。虽然它们不可能当选leader,但会导致raft集群反复进入投票选举阶段,影响集群的正常运行。
      • 为了解决这个问题,Raft在RequestVote RPC上补充了一个规则:一个节点如果在最小超时时间之内收到了RequestVote RPC,那么它会拒绝此RPC。
      • 这样,只要follower连续收到leader的心跳,那么退出集群节点的RequestVote RPC就不会影响到raft集群的正常运行了。

联合一致(jointconsensus)集群成员变更方法是比较复杂的,Raft作者对它做出了优化,即单节点集群成员变更方法,这里暂时不做讲解,有兴趣的同学可以在章节8.3中翻阅或者去翻阅Raft作者的文章

8. Raft总结与性能测试

8.1 深入理解复制状态机

  • 共识算法的本质是实现复制状态机。
  • 我们构建分布式存储系统,是为了获取更大的存储容量(Scalability)
  • 为了获取更大的存储容量,我们把数据进行分片(Sharding)
  • 而更多的机器带来了更高的出错频率(Fault)
  • 为了容错(FaultTolerance),我们要对每个分片建立副本(Replication)
  • 而为了维持副本之间的一致,就要引入共识算法(Consensus)
  • 而共识算法会需要额外的资源与性能(LowPerformance),这里又会反过来影响系统的容量和分片数设计。

  • 把复制状态机需要同步的数据量按大小进行分类,它们分别适合不同类型的共识算法。
    1. 数据量非常小,如集群成员信息、配置文件、分布式锁、小容量分布式任务队列。
      • ->无leader的共识算法(如Basic Paxos),实现有Chubby,Zookeeper等。
    2. 数据量比较大但可以拆分为不相干的各部分,如大规模存储系统。
      • ->有leader的共识算法(如Multi Paxos,Raft),实现有GFS,HDFS等。
    3. 不仅数据量大,数据之间还存在关联。
      • 一个共识算法集群容纳不了所有的数据。这种情况下,就要把数据分片(partition)到多个状态机中,状态机之间通过两阶段提交来保证一致性
  • 这类场景就主要是一些如Spanner、OceanBase、TiDB等支持分布式事务的分布式数据库。它们通常会对Paxos或Raft等共识算法进行一定的改造,来满足事务级的要求。

8.2 Raft基本概念总结

  • 共识算法的三个主要特性:

    • 共识算法可以保证在任何非拜占庭情况下的正确性。
    • 通常来说,共识算法可以解决网络延迟、网络分区、丢包、重复发送、乱序问题,无法解决拜占庭问题(如存储不可靠、消息错误)。
    • 共识算法可以保证在大多数机器正常的情况下集群的高可用性,而少部分的机器缓慢不影响整个集群的性能。
    • 不依赖外部时间来保证日志的一致性。
      • 这一点既是共识算法的优势,因为共识算法不受硬件影响,不会因外部因素造成错误。但也造成了一些限制,让共识算法受网络影响很大,在异地容灾场景下,共识算法的支持性比较差。
  • raft区分于其他共识算法的三个特征:

    • Strong leader:在Raft中,日志只能从leader流向其他服务器。这简化了复制日志的管理,使得raft更容易理解。
    • Leader election:Raft使用随机计时器进行leader选举。这只需在任何共识算法都需要的心跳(heartbeats)上增加少量机制,同时能够简单快速地解决冲突。
    • Membership changes:Raft使用一种共同一致(joint consensus)的方法来处理集群成员变更的问题,变更时,两种不同的配置的大多数机器会重叠。允许整个集群在配置变更期间可以持续正常运行。
  • no-op补丁

    • 一个节点当选leader后,立刻发送一个自己当前任期的空日志体的AppendEntries RPC。这样,就可以把之前任期内满足提交条件的日志都提交了。
    • 一旦no-op完成复制,就可以把之前任期内符合提交条件的日志保护起来了,从而就可以使它们安全提交。因为没有日志体,这个过程应该是很快的。
    • 目前大部分应用于生产系统的raft算法,都是启用no-op的。

8.3 集群成员变更拓展

  • 联合一致(joint consensus)集群成员变更方法比较复杂,不太契合raft的易理解性。
  • 在Diego Ongaro的博士论文,和后续的大部分对raft实现中,都使用的是另一种更简单的单节点并更方法,即一次只增减一个节点,称为单节点集群成员变更方法。
  • 每次只增减一个节点,相比于多节点变更,最大的差异是新旧配置集群的大多数,是一定会有重合的。


  • leader宕机情况讨论
  • \(C_{new}\)没有复制到大多数节点时leader宕机
  • 选出的新leader可能是S4,(3/4),具有\(C_{new}\),那么它会继续进行集群成员变更。
  • 也可能是S1或S2,(2/3),没有\(C_{new}\),这时集群成员变更失败。
  • 因为S4复制了\(C_{new}\),所以它需要三个节点的选票才能当选,也就是S1S2S3至少有两者给它投票了。这也是老配置的大多数,所以这里不会产生脑裂现象

  • 单节点集群成员变更十分简单,但却存在以下缺陷:
    1. 联合一致支持一步完成机器的替换,比如我们可以通过联合一致的方法把原来集群的(a,b,c)三台机器替换为(d,b,c)三台机器。
      • 但使用单节点变更就只能由(a,b,c)替换为(a,b,c,d)再替换为(d,b,c),需要两步
    2. 单节点变更过程必然经历偶数节点的状态,这会降低集群的高可用性
      • 机器两两分布时,如果发生网络分区,无法选出leader。
      • 优化单节点变更的过程中偶数节点集群的大多数概念。
      • 老配置的任意两个节点(a,b)(a,c)(b,c)也可以算作变更过程中四节点的大多数,可以让\(C_{new}\)提交。
      • 因为(a,b)(a,c)(b,c)是新老配置的最小交集,所以不会脑裂。
    3. 连续的两次变更,第一步变更的过程中如果出现了切主,那么紧跟着的下一次变更可能,出现错误。


  • 解决方法:新leader必须提交一条自己任期内的no-op日志,才能开始单节点集群成员变更。
  • 这样,图③中,S1在当选新leader后,就可以通过no-op把未提交的$C_{new}\(1覆盖掉,再开始\)C_{new}$2的复制,就不会出问题。

8.4 日志压缩机制

  • 为什么要进行日志压缩呢,因为随着raft集群的不断运行,各状态机上的log也在不断地累积,总会有一个时间会把状态机的内存打爆,所以我们需要一个机制来安全地清理状态机上的log。
  • Raft采用的是一种快照技术,每个节点在达到一定条件之后,可以把当前日志中的命令都写入自己的快照,然后就可以把已经并入快照的日志都删除了。
  • 快照中一个key只会留有最新的一份value,占用空间比日志小得多。
  • 如果一个follower落后leader很多,如果老的日志被清理了,leader怎么同步给follower呢?
  • Raft的策略是直接向follower发送自己的快照

8.5 只读操作处理

  • 直观上讲,raft的读只要直接读取leader上的结果就行了。
  • 直接从leader的状态机取值,实际上并不是线性一致性读(一般也称作强一致性读)。
  • 我们对线性一致性读的定义:读到的结果要是读请求发起时已经完成提交的结果(快照)。
  • 在leader和其他节点发生了网络分区情况下,其他节点可能已经重新选出了一个leader,而如果老leader在没有访问其他节点的情况下直接拿自身的值返回客户端,这个读取的结果就有可能不是最新的。

  • 要追求强一致性读的话,就需要让这个读的过程或结果,也在大多数节点上达到共识。

  • 稳妥的方法:把读也当做一个log,由leader发到所有的所有节点上寻求共识,这个读的log提交后,得到的结果是一定符合线性一致性的。

  • 优化后的方法,要符合以下规则:

    1. 线性一致性读一定要发往leader
    2. 如果一个leader在它的任期内还没有提交一个日志,那么它要在提交了一个日志后才能反馈client读请求。(可以通过no-op补丁来优化这一点)
  • 因为只有在自己任期内提交了一个日志,leader才能确认之前任期的哪些日志已被提交,才不会出现已提交的数据读取不到的情况。

  • 安全性规则能保证被选出的leader一定具有所有已被提交的日志,但它可能有的日志还没有提交,它并不能确定哪些日志是已提交的,哪些日志没提交,而在它任期内提交一个日志,就能确定这一点。

    1. 在进行读操作前,leader要向所有节点发送心跳,并得到大多数节点的反馈。(为了确保自己仍是leader)
    2. leader把自己已提交的日志号设为readIndex,只要leader应用到了readIndex的日志,就可以查询状态机结果并返回client了。
  • 优化过后的线性一致性读,也至少需要一轮RPC(leader确认的心跳)。并不比写操作快多少(写操作最少也就一轮RPC)。

  • 所以,还可以更进一步,因为读的这轮RPC仅仅是为了确认集群中没有新leader产生。那么如果leader上一次心跳发送的时间还不到选举超时时间的下界,集群就不能选出一个新leader,那么这段时间就可以不经过这轮心跳确认,直接返回读的结果。(但不建议使用这种方法)

  • 如果不要求强一致性读,怎么样利用follower承载更大的读压力呢?

  1. follower接收到读请求后,向leader请求readIndex
  2. follower等待自身状态机应用日志到readIndex
  3. follower查询状态机结果,并返回客户端。

8.6 性能及与Paxos比较

  • 分析Raft的性能

  • 最根本的,每完成一个日志(命令)的复制与提交,需要的网络(RPC)来回次数。raft在理想情况下,只需要一次AppendEntries RPC来回即可提交日志(理论上的极限)。

  • 影响Raft性能的因素以及优化方法

    1. 选举及维持leader所需的代价->合理设置选举超时时间。

    2. Batch:一个日志可以包含多个命令,然后批量进行复制,来节省网络。

    3. Pipeline:leader不用等待follower的回复,就继续给follower发送下一个日志

    4. Multi-Raft:将数据分组,每组数据是独立的,用自己的raft来同步。

  • Raft与Paxos比较

  • “raft不允许日志空洞,所以性能没Paxos好。”

  • 这里的Paxos,实际上指的是一个能完美处理所有日志空洞带来的边界情况,并能保证处理这些边界情况的代价,要小于允许日志空洞带来的收益的共识算法。

  • 总结:raft确实有不允许日志空洞这个性能上限,但大部分系统实现,连raft的上限,都是远远没有达到的。所以无需考虑raft本身的瓶颈。

  • raft允许日志空洞的改造 -> ParallelRaft。

9. 拓展:ParallelRaft

  • ParallelRaft是阿里云原生数据库PolarDB的底层文件PolarFS对Raft的一种优化的实现。
  • PolarFS: An Ultra-low Latency and Failure Resilient Distributed File System for Shared Storage Cloud Database [VLDB 2018]

  • 实际应用中大多数是并发场景,也就意味着会建立多个连接
  • 因为多个连接会并行向follower发送日志的,只要一个连接慢了,那么整个日志的顺序就乱掉了。
  • Follower会拒绝掉没有前序日志的日志,造成大量的失败。

  • Multi-Raft将数据拆成一个个部分(Region),每个Region单独使用一个Raft组进行同步。这样,不同

  • Region之间的数据就不需要保持顺序了。• 但Region内部的数据还是要顺序复制与提交,Multi-Raft没有根本解决此问题。

  • 我们把Raft中的限制总结为以下两点:

    1. log复制的顺序性:Raft的follower如果接收了一个日志,意味着它具有这个日志之前的所有日志,并且和leader完全一样。
    2. log提交(应用)的顺序性:Raft的任何节点一旦提交了一个日志,意味着它已经提交了之前的所有日志。
  • ParallelRaft就要打破这两点规则,让log可以乱序确认(Out-of-Order Acknowledge)乱序提交(Out-of-Order Commit)

    • 乱序确认(Out-of-Order Acknowledge):ParallelRaft中,任何log成功持久化后立即返回success,无需等待前序日志复制完成,从而大大降低系统的平均延迟。
    • 乱序提交(Out-of-Order Commit):ParallelRaft中,leader在收到大多数节点的复制成功信息后,无需等待前序日志提交,即可提交当前日志。
  • 当然,直接应用这两个“乱序”会造成算法的错误,所以ParallelRaft采用了一些措施来保证在这两个“乱序”的情况下依旧保持算法的正确性。

  • 为了便于理解,我们先说明乱序提交问题的解决方案。

  • 乱序提交(Out-of-Order Commit)

  • 因为有的日志中的命令可能会修改相同的数据,如果跳过空洞先应用了后续的日志,就可能造成状态机间的不一致,导致错误。

  • 为了解决这个问题,ParallelRaft引入了一种名叫look behind buffer的数据结构。

  • ParallelRaft的每个log都附带有一个look behind buffer。look behind buffer存放了前N个log修改的LBA(逻辑块地址)信息。

  • 通过look behind buffer,follower能够知道一个log是否与日志空洞里的log冲突,也就是可以判断出当前log的LBA和其look behind buffer中对应日志空洞的LBA是否重合。
  • 没有冲突的log entry可以被安全执行。有冲突的log要加到一个pending list中,等到日志空洞补齐,相关LBA的日志执行完成后,才能执行。
  • PolarFS把N设为2,也就是日志空洞最大为2。

  • 乱序确认(Out-of-Order Acknowledge)
  • 乱序确认会造成日志存在空洞(hole),日志空洞会有一个很直接的影响:怎样选举出具有完整日志的leader?
  • ParallelRaft把选出来的主定义为Leader Candidate,Leader Candidate只有经过一个Merge阶段,弥补完所有日志空洞后才能开始接收并复制日志。
  • 在选举阶段,ParallelRaft的规则与Raft略有差异,会选择具有最新checkpoint的节点当选Leader Candidate。
  • 这时其它没有宕机的节点变为Follower Candidate。(论文中没有具体说明Follower Candidate的组成,这是我自己的理解)

  • Merge阶段总体流程

    1. Follower Candidate发送自己的日志(应该是checkpoint之后的所有日志)给Leader Candidate。Leader Candidate把自己的日志与收到的日志进行Merge
    2. Leader Candidate同步状态信息(推测应该是具体选择哪些log)给Follower。
    3. Leader Candidate提交所有日志并通知Follower Candidate提交。
    4. Leader Candidate升级为Leader,并开始自己任期内的服务。
  • 进入Merge阶段,Leader Candidate会通过所有FollowerCandidate的log来补齐自己所有的日志空洞。

  • 下图中,S2S3宕机,选出S1为Leader Candidate,S4S5为Follower Candidate。

  • 我们可以把所有的log分为三类:

    1. 已提交的日志

      • 在任何节点上已经提交过的日志,如log index=4的日志,这类日志一定要在Leader Candidate上补上空洞并提交。
      • 对于某些日志,可能在不同节点上log index相同,term不同,如log index=5的日志,这类日志要选择term最大的日志。即Leader Candidate要把自己log index=5的日志改为任期号为3的日志,并提交。
    2. 未提交的日志

      • 在任何节点上都未提交过,或确定在大多数节点上都没有的日志(拥有这个日志的节点数+没有响应(宕机)的节点数 < 不包含这个日志的节点数),如log index=7的日志,这类日志如果Leader Candidate上没有,就可以用空日志替代。
    3. 不确定提没提交的日志

      • 如果拥有这个日志的节点数+没有响应(宕机)的节点数 > 不包含这个日志的节点数,如log index=6的日志,我们就无法判断这个日志是否提交。这时,为了保证安全,LeaderCandidate要提交这个日志。
  • checkpoint

  • ParallelRaft的checkpoint就是状态机的一个快照。在实际实现中,ParallelRaft会选择有最新checkpoint的节点做Leader Candidate,而不是拥有最新日志的节点,有两个原因:

    1. Merge阶段时,Leader Candidate可以从其它节点获取最新的日志,并且无需处理checkpoint之前的日志。所以checkpoint越新,Merge阶段的代价就越小。
    2. Leader的checkpoint越新,catch up时就更高效。
  • catch up

  • ParallelRaft把落后的Follower追上Leader的过程称为catch up,有两种类型:

  • fast-catch-up:Follower和Leader差距较小时(差距小于上一个checkpoint),仅同步日志。

  • streaming-catch-up:Follower和Leader差距较大时(差距大于上一个checkpoint),同步checkpoint和日志。

  • case 1需要streaming-catch-up。

  • case 2只用fast-catch-up。

  • case 3中多出来的日志会被Leader覆盖掉,和Raft一样。

  • 性能提升
  • 随着IO队列的增大,ParallelRaft的延迟要比Raft低,吞吐量也要显著大于Raft。

  • 总结
  • Raft论文原文中有这样一段话:
  • 在所有基于leader的共识算法中,leader最终都需要存储所有已提交的日志
  • 一些共识算法中,一个leader可以在被选举出来时没有所有已提交的日志,然后通过额外的机制来识别并补充这些日志。
  • 但额外的机制就会造成更高的复杂度和理解成本
  • 所以Raft选择一种更简单的方式,来使leader在被选出时天然就具有所有已提交的日志。这样就可以让日志只能从leader流向follower,leader永远不会复写自己的日志。

本文章仅限个人学习使用,侵删

原文地址:http://www.cnblogs.com/jiuyou2020/p/16930038.html

1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长! 2. 分享目的仅供大家学习和交流,请务用于商业用途! 3. 如果你也有好源码或者教程,可以到用户中心发布,分享有积分奖励和额外收入! 4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解! 5. 如有链接无法下载、失效或广告,请联系管理员处理! 6. 本站资源售价只是赞助,收取费用仅维持本站的日常运营所需! 7. 如遇到加密压缩包,默认解压密码为"gltf",如遇到无法解压的请联系管理员! 8. 因为资源和程序源码均为可复制品,所以不支持任何理由的退款兑现,请斟酌后支付下载 声明:如果标题没有注明"已测试"或者"测试可用"等字样的资源源码均未经过站长测试.特别注意没有标注的源码不保证任何可用性