使用etcd实现动态分布式选主

日常开发中经常会有后台运行的worker类任务,由于服务是分布式的,我们可能会有多个分布式的worker同时在运行,有时候我们需要分布式下只有一个worker在运行,这时候就可以用到etcd的分布式选主。

etcd中concurrency包下已经帮我们实现好了选主,我们只需要调用其api实现就可以了,下面我们分析下etcd是如何实现选主机制的。直接进行源码分析:

// Campaign puts a value as eligible for the election on the prefix
// key.
// Multiple sessions can participate in the election for the
// same prefix, but only one can be the leader at a time.
//
// If the context is 'context.TODO()/context.Background()', the Campaign
// will continue to be blocked for other keys to be deleted, unless server
// returns a non-recoverable error (e.g. ErrCompacted).
// Otherwise, until the context is not cancelled or timed-out, Campaign will
// continue to be blocked until it becomes the leader.

// 多个etcd的session可以通过prefix来参与选举。但是只有一个session能成为leader。
// Campaign方法会阻塞,直到session成功成为leader才返回。
func (e *Election) Campaign(ctx context.Context, val string) error {
	s := e.session
	client := e.session.Client()
	// 根据前缀和租约创建当前key
	k := fmt.Sprintf("%s%x", e.keyPrefix, s.Lease())
	// 如果是第一次创建key,那么key的revision为0
	// 这里用到了etcd的事务,如果if判断为true,那么put这个key,否则get这个key;最终都能获取到这个key的内容。
	txn := client.Txn(ctx).If(v3.Compare(v3.CreateRevision(k), "=", 0))
	txn = txn.Then(v3.OpPut(k, val, v3.WithLease(s.Lease())))
	txn = txn.Else(v3.OpGet(k))
	resp, err := txn.Commit()
	if err != nil {
		return err
	}
	e.leaderKey, e.leaderRev, e.leaderSession = k, resp.Header.Revision, s
	// 这里是事务中if判断为false,即执行了else
	if !resp.Succeeded {
		kv := resp.Responses[0].GetResponseRange().Kvs[0]
		e.leaderRev = kv.CreateRevision
		if string(kv.Value) != val { // 判定val是否相同,不相同的话,在不更换leader的情况下,更新val
			if err = e.Proclaim(ctx, val); err != nil {
				e.Resign(ctx)
				return err
			}
		}
	}
	// 等待prefix前缀下所有比当前key的revision小的其他key都被删除后,才返回,竞选为leader
	_, err = waitDeletes(ctx, client, e.keyPrefix, e.leaderRev-1)
	if err != nil {
		// clean up in case of context cancel
		select {
		case <-ctx.Done():
			e.Resign(client.Ctx())
		default:
			e.leaderSession = nil
		}
		return err
	}
	e.hdr = resp.Header

	return nil
}
func waitDelete(ctx context.Context, client *v3.Client, key string, rev int64) error {
	cctx, cancel := context.WithCancel(ctx)
	defer cancel()

	var wr v3.WatchResponse
	// 这里watch指定的key,对于这个key所有events事件,都会收到服务端的推送。
	wch := client.Watch(cctx, key, v3.WithRev(rev))
	for wr = range wch {
		for _, ev := range wr.Events {
			if ev.Type == mvccpb.DELETE { // 如果当前这个key被删除了,那么会退出这个方法,watch下一个key。
				return nil
			}
		}
	}
	if err := wr.Err(); err != nil {
		return err
	}
	if err := ctx.Err(); err != nil {
		return err
	}
	return fmt.Errorf("lost watcher waiting for delete")
}

// waitDeletes efficiently waits until all keys matching the prefix and no greater
// than the create revision.
func waitDeletes(ctx context.Context, client *v3.Client, pfx string, maxCreateRev int64) (*pb.ResponseHeader, error) {
        // option,获取createRevision不大于maxCreateRev的key,只取最后一个revision最大的
	getOpts := append(v3.WithLastCreate(), v3.WithMaxCreateRev(maxCreateRev))
	for {
		// 获取前缀prefix下,所有比指定revision小的key
		resp, err := client.Get(ctx, pfx, getOpts...)
		if err != nil {
			return nil, err
		}
		if len(resp.Kvs) == 0 {
			return resp.Header, nil
		}
		lastKey := string(resp.Kvs[0].Key)
		// 去watch revision最大的key,这里也会阻塞的watch。外层有循环判断,要等所有比revision小的key的没了,才退出。
                // 下方有具体的说明
		if err = waitDelete(ctx, client, lastKey, resp.Header.Revision); err != nil {
			return nil, err
		}
	}
}

总体来说还是比较好理解的,主要是利用watch机制来实现了节点在不是leader的时候的阻塞机制。

可以看到,每个节点都创建了自己的key,但是这些key的前缀是一致的,选主是根据前缀去选主的。

如果有a,b,c三个节点同时去竞选,分别对应竞选的createRevision是0,1,2,那么每个节点会watch比自己createRevision小并且最大的节点,这是个循环的过程,等到所有比自己createRevision小的节点都被删除后,自己才成为leader。

对应的,a节点会成为leader,b节点在watch a节点,c节点在watch b节点。如果b节点key被删除了,c节点会去watch a节点。

如果a节点key被删除了,b节点会成为leader。

TODO:后面补充如何使用……

好了,现在补充如何使用etcd提供的这个包来实现分布式下选主。

直接贴代码吧,然后注释里会讲解。

const CampaignPrefix = "/election-test-demo" // 这是选举的prefix

func Campaign(c *clientv3.Client, parentCtx context.Context, wg *sync.WaitGroup) (success <-chan struct{}) {
	// 我们设置etcd的value为当前机器的ip,这个不是关键
	ip, _ := getLocalIP()
	// 当外层的context关闭时,我们也会优雅的退出。
	ctx, _ := context.WithCancel(parentCtx)
	// ctx的作用是让外面通知我们要退出,wg的作用是我们通知外面已经完全退出了。当然外面要wg.Wait等待我们。
	if wg != nil {
		wg.Add(1)
	}
	// 创建一个信号channel,并返回,所有worker可以监听这个channel,这种实现可以让worker阻塞等待节点成为leader,而不是轮询是否是leader节点。
	// 返回只读channel,所有worker可以阻塞在这。
	notify := make(chan struct{}, 100)
	go func() {
		defer func() {
			if wg != nil {
				wg.Done()
			}
		}()
		for {
			select {
			case <-ctx.Done(): // 如果是非leader节点,会阻塞在Campaign方法,context被cancel后,Campaign报错,最终会从这里退出。
				return
			default:
			}
			// 创建session,session参与选主,etcd的client需要自己传入。
			// session中keepAlive机制会一直续租,如果keepAlive断掉,session.Done会收到退出信号。
			s, err := concurrency.NewSession(c, concurrency.WithTTL(5))
			if err != nil {
				fmt.Println("NewSession", "error", "err", err)
				time.Sleep(time.Second * 2)
				continue
			}
			// 创建一个新的etcd选举election
			e := concurrency.NewElection(s, CampaignPrefix)
			//调用Campaign方法,成为leader的节点会运行出来,非leader节点会阻塞在里面。
			if err = e.Campaign(ctx, ip); err != nil {
				fmt.Println("Campaign", "error", "err", err)
				s.Close()
				time.Sleep(1 * time.Second) //不致于重试的频率太高
				continue
			}
			// 运行到这的协程,成为leader,分布式下只有一个。
			fmt.Println("campaign", "success", "ip", ip)
			shouldBreak := false
			for !shouldBreak {
				select {
				case notify <- struct{}{}: // 不断向所有worker协程发信号
				case <-s.Done():  // 如果因为网络因素导致与etcd断开了keepAlive,这里break,重新创建session,重新选举
					fmt.Println("campaign", "session has done")
					shouldBreak = true
					break
				case <-ctx.Done():
					ctxTmp, _ := context.WithTimeout(context.Background(), time.Second*1)
					e.Resign(ctxTmp)
					s.Close()
					return
				}
			}
		}
	}()
	return notify
}

// 获取本机网卡IP
func getLocalIP() (ipv4 string, err error) {
	var (
		addrs   []net.Addr
		addr    net.Addr
		ipNet   *net.IPNet // IP地址
		isIpNet bool
	)
	// 获取所有网卡
	if addrs, err = net.InterfaceAddrs(); err != nil {
		return
	}
	// 取第一个非lo的网卡IP
	for _, addr = range addrs {
		//fmt.Println(addr)
		// 这个网络地址是IP地址: ipv4, ipv6
		if ipNet, isIpNet = addr.(*net.IPNet); isIpNet && !ipNet.IP.IsLoopback() {
			// 跳过IPV6
			if ipNet.IP.To4() != nil {
				ipv4 = ipNet.IP.String() // 192.168.1.1
				return
			}
		}
	}

	err = errors.New("no local ip")
	return
}

 

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值