分佈式鎖實現原理與最佳實踐
分佈式鎖應用場景
很多應用場景是需要系統保證冪等性的(如 api 服務或消息消費者),併發情況下或消息重複很容易造成系統重入,那麼分佈式鎖是保障冪等的一個重要手段。
另一方面,很多搶單場景或者叫交易撮合場景,如 dd 司機搶單或唯一商品搶拍等都需要用一把 “全局鎖” 來解決併發造成的問題。在防止併發情況下造成庫存超賣的場景,也常用分佈式鎖來解決。
實現分佈式鎖方案
這裏介紹常見兩種:redis 鎖、zookeeper 鎖
1.Redis 實現方案
1.1 實現原理
redis 分佈式鎖基本都知道 setnx 命令(if not exists),其實現原理即:如果進入 redis 添加某個鍵不存在可以設置成功,如果已存在則會設置失敗。
說明:setnx 命令已過時,這裏推薦使用 set +nx 參數來實現。
set 命令:set key value ex seconds nx
-
ex 表示過期時間,精確到秒 (對應另一個參數 px 過期時間精確到毫秒)
-
nx 表示 if not exists,只有鍵不存在才能設置成功(對應另一個參數 xx 只有鍵存在才能設置成功)
設置過期時間的作用,如果某個並行任務(進程 / 線程 / 協程)持有鎖,但不能正常釋放,將導致所有任務都無法獲取鎖,獲取執行權限。而引入了過期時間解決此問題的同時,也會引入新的問題,具體後面分析。
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. 方案對比選擇**
如果不是對鎖有特別高的要求,一般情況下使用 redis 鎖就夠了。除提到的這兩種外使用 etcd 也可以完成鎖需求,具體可以參考下方資料。
更多參考資料
etcd 實現鎖:
https://github.com/zieckey/etcdsync
文章相關實現代碼:
https://github.com/skyhackvip/lock
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/M92bQQ4ESeE-RkiPiu-oRw