分佈式鎖實現原理與最佳實踐

分佈式鎖應用場景

很多應用場景是需要系統保證冪等性的(如 api 服務或消息消費者),併發情況下或消息重複很容易造成系統重入,那麼分佈式鎖是保障冪等的一個重要手段。

另一方面,很多搶單場景或者叫交易撮合場景,如 dd 司機搶單或唯一商品搶拍等都需要用一把 “全局鎖” 來解決併發造成的問題。在防止併發情況下造成庫存超賣的場景,也常用分佈式鎖來解決。

實現分佈式鎖方案

這裏介紹常見兩種:redis 鎖、zookeeper 鎖

1.Redis 實現方案

1.1 實現原理

redis 分佈式鎖基本都知道 setnx 命令(if not exists),其實現原理即:如果進入 redis 添加某個鍵不存在可以設置成功,如果已存在則會設置失敗。

說明:setnx 命令已過時,這裏推薦使用 set +nx 參數來實現。

set 命令:set key value ex seconds nx

設置過期時間的作用,如果某個並行任務(進程 / 線程 / 協程)持有鎖,但不能正常釋放,將導致所有任務都無法獲取鎖,獲取執行權限。而引入了過期時間解決此問題的同時,也會引入新的問題,具體後面分析。

1.2 代碼實現

import "github.com/go-redis/redis"  //redis package
//connect redis
var client = redis.NewClient(&redis.Options{
    Addr:     "localhost:6379",
    Password: "",
    DB:       0,
})
//lock
func lock(myfunc func()) {
    var lockKey = "mylockr"
    //lock
    lockSuccess, err := client.SetNX(lockKey, 1, time.Second*5).Result()
    if err != nil || !lockSuccess {
        fmt.Println("get lock fail")
        return
    } else {
        fmt.Println("get lock")
    }
    //run func
    myfunc()
    //unlock
    _, err := client.Del(lockKey).Result()
    if err != nil {
        fmt.Println("unlock fail")
    } else {
        fmt.Println("unlock")
    }
}
//do action
var counter int64
func incr() {
    counter++
    fmt.Printf("after incr is %d\n", counter)
}
//5 goroutine compete lock
var wg sync.WaitGroup
func main() {
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            lock(incr)
        }()
    }
    wg.Wait()
    fmt.Printf("final counter is %d \n", counter)
}

以上代碼截取關鍵部分,完整代碼參見:

https://github.com/skyhackvip/lock/blob/master/redislock.go

代碼執行結果:

根據執行結果可以看到,每次執行最後的計數不一樣,多個協程間互相搶鎖,只有拿到鎖纔會計數加 1,搶鎖失敗則不執行。

這裏說明下:由於 routine 執行時間太短,執行完把鎖釋放了所以纔有其他 routine 可以拿到鎖。如果 incr 代碼中增加 sleep 時間,那麼結果都是 1 了。

用一張圖來更直觀解釋具體執行情況:

1.3 方案缺陷

剛纔提到使用了過期時間,雖然解決了 “死鎖” 問題,但會引來新的問題,具體問題分析如下:

可以看到 routine1 拿到鎖,但由於執行時間過長(比鎖失效時間長),導致鎖提前失效釋放,routine3 可以正常拿到鎖,而之後 routine1 進行鎖釋放,當 routine3 進行鎖釋放時就會失敗,如果此時有其他併發來的時候鎖也會有問題。

1.4 方案優化

那麼有什麼有效解決方案呢?

簡單來說就是利用 lock 的 value,還記得之前代碼設置 lock 的時候隨便使用了一個值 1 就打發了。

resp := client.SetNX(lockKey, 1, time.Second*5)

這裏的 1 可以改爲能識別該 routine 的唯一值(如 uid,orderid 等),也可以使用 uuid 隨機生成一個。(關於如何生成 uuid 方案參見公衆號上一篇文章)

func lock(myfunc func()) {
    //lock
    uuid := getUuid()
    lockSuccess, err := client.SetNX(lockKey, uuid, time.Second*5).Result()
    if err != nil || !lockSuccess {
        fmt.Println("get lock fail")
        return
    } else {
        fmt.Println("get lock")
    }   
    //run func
    myfunc()
    //unlock
    value, _ := client.Get(lockKey).Result()
    if value == uuid { //compare value,if equal then del
        _, err := client.Del(lockKey).Result()
        if err != nil {
            fmt.Println("unlock fail")
        }  else {
            fmt.Println("unlock")
        }
    }
}

這裏增加了 value 的比較,確認了是當前 routine,纔會進行刪除。至此問題解決了嗎?

value, _ := client.Get(lockKey).Result()value==uuid

這個操作本身不具有 “原子性”,可能當獲取到 value 並且對比一致了,但此時 lock 過期失效了,而同時另一個 routine 拿到了結果,那麼這裏又會把別人的鎖誤刪除了。

1.5 方案再優化

那麼有沒有辦法保障操作的原子性呢,這裏可以使用 lua 徹底解決,lua 是嵌入式語言,redis 本身支持。使用 golang 操作 redis 運行 lua 命令,保障問題解決。上代碼如下:

func lock(myfunc func()) {
    //...code
    //unlock
    var luaScript = redis.NewScript(`
        if redis.call("get", KEYS[1]) == ARGV[1]
            then
                return redis.call("del", KEYS[1])
            else
                return 0
        end
    `)
    rs, _ := luaScript.Run(client, []string{lockKey}, uuid).Result()
    if rs == 0 {
        fmt.Println("unlock fail")
    } else {
        fmt.Println("unlock")
    }
}

lua 腳本中 KEYS[1] 代表 lock 的 key,ARGV[1] 代表 lock 的 value,也就是生成的 uuid。通過執行 lua 來保障這裏刪除鎖的操作是原子的。

完整代碼參見:https://github.com/skyhackvip/lock/blob/master/redislualock.go

1.6redis 鎖適用場景

由 redis 設置的鎖,多個併發任務進行爭搶佔用,因此非常適合高併發情況下,用來進行搶鎖。

2.zookeeper 鎖

2.1 實現原理

使用 zk 的臨時節點插入值,如果插入成功後 watch 會通知所有監聽節點,此時其他並行任務不可再進行插入。具體圖示如下:

2.2 代碼實現

import "github.com/samuel/go-zookeeper/zk" //package
//connect zk
conn, _, err := zk.Connect([]string{"localhost:2181"}, time.Second)
//zklock
func zklock(conn *zk.Conn, myfunc func()) {
    lock := zk.NewLock(conn, "/mylock", zk.WorldACL(zk.PermAll))    
    err := lock.Lock()
    if err != nil {
        panic(err)
    }   
    fmt.Println("get lock")
    myfunc()
    lock.Unlock()
    fmt.Println("unlock")
}
//goroutine run
for i := 0; i < 5; i++ {
     go zklock(conn, incr)
}

完整代碼參見:https://github.com/skyhackvip/lock/blob/master/zklock.go

執行結果如下:

每次執行,執行結果都是 5。

2.3zookeeper 鎖適用場景

相比於 redis 搶鎖導致其他 routine 搶鎖失敗退出,使用 zk 實現的鎖會讓其他 routine 處於 “等鎖” 狀態。

** 3. 方案對比選擇**

uqb5Ql

如果不是對鎖有特別高的要求,一般情況下使用 redis 鎖就夠了。除提到的這兩種外使用 etcd 也可以完成鎖需求,具體可以參考下方資料。

更多參考資料

etcd 實現鎖:

https://github.com/zieckey/etcdsync

文章相關實現代碼:

https://github.com/skyhackvip/lock

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/M92bQQ4ESeE-RkiPiu-oRw