Etcd 實戰練習(二)

Remember 發表於 2021-04-21

文章持續更新,微信搜一搜「 吳親強的深夜食堂

上一篇etcd 實戰基礎篇(一)我們主要介紹了 etcd 使用場景以及最基礎性的一些操作(put、get、watch)。 這一篇我們接著實戰etcd其他業務場景。

基於 etcd 的分散式鎖

基於 etcd 實現一個分散式鎖特別簡單。etcd 提供了開箱即用的包 concurrency,幾行程式碼就實現一個分散式鎖。

package src

import (
  "context"
  "flag"
  "fmt"
  "github.com/coreos/etcd/clientv3"
  "github.com/coreos/etcd/clientv3/concurrency"
  "log"
  "strings"
  "time"
)

var addr = flag.String("addr", "http://127.0.0.1:2379", "etcd address")

// 初始化etcd客戶端
func initEtcdClient() *clientv3.Client {
  var client *clientv3.Client
  var err error
  // 解析etcd的地址,程式設計[]string
  endpoints := strings.Split(*addr, ",")
  // 建立一個 etcd 的客戶端
  client, err = clientv3.New(clientv3.Config{Endpoints: endpoints,
    DialTimeout: 5 * time.Second})
  if err != nil {
    fmt.Printf("初始化客戶端失敗:%v\\n", err)
    log.Fatal(err)
  }
  return client
}

func Lock(id int, lockName string) {
  client := initEtcdClient()
  defer client.Close()

  // 建立一個 session,如果程式當機奔潰,etcd可以知道
  s, err := concurrency.NewSession(client)
  if err != nil {
    log.Fatal(err)
  }
  defer s.Close()

  // 建立一個etcd locker
  locker := concurrency.NewLocker(s, lockName)

  log.Printf("id:%v 嘗試獲取鎖%v", id, lockName)
  locker.Lock()
  log.Printf("id:%v取得鎖%v", id, lockName)

  // 模擬業務耗時
  time.Sleep(time.Millisecond * 300)

  locker.Unlock()
  log.Printf("id:%v釋放鎖%v", id, lockName)
}

我們再寫個指令碼執行,看看結果。

package main

import (
  "etcd-test/src"
  "sync"
)

func main() {
  var lockName = "locker-test"
  var wg sync.WaitGroup
  for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(item int) {
      defer wg.Done()
      src.Lock(item, lockName)
    }(i)
  }
  wg.Wait()
}

我們發起了10個併發搶同一個 key 鎖的命令。執行結果如下,

圖片

從圖片可以看到,同一時刻一定只有一個 G 得到鎖,一個 G 獲取到一個鎖的前提一定是當前 key 未被鎖。

有人要問了,當一個鎖解開時,之前未獲取到鎖而發生等待的客戶端誰先獲取到這把鎖? 這個問題,我們後續分析原理的時候再揭曉。

說到分散式鎖,不得不提起 redis。它有一個看似安全實際一點都不安全的分散式鎖。它的命令模式是,

set key value [EX seconds] [PX milliseconds] [NX|XX]

這其中,介紹兩個關鍵的屬性:

  • EX 標示設定過期時間,單位是秒。

  • NX 表示 當對應的 key 不存在時,才建立。

我們在使用 redis 做分散式鎖的時候會這麼寫。(程式碼用了包 https://github.com/go-redis/redis)

func RedisLock(item int) {
  rdb = redis.NewClient(&redis.Options{
    Addr: "127.0.0.1:6379",
    Password: "",
    DB: 0,
  })
  fmt.Printf("item:%v 嘗試獲取鎖,時間:%v\\n", item, time.Now().String())
  res, _ := rdb.SetNX(ctx, "key", "value", 2*time.Second).Result()
  if !res {
    fmt.Printf("item:%v 嘗試獲取鎖失敗\\n", item)
    return
  }

  fmt.Printf("item:%v 獲取到鎖,時間:%v\\n", item, time.Now().String())
  time.Sleep(1 * time.Second) //模擬業務耗時
  fmt.Printf("item:%v 釋放鎖,時間:%v\\n", item, time.Now().String())
  rdb.Del(ctx, "key")
}
rdb.SetNX(ctx, "key", "value", 2*time.Second)

我們規定鎖的過期時間是2秒,下面有一句 time.Sleep(1 * time.Second) 用來模擬處理業務的耗時。業務處理結束,我們刪除 key rdb.Del(ctx, "key")

我們寫個簡單的指令碼,

func main() {
  var wg sync.WaitGroup
  for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(item int) {
      defer wg.Done()
      RedisLock(item)
    }(i)
  }
  wg.Wait()
}

我們開啟十個 G 併發的呼叫 RedisLock 函式。每次呼叫,函式內部都會新建一個 redis 客戶端,本質上是10個客戶端。

執行這段程式,

圖片

從圖中看出,同一時刻只有一個客戶端獲取到鎖,並且在一秒的任務處理後,釋放了鎖,好像沒太大的問題。

那麼,我再寫一個簡單的例子。

import (
  "context"
  "fmt"
  "github.com/go-redis/redis/v8"
  "sync"
  "time"
)

var ctx = context.Background()
var rdb *redis.Client

func main() {
  var wg sync.WaitGroup
  wg.Add(2)
  go func() {
    defer wg.Done()
    ExampleLock(1, 0)
  }()

  go func() {
    defer wg.Done()
    ExampleLock(2, 5)
  }()
  wg.Wait()
}


func ExampleLock(item int, timeSleep time.Duration) {
  rdb = redis.NewClient(&redis.Options{
    Addr: "127.0.0.1:6379",
    Password: "",
    DB: 0,
  })
  if timeSleep > 0 {
    time.Sleep(time.Second * timeSleep)
  }
  fmt.Printf("item:%v 嘗試獲取鎖,時間:%v\\n", item, time.Now().String())
  res, _ := rdb.SetNX(ctx, "key", "value", 3*time.Second).Result()
  if !res {
    fmt.Printf("item:嘗試獲取鎖失敗:%v\\n", item)
    return
  }

  fmt.Printf("item:%v 獲取到鎖,時間:%v\\n", item, time.Now().String())
  time.Sleep(7 * time.Second)
  fmt.Printf("item:%v 釋放鎖,時間:%v\\n", item, time.Now().String())
  rdb.Del(ctx, "key")
}

我們設定鎖的過期時間是 3 秒,而獲取鎖之後的任務處理時間為 7 秒。

然後我們開啟兩個 G。

ExampleLock(1, 0)ExampleLock(2, 5)

其中第二行數字5,從程式碼中可以看出,是指啟動 G 後過5秒去獲取鎖。

這段程式碼整體流程是這樣的:G(1) 獲取到鎖後,設定的鎖持有時間是3秒,由於任務執行需要7秒的時間,因此在3秒過後鎖會自動釋放。G(2) 可以在第5秒的時候獲取到鎖,然後它執行任務也得7秒。

最後,G(1)在獲取鎖後7秒執行釋放鎖的操作,G(2)同理。

圖片

發現問題了嗎?

G(1) 的鎖在3秒後已經自動釋放了。但是在任務處理結束後又執行了解鎖的操作,可此時這個鎖是 G(2) 的呀。

那麼接下來由於 G(1) 誤解了 G(2) 的鎖,如果此時有其他的 G,那麼就可以獲取到鎖。

等 G(2) 任務執行結束,同理又會誤解其他 G 的鎖,這是一個惡性迴圈。 這也是掘金一篇由 redis 分散式鎖造成茅臺超賣重大事故的原因之一。

至於其他的,可以自行檢視這篇文章Redis——由分散式鎖造成的重大事故

基於 etcd 的分散式佇列

對佇列更多的理論知識就不加以介紹了。我們都知道,佇列是一種先進先出的資料結構,一般也只有入隊和出隊兩種操作。 我們常常在單機的應用中使用到佇列。

那麼,如何實現一個分散式的佇列呢?。

我們可以使用 etcd 開箱即用的工具,在 etcd 底層 recipe 包裡結構 Queue,實現了一個多讀多寫的分散式佇列。

type Queue struct {
  client *v3.Client
  ctx context.Context

  keyPrefix string
}
func NewQueue(client *v3.Client, keyPrefix string) *Queue
func (q *Queue) Dequeue() (string, error)
func (q *Queue) Enqueue(val string)

我們基於此包可以很方便的實現。

package src

import (
  "github.com/coreos/etcd/clientv3"
  recipe "github.com/coreos/etcd/contrib/recipes"
  "log"
  "strconv"
  "strings"
  "sync"
  "time"
)
var addr = flag.String("addr", "http://127.0.0.1:2379", "etcd address")

// 初始化etcd客戶端
func initEtcdClient() *clientv3.Client {
  var client *clientv3.Client
  var err error
  // 解析etcd的地址,程式設計[]string
  endpoints := strings.Split(*addr, ",")
  // 建立一個 etcd 的客戶端
  client, err = clientv3.New(clientv3.Config{Endpoints: endpoints,
    DialTimeout: 5 * time.Second})
  if err != nil {
    log.Printf("初始化客戶端失敗:%v\\n", err)
    log.Fatal(err)
  }
  return client
}

func Push(keyName string) {
  client := initEtcdClient()
  defer client.Close()
  q := recipe.NewQueue(client, keyName)
  var wg sync.WaitGroup

  for i := 0; i < 3; i++ {
    for j := 0; j < 10; j++ {
      wg.Add(1)
      go func(item int) {
        defer wg.Done()
        err := q.Enqueue(strconv.Itoa(item))
        if err != nil {
          log.Printf("push err:%v\\n", err)
        }
      }(j)
    }
    time.Sleep(2 * time.Second)
  }
  wg.Wait()
}

func Pop(keyName string) {
  client := initEtcdClient()
  defer client.Close()
  q := recipe.NewQueue(client, keyName)
  for {
    res, err := q.Dequeue()
    if err != nil {
      log.Fatal(err)
      return
    }
    log.Printf("接收值:%v\\n", res)
  }
}

push 中,我們開啟3輪傳送值入隊,每次傳送10個,傳送一輪休息2秒。 在 pop 中,通過死迴圈獲取佇列中的值。

執行指令碼程式如下。

package main

import (
  "etcd-test/src"
  "time"
)

func main() {
  key := "test-queue"
  go src.Pop(key)
  time.Sleep(1 * time.Second)
  go src.Push(key)
  time.Sleep(20 * time.Second)
}

我們使用兩個 G 代表 分別執行 pushpop 操作。 同時為了達到執行效果,我們先執行 pop 等待有入隊的元素。 執行結果動畫如下,

圖片

etcd 還提供了優先順序的分散式的佇列。和上面的用法相似。只是在入隊的時候,不僅僅需要提供一個值,還需要提供一個整數,來表示當前 push 值的優先順序。數值越小,優先順序越高。

我們改動一下上述的程式碼。

package src

import (
  "github.com/coreos/etcd/clientv3"
  recipe "github.com/coreos/etcd/contrib/recipes"
  "log"
  "strconv"
  "strings"
  "sync"
  "time"
)
var addr = flag.String("addr", "http://127.0.0.1:2379", "etcd address")

// 初始化etcd客戶端
func initEtcdClient() *clientv3.Client {
  var client *clientv3.Client
  var err error
  // 解析etcd的地址,程式設計[]string
  endpoints := strings.Split(*addr, ",")
  // 建立一個 etcd 的客戶端
  client, err = clientv3.New(clientv3.Config{Endpoints: endpoints,
    DialTimeout: 5 * time.Second})
  if err != nil {
    log.Printf("初始化客戶端失敗:%v\\n", err)
    log.Fatal(err)
  }
  return client
}

func PriorityPush(keyName string) {
  client := initEtcdClient()
  defer client.Close()
  q := recipe.NewPriorityQueue(client, keyName)
  var wg sync.WaitGroup

  for j := 0; j < 10; j++ {
    wg.Add(1)
    go func(item int) {
      defer wg.Done()
      err := q.Enqueue(strconv.Itoa(item), uint16(item))
      if err != nil {
        log.Printf("push err:%v\\n", err)
      }
    }(j)
  }
  wg.Wait()
}

func PriorityPop(keyName string) {
  client := initEtcdClient()
  defer client.Close()
  q := recipe.NewPriorityQueue(client, keyName)
  for {
    res, err := q.Dequeue()
    if err != nil {
      log.Fatal(err)
      return
    }
    log.Printf("接收值:%v\\n", res)
  }
}

然後以下是我們的測試程式碼:

package main

import (
  "etcd-test/src"
  "sync"
  "time"
)
func main() {
  key := "test-queue"
  var wg sync.WaitGroup
  wg.Add(1)
  go func() {
    defer wg.Done()
    src.PriorityPush(key)
  }()
  wg.Wait()
  go src.PriorityPop(key)
  time.Sleep(20 * time.Second)
}

我們把0到9的數併發的 push 到佇列中,對應的優先順序整數值就是它本身,push 完畢,我們執行 PriorityPop 函式,看最終結果顯示就是從0到9。

圖片

總結

這篇文章主要介紹瞭如何使用 etcd 實現分散式鎖以及分散式佇列。其他etcd的場景,可以自行實踐。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
吳親庫裡