文章持續更新,微信搜一搜「 吳親強的深夜食堂 」
上一篇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
代表 分別執行 push
和 pop
操作。 同時為了達到執行效果,我們先執行 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 協議》,轉載必須註明作者和本文連結