一种基于etcd实践节点自动故障转移的思路

自动故障转移是服务高可用的一种实现方式。mongodb,redis哨兵集群、 etcd都具备某种程度的故障转移能力。

今天记录利用etcd选举sdk实践 服务自动故障转移

服务以leader、follower多节点启动,日常leader接受所有业务流量,follower作为备用实例,不接受业务流量;

监测到leader宕机,follower节点自动提升为leader并接管业务流量。

1. 节点故障转移

既然是故障转移, 故所有节点的状态会发生变化, 这是一个状态机模型。

1>. 各节点向etcd注册节点标记, 并各自维持稳定的心跳保活;

2>. 参与选举

2>. 算法选出leader

4>. 迅速感知选举结果和换届

etcd作为基于raft强一致性协议实现的分布式存储, CP模型,天生对外输出协调和共识能力, 能确保不同客户端在同一时间读到的内容相同。 在我们这个故障转移的场景,能稳定的输出唯一的leader。

2. etcd 实现节点故障的实践

etcd的客户端concurrent包提供了依赖于etcd并发操作的上层行为, 比如: 分布式锁、选举、屏障。

选举能力由concurrent 包中的Election中提供。

下面是一个故障转移客户端

go 复制代码
type Client struct {
 addr               []string
 Leader             string                  // 每个服务节点都知道集群中谁是leader
 cli                  *clientv3.Client
 val                 string                   // 注册到etcd的值, 标记节点
 election          *concurrency.Election
 electSessionDoneCh <-chan struct{}      // 换届的信号
 IsLeader           bool                        //标示当前节点是否是leader
}

2.1 节点初始化,维持心跳保活

每个节点需要维持稳定的心跳保活, 以便参选和换届。

go 复制代码
session, err := concurrency.NewSession(cli, concurrency.WithTTL(10))         // 使用etcd的租约机制来实现心跳保活
 if err != nil {
  return nil, err
 }
 ele := concurrency.NewElection(session, LeaderkeyPfx)

`NewSession`^[1]^ 实现保活会话。

对应到原始的etcdctl是利用租约:

etcd 有租约操作,租约可以绑定到键值对,实现键值对的存活周期控制; 甚至租约可以不绑定到键值对,仅做心跳保活(有刷新租约的机制)

etcdctl lease grent 30
etcdctl lease keep-alive 41ce93a9f806a53b

2.2 参选

向etcd注册节点标识,这里会将以上保活会话绑定到键值对,

注意: 没有选上的节点会阻塞等待,选上的节点快速返回执行业务逻辑。

go 复制代码
func (c *Client) Election(ctx context.Context, id string) bool {
 c.Leader = c.leader() 
 err := c.election.Campaign(ctx, id)
 if err != nil {
  log.WithError(err).WithField("id", id).Error("Campaign error")
  return false
 }
 c.IsLeader = true
 return true
}

核心是利用`Campaign`API^[2]^, 这里面有etcd的事务,源码值得一看,

对应到原始的etcdctl操作:etcdctl put ‐‐lease=41ce93a9f806a53b /merc/leader/41ce93a9f806a53b 127.0.0.1:8686注意: key= /merc/leader/41ce93a9f806a53b, value= 127.0.0.1:8686, 租约是41ce93a9f806a53b(持续保活的租约)

2.3 选举算法

根据当前存活的、最早创建的节点信息键值对 来决定leader , 核心API是Leader接口

go 复制代码
// Leader returns the leader value for the current election.
func (e *Election) Leader(ctx context.Context) (*v3.GetResponse, error) {
 client := e.session.Client()
 resp, err := client.Get(ctx, e.keyPrefix, v3.WithFirstCreate()...)
if err != nil {
return nil, err
 } elseif len(resp.Kvs) == 0 {
  // no leader currently elected
return nil, ErrElectionNoLeader
 }
return resp, nil
}

func (c *Client) leader() string {
 ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
 defer cancel()

 resp, err := c.election.Leader(ctx)
if err != nil {
return""
 }
return string(resp.Kvs[0].Value)
}

对应到原始的etcdctl操作是:etcdctl --endpoints=127.0.0.1:2379 get /merc/leader --prefix --sort-by=CREATE --order=ASCEND --limit=1

--prefix 这里我们指定--prefix /merc/leader筛选key

--sort-by :以x标准(创建时间)检索数据

-- order : 以升降序对已检出的数据排序

-- limit: 从已检出的数据中取x条数据显示

2.4 监控选举结果和换届

通过watch机制通知节点业务代码leader变更,核心是`Observe` API^[3]^

go 复制代码
func (c *Client) Watchloop(id string, notify chan<- bool) error {
 ch := c.election.Observe(context.TODO()) // 信道传递最新的leader节点, 但是如果底层watcher被一其他方式中段或者超时, 信道会被关闭
 tick := time.NewTicker(time.Minute*5)          // 5min去问下,防止假死
 defer tick.Stop()
for {
  select {
case <-c.electSessionDoneCh: // Done returns a channel that closes when the lease is orphaned, expires, or  is otherwise no longer being refreshed.
   log.Warning("Recv session event")
   return fmt.Errorf("session Done")    // 意味心跳保活失败
case latestLeaderResp, ok := <-ch: // 注意, 从closed(chan) 会持续读到零值, 造成死循环。
   if !ok {
    log.WithField("topic", "watch-loop").Warn("channel closed, something underlying cause error.")
    ch = c.election.Observe(context.TODO())
   } else {
    log.WithField("topic", "watch-loop").Info(latestLeaderResp)
   }
        case <-tick.C:
  }
  ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
  defer cancel()
  resp, err := c.election.Leader(ctx)
  var isLeader bool
if err != nil {
   if err == concurrency.ErrElectionNoLeader {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
    defer cancel()
    isLeader = c.Election(ctx, id)
   } else {
    log.WithError(err).Errorf("watchLoop get leader error")
    isLeader = false
   }
  } elseif string(resp.Kvs[0].Value) != id { //收到leader变化消息,判断发现自己不是leader,停止工作
   c.Leader = string(resp.Kvs[0].Value)
   isLeader = false
  } else {
   c.Leader = string(resp.Kvs[0].Value)
   isLeader = true
  }
if isLeader != c.IsLeader {
   log.WithField("after", isLeader).WithField("before", c.IsLeader).WithField("leader", c.Leader).Info("reElect")
   notify <- isLeader
   c.IsLeader = isLeader
  }
 }
}

监听etcd键值对的变化, 用到了etcd的watch机制:

etcdctl watch --preifx=/merc/leader --start-rev=12345 --prefix监听指定前缀key在全局12345修订版之后的键值对。

2.5 自动故障转移是节点的基础服务

自动故障转移,不是业务代码, 故需要在后台持续运行, 我们开两个goroutine去执行选举和监控换届的逻辑。

讲道理,每个节点只需要知道两个信息就能各司其职

  • 谁是leader ==> 当前节点是什么角色===> 当前节点该做什么事情

  • 感知集群leader变更的能力 ===>当前节点现在要不要改变行为

go 复制代码
go func() {
  err := eCli.Watchloop(Id, notify) //后台监测与etcd的连通性以及leader节点的变化
  log.WithError(err).Error("watchLoop error")
  notify <- false
 }()

 go func() {
  if eCli.Election(context.TODO(), Id) {
   notify <- true
  }
 }()

业务逻辑的承载有赖于 notify信道的传递。

3. etcd式特色民主选举

如果不考虑依赖的CP模型,我们甚至可以使用 mysql,redis做选举

3.1 etcd强烈推荐使用层次化的键空间

与redis类似,虽然可以插入hello:world到键值对存储, 但在编程实践都都推荐使用命名空间的做法来避免键值冲突, redis推荐使用 shopping:users:u1200;

etcd v3^[4]^ 从逻辑上也是一个扁平的二进制键空间, 推荐使用前缀字符串来做命名空间。

这里我提出一个疑问?

Q: 为什么相比redis,etcd将键前缀看的如此重要,单独提供了查询配置选项?

A: etcd用于是为分布式系统的配置管理、服务发现和协调而设计的,它提供了强一致性和高可用性。它允许以层次化的方式组织数据, 这种层次化的特性比redis缓存要求的命名空间更强烈。

election.Campaign(val) 的实质是将K:V(节点id)添加到etcd, 并给予持续保活.
etcdctl put ‐‐lease=41ce93a9f806a53b /merc/leader/41ce93a9f806a53b 127.0.0.1:8686 内部实现上, 续约值成为了注册键的一部分, 参与竞选的节点都注册到/merc/leader 前缀下。

go 复制代码
func (e *Election) Campaign(ctx context.Context, val string) error {
     s := e.session
     client := e.session.Client()

     k := fmt.Sprintf("%s%x", e.keyPrefix, s.Lease())
     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 !resp.Succeeded {
      kv := resp.Responses[0].GetResponseRange().Kvs[0]
      e.leaderRev = kv.CreateRevision
      if string(kv.Value) != val {
       if err = e.Proclaim(ctx, val); err != nil {
        e.Resign(ctx)
        return err
       }
      }
     }

3.2 etcd全局修订版本号在选举算法中的应用?

当选: 当前存活的、最早创建的key是leader , 也就是说master/slave故障转移并不是随机的,下一个当上leader的是次早创建的节点。

client.Get(ctx, e.keyPrefix, v3.WithFirstCreate()...)

当前存活的,最早创建的节点: 如何定义最早创建的节点?

应该是利用找到的存活节点中 revision最小的那一个key。

什么是revision修订版?

etcd 提供了对不常变更的数据的一致性查询和变更监听能力, 同时包含对于变更的快照查询和历史版本查询, etcd在全局键空间有一个revision修订版。键空间任意一个变更,该修订版都会单调递增。

etcd的客户端交互有赖于grpc请求, 我们看了通过发起的grpc请求来验证此次使用了修订版机制。

etcd的客户端交互API氛围三大类: KV、Watch、Lease, KV 操作都收敛到do()函数内枚举发起grpc请求:

=> client.Get(ctx, e.keyPrefix, v3.WithFirstCreate()...)

==> r, err := kv.Do(ctx, OpGet(key, opts...))

====> op.toRangeRequest() 产生了https://etcd.io/docs/v3.5/learning/api/ 文档规定的grpc参数

go 复制代码
func (kv *kv) Do(ctx context.Context, op Op) (OpResponse, error) {
 var err error
 switch op.t {
case tRange:
if op.IsSortOptionValid() {
   var resp *pb.RangeResponse
   resp, err = kv.remote.Range(ctx, op.toRangeRequest(), kv.callOpts...)
   if err == nil {
    return OpResponse{get: (*GetResponse)(resp)}, nil
   }
  } else {
   err = rpctypes.ErrInvalidSortOption
  }
case tPut:

其中: v3.WithFirstCreate()构造了[]OpOption { return withTop(SortByCreateRevision, SortAscend) } grpc range请求参数, 此处可验证找到一组key之后, 按照revision升序排列,第一个key为leader。

3.3 etcd的watch机制

在监控换届时,我们用到了etcd异步监听变更事件的watch API^[5]^

异步监听变更, 本身是一个长期运行的行为, 在etcd是使用grpc的双向流式通信 来实现。这个留待读者自行去解码^[6]^。

复盘

本文从一个自动故障转移的生产实践, 延伸到

  • 状态机机制

  • etcd提供一致性共识的基础能力

  • 选举需要实现的: 参选机制、当选算法、换届方式、

描述了使用编程来实现选举和换届这一民主生活实践。

落地到etcd式特色选举,提炼了etcd全局修订版机制在选举算法中的应用, grpc流式通信在etcd watch机制中的应用。

参考资料

[1]

NewSession:https://github.com/etcd-io/etcd/blob/55500416335e959e347d368c7f8a7a0229db3f6a/client/v3/concurrency/session.go#L38
[2]

CampaignAPI:https://github.com/etcd-io/etcd/blob/9fa35e53f429ca8f21b0d6b26f24e1848f2652a6/client/v3/concurrency/election.go#L69
[3]

Observe API:https://github.com/etcd-io/etcd/blob/9fa35e53f429ca8f21b0d6b26f24e1848f2652a6/client/v3/concurrency/election.go#L173
[4]

etcd v3:https://etcd.io/docs/v3.5/learning/data_model/#logical-view
[5]

watch API:https://etcd.io/docs/v3.5/learning/api/#watch-api
[6]

解码:https://github.com/etcd-io/etcd/blob/9fa35e53f429ca8f21b0d6b26f24e1848f2652a6/client/v3/watch.go#L548

本篇文字和图片均为原创,读者可结合图片探索源码, 欢迎反馈 ~。。~。

相关推荐
Ahern_5 分钟前
Oracle 普通表至分区表的分区交换
大数据·数据库·sql·oracle
夜半被帅醒24 分钟前
MySQL 数据库优化详解【Java数据库调优】
java·数据库·mysql
不爱学习的啊Biao38 分钟前
【13】MySQL如何选择合适的索引?
android·数据库·mysql
破 风1 小时前
SpringBoot 集成 MongoDB
数据库·mongodb
Rverdoser1 小时前
MySQL-MVCC(多版本并发控制)
数据库·mysql
m0_748233641 小时前
SQL数组常用函数记录(Map篇)
java·数据库·sql
dowhileprogramming1 小时前
Python 中的迭代器
linux·数据库·python
0zxm2 小时前
08 Django - Django媒体文件&静态文件&文件上传
数据库·后端·python·django·sqlite
Minxinbb7 小时前
MySQL中Performance Schema库的详解(上)
数据库·mysql·dba
mmsx8 小时前
android sqlite 数据库简单封装示例(java)
android·java·数据库