Etcd 使用場景:通過分散式鎖思路實現自動選主

2022-07-11 06:49:14

分散式鎖?選主?

分散式鎖可以保證當有多臺範例同時競爭一把鎖時,只有一個人會成功,其他的都是失敗。諸如共用資源修改、冪等、頻控等場景都可以通過分散式鎖來實現。

還有一種場景,也可以通過分散式鎖來實現,那就是選主,為了保證服務的可用性,我們都會以一主多從的方式去部署,特別是提供儲存能力的服務。Leader服務來接收資料的寫入,然後將資料同步給Follower服務。當Leader服務掛掉時,我們需要從Follower服務中重新選舉一個服務來當Leader,複雜的方式是通過Raft協定去協商,簡單點,可以通過分散式鎖的思路來做:

  1. 所有的Follower服務去競爭同一把鎖,並給這個鎖設定一個過期時間
  2. 只會有一個Follower服務取到鎖,這把鎖的值就為它的標識,他就變成了Leader服務
  3. 其他Follower服務競爭失敗後,去獲取鎖得到的當前的Leader服務標識,與之通訊
  4. Leader服務需要在鎖過期之前不斷的續期,證明自己是健康的
  5. 所有Follower服務監控這把鎖是否還被Leader服務持有,如果沒有,就跳到了第1步

通過 Redis、Zookeeper 都可以實現,不過這次,我們使用 Etcd 來實現。

Etcd 簡單介紹

Etcd:A highly-available key value store for shared configuration and service discovery。

Etcd 是一個K/V儲存,和 Redis 功能類似,這是我對它的直觀印象,和實現Master選舉好像八竿子打不著。隨著對 Etcd 瞭解的加深,我才開始對官網介紹那句話有了一定理解,Redis K/V 儲存是用來做純粹的快取功能,高並行讀寫是核心,而 Etcd 這個基於 Raft 的分散式 K/V 儲存,強一致性的 K/V 讀寫是核心,基本這點誕生了很多有想象力的使用場景:服務發現、分散式鎖、Master 選舉等等。
基於 Etcd 以下特性,我們可以實現自動選主:

  • MVCC,key存在版本屬性,沒被建立時版本號為0
  • CAS操作,結合MVCC,可以實現競選邏輯,if(version == 0) set(key,value),通過原子操作,確保只有一臺機器能set成功;
  • Lease租約,可以對key繫結一個租約,租約到期時沒預約,這個key就會被回收;
  • Watch監聽,監聽key的變化事件,如果key被刪除,則重新發起競選。

準備工作

啟動 Etcd

我們使用 Docker 安裝,簡單方便:

> docker run -d --name Etcd-server \
    --publish 2379:2379 \
    --publish 2380:2380 \
    --env ALLOW_NONE_AUTHENTICATION=yes \
    --env ETCD_ADVERTISE_CLIENT_URLS=http://etcd-server:2379 \
    bitnami/etcd:latest

最好是使用最新般本

Go 依賴庫安裝

Etcd 提供開箱即用的選主工作庫,我們直接使用就行

> go get go.etcd.io/etcd/client/v3

這一步看似簡單,如果放在以前,少不了一頓百度,原因是因為它依賴的 grpc 和 bbolt 庫的版本不能是最新的,需要在 go.mod 中去寫死版本。所幸趕上了好時代,官方終於出手整改了,現在只要一行命令列。

選主Demo

package main

import (
   "context"
   "flag"
   "fmt"
   "os"
   "os/signal"
   "time"

   clientv3 "go.etcd.io/etcd/client/v3"
   "go.etcd.io/etcd/client/v3/concurrency"
)

var (
   serverName = flag.String("name", "", "")
)

func main() {
   flag.Parse()

   // Etcd 伺服器地址
   endpoints := []string{"127.0.0.1:2379"}
   clientConfig := clientv3.Config{
      Endpoints:   endpoints,
      DialTimeout: 2 * time.Second,
   }
   cli, err := clientv3.New(clientConfig)
   if err != nil {
      panic(err)
   }

   s1, err := concurrency.NewSession(cli)
   if err != nil {
      panic(err)
   }
   fmt.Println("session lessId is ", s1.Lease())

   e1 := concurrency.NewElection(s1, "my-election")
   go func() {
      // 參與選舉,如果選舉成功,會定時續期
      if err := e1.Campaign(context.Background(), *serverName); err != nil {
         fmt.Println(err)
      }
   }()

   masterName := ""
   go func() {
      ctx, cancel := context.WithCancel(context.TODO())
      defer cancel()
      timer := time.NewTicker(time.Second)
      for range timer.C {
         timer.Reset(time.Second)
         select {
         case resp := <-e1.Observe(ctx):
            if len(resp.Kvs) > 0 {
               // 檢視當前誰是 master
               masterName = string(resp.Kvs[0].Value)
               fmt.Println("get master with:", masterName)
            }
         }
      }
   }()

   go func() {
      timer := time.NewTicker(5 * time.Second)
      for range timer.C {
         // 判斷自己是 master 還是 slave
         if masterName == *serverName {
            fmt.Println("oh, i'm master")
         } else {
            fmt.Println("slave!")
         }
      }
   }()

   c := make(chan os.Signal, 1)
   // 接收 Ctrl C 中斷
   signal.Notify(c, os.Interrupt, os.Kill)

   s := <-c
   fmt.Println("Got signal:", s)
   e1.Resign(context.TODO())
}

我們在兩個終端分別執行下面兩個命令,模擬兩個服務去競爭:

> go run main.go -name A
session lessId is  7587863771971134868
get master with: A
get master with: A
get master with: A
get master with: A
oh, i'm master
> go run main.go -name B
session lessId is  7587863771971134876
get master with: A
get master with: A
get master with: A
get master with: A
slave!

當我們使用 Ctrl C 中斷,此時 B 就成為了 master

> go run main.go -name A
session lessId is  7587863771971134868
get master with: A
get master with: A
get master with: A
get master with: A
oh, i'm master
^CGot signal: interrupt
> go run main.go -name B
session lessId is  7587863771971134876
get master with: A
get master with: A
get master with: A
get master with: A
slave!
get master with: B
get master with: B
get master with: B
get master with: B
oh, i'm master

原理

當我們啟動 A 和 B 兩個服務時,他們後會在公共字首 "my-election/" 下建立自己的 key,這個 key 的構成為 "my-election/" + 十六進位制(LessId)。這個LessId 是在服務啟動時,從 Etcd 伺服器端取到的使用者端唯一標識。比如上面程式執行的兩個服務建立的 key 分別是:

  • A 服務建立的 key 是 "my-election/694d81e5fc652594",值是 "A"
  • B 服務建立的 key 是 "my-election/694d81e5fc65259c",值是 "B"

因為是通過事務的方式去建立 key,可以保證如果這個 key 已經建立了,不去建立了。並且這個 key 是有過期時間,兩個服務 A 和 B 會啟動一個協程定期去重新整理過期時間,通過這個方式證明自己的健康的。

現在兩個服務都建立了 key, 那麼那個才是 master 呢?我們選取最早建立的那個 key 的擁有者作為 master。
Etcd 服務的查詢介面支援根據字首查詢和按照建立時間排序,所以我們可以輕鬆的拿到第一個建立成功的 key,這個 key 對應的值就是 master 了,也就是 A 服務。

當現在 master 服務掛掉了,因為它的 key 沒有在過期之前續期,就會被刪除的,此時當初第二個建立的 key 就會變成第一個,那個 master 就變成了 B 服務。

我們是通過e1.Campaign(context.Background(), *serverName)行程式碼是參加去參加選舉的,裡面有一個細節:如果競爭失敗,這個函數會阻塞,直到它選舉成功或者服務中斷。也就是說:如果 B 服務建立的 key
不是最早的一個,那它會一直等待,直到服務 A 的 key 被刪除後,函數才會有返回。