「第四届青训营 」笔记

87 阅读15分钟

这是我参与「第四届青训营 」笔记创作活动的的第15天。


分布式系统

分布式系统面临的挑战

  • 数据规模越来越大
  • 服务的可用性要求越来越高
  • 快速迭代的业务要求系统足够易用

理想中的分布式系统

  • 高性能:可拓展、低时延、高吞吐
  • 正确:一致性、易于理解
  • 可靠:容错、高可用

🎈从HDFS开始

image.png

自我思考

分布式系统就是让终端用户把一组工作在一起的计算机当做一个单独的机器来使用

数据规模的不断增加,我们需要大规模分布式系统

分布式系统最大的好处就是能够让开发者横向地扩展系统。


一致性与共识算法

什么是一致性

一致性是一种模型(或语义)来约定一个分布式系统如何向外界 (应用)提供服务。

对于我们的KV像操作一台机器一样要读到最近写入的值。

KV中常见的一致性模型:

  • 最终一致性: 读取可能暂时读不到但是总会读到
  • 线性一致性:最严格,线性执行

复制协议

  1. 当主副本失效时,为了使得算法简单,我们人肉切换,只要足够快我们还是可以保证较高的可用性。
  2. 但是如何保证主副本是真的失效了呢?
  • 在切换的过程中,主副本又开始接收client端的请求
  • 两个主副本显然是不正确的,log 会被覆盖写掉
  • 我们希望算法能在这种场景下仍然保持正确
  1. 要是增加到三个节点呢?
  • 每次都等其他节点操落盘性能较差
  • falut-tolerance能允许少数节点挂了的情况下,仍然可以工作

共识算法

概念

协商一致问题需要多个进程(或代理)达成一致。一个单一的数据值。有些过程(代理)可能在其他方面失败或不可靠,因此,协商一致的协议必须具有容错性或弹性。

简而言之一个值一旦确定,所有人都认同,


  1. 共识协议不等于一致性
  • 应用层面不同的一致性,都可以用共识协议来实现
  • 简单的复制协议也可以提供线性一致性
  1. 一般讨论共识协议时提到的一致性,都指线性一致性
  • 因为弱一致性往往可以使用相对简单的复制算法实现

自我思考

一致性往往指分布式系统中多个副本对外呈现的数据的状态。

共识则描述了分布式系统中多个节点之间,彼此对某个状态达成一致结果的过程。系统中多个节点最关键的是对多个事件的顺序进行共识(排序)。


一致性协议案例:Raft

「自我思考」

Raft是一个分布式共识算法,分布式共识算法还有Paxos,不过Paxos很难理解。

Raft与2014年发表,以易于理解作为算法的设计目标,使用了

  • 使用了RSM、Log、RPC的概念
  • 直接使用RPC对算法进行了描述
  • Strong Leader-based
  • 使用了随机的方法减少约束

Raft角色

image.png


Raft整体流程

image.png


Raft Term

  • 每个Leader服务于一个term
  • 每个term至多只有一个leader
  • 每个节点存储当前的term
  • 每个节点term从一开始,只增不减
  • 所有rpc的request reponse都携带term
  • 只commit本term内的log

Raft的安全性

  1. 对于Term内的安全性
  • 对于所有已经的commited的<term, index>位置上至多只有一条log(目标)
  1. 由于Raft的多数派选举,我们可以保证在一个term 中只有一个leader
  2. 对于跨Term的安全性:
  • 目标:如果一个log被标记commited,那这个log一定会在未来所有的leader中出现Leader completeness

1. Raft

Raft协议的发布,对分布式行业是一大福音,虽然在核心协议上基本都是师继Paxos祖师爷(lamport)的精髓,基于多数派的协议。但是Raft一致性协议的贡献在于,定义了可易于实现的一致性协议的事实标准。把一致性协议从“阳春白雪” 变成普通学生、IT码农都可以上手试一试玩一玩的东东,MIT的分布式教学课程6.824都是直接使用Raft来介绍一致性协议。

从《In Search of An Understandable Consensus Algorithm(Extend Version)》论文中,我们可以看到,与其他一致性协议的论文不同的点是,Diego 基本已经算是把一个易于工程实现算法讲得非常明白了,just do it,没有太多争议和发挥的空间,即便如此,要实现一个工业级的靠谱的raft还是要花不少力气。

raft一致性协议相对易于实现主要归结为以下几个原因:

  1. 模块化的拆分:把一致性协议划分为 Leader选举、MemberShip变更、日志复制、SnapShot等相对比较解耦的模块
  2. 设计的简化: 比如不允许类似Paxos算法的乱序提交、使用Randomization 算法设计Leader Election算法以简化系统的状态,只有Leader、Follower、Candidate等等。

本文不打算对Basic Raft一致性协议的具体内容进行说明,而是介绍记录一些关键点,因为绝大部份内容,原文已经说明得很详实,但凡有一定英文基础,直接看raft paper就可以了,如意犹未尽,还可以把raft 作者 Diego Ongaro 200多页的博士论文刷一遍(链接在文末,可自取)。

2. Points

2.1 Old Term LogEntry 处理

旧Term未提交的日志的提交依赖于新一轮的日志的提交

这个在原文 “5.4.2 Committing entries from previews terms” 有说明,但是在看的时候可能会觉得有点绕。

Raft协议约定,Candidate在使用新的Term进行选举的时候,Candidate能够被选举为Leader的条件为:

  1. 得到一半以上(包括自己)节点的投票
  2. 得到投票的前提是:Candidate节点的最后一个LogEntry的Term比投票节点大,或者在Term一样情况下,LogEnry的SN(serial number)必须大于等于投票者。

并且有一个安全截断机制:

  1. Follower 在接收到logEntry的时候,如果发现发送者节点当前的Term大于等于Follower当前的Term;并且发现相同序号的(相同SN)LogEntry在Follower上存在,未Commit,并且LogEntry Term 不一致,那么Follower直接截断从[SN~文件末尾)的所有内容,然后将接收到的LogEntryAppend到截断后的文件末尾。

在以上条件下,Raft论文列举了一个Corner Case ,如图所示

\

\

  • (a): S1 成为 Leader,Append Term2的LogEntry(黄色)到S1、S2 成功;
  • (b): S1 Crash, S5使用 Term(3) 成功竞选为 Term(3)的 Leader(通过获得S3、S4、S5的投票),并且将Term为 3的 LogEntry(蓝色) Append到本地;
  • (c): S5 Crash, S1 使用 Term(4) 成功竞选为Leader(通过获得S1、S2、S3的投票),将黄色的LogEntry复制到S3,得到多数派响应(S1、S2、S3)的响应,提交黄色LogEntry为Commit,并将Term为4的LogEntry(红色) Append到本地。
  • (d) S5 使用新的Term(5) 竞选为Leader(得到 S2、S3、S4 的投票),按照协议将所有所有节点上的黄色和红色的LogEntry截断覆盖为自己的Term为3 的LogEntry。

进行到这步的时候我们已经发现,黄色的LogEnry(2) 在被设置为Commit之后重新又被否定了。

所以协议又强化了一个限制;

  1. 只有当前Term的LogEntry提交条件为:满足多数派响应之后(一半以上节点Append LogEntry到日志)设置为commit;
  2. 前一轮Term未Commit的LogEntry的Commit依赖于高轮Term LogEntry的Commit

如图所示 (c) 状态 Term2的LogEntry(黄色) 只有在 (e)状态 Term4 的LogEntry(红色)被commit才能够提交。

提交NO-OP LogEntry 提交系统可用性

在Leader通过竞选刚刚成为Leader的时候,有一些等待提交的LogEntry(即SN > CommitPt的LogEntry),有可能是Commit的,也有可能是未Commit的。(PS: 因为在Raft协议中CommitPt 不用实时刷盘)

所以为了防止出现非线性一致性(Non Linearizable Consistency);即之前已经响应客户端的已经Commit的请求回退,并且为了避免出现上图中的Corner Case,往往我们需要通过下一个Term的LogEntry的Commit来实现之前的Term的LogEntry的Commit(隐式commit),才能保障提供线性一致性。

但是有可能接下来的客户端的写请求不能及时到达,那么为了保障Leader快速提供读服务,系统可首先发送一个NO-OP LogEntry 来保障快速进入正常可读状态。

2.2 Current Term、VotedFor 持久化

上图其实隐含了一些需要持久化的重要信息,即 Current Term、VotedFor!!! 为什么(b) 状态 S5 使用的Term Number 为3,而不是2?

因为竞选为Leader就必须是使用新的Term发起选举,并且得到多数派阶段的同意,同意的操作为将Current Term、VotedFor持久化。

比如(a) 状态 S1 为什么能竞选为Leader?首先S1满足成为Leader的条件,S2~S5 都可以接受 S1 成为发起Term 为2 的Leader选举。S2~S5 同意S1成为Leader的操作为:将 Current Term 设置为2、VotedFor 设置为S1 并且持久化,然后返回S1。即S1 成功成为Term 为2的Leader的前提是一个多数派已经记录 Current Term 为2 ,并且VotedFor为S1。那么(b) 状态 S5 如使用Term为2进行Leader选举,必然得不到多数派同意,因为Term 2 已经投给S1,S5只能 将Term++ 使用Term 为3 进行重新发起请求。

Current Term、VotedFor 如何持久化?

type CurrentTermAndVotedFor {
    Term int64 `json:"Term"`
    VotedFor int64 `json:"Votedfor"`
    Crc int32
}

//current state
var currentState  CurrentTermAndVotedFor

.. set value and calculate crc ...

content, err := json.Marshal(currentState)

//flush to disk
f, err := os.Create("/dist/currentState.txt")
f.Write(content)
f.Sync()

简单的方法,只需要保存在一个单独的文件,如上为简单的go语言示例;其他简单的方式比如在设计Log File的时候,Log File Header中包含 Current Term 以及VotedFor 的位置。

如果再深入思考一层,其实这里头有一个疑问?如何保证写了一半(写入一半然后挂了)的问题?写了Term、没写VoteFor?或者只写了Term的高32位?

可以看到磁盘能够保证512 Byte的写入原子性,这个在知乎事务性(Transactional)存储需要硬件参与吗? 这个问答上就能找到答案。所以最简单的方法是直接写入一个tmpfile,写入完成之后,讲tmpfile mv成CurrentTermAndVotedFor文件,基本可保障更新的原子性。其他方式比如采用Append Entry的方式也可以实现。

2.3 Cluser Membership 变更

在Raft的Paper中,简要说明了一种一次变更多个节点的Cluser Membership变更方式。但是没有给出更多的在Securey以及Avaliable上的更多的说明。

其实现在开源的raft实现一般都不会使用这种方式,比如Etcd raft 都是采用了更佳简洁的一次只能变更一个节点的 “single Cluser MemberShip Change” 算法。

当然single cluser MemberShip 并非Etcd 自创,其实raft 协议作者 Diego 在其博士论文中已经详细介绍了Single Cluser MemberShip Change 机制,包括Security、Avaliable方面的详细说明,并且作者也说明了在实际工程实现过程中更加推荐Single方式,首先因为简单,再则所有的集群变更方式都可以通过Single 一次一个节点的方式达到任何想要的Cluster 状态。

原文:“Raft restrict the types of change that allowed: only one server can be added or removed from the cluster at once. More complex changes in membership are implemented as a series of single-server-change”

2.3.1 Safty

回到问题的第一大核心要点:Safety,membership 变更必须保持raft协议的约束:同一时间(同一个Term)只能存在一个有效的Leader。

<一>:为什么不能直接变更多个节点,直接从Old变为New有问题? for example change from 3 Node to 5 Node?

\

\

如上图所示,在集群状态变跟过程中,在红色箭头处出现了两个不相交的多数派(Server3、Server4、Server 5 认知到新的5 Node 集群;而1、2 Server的认知还是处在老的3 Node状态)。在网络分区情况下(比如S1、S2 作为一个分区;S3、S4、S5作为一个分区),2个分区分别可以选举产生2个新的Leader(属于configuration< Cold>的Leader 以及 属于 new configuration < Cnew > 的 Leader ) 。

当然这就导致了Safty没法保证;核心原因是对于Cold 和 CNew 不存在交集,不存在一个公共的交集节点 充当仲裁者的角色。

但是如果每次只允许出现一个节点变更(增加 or 减小),那么Cold 和 CNew 总会相交。 如下图所示

\

\

<二>: 如何实现Single membership change

论文中以下提几个关键点:

  1. 由于Single方式无论如何 Cold 和 CNew 都会相交,所以raft采用了直接提交一个特殊的replicated LogEntry的方式来进行 single 集群关系变更。
  2. 跟普通的 LogEntry提交的不同点,configuration LogEntry 只需要commit就生效,只需要append 到Log中即可。(PS: 原文 "The New configuration takes effect on each server as soon as it is added to the server's log")
  3. 后一轮 MemberShip Change 的开始必须在前一轮 MemberShip Change Commit之后进行,以避免出现多个Leader的问题

\

\

  • 关注点1

如图所示,如在前一轮membership configure Change 未完成之前,又进行下一次membership change会导致问题,所以外部系统需要确保不会在第一次Configuration为成功情况下,发起另外一个不同的Configuration请求。 ( PS:由于增加副本、节点宕机丢失节点进行数据恢复的情况都是由外部触发进行的,只要外部节点能够确保在前一轮未完成之前发起新一轮请求,即可保障。)

  • 关注点2

跟其他客户端的请求不一样的,Single MemberShip Change LogEntry只需要Append持久化到Log(而不需要commit)就可以应用。

一方面是可用性方面的考虑,如下所示:Leader S1 接收到集群变更请求将集群状态从(S1、S2、S3、S4)变更为 (S2、S3、S4);提交到所有节点之后commit之后,返回客户端集群状态变更完成(如下状态a),S1退出(如下状态b);由于Basic Raft 并不需要commit消息实施传递到其他S1、S2、S3节点,S1退出之后,S1、S2、S3 由于没有接收到Leader S1 的心跳,导致进行选举,但是不幸的是S4故障退出。假设这个时候S2、S3由于 Single MemberShip Change LogEntry 没有Commit 还是以(S1、S2、S3、S4)作为集群状态,那么集群没法继续工作。但是实质上在(b)状态 S1 返回客户端 集群状态变更请求完成之后,实质上是认为可独立进入正常状态。

\

\

另一方面,即使没有提交到一个多数派,也可以截断,没什么问题。(这里不多做展开)

另一方面可靠性&正确性

raft协议 Configuration 请求和普通的用户写请求是可以并行的,所以在并发进行的时候,用户写请求提交的备份数是无法确保是在Configuration Change之前的备份数还是备份之后的备份数。但是这个没有办法,因为在并发情况下本来就没法保证,这是保证Configuration截断系统持续可用带来的代价。(只要确保在多数派存活情况下不丢失即可(PS:一次变更一个节点情况下,返回客户端成功,其中必然存在一个提交了客户端节点的 Server 被选举为Leader))

  • 关注点3

single membership change 其他方面的safty保障是跟原始的Basic Raft是一样的(在各个协议处理细节上对此类请求未有任何特殊待遇),即只要一个多数派(不管是新的还是老的)将 single membership change 提交并返回给客户端成功之后,接下来无论节点怎么重启,都会保障确保新的Leader将会在已经知晓(应用)新的,前一轮变更成功的基础上处理接下来的请求:可以是读写请求、当然也可以是新的一轮Configuration 请求。

2.3.2 初始状态如何进入最小备份状态

比如如何进入3副本的集群状态。可以使用系统元素的Single MemberShip 变更算法实现。

刚开始节点的副本状态最简单为一个节点1(自己同意自己非常简单),得到返回之后,再选择添加一个副本,达到2个副本的状态。然后再添加一个副本,变成三副本状态,满足对系统可用性和可靠性的要求,此事该raft实例科对外提供服务。

2.4 其他需要关注的事项

  • servers process incoming RPC requests without consulting their current configurations. server处理在AppendEntries & Voting Request 的时候不用考虑本地的configuration信息
  • catchup:为了保障系统的可靠性和可用性,加入 no-voting membership状态,进行catchup,需要加入的节点将历史LogEntry基本全部Get到之后再发送 Configuration。
  • Disrptive serves:为了防止移除的节点由于没有接收到新的Leader的心跳,而发起Leader选举而扰绕当前正在进行的集群状态。集群中节点在Leader心跳租约期间内收到Leader选举请求可以直接Deny。(PS:当然对于一些确定性的事情,比如发现Leader listen port reset,那么可以发起强制Leader选举的请求)