【Raft】学习九:成员变更ConfChangeV2

13 篇文章 0 订阅
8 篇文章 2 订阅

前言

在分布式系统中,节点的增删是常见也是必须的操作,对于实用的共识算法Raft自然也提供了关于节点变更的理论基础。在Raft算法中一次变更一个节点是天然支持的,如果一次涉及多个节点的变更,对于一个稳定的系统来说就具有一定风险。例如,在前面的介绍leader选举时,leader的选举依赖集群中的大多数,如果改变了这个大多数,就会导致leader在选举出现问题。如下图(图来源于这里)所示,当新配置占据大多数,而原leader在老配置中就可能会产生新两个leader。因而本文基于Raft的理论和etcd/raft时间,探讨学习Raft的成员变更。本文主要尝试解决的问题:

  1. Raft提供的方案为什么能够解决多成员变更带来的不一致问题?
  2. etxd/raft的是如何实现成员变更的问题?
    在这里插入图片描述

理论基础

Raft在成员变更提出联合共识方案来解决成员添加的问题。联合共识有如下要求:

  1. 日志条目会复制到两个配置的所有服务节点;
  2. 任何一个配置的任何服务节点都可以成为leader;
  3. 一致(用来选举和日志条目提交)需要在旧配置和新配置中的大多数分别达成。
    在这里插入图片描述
    如图所示,这个配置更改过程分为两个步骤,当有收到新的配置时,首先会生成的新老联合配置,这个配置在未提交以前老配置可以独立决策,只要这个配置提交,就需要新老配置共同决策,也就是说需要两份配置中的各自的大多数,这时候就会生成新的配置,这个配置提交后,新配置才可以独立做决策。
    简要分析: 这种方式为什么能够避免数据不一致,首先数据不一致一般是集群中产生多个leader,正常情况下,只要在任意时刻避免多个leader产生就可以达到数据的一致性要求。而发生多个leader主要是当新节点的加入影响了大多数这个成为leader的条件。例如在当前配置中我们有【A,B,C】三个节点,现在新加入四个节点【D,E,F,G】,如果不采用联合配置,那么【D,E,F,G】就可能在收到leader的心跳之前选出新的leader,集群中就会出现两个leader。如果采用联合配置,就需要两个配置中的大多数,然而在老配置中【A,B,C】就会拒绝选举,这样【D,E,F,G】选举leader就不会成功,因为没有达到老配置的大多数。只要保证了在新配置应用之前没有发生多个leader的分化,配置更新就是安全地。只要在节点在等待主节点发送心跳之前没有选举leader成功,这样集群就算更新配置成功了。

etcd/raft 实现

etcd在实现中给出了两个方案:one by one 和联合共识方案。本文跟着上报消息流程来看看一次变更经历哪些步骤。
对于消息上报,etcd/Raft提供了两个方法在Node接口中

	// Propose proposes that data be appended to the log. Note that proposals can be lost without
	// notice, therefore it is user's job to ensure proposal retries.
	Propose(ctx context.Context, data []byte) error
	// ProposeConfChange proposes a configuration change. Like any proposal, the
	// configuration change may be dropped with or without an error being
	// returned. In particular, configuration changes are dropped unless the
	// leader has certainty that there is no prior unapplied configuration
	// change in its log.
	//
	// The method accepts either a pb.ConfChange (deprecated) or pb.ConfChangeV2
	// message. The latter allows arbitrary configuration changes via joint
	// consensus, notably including replacing a voter. Passing a ConfChangeV2
	// message is only allowed if all Nodes participating in the cluster run a
	// version of this library aware of the V2 API. See pb.ConfChangeV2 for
	// usage details and semantics.
	ProposeConfChange(ctx context.Context, cc pb.ConfChangeI) error

这两个方法都可以上报成员变更(confchange)的消息,我们这里看看Propose方法,经过Propose的方法最后都会来到stepLeader的处理阶段,也就是只有leader节点才能够处理上报消息的权力。这里我们着重看看处理配置改变的工作,EntryConfChangeEntryConfChangeV2分别代表第一个版本和第二个版本的配置变更,第二种主要是增加联合配置。

if e.Type == pb.EntryConfChange {
				var ccc pb.ConfChange
				if err := ccc.Unmarshal(e.Data); err != nil {
					panic(err)
				}
				cc = ccc
			} else if e.Type == pb.EntryConfChangeV2 {
				var ccc pb.ConfChangeV2
				if err := ccc.Unmarshal(e.Data); err != nil {
					panic(err)
				}
				cc = ccc
			}

在广播消息之前,需要先做几件事情:

if cc != nil {
				alreadyPending := r.pendingConfIndex > r.raftLog.applied
				alreadyJoint := len(r.prs.Config.Voters[1]) > 0
				wantsLeaveJoint := len(cc.AsV2().Changes) == 0

				var refused string
				if alreadyPending {
					refused = fmt.Sprintf("possible unapplied conf change at index %d (applied to %d)", r.pendingConfIndex, r.raftLog.applied)
				} else if alreadyJoint && !wantsLeaveJoint {
					refused = "must transition out of joint config first"
				} else if !alreadyJoint && wantsLeaveJoint {
					refused = "not in joint state; refusing empty conf change"
				}

				if refused != "" {
					r.logger.Infof("%x ignoring conf change %v at config %s: %s", r.id, cc, r.prs.Config, refused)
					m.Entries[i] = pb.Entry{Type: pb.EntryNormal}
				} else {
					r.pendingConfIndex = r.raftLog.lastIndex() + uint64(i) + 1
				}
			}
  1. pendingConfIndex:是否有配置正在变更,这个和切主不一样,这里如果有配置正在变更,会舍弃当前的配置变更,返回失败;
  2. wantsLeaveJoint:是否是联合配置,换句话说就是是不是涉及多个节点的变更。
  3. alreadyJoint:之前的联合配置是否已经更新为最新配置,也就是有没有清理联合配置,保留最新配置。

注意:如果alreadyJoint && !wantsLeaveJoint为真,就表示涉及多个节点变更但上次的变更还没有清理联合配置,返回失败;不是联合配置便拒绝空配置。

如果正常就将消息广播,然后返回应用层处理。
广播之后就等待消息提交然后被状态机应用。。。

two years later…

我们的confchange消息来到了这里,这里一般是由状态机应用调用,如下所示

func (n *node) ApplyConfChange(cc pb.ConfChangeI) *pb.ConfState {
	var cs pb.ConfState
	select {
	case n.confc <- cc.AsV2():
	case <-n.done:
	}
	select {
	case cs = <-n.confstatec:
	case <-n.done:
	}
	return &cs
}

这里给n.confc里面塞入消息,然后被raft监听到。也就是说应用层告诉raft说准备好了可以应用这个配置了,然后通过node就进入raft协议层

case cc := <-n.confc:
			_, okBefore := r.prs.Progress[r.id]
			// 进入raft协议层进行配置变更应用
			cs := r.applyConfChange(cc)
			// If the node was removed, block incoming proposals. Note that we
			// only do this if the node was in the config before. Nodes may be
			// a member of the group without knowing this (when they're catching
			// up on the log and don't have the latest config) and we don't want
			// to block the proposal channel in that case.
			//
			// NB: propc is reset when the leader changes, which, if we learn
			// about it, sort of implies that we got readded, maybe? This isn't
			// very sound and likely has bugs.
			if _, okAfter := r.prs.Progress[r.id]; okBefore && !okAfter {
				var found bool
			outer:
				for _, sl := range [][]uint64{cs.Voters, cs.VotersOutgoing} {
					for _, id := range sl {
						if id == r.id {
							found = true
							break outer
						}
					}
				}
				if !found {
					propc = nil
				}
			}
			select {
			case n.confstatec <- cs:
			case <-n.done:
			}

正片开始。。。
下面这一段代码,看似简单,其实只是冰山一角,接下来正式撸这一段

func (r *raft) applyConfChange(cc pb.ConfChangeV2) pb.ConfState {
	// 整个配置变更准备就在这个虚拟函数中做了
	cfg, prs, err := func() (tracker.Config, tracker.ProgressMap, error) {
		// 首先初始化一个Changer结构体
		changer := confchange.Changer{
			Tracker:   r.prs,
			LastIndex: r.raftLog.lastIndex(),
		}
		// 判断是不是来解除联合配置的,这里为什么能够判断我们暂时按下不表 ①
		if cc.LeaveJoint() {
		// 进入解除流程
			return changer.LeaveJoint()
		} else if autoLeave, ok := cc.EnterJoint(); ok { // 判断能否进入联合配置,就是来看是否有多个节点变更②
		// 然后进入联合配置变更 ③
			return changer.EnterJoint(autoLeave, cc.Changes...)
		}
		// 如果不是就施行one by one模式,就比较简单 
		return changer.Simple(cc.Changes...)
	}()
	if err != nil {
		// TODO(tbg): return the error to the caller.
		panic(err)
	}
	// 切换到准备好的配置 ④
	return r.switchToConfig(cfg, prs)
}

首先看第①点,要追述第①点我们还要看上一次配置变更的收尾工作
这段代码在raft.advance()函数中,就是当应用层把Ready结构中的数据处理完了,这是就该raft做一些收尾工作

if r.prs.Config.AutoLeave && oldApplied <= r.pendingConfIndex && newApplied >= r.pendingConfIndex && r.state == StateLeader {
			// If the current (and most recent, at least for this leader's term)
			// 这里做了一件事,就是如果AutoLeave为真,上一个配置还没收尾,就该收尾了,当然只能是leader收尾
			// 怎么收尾呢,就是发一个空配置
			ent := pb.Entry{
				Type: pb.EntryConfChangeV2,
				Data: nil,
			}
			// There's no way in which this proposal should be able to be rejected.
			if !r.appendEntry(ent) {
				panic("refused un-refusable auto-leaving ConfChangeV2")
			}
			r.pendingConfIndex = r.raftLog.lastIndex()
			r.logger.Infof("initiating automatic transition out of joint configuration %s", r.prs.Config)
		}

这个空消息在stepLeader阶段会被解析为ConfChangeV2{},然后传到applyConfChange函数中,这样就可以通过下面这个函数

func (c ConfChangeV2) LeaveJoint() bool {
	// NB: c is already a copy.
	c.Context = nil
	return proto.Equal(&c, &ConfChangeV2{})
}

来判断是否是来解除(等价于 C o l d , n e w C_{old,new} Coldnew C n e w C_{new} Cnew)上一次的联合配置的,这样也能看出为什么是两阶段的形式。
然后来看第②点,这一点比较简单,看代码

func (c ConfChangeV2) EnterJoint() (autoLeave bool, ok bool) {
	// NB: in theory, more config changes could qualify for the "simple"
	// protocol but it depends on the config on top of which the changes apply.
	// For example, adding two learners is not OK if both nodes are part of the
	// base config (i.e. two voters are turned into learners in the process of
	// applying the conf change). In practice, these distinctions should not
	// matter, so we keep it simple and use Joint Consensus liberally.
	if c.Transition != ConfChangeTransitionAuto || len(c.Changes) > 1 {
		// Use Joint Consensus.
		var autoLeave bool
		switch c.Transition {
		// ConfChangeTransitionAuto和ConfChangeTransitionJointImplicit都会在raft的收尾工作中自动完成联合配置解除
		case ConfChangeTransitionAuto:
			autoLeave = true
		case ConfChangeTransitionJointImplicit:
			autoLeave = true
			// 需要应用层显示发一个空配置告诉Raft,解除上一次的联合配置
		case ConfChangeTransitionJointExplicit:
		default:
			panic(fmt.Sprintf("unknown transition: %+v", c))
		}
		return autoLeave, true
	}
	return false, false
}

主要是判断是否自动解除联合配置以及是否需要应用联合共识
再来看第③点,这个应该是是整个联合配置最复杂的一段,先看代码

func (c Changer) EnterJoint(autoLeave bool, ccs ...pb.ConfChangeSingle) (tracker.Config, tracker.ProgressMap, error) {
	// 拷贝出要正在应用的配置和Tracker.Progressyi ③-①
	cfg, prs, err := c.checkAndCopy()
	if err != nil {
		return c.err(err)
	}
	// 如果已经是联合配置了,就返回错误 ③-②
	if joint(cfg) {
		err := errors.New("config is already joint")
		return c.err(err)
	}
	// 只是为了测试,别当真,正常运行的raft集群怎么会没有Voters
	if len(incoming(cfg.Voters)) == 0 {
		// We allow adding nodes to an empty config for convenience (testing and
		// bootstrap), but you can't enter a joint state.
		err := errors.New("can't make a zero-voter config joint")
		return c.err(err)
	}
	// 保留之前的配置放到joint[1]中
	*outgoingPtr(&cfg.Voters) = quorum.MajorityConfig{}
	// Copy incoming to outgoing.
	for id := range incoming(cfg.Voters) {
		// 赋值为原来配置,意思是即将不用的配置outgoing,即将走了。。。有没有字面意思的感觉哈哈哈
		outgoing(cfg.Voters)[id] = struct{}{}
	}
	// 应用为联合配置,这里很重要③-③
	if err := c.apply(&cfg, prs, ccs...); err != nil {
		return c.err(err)
	}
	// 这里的变更主要是让raft后面进行收尾工作
	cfg.AutoLeave = autoLeave
	//  这个我们在第①点里说一下
	return checkAndReturn(cfg, prs)
}

首先看第③-①点

func (c Changer) checkAndCopy() (tracker.Config, tracker.ProgressMap, error) {
	cfg := c.Tracker.Config.Clone()
	prs := tracker.ProgressMap{}

	for id, pr := range c.Tracker.Progress {
		// A shallow copy is enough because we only mutate the Learner field.
		ppr := *pr
		// 这里拷贝的是每个progress地址
		prs[id] = &ppr
	}
	return checkAndReturn(cfg, prs)
}

其实就是把我们在前面初始化的changer中的数据拷贝出来,初始化时传入的是当前正在应用的prs和lastIndex,因此在函数中才能拷贝出当前运行的配置。
然后看第③-②点,就更简单了,就是看看原来的配置中有没有老配置没有清理

func joint(cfg tracker.Config) bool {
	return len(outgoing(cfg.Voters)) > 0
}

继续看第③-③点,最重要的一点

// 这个函数主要做什么呢?就是根据ConfChangeSingle的内容,对原来的配置做一个变更
func (c Changer) apply(cfg *tracker.Config, prs tracker.ProgressMap, ccs ...pb.ConfChangeSingle) error {
	for _, cc := range ccs {
		if cc.NodeID == 0 {
			// etcd replaces the NodeID with zero if it decides (downstream of
			// raft) to not apply a change, so we have to have explicit code
			// here to ignore these.
			continue
		}
		switch cc.Type {
		// 如果是增加节点,就在prs中加一个,顺便清理Learners 和LearnersNext中相关的节点,下同就不啰嗦了
		case pb.ConfChangeAddNode:
			c.makeVoter(cfg, prs, cc.NodeID)
		case pb.ConfChangeAddLearnerNode:
			c.makeLearner(cfg, prs, cc.NodeID)
		case pb.ConfChangeRemoveNode:
			c.remove(cfg, prs, cc.NodeID)
		case pb.ConfChangeUpdateNode:
		default:
			return fmt.Errorf("unexpected conf type %d", cc.Type)
		}
	}
	// 这个就是如果变更后没有节点了,就是说全给删了或者变为learner节点,这必然不行啊,那干嘛不直接整一个新的集群,然后把这个集群给下了
	if len(incoming(cfg.Voters)) == 0 {
		return errors.New("removed all voters")
	}
	return nil
}

这样一看最重要但不一定难哈
总的来说,第③点做了什么事呢,就是生成联合配置,将Config中Joint的增加一个MajorityConfig,将老的命名为outgoing在joint[1],变更后为incomming在joint[0],意思为即将应用的的配置,这样就组成一个联合配置了,然后把这个配置返回给applyConfChange。现在我们就可以来看第④点了,变更切换,先看代码

// switchToConfig reconfigures this node to use the provided configuration. It
// updates the in-memory state and, when necessary, carries out additional
// actions such as reacting to the removal of nodes or changed quorum
// requirements.
//
// The inputs usually result from restoring a ConfState or applying a ConfChange.
func (r *raft) switchToConfig(cfg tracker.Config, prs tracker.ProgressMap) pb.ConfState {
// 把老配置应用程序新配置,以及prs也换一下
	r.prs.Config = cfg
	r.prs.Progress = prs

	r.logger.Infof("%x switched to configuration %s", r.id, r.prs.Config)
	// 拿到之前配置状态
	cs := r.prs.ConfState()
	// 检查当前节点是否在prs中,就是看看是否被移除
	pr, ok := r.prs.Progress[r.id]

	// Update whether the node itself is a learner, resetting to false when the
	// node is removed.
	// 当前节点是否被变为learner节点
	r.isLearner = ok && pr.IsLearner
	// 如果如果是主节点且被降级了或者移除了
	if (!ok || r.isLearner) && r.state == StateLeader {
	// 直接返回
		// This node is leader and was removed or demoted. We prevent demotions
		// at the time writing but hypothetically we handle them the same way as
		// removing the leader: stepping down into the next Term.
		//
		// TODO(tbg): step down (for sanity) and ask follower with largest Match
		// to TimeoutNow (to avoid interruption). This might still drop some
		// proposals but it's better than nothing.
		//
		// TODO(tbg): test this branch. It is untested at the time of writing.
		return cs
	}

	// Follower或者candidate
	if r.state != StateLeader || len(cs.Voters) == 0 {
		return cs
	}
	// 如果是主节点,就准备提交配置,看看大多数节点的Match Index是否大于当前commitIndex,如果大于就可提交到MatchIndex
	if r.maybeCommit() {
		// If the configuration change means that more entries are committed now,
		// broadcast/append to everyone in the updated config.
		// 然后广播给其他节点,我准备提交到MatchIndex
		r.bcastAppend()
	} else {
		// Otherwise, still probe the newly added replicas; there's no reason to
		// let them wait out a heartbeat interval (or the next incoming
		// proposal).
		r.prs.Visit(func(id uint64, pr *tracker.Progress) {
			// 或者给落后的节点发送append消息
			r.maybeSendAppend(id, false /* sendIfEmpty */)
		})
	}
	// 如果将要切主的节点被移除了,就驳回切主请求
	if _, tOK := r.prs.Config.Voters.IDs()[r.leadTransferee]; !tOK && r.leadTransferee != 0 {
		r.abortLeaderTransfer()
	}
	// 返回变更后的ConfState
	return cs
}

到这里我们在applyConfChange函数中的四点就说清楚了,然后会到node.run()中

case cc := <-n.confc:
			_, okBefore := r.prs.Progress[r.id]
			cs := r.applyConfChange(cc)
			// If the node was removed, block incoming proposals. Note that we
			// only do this if the node was in the config before. Nodes may be
			// a member of the group without knowing this (when they're catching
			// up on the log and don't have the latest config) and we don't want
			// to block the proposal channel in that case.
			//
			// NB: propc is reset when the leader changes, which, if we learn
			// about it, sort of implies that we got readded, maybe? This isn't
			// very sound and likely has bugs.
			if _, okAfter := r.prs.Progress[r.id]; okBefore && !okAfter {
				var found bool
			outer:
				for _, sl := range [][]uint64{cs.Voters, cs.VotersOutgoing} {
					for _, id := range sl {
						if id == r.id {
							found = true
							break outer
						}
					}
				}
				if !found {
					propc = nil
				}
			}
			select {
			case n.confstatec <- cs:
			case <-n.done:
			}

这里主要检查当前节点是否被移除,如果移除就停止接受上报信息,然后把变更后的配置返回给应用层。这个阶段对应的是从 C o l d C_{old} Cold C o l d , n e w C_{old,new} Cold,new。后面的leaveJoint就是从 C o l d , n e w C_{old,new} Coldnew C n e w C_{new} Cnew。后面就比较简单了,大同小异,读者可以自己去看。

总结

本文主要解决两个问题,什么是联合共识?在etcd/raft中是如何实现的?本次分享就到这里,如果有错误欢迎指正。后面准备再详细介绍一下JointConfig和MajorityConfig,这个涉及选举提交,还是很重要。好了,谢谢观看!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值