Go:分布式鎖實(shí)現(xiàn)原理與最佳實(shí)踐
分布式鎖應(yīng)用場(chǎng)景
很多應(yīng)用場(chǎng)景是需要系統(tǒng)保證冪等性的(如api服務(wù)或消息消費(fèi)者),并發(fā)情況下或消息重復(fù)很容易造成系統(tǒng)重入,那么分布式鎖是保障冪等的一個(gè)重要手段。
另一方面,很多搶單場(chǎng)景或者叫交易撮合場(chǎng)景,如dd司機(jī)搶單或唯一商品搶拍等都需要用一把“全局鎖”來(lái)解決并發(fā)造成的問(wèn)題。在防止并發(fā)情況下造成庫(kù)存超賣(mài)的場(chǎng)景,也常用分布式鎖來(lái)解決。
實(shí)現(xiàn)分布式鎖方案
這里介紹常見(jiàn)兩種:redis鎖、zookeeper鎖
1.Redis實(shí)現(xiàn)方案
1.1實(shí)現(xiàn)原理
redis分布式鎖基本都知道setnx命令(if not exists),其實(shí)現(xiàn)原理即:如果進(jìn)入redis添加某個(gè)鍵不存在可以設(shè)置成功,如果已存在則會(huì)設(shè)置失敗。
說(shuō)明:setnx命令已過(guò)時(shí),這里推薦使用set +nx參數(shù)來(lái)實(shí)現(xiàn)。
set命令:set key value ex seconds nx
ex 表示過(guò)期時(shí)間,精確到秒 (對(duì)應(yīng)另一個(gè)參數(shù)px過(guò)期時(shí)間精確到毫秒)
nx 表示if not exists,只有鍵不存在才能設(shè)置成功(對(duì)應(yīng)另一個(gè)參數(shù)xx只有鍵存在才能設(shè)置成功)

設(shè)置過(guò)期時(shí)間的作用,如果某個(gè)并行任務(wù)(進(jìn)程/線程/協(xié)程)持有鎖,但不能正常釋放,將導(dǎo)致所有任務(wù)都無(wú)法獲取鎖,獲取執(zhí)行權(quán)限。而引入了過(guò)期時(shí)間解決此問(wèn)題的同時(shí),也會(huì)引入新的問(wèn)題,具體后面分析。
1.2代碼實(shí)現(xiàn)
import?"github.com/go-redis/redis"??//redis package//connect?redisvar client = redis.NewClient(&redis.Options{Addr: "localhost:6379",Password: "",DB: 0,})//lockfunc?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 actionvar counter int64func incr() {counter++fmt.Printf("after incr is %d\n", counter)}//5?goroutine?compete?lockvar wg sync.WaitGroupfunc?main()?{for i := 0; i < 5; i++ {wg.Add(1)go func() {lock(incr)}()}wg.Wait()fmt.Printf("final counter is %d \n", counter)}
以上代碼截取關(guān)鍵部分,完整代碼參見(jiàn):
https://github.com/skyhackvip/lock/blob/master/redislock.go
代碼執(zhí)行結(jié)果:

根據(jù)執(zhí)行結(jié)果可以看到,每次執(zhí)行最后的計(jì)數(shù)不一樣,多個(gè)協(xié)程間互相搶鎖,只有拿到鎖才會(huì)計(jì)數(shù)加1,搶鎖失敗則不執(zhí)行。
這里說(shuō)明下:由于routine執(zhí)行時(shí)間太短,執(zhí)行完把鎖釋放了所以才有其他routine可以拿到鎖。如果incr代碼中增加sleep時(shí)間,那么結(jié)果都是1了。
用一張圖來(lái)更直觀解釋具體執(zhí)行情況:

1.3方案缺陷
剛才提到使用了過(guò)期時(shí)間,雖然解決了“死鎖”問(wèn)題,但會(huì)引來(lái)新的問(wèn)題,具體問(wèn)題分析如下:

可以看到routine1拿到鎖,但由于執(zhí)行時(shí)間過(guò)長(zhǎng)(比鎖失效時(shí)間長(zhǎng)),導(dǎo)致鎖提前失效釋放,routine3可以正常拿到鎖,而之后routine1進(jìn)行鎖釋放,當(dāng)routine3進(jìn)行鎖釋放時(shí)就會(huì)失敗,如果此時(shí)有其他并發(fā)來(lái)的時(shí)候鎖也會(huì)有問(wèn)題。
1.4方案優(yōu)化
那么有什么有效解決方案呢?
簡(jiǎn)單來(lái)說(shuō)就是利用lock的value,還記得之前代碼設(shè)置lock的時(shí)候隨便使用了一個(gè)值1就打發(fā)了。
resp := client.SetNX(lockKey, 1, time.Second*5)
這里的1可以改為能識(shí)別該routine的唯一值(如uid,orderid等),也可以使用uuid隨機(jī)生成一個(gè)。(關(guān)于如何生成uuid方案參見(jiàn)公眾號(hào)上一篇文章)
func?lock(myfunc func())?{????//lockuuid := 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?funcmyfunc()//unlockvalue, _ := 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的比較,確認(rèn)了是當(dāng)前routine,才會(huì)進(jìn)行刪除。至此問(wèn)題解決了嗎?
value, _ := client.Get(lockKey).Result() 和 value==uuid
這個(gè)操作本身不具有“原子性”,可能當(dāng)獲取到value并且對(duì)比一致了,但此時(shí)lock過(guò)期失效了,而同時(shí)另一個(gè)routine拿到了結(jié)果,那么這里又會(huì)把別人的鎖誤刪除了。
1.5方案再優(yōu)化
那么有沒(méi)有辦法保障操作的原子性呢,這里可以使用lua徹底解決,lua是嵌入式語(yǔ)言,redis本身支持。使用golang操作redis運(yùn)行l(wèi)ua命令,保障問(wèn)題解決。上代碼如下:
func lock(myfunc func()) {//...code//unlockvar luaScript = redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1]thenreturn redis.call("del", KEYS[1])elsereturn 0end`)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。通過(guò)執(zhí)行l(wèi)ua來(lái)保障這里刪除鎖的操作是原子的。
完整代碼參見(jiàn):https://github.com/skyhackvip/lock/blob/master/redislualock.go
1.6redis鎖適用場(chǎng)景
由redis設(shè)置的鎖,多個(gè)并發(fā)任務(wù)進(jìn)行爭(zhēng)搶占用,因此非常適合高并發(fā)情況下,用來(lái)進(jìn)行搶鎖。
2.zookeeper鎖
2.1實(shí)現(xiàn)原理
使用zk的臨時(shí)節(jié)點(diǎn)插入值,如果插入成功后watch會(huì)通知所有監(jiān)聽(tīng)節(jié)點(diǎn),此時(shí)其他并行任務(wù)不可再進(jìn)行插入。具體圖示如下:

2.2代碼實(shí)現(xiàn)
import "github.com/samuel/go-zookeeper/zk" //package//connect?zkconn,?_,?err?:=?zk.Connect([]string{"localhost:2181"},?time.Second)//zklockfunc 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?runfor?i?:=?0;?i?5;?i++?{?????go?zklock(conn,?incr)}
完整代碼參見(jiàn):https://github.com/skyhackvip/lock/blob/master/zklock.go
執(zhí)行結(jié)果如下:

每次執(zhí)行,執(zhí)行結(jié)果都是5。
2.3zookeeper鎖適用場(chǎng)景
相比于redis搶鎖導(dǎo)致其他routine搶鎖失敗退出,使用zk實(shí)現(xiàn)的鎖會(huì)讓其他routine處于“等鎖”狀態(tài)。
?3.方案對(duì)比選擇
redis鎖 | zookeeper鎖 | |
描述 | 使用set nx實(shí)現(xiàn) | 使用臨時(shí)節(jié)點(diǎn)+watch實(shí)現(xiàn) |
依賴(lài) | redis | zookeeper |
適用場(chǎng)景 | 并發(fā)搶鎖 | 鎖占用時(shí)間長(zhǎng)其他任務(wù)可等待。如消息冪等消費(fèi)。 |
高可用性 | redis發(fā)生故障主從切換等可能導(dǎo)致鎖失效 | 利用paxos協(xié)議能保證分布式一致性,數(shù)據(jù)更可靠 |
如果不是對(duì)鎖有特別高的要求,一般情況下使用redis鎖就夠了。除提到的這兩種外使用etcd也可以完成鎖需求,具體可以參考下方資料。
更多參考資料
etcd實(shí)現(xiàn)鎖:
https://github.com/zieckey/etcdsync
文章相關(guān)實(shí)現(xiàn)代碼:
https://github.com/skyhackvip/lock
推薦閱讀
