日常开发中经常会有后台运行的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) {
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的时候的阻塞机制。
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
}