作者 | 祥光
来源 | 阿里技术公众号
一 引言
成员变更是一致性系统实现绕不开的难题,对于提升运维能力以及服务可用性都有很大的帮助。
本文从Raft成员变更理论出发,介绍了Raft成员变更和单步成员变更的问题,其中包括Raft著名的Bug。
对于Raft成员变更的工程实现上需要考虑的问题,本文给出了一些工程实践经验。
二 Raft成员变更简介
分布式系统运行过程中节点经常会出现故障,需要支持节点的动态增加和删除。
成员变更是在集群运行过程中改变运行一致性协议的节点,如增加、减少节点、节点替换等。成员变更过程不能影响系统的可用性。
成员变更也是一个一致性问题,即所有节点对新成员达成一致。但是成员变更又有其特殊性,因为在成员变更的过程中,参与投票的成员会发生变化。
如果将成员变更当成一般的一致性问题,直接向Leader节点发送成员变更请求,Leader同步成员变更日志,达成多数派之后提交,各节点提交成员变更日志后从旧成员配置(Cold)切换到新成员配置(Cnew)。
因为各个节点提交成员变更日志的时刻可能不同,造成各个节点从旧成员配置(Cold)切换到新成员配置(Cnew)的时刻不同。可能在某一时刻出现Cold和Cnew中同时存在两个不相交的多数派,进而可能选出两个Leader,形成不同的决议,破坏安全性。
图1 成员变更的某一时刻Cold和Cnew中同时存在两个不相交的多数派
如图1是3个节点的集群扩展到5个节点的集群,直接扩展可能会造成Server1和Server2构成老成员配置的多数派,Server3、Server4和Server5构成新成员配置的多数派,两者不相交从而可能导致决议冲突。
由于成员变更的这一特殊性,成员变更不能当成一般的一致性问题去解决。为了解决这个问题,Raft提出了两阶段的成员变更方法Joint Consensus。
1 Joint Consensus成员变更
Joint Consensus成员变更让集群先从旧成员配置Cold切换到一个过渡成员配置,称为联合一致成员配置(Joint Consensus),联合一致成员配置是旧成员配置Cold和新成员配置Cnew 的组合Cold,new,一旦联合一致成员配置Cold,new提交,再切换到新成员配置Cnew 。
图2 Joint Consensus成员变更
Leader收到成员变更请求后,先向Cold和Cnew同步一条Cold,new日志,此后所有日志都需要Cold和Cnew两个多数派的确认。Cold,new日志在Cold和Cnew都达成多数派之后才能提交,此后Leader再向Cold和Cnew同步一条只包含Cnew的日志,此后日志只需要Cnew的多数派确认。Cnew日志只需要在Cnew达成多数派即可提交,此时成员变更完成,不在Cnew中的成员自动下线。
成员变更过程中如果发生Failover,老Leader宕机,Cold,new中任意一个节点都可能成为新Leader,如果新Leader上没有Cold,new日志,则继续使用Cold,Follower上如果有Cold,new日志会被新Leader截断,回退到Cold,成员变更失败;如果新Leader上有Cold,new日志,则继续将未完成的成员变更流程走完。
Joint Consensus成员变更比较通用且容易理解,但是实现比较复杂,之所以分为两个阶段,是因为对 与 的关系没有做任何假设,为了避免 和 各自形成不相交的多数派而选出两个Leader,才引入了两阶段方案。
如果增强成员变更的限制,假设Cold与Cnew任意的多数派交集不为空,Cold与Cnew就无法各自形成多数派,则成员变更就可以简化为一阶段。
2 单步成员变更
实现单步的成员变更,关键在于限制Cold与Cnew,使之任意的多数派交集不为空。方法就是每次成员变更只允许增加或删除一个成员。
图3 增加或删除一个成员
增加或删除一个成员时的情形,如图3所示,可以从数学上严格证明,只要每次只允许增加或删除一个成员,Cold与Cnew不可能形成两个不相交的多数派。因此只要每次只增加或删除一个成员,从Cold可直接切换到Cnew,无需过渡成员配置,实现单步成员变更。
单步成员变更一次只能变更一个成员,如果需要变更多个成员,可以通过执行多次单步成员变更来实现。
单步成员变更理论虽然简单,但却埋了很多坑,实际用起来并不是那么简单。
三 Raft单步成员变更的问题
Raft单步成员变更的问题,最著名的莫过于Raft著名的正确性问题,另外单步成员变更还有潜在的可用性问题。
1 Raft单步成员变更的正确性问题
Raft单步变更过程中如果发生Leader切换会出现正确性问题,可能导致已经提交的日志又被覆盖。Raft作者(Diego Ongaro)早在2015年就发现了这个问题,并且在Raft-dev详细的说明了这个问题[1]。
下面是一个Raft单步变更出问题的例子, 初始成员配置是abcd这4节点,节点u和V要加入集群, 如果中间出现Leader切换, 就会丢失已提交的日志:
图4 Raft单步成员变更的正确性问题
- t0:节点abcd的成员配置为C0;
- t1 :节点abcd在Term 0选出a为Leader,b和c为Follower;
- t2:节点a同步成员变更日志Cu,只同步到a和u,未成功提交;
- t3:节点a宕机;
- t4:节点d在Term 1被选为Leader,b和c为Follower;
- t5:节点d同步成员变更日志Cv,同步到c、d、V,成功提交;
- t6:节点d同步普通日志E,同步到c、d、V,成功提交;
- t7:节点d宕机;
- t8:节点a在Term 2重新选为Leader,u和b为Follower;
- t9:节点a同步本地的日志Cu给所有人,造成已提交的Cv和E丢失。
为什么会出现这样的问题呢?根本原因是上一任Leader的成员变更日志还没有同步到多数派就宕机了,新Leader一上任就进行成员变更,使用新的成员配置提交日志,之前上一任Leader重新上任之后可能形成另外一个多数派集合,产生脑裂,将已提交的日志覆盖,造成数据丢失。
Raft作者在发现这个问题之后,也给出了修复方法。修复方法很简单, 跟Raft的日志Commit条件类似:新任Leader必须在当前Term提交一条日志之后,才允许同步成员变更日志。也即Leader在当前Term还未提交日志之前,不允许同步成员变更日志。
按照这个修复方法,最简单的实现就是Leader上任后先提交一条no-op日志,然后再同步成员变更日志。这条no-op日志可以保证跟上一任Leader未提交的成员变更日志至少有一个节点交集,这样可以发现上一任Leader的日志是旧的,从而阻止上一任Leader重新选为Leader,进而阻止了脑裂的产生。
对应上面这个例子,就是L1当选Leader后必须先提交一条no-op日志,然后才能开始同步Cv和E,以便能发现L2的日志是旧的,从而阻止L2当选Leader。
另一种方法是使用Joint Consensus成员变更,没有这样的正确性问题。
2 Raft单步成员变更的可用性问题
单步成员变更每次只能增加或者减少一个成员,在做成员替换的时候需要分两次变更,第一次变更先将新成员加入进来,第二次变更再将老成员删除,中间如果如果网络分区,有可能会导致服务不可用。
考虑a、b、c三个成员部署在三个机房,现在因为a发生故障要将a替换为同机房的d。按照单步成员变更,abc要先变为abcd,再变为bcd。
中间经历的4节点abcd的状态, 有可能在出现二分的网络分区(ad|bc)时导致整个集群不可用。因为a与d位于同一机房,这种二分网络分区的情况在实际情况中还是不容忽视的。
怎么解决这个问题呢?一种方法是做成员替换的时候,先删除老成员,再加入新成员,即abc先变为bc,再变为bcd,这样可以避免abcd的状态。
另一种方法是使用Joint Consensus成员变更,abc先变为abc U bcd ,再变为bcd,也不会经历abcd的状态。
四 Raft成员变更的工程实践
Raft成员变更的理论虽简单,但实际工程实现上还是有很多地方要考虑。因为Raft单步成员变更有正确性问题及可用性问题,工程上建议尽量使用Joint Consensus成员变更,这里主要讨论一些Joint Consensus成员变更工程实现上必须考虑的问题。
1 新成员先加入再同步数据还是先同步数据再加入
因为Raft需要严格保证顺序,而新成员上还没有任何数据,因此新成员加入集群后需要先同步数据才能正常工作。工程实现时就有两种选择,一种是让新成员先加入再同步数据,另一种是先给新成员同步数据,同步完成后再加入。这两种方式各有利弊。
表1 新成员先加入再同步数据和先同步数据再加入的优缺点
新成员先加入再同步数据,成员变更可以立即完成,并且因为只要大多数成员同意即可加入,甚至可以加入还不存在的成员,加入后再慢慢同步数据。但在数据同步完成之前新成员无法服务,但新成员的加入可能让多数派集合增大,而新成员暂时又无法服务,此时如果有成员发生Failover,很可能导致无法满足多数成员存活的条件,让服务不可用。因此新成员先加入再同步数据,简化了成员变更,但可能降低服务的可用性。
新成员先同步数据再加入,成员变更需要后台异步进行,先将新成员作为Learner角色加入,只能同步数据,不具有投票权,不会增加多数派集合,等数据同步完成后再让新成员正式加入,正式加入后可立即开始工作,不影响服务可用性。因此新成员先同步数据再加入,不影响服务的可用性,但成员变更流程复杂,并且因为要先给新成员同步数据,不能加入还不存在的成员。
2 成员变更日志使用什么配置
成员变更日志本身是为了改变成员配置,处在成员配置变更的临界点上,因此成员变更日志使用什么配置就很关键。
表2 Joint Consensus成员变更日志使用的成员配置
对于Joint Consensus成员变更,成员变更日志使用什么配置是确定的。Cold,new日志使用联合一致成员配置Cold,new,需要老成员配置Cold和新成员配置Cnew两个多数派确认才能提交,Cnew日志使用新成员配置Cnew,只需要新成员配置Cnew的多数派确认即可提交,但Cnew日志也会同步给老成员配置Cold,主要是为了让Cold中不在Cnew中的成员自动退出。
3 成员变更日志什么时候生效
成员变更通过成员变更日志来完成,让各成员对成员配置达成一致,但成员变更日志与普通日志不同,并不一定要等到提交后Apply生效。
表3 成员变更日志的生效时机
对于Joint Consensus成员变更,成员变更日志什么时候生效是确定的。在Leader上开始同步成员变更日志之前就需要生效,在Follower上成员变更日志持久化完成后就需要生效。成员变更日志还未提交就先生效了,因此在Leader切换后可能会回滚。
4 成员变更期间日志是否需要严格按序提交
考虑这样一种情况,成员变更减少了成员数量,进而减小了多数派集合,而更小的多数派更容易达成,造成成员变更之后的日志比之前的日志先达成多数派。
按照Raft论文中的commitIndex的推进算法:
If there exists an N such that N > commitIndex, a majority of matchIndex[i] ≥ N, and log[N].term == currentTerm:
set commitIndex = N
一条日志达成多数派就往前推进commitIndex至该日志,如果该日志之前有日志按照老成员配置还未达成多数派,也一并提交了。
这种情况是否会出问题呢?实际上并不会,因为成员变更之后,已经有日志使用新成员配置提交了,不在新成员配置中的节点不可能再当选Leader了,进而不会覆盖之前的日志,因此就算之前的日志按照老成员配置未达成多数派也可以安全的提交。
hashicorp raft的实现还是严格按序提交的,即只有前面的日志都达成多数派之后才能提交。
5 只有少数成员存活时怎么恢复服务
Raft只能在大多数成员存活的情况下才能正常工作,实际可能会遇到只有少数成员存活的情况,这个时候要怎么恢复服务呢。
因为只有少数成员存活,已经不能达成多数派,不能写入数据,也不能做正常的成员变更。需要提供一个强制更改成员配置的接口,通过它设置每个成员的成员配置列表,便于从大多数成员故障中恢复。
比如只剩一个成员S1存活的时候,强制更改成员配置设置成员列表为{S1},这样形成一个只有S1的成员列表,让S1继续提供读写服务,后续再调度其他节点通过成员变更加入。通过强制修改成员列表,可以实现最大可用模式。
五 单步成员变更的工程实践
单步成员变更虽然不推荐在工程中使用,这里还是总结一下单步成员变更的一些工程实践,供研究讨论。
1 单步成员变更日志使用什么配置
对于单步成员变更,成员变更日志是使用新成员配置 还是老成员配置Cnew呢?实际上单步成员变更日志无论使用新成员配置Cold还是老成员配置Cnew都不会破坏Cold与Cnew的多数派至少有一个节点相交,因此单步成员变更日志既可以使用新成员配置Cold也可以使用老成员配置Cnew,两种方式各有利弊。
表4 单步成员变更日志使用老成员配置和使用新成员配置的优缺点
单步成员变更日志使用老成员配置Cold,可以避免单步成员变更的正确性问题,因此可以省略掉Leader上任后的no-op日志,同时在增加成员时可能只需要更小的多数派集合,但在减少成员时可能需要更大的多数派集合。
单步成员变更日志使用新成员配置Cnew,需要Leader上任后先提交一条no-op日志,以避免单步成员变更的正确性问题,同时在减少成员时可能只需要更小的多数派集合,但在增加成员时可能需要更大的多数派集合。
单步成员变更日志不管使用新成员配置还是老成员配置,最好都同步给新老成员配置中的所有成员,这样在增加成员时可以让新成员迟早收到通知,在减少成员时也可以让被删除的成员收到通知而自动退出。
Raft论文中单步成员变更日志使用新成员配置Cnew,etcd中单步成员变更日志使用老成员配置Cold。
2 单步成员变更日志什么时候生效
表5 单步成员变更日志的生效时机
对于单步成员变更,如果成员变更日志使用新成员配置,则与Joint Consensus成员变更一样,Leader上开始同步成员变更日志之前就需要生效,在Follower上成员变更日志持久化完成后就需要生效。如果成员变更日志使用老成员配置,理论上只需要在下一次成员变更开始之前生效即可,但实际为了让新加入的节点尽快开始服务,一般在成员变更日志提交后就生效。
Raft论文中单步成员变更日志使用新成员配置Cnew,本地持久化完成就生效;etcd中单步成员变更日志使用老成员配置Cold,提交后再生效。
六 总结
Raft提供了Joint Consensus成员变更和单步成员变更,极大的推动了成员变更在工程中的应用。本文总结了一些Raft单步成员变更的问题,以及成员变更的工程实践。Joint Consensus通用并且不容易踩坑,一阶段成员变更坑比较多。工程上建议尽量使用Joint Consensus成员变更。
相关链接
[1]https://groups.google.com/g/raft-dev/c/t4xj6dJTP6E