Redis分佈式鎖原理及go的實現

業務背景: 後臺定時任務刷新Redis的數據到數據庫中,有多臺機器開啓了此定時同步的任務,但是需要其中一臺工作,其他的作爲備用,提高可用性。使用Redis分佈式鎖進行限制,拿到鎖的機器去執行具體業務,拿不到鎖的繼續輪詢。

分佈式鎖原理

分佈式鎖:當多個進程不在同一個系統中,多個進程共同競爭同一個資源,用分佈式鎖控制多個進程對資源的互斥訪問。採用Redis服務器存儲鎖信息(即SET一個Key表示已加鎖),可以實現多進程的併發讀鎖的狀態,如果沒有鎖,則只允許一個進程加鎖。
Redis分佈式鎖實現的關鍵點:

問題 問題描述 解決方案
互斥性 保證只有一個client可以獲取資源 加鎖
原子性 如果鎖不存在則執行加鎖操作,必須是原子性操作 原子性命令或者執行Lua腳本
避免死鎖 當拿到鎖的Client因宕機或網絡原因斷線後,如果鎖不能釋放就會產生死鎖 爲鎖加超時時間
鎖超時時間設定 鎖超時時間到了,業務沒執行完問題 心跳線程,不斷更新鎖超時時間
鎖的所屬權 解鈴還須繫鈴人,加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。 Client與鎖進行一一對應,使用UUID作爲鎖的值
自動重連 網絡故障導致Client連接Redis失敗的情況,網絡恢復後可以自動重連 輪詢

實現方案

方案一:採用Redis的原子性命令“SET key value EX expire-time NX”可以實現分佈式鎖的基本功能,其中的NX(Not Exist)即判斷是否已存在鎖,如果不存在key則可進行操作,SET key value 等同於加鎖,EX expire-time即設置超時時間,可以避免死鎖,但是超時時間的設置需要根據具體業務設置一個合理的經驗值,避免鎖超時時間到了,業務沒執行完的問題。

方案二:採用Lua腳本實現,Redis會將整個腳本作爲一個整體執行,因此Lua腳本可以實現原子性操作。相較於方案一,此處增加了心跳線程,不斷更新鎖超時時間,解決鎖超時時間設置不合理的問題;生成UUID(或者是隨機數字符串)作爲鎖的值,用於保證鎖與Client的一一對應;採用輪詢來實現斷線自動重連。
Talk is cheap. Show me the code.

實現方案1:SET EX NX

加鎖流程圖:
在這裏插入圖片描述
定義鎖的變量名爲lock,那麼對應Redis命令:
判斷是否加鎖的命令:GET lock
加鎖的命令:SET lock
設置超時時間的命令:EXPIRE expire-time
三條命令分開執行是不具有原子性的,比如可能會出現一個進程執行GET lock得到的結果爲nil即尚未加鎖,在其執行SET lock前另一個進程也執行了SET lock,導致兩個進程都認爲是可以加鎖的,失去互斥性。
因此判斷尚未加鎖、加鎖、設置超時時間必須原子操作,使用Redis的命令“SET key value EX expire-time NX”可以實現該原子操作。

package main

import (
 "fmt"
 "time"
 "github.com/gomodule/redigo/redis"
)

func main() {
    rds, err := redis.Dial("tcp", "127.0.0.1:6379")
    if err != nil {
        fmt.Println("Connect to redis error", err)
        return
    }   
    defer rds.Close()
    
    for true {
    	//檢查是否有所與加鎖必須是原子性操作
        result, err := rds.Do("SET", "lock", 1, "EX", 5, "NX")	
        if err != nil {
            fmt.Println("redis set error.", err)
            return
        }
        result, err = redis.String(result, err)
        // 加鎖失敗,繼續輪詢
		if result != "OK" {
            fmt.Println("SET lock failed.")
            time.Sleep(5 * time.Second)
            continue
        }

		// 加鎖成功
        fmt.Println("work begining...")
        // 此處處理業務
        fmt.Println("work end");
        
        // 業務處理結束後釋放鎖
        result, err := rds.Do("del", "lock")
        break;
    }
}

此方法弊端是對超時時間的設置有要求,需要根據具體業務設置一個合理的經驗值,避免鎖超時時間到了,業務沒執行完的問題。

實現方案2:Lua腳本

在這裏插入圖片描述

package main

import (
 "fmt"
 "time"
 "github.com/satori/go.uuid"
 "github.com/gomodule/redigo/redis"
)

var uuidClient uuid.UUID

const (
    SCRIPT_LOCK = ` 
    local res=redis.call('GET', KEYS[1])
    if res then
        return 0
    else
        redis.call('SET',KEYS[1],ARGV[1]);
        redis.call('EXPIRE',KEYS[1],ARGV[2])
        return 1
    end 
    `   

    SCRIPT_EXPIRE = ` 
    local res=redis.call('GET', KEYS[1])
    if not res then
        return -1
    end 
    if res==ARGV[1] then
        redis.call('EXPIRE', KEYS[1], ARGV[2])
        return 1
    else
        return 0
    end 
    `   

    SCRIPT_DEL = ` 
    local res=redis.call('GET', KEYS[1])
    if not res then 
        return -1
    end 
    if res==ARGV[1] then
        redis.call('DEL', KEYS[1])
    else
        return 0
    end 
    `   
)

func ResetExpire() {
    fmt.Println("Reset expire begin...")
    rds, err := redis.Dial("tcp", "127.0.0.1:6379")
    if err != nil {
        fmt.Println("Connect to redis server error.", err)
        return
    }

    for true {
        luaExpire := redis.NewScript(1, SCRIPT_EXPIRE)
        result, err := redis.Int(luaExpire.Do(rds, "lock", uuidClient.String(), 5))
        if err != nil {
            fmt.Println("luaExpire exec error", err)
            break
        }
        if result != 1 {
            fmt.Println("Reset expire failed.")
            break
        } else {
            fmt.Println("Reset expire succeed.")
        }
        time.Sleep(3 * time.Second)
    }
    fmt.Println("Reset expire end.")
}

func main() {
    for true {
        rds, err := redis.Dial("tcp", "127.0.0.1:6379")
        if err != nil {
            fmt.Println("Connect to redis server error.", err)
            time.Sleep(5 * time.Second)
            continue
        }
        defer rds.Close()
	
		// 生成UUID標識鎖與Client的對應關係
        uuidClient,err = uuid.NewV4()	//也可以生成隨機數字符串來代替
        if err != nil {
            fmt.Println("New uuid error.", err)
            return
        }

        luaLock := redis.NewScript(1, SCRIPT_LOCK)
        luaDel:= redis.NewScript(1, SCRIPT_DEL)
        for true {
            result, err := redis.Int(luaLock.Do(rds, "lock", uuidClient.String(), 5))
            if err != nil {
                fmt.Println("luaLock exec error.", err)
                break
            }

            if result == 0 {
                fmt.Println("Set lock failed.")
                time.Sleep(5 * time.Second)
                continue
            }
            fmt.Println("Set lock succeed.")

            go ResetExpire()
            // 加鎖成功
        	fmt.Println("work begining...")
	        // 此處處理業務
	        fmt.Println("work end");
	        
	        // 業務處理結束後釋放鎖
	        result, err = redis.Int(luaDel.Do(rds, "lock", uuidClient.String()))
	        return
        }
    }
}

Redis採用Lua腳本可以執行更多的個性化的原子操作,在我項目中就採用這種容錯性更高的方式。

參考文獻

[1] Redis分佈式鎖的正確實現方式
[2] 解決redis分佈式鎖過期時間到了業務沒執行完問題

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章