使用Redis實(shí)現(xiàn)分布式鎖的方法
Redis 中的分布式鎖如何使用
分布式鎖的使用場景
為了保證我們線上服務(wù)的并發(fā)性和安全性,目前我們的服務(wù)一般拋棄了單體應(yīng)用,采用的都是擴(kuò)展性很強(qiáng)的分布式架構(gòu)。
對于可變共享資源的訪問,同一時(shí)刻,只能由一個(gè)線程或者進(jìn)程去訪問操作。這時(shí)候我們就需要做個(gè)標(biāo)識,如果當(dāng)前有線程或者進(jìn)程在操作共享變量,我們就做個(gè)標(biāo)記,標(biāo)識當(dāng)前資源正在被操作中, 其它的線程或者進(jìn)程,就不能進(jìn)行操作了。當(dāng)前操作完成之后,刪除標(biāo)記,這樣其他的線程或者進(jìn)程,就能來申請共享變量的操作。通過上面的標(biāo)記來保證同一時(shí)刻共享變量只能由一個(gè)線程或者進(jìn)行持有。
對于單體應(yīng)用:多個(gè)線程之間訪問可變共享變量,比較容易處理,可簡單使用內(nèi)存來存儲標(biāo)示即可;
分布式應(yīng)用:這種場景下比較麻煩,因?yàn)槎鄠€(gè)應(yīng)用,部署的地址可能在不同的機(jī)房,一個(gè)在北京一個(gè)在上海。不能簡單的存儲標(biāo)示在內(nèi)存中了,這時(shí)候需要使用公共內(nèi)存來記錄該標(biāo)示,栗如 Redis,MySQL 。。。
使用 Redis 來實(shí)現(xiàn)分布式鎖
這里來聊聊如何使用 Redis 實(shí)現(xiàn)分布式鎖
Redis 中分布式鎖一般會用 set key value px milliseconds nx
或者 SETNX+Lua
來實(shí)現(xiàn)。
因?yàn)?SETNX
命令,需要配合 EXPIRE
設(shè)置過期時(shí)間,Redis 中單命令的執(zhí)行是原子性的,組合命令就需要使用 Lua 才能保證原子性了。
看下如何實(shí)現(xiàn)
使用 set key value px milliseconds nx
實(shí)現(xiàn)
因?yàn)檫@個(gè)命令同時(shí)能夠設(shè)置鍵值和過期時(shí)間,同時(shí)Redis中的單命令都是原子性的,所以加鎖的時(shí)候使用這個(gè)命令即可
func (r *Redis) TryLock(ctx context.Context, key, value string, expire time.Duration) (isGetLock bool, err error) { // 使用 set nx res, err := r.Do(ctx, "set", key, value, "px", expire.Milliseconds(), "nx").Result() if err != nil { return false, err } if res == "OK" { return true, nil } return false, nil }
SETNX+Lua 實(shí)現(xiàn)
如果使用 SETNX 命令,這個(gè)命令不能設(shè)置過期時(shí)間,需要配合 EXPIRE 命令來使用。
因?yàn)槭怯玫搅藘蓚€(gè)命令,這時(shí)候兩個(gè)命令的組合使用是不能保障原子性的,在一些并發(fā)比較大的時(shí)候,需要配合使用 Lua 腳本來保證命令的原子性。
func tryLockScript() string { script := ` local key = KEYS[1] local value = ARGV[1] local expireTime = ARGV[2] local isSuccess = redis.call('SETNX', key, value) if isSuccess == 1 then redis.call('EXPIRE', key, expireTime) return "OK" end return "unLock" ` return script } func (r *Redis) TryLock(ctx context.Context, key, value string, expire time.Duration) (isGetLock bool, err error) { // 使用 Lua + SETNX res, err := r.Eval(ctx, tryLockScript(), []string{key}, value, expire.Seconds()).Result() if err != nil { return false, err } if res == "OK" { return true, nil } return false, nil }
除了上面加鎖兩個(gè)命令的區(qū)別之外,在解鎖的時(shí)候需要注意下不能誤刪除別的線程持有的鎖
為什么會出現(xiàn)這種情況呢,這里來分析下
舉個(gè)栗子
1、線程1獲取了鎖,鎖的過期時(shí)間為1s;
2、線程1完成了業(yè)務(wù)操作,用時(shí)1.5s ,這時(shí)候線程1的鎖已經(jīng)被過期時(shí)間自動釋放了,這把鎖已經(jīng)被別的線程獲取了;
3、但是線程1不知道,接著去釋放鎖,這時(shí)候就會將別的線程的鎖,錯(cuò)誤的釋放掉。
面對這種情況,其實(shí)也很好處理
1、設(shè)置 value 具有唯一性;
2、每次刪除鎖的時(shí)候,先去判斷下 value 的值是否能對的上,不相同就表示,鎖已經(jīng)被別的線程獲取了;
看下代碼實(shí)現(xiàn)
var UnLockErr = errors.New("未解鎖成功") func unLockScript() string { script := ` local value = ARGV[1] local key = KEYS[1] local keyValue = redis.call('GET', key) if tostring(keyValue) == tostring(value) then return redis.call('DEL', key) else return 0 end ` return script } func (r *Redis) Unlock(ctx context.Context, key, value string) (bool, error) { res, err := r.Eval(ctx, unLockScript(), []string{key}, value).Result() if err != nil { return false, err } return res.(int64) != 0, nil }
代碼可參考lock
上面的這類鎖的最大缺點(diǎn)就是只作用在一個(gè)節(jié)點(diǎn)上,即使 Redis 通過 sentinel 保證高可用,如果這個(gè) master 節(jié)點(diǎn)由于某些原因放生了主從切換,那么就會出現(xiàn)鎖丟失的情況:
1、在 Redis 的 master 節(jié)點(diǎn)上拿到了鎖;
2、但是這個(gè)加鎖的 key 還沒有同步到 slave 節(jié)點(diǎn);
3、master 故障,發(fā)生了故障轉(zhuǎn)移,slave 節(jié)點(diǎn)升級為 master 節(jié)點(diǎn);
4、導(dǎo)致鎖丟失。
針對這種情況如何處理呢,下面來聊聊 Redlock 算法
使用 Redlock 實(shí)現(xiàn)分布式鎖
在 Redis 的分布式環(huán)境中,我們假設(shè)有 N 個(gè) Redis master
。這些節(jié)點(diǎn)完全互相獨(dú)立,不存在主從復(fù)制或者其他集群協(xié)調(diào)機(jī)制。我們確保將在 N 個(gè)實(shí)例上使用與在 Redis 單實(shí)例下相同方法獲取和釋放鎖?,F(xiàn)在我們假設(shè)有 5 個(gè) Redis master
節(jié)點(diǎn),同時(shí)我們需要在5臺服務(wù)器上面運(yùn)行這些 Redis 實(shí)例,這樣保證他們不會同時(shí)都宕掉。
為了取到鎖,客戶端營該執(zhí)行以下操作:
1、獲取當(dāng)前Unix時(shí)間,以毫秒為單位。
2、依次嘗試從5個(gè)實(shí)例,使用相同的key和具有唯一性的 value(例如UUID)獲取鎖。當(dāng)向 Redis 請求獲取鎖時(shí),客戶端應(yīng)該設(shè)置一個(gè)網(wǎng)絡(luò)連接和響應(yīng)超時(shí)時(shí)間,這個(gè)超時(shí)時(shí)間應(yīng)該小于鎖的失效時(shí)間。例如你的鎖自動失效時(shí)間為10秒,則超時(shí)時(shí)間應(yīng)該在 5-50 毫秒之間。這樣可以避免服務(wù)器端 Redis 已經(jīng)掛掉的情況下,客戶端還在死死地等待響應(yīng)結(jié)果。如果服務(wù)器端沒有在規(guī)定時(shí)間內(nèi)響應(yīng),客戶端應(yīng)該盡快嘗試去另外一個(gè) Redis 實(shí)例請求獲取鎖;
3、客戶端使用當(dāng)前時(shí)間減去開始獲取鎖時(shí)間(步驟1記錄的時(shí)間)就得到獲取鎖使用的時(shí)間。當(dāng)且僅當(dāng)從大多數(shù)(N/2+1
,這里是3個(gè)節(jié)點(diǎn))的 Redis 節(jié)點(diǎn)都取到鎖,并且使用的時(shí)間小于鎖失效時(shí)間時(shí),鎖才算獲取成功;
4、如果取到了鎖,key 的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間(步驟3計(jì)算的結(jié)果);
5、如果因?yàn)槟承┰颍@取鎖失?。]有在至少N/2+1
個(gè) Redis 實(shí)例取到鎖或者取鎖時(shí)間已經(jīng)超過了有效時(shí)間),客戶端應(yīng)該在所有的Redis實(shí)例上進(jìn)行解鎖(即便某些 Redis 實(shí)例根本就沒有加鎖成功,防止某些節(jié)點(diǎn)獲取到鎖但是客戶端沒有得到響應(yīng)而導(dǎo)致接下來的一段時(shí)間不能被重新獲取鎖)。
根據(jù)官方的推薦,go 版本中 Redsync 實(shí)現(xiàn)了這一算法,這里看下具體的實(shí)現(xiàn)過程
redsync項(xiàng)目地址
// LockContext locks m. In case it returns an error on failure, you may retry to acquire the lock by calling this method again. func (m *Mutex) LockContext(ctx context.Context) error { if ctx == nil { ctx = context.Background() } value, err := m.genValueFunc() if err != nil { return err } for i := 0; i < m.tries; i++ { if i != 0 { select { case <-ctx.Done(): // Exit early if the context is done. return ErrFailed case <-time.After(m.delayFunc(i)): // Fall-through when the delay timer completes. } } start := time.Now() // 嘗試在所有的節(jié)點(diǎn)中加鎖 n, err := func() (int, error) { ctx, cancel := context.WithTimeout(ctx, time.Duration(int64(float64(m.expiry)*m.timeoutFactor))) defer cancel() return m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) { // acquire 加鎖函數(shù) return m.acquire(ctx, pool, value) }) }() if n == 0 && err != nil { return err } // 如果加鎖節(jié)點(diǎn)書沒有達(dá)到的設(shè)定的數(shù)目 // 或者鍵值的過期時(shí)間已經(jīng)到了 // 在所有的節(jié)點(diǎn)中解鎖 now := time.Now() until := now.Add(m.expiry - now.Sub(start) - time.Duration(int64(float64(m.expiry)*m.driftFactor))) if n >= m.quorum && now.Before(until) { m.value = value m.until = until return nil } _, err = func() (int, error) { ctx, cancel := context.WithTimeout(ctx, time.Duration(int64(float64(m.expiry)*m.timeoutFactor))) defer cancel() return m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) { // 解鎖函數(shù) return m.release(ctx, pool, value) }) }() if i == m.tries-1 && err != nil { return err } } return ErrFailed } // 遍歷所有的節(jié)點(diǎn),并且在每個(gè)節(jié)點(diǎn)中執(zhí)行傳入的函數(shù) func (m *Mutex) actOnPoolsAsync(actFn func(redis.Pool) (bool, error)) (int, error) { type result struct { Status bool Err error } ch := make(chan result) // 執(zhí)行傳入的函數(shù) for _, pool := range m.pools { go func(pool redis.Pool) { r := result{} r.Status, r.Err = actFn(pool) ch <- r }(pool) } n := 0 var err error // 計(jì)算執(zhí)行成功的節(jié)點(diǎn)數(shù)目 for range m.pools { r := <-ch if r.Status { n++ } else if r.Err != nil { err = multierror.Append(err, r.Err) } } return n, err } // 手動解鎖的lua腳本 var deleteScript = redis.NewScript(1, ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end `) // 手動解鎖 func (m *Mutex) release(ctx context.Context, pool redis.Pool, value string) (bool, error) { conn, err := pool.Get(ctx) if err != nil { return false, err } defer conn.Close() status, err := conn.Eval(deleteScript, m.name, value) if err != nil { return false, err } return status != int64(0), nil }
分析下思路
1、遍歷所有的節(jié)點(diǎn),然后嘗試在所有的節(jié)點(diǎn)中執(zhí)行加鎖的操作;
2、收集加鎖成功的節(jié)點(diǎn)數(shù),如果沒有達(dá)到指定的數(shù)目,釋放剛剛添加的鎖;
關(guān)于 Redlock 的缺點(diǎn)可參見
How to do distributed locking
鎖的續(xù)租
Redis 中分布式鎖還有一個(gè)問題就是鎖的續(xù)租問題,當(dāng)鎖的過期時(shí)間到了,但是業(yè)務(wù)的執(zhí)行時(shí)間還沒有完成,這時(shí)候就需要對鎖進(jìn)行續(xù)租了
續(xù)租的流程
1、當(dāng)客戶端加鎖成功后,可以啟動一個(gè)定時(shí)的任務(wù),每隔一段時(shí)間,檢查業(yè)務(wù)是否完成,未完成,增加 key 的過期時(shí)間;
2、這里判斷業(yè)務(wù)是否完成的依據(jù)是:
- 1、這個(gè) key 是否存在,如果 key 不存在了,就表示業(yè)務(wù)已經(jīng)執(zhí)行完成了,也就不需要進(jìn)行續(xù)租操作了;
- 2、同時(shí)需要校驗(yàn)下 value 值,如果 value 對應(yīng)的值和之前寫入的值不同了,說明當(dāng)前鎖已經(jīng)被別的線程獲取了;
看下 redsync 中續(xù)租的實(shí)現(xiàn)
// Extend resets the mutex's expiry and returns the status of expiry extension. func (m *Mutex) Extend() (bool, error) { return m.ExtendContext(nil) } // ExtendContext resets the mutex's expiry and returns the status of expiry extension. func (m *Mutex) ExtendContext(ctx context.Context) (bool, error) { start := time.Now() // 嘗試在所有的節(jié)點(diǎn)中加鎖 n, err := m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) { return m.touch(ctx, pool, m.value, int(m.expiry/time.Millisecond)) }) if n < m.quorum { return false, err } // 判斷下鎖的過期時(shí)間 now := time.Now() until := now.Add(m.expiry - now.Sub(start) - time.Duration(int64(float64(m.expiry)*m.driftFactor))) if now.Before(until) { m.until = until return true, nil } return false, ErrExtendFailed } var touchScript = redis.NewScript(1, ` // 需要先比較下當(dāng)前的value值 if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("PEXPIRE", KEYS[1], ARGV[2]) else return 0 end `) func (m *Mutex) touch(ctx context.Context, pool redis.Pool, value string, expiry int) (bool, error) { conn, err := pool.Get(ctx) if err != nil { return false, err } defer conn.Close() status, err := conn.Eval(touchScript, m.name, value, expiry) if err != nil { return false, err } return status != int64(0), nil }
1、鎖的續(xù)租需要客戶端去監(jiān)聽和操作,啟動一個(gè)定時(shí)器,固定時(shí)間來調(diào)用續(xù)租函數(shù)給鎖續(xù)租;
2、每次續(xù)租操作的時(shí)候需要匹配下當(dāng)前的 value 值,因?yàn)殒i可能已經(jīng)被當(dāng)前的線程釋放了,當(dāng)前的持有者可能是別的線程;
看看 SETEX 的源碼
SETEX 能保證只有在 key 不存在時(shí)設(shè)置 key 的值,那么這里來看看,源碼中是如何實(shí)現(xiàn)的呢
// https://github.com/redis/redis/blob/7.0/src/t_string.c#L78 // setGenericCommand()函數(shù)是以下命令: SET, SETEX, PSETEX, SETNX.的最底層實(shí)現(xiàn) void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) { ... found = (lookupKeyWrite(c->db,key) != NULL); // 這里是 SETEX 實(shí)現(xiàn)的重點(diǎn) // 如果nx,并且在數(shù)據(jù)庫中找到了這個(gè)值就返回 // 如果是 xx,并且在數(shù)據(jù)庫中沒有找到鍵值就會返回 // 因?yàn)?Redis 中的命令執(zhí)行都是單線程操作的 // 所以命令中判斷如果存在就返回,能夠保證正確性,不會出現(xiàn)并發(fā)訪問的問題 if ((flags & OBJ_SET_NX && found) || (flags & OBJ_SET_XX && !found)) { if (!(flags & OBJ_SET_GET)) { addReply(c, abort_reply ? abort_reply : shared.null[c->resp]); } return; } ... }
1、命令的實(shí)現(xiàn)里面加入了鍵值是否存在的判斷,來保證 NX 只有在 key 不存在時(shí)設(shè)置 key 的值;
2、因?yàn)?Redis 中總是一個(gè)線程處理命令的執(zhí)行,單命令是能夠保證原子性,不會出現(xiàn)并發(fā)的問題。
為什么 Redis 可以用來做分布式鎖
分布式鎖需要滿足的特性
- 互斥性:在任意時(shí)刻,對于同一個(gè)鎖,只有一個(gè)客戶端能持有,從而保證一個(gè)共享資源同一時(shí)間只能被一個(gè)客戶端操作;
- 安全性:即不會形成死鎖,當(dāng)一個(gè)客戶端在持有鎖的期間崩潰而沒有主動解鎖的情況下,其持有的鎖也能夠被正確釋放,并保證后續(xù)其它客戶端能加鎖;
- 可用性:當(dāng)提供鎖服務(wù)的節(jié)點(diǎn)發(fā)生宕機(jī)等不可恢復(fù)性故障時(shí),“熱備” 節(jié)點(diǎn)能夠接替故障的節(jié)點(diǎn)繼續(xù)提供服務(wù),并保證自身持有的數(shù)據(jù)與故障節(jié)點(diǎn)一致。
- 對稱性:對于任意一個(gè)鎖,其加鎖和解鎖必須是同一個(gè)客戶端,即客戶端 A 不能把客戶端 B 加的鎖給解了。
那么 Redis 對上面的特性是如何支持的呢?
1、Redis 中命令的執(zhí)行都是單線程的,雖然在 Redis6.0 的版本中,引入了多線程來處理 IO 任務(wù),但是命令的執(zhí)行依舊是單線程處理的;
2、單線程的特點(diǎn),能夠保證命令的執(zhí)行的是不存在并發(fā)的問題,同時(shí)命令執(zhí)行的原子性也能得到保證;
3、Redis 中提供了針對 SETNX 這樣的命令,能夠保證同一時(shí)刻是只會有一個(gè)請求執(zhí)行成功,提供互斥性的保障;
4、Redis 中也提供了 EXPIRE 超時(shí)釋放的命令,可以實(shí)現(xiàn)鎖的超時(shí)釋放,避免死鎖的出現(xiàn);
5、高可用,針對如果發(fā)生主從切換,數(shù)據(jù)丟失的情況,Redis 引入了 RedLock 算法,保證了 Redis 中主要大部分節(jié)點(diǎn)正常運(yùn)行,鎖就可以正常運(yùn)行;
6、Redis 中本身沒有對鎖提供續(xù)期的操作,不過一些第三方的實(shí)現(xiàn)中實(shí)現(xiàn)了 Redis 中鎖的續(xù)期,類似 使用 java 實(shí)現(xiàn)的 Redisson,使用 go 實(shí)現(xiàn)的 redsync,當(dāng)然自己實(shí)現(xiàn)也不是很難,實(shí)現(xiàn)過程可參見上文。
總體來說,Redis 中對分布式鎖的一些特性都提供了支持,使用 Redis 實(shí)現(xiàn)分布式鎖,是一個(gè)不錯(cuò)的選擇。
分布式鎖如何選擇
1、如果業(yè)務(wù)規(guī)模不大,qps 很小,使用 Redis,etcd,ZooKeeper 去實(shí)現(xiàn)分布式鎖都不會有問題,就看公司了基礎(chǔ)架構(gòu)了,如果有現(xiàn)成的 Redis,etcd,ZooKeeper 直接用就可以了;
2、Redis 中分布式鎖有一定的安全隱患,如果業(yè)務(wù)中對安全性要求很高,那么 Redis 可能就不適合了,etcd 或者 ZooKeeper 就比較合適了;
3、如果系統(tǒng) qps 很大,但是可以容忍一些錯(cuò)誤,那么 Redis 可能就更合適了,畢竟 etcd或者ZooKeeper 背面往往都是較低的吞吐量和較高的延遲。
總結(jié)
1、在分布式的場景下,使用分布式鎖是我們經(jīng)常遇到的一種場景;
2、使用 Redis 實(shí)現(xiàn)鎖是個(gè)不錯(cuò)的選擇,Redis 的單命令的執(zhí)行是原子性的同時(shí)借助于 Lua 也可以很容易的實(shí)現(xiàn)組合命令的原子性;
3、針對分布式場景下主從切換,數(shù)據(jù)同步不及時(shí)的情況,redis 中引入了 redLock 來處理分布式鎖;
4、根據(jù) martin 的描述,redLock 是繁重的,且存在安全性,不過我們可以根據(jù)自己的業(yè)務(wù)場景做出判斷;
5、需要注意的是在設(shè)置分布式鎖的時(shí)候需要設(shè)置 value 的唯一性,并且每次主動刪除鎖的時(shí)候需要匹配下 value 的正確性,避免誤刪除其他線程的鎖;
參考
【Redis核心技術(shù)與實(shí)戰(zhàn)】https://time.geekbang.org/column/intro/100056701
【Redis設(shè)計(jì)與實(shí)現(xiàn)】https://book.douban.com/subject/25900156/
【Redis 的學(xué)習(xí)筆記】https://github.com/boilingfrog/Go-POINT/tree/master/redis
【Redis 分布式鎖】https://redis.io/docs/reference/patterns/distributed-locks/
【How to do distributed locking】https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
【etcd 實(shí)現(xiàn)分布式鎖】https://www.cnblogs.com/ricklz/p/15033193.html#分布式鎖
【Redis中的原子操作(3)-使用Redis實(shí)現(xiàn)分布式鎖】https://boilingfrog.github.io/2022/06/15/ Redis中的原子操作 (3)-使用Redis實(shí)現(xiàn)分布式鎖/
到此這篇關(guān)于使用Redis實(shí)現(xiàn)分布式鎖的方法的文章就介紹到這了,更多相關(guān)Redis分布式鎖內(nèi)容請搜索本站以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持本站!
版權(quán)聲明:本站文章來源標(biāo)注為YINGSOO的內(nèi)容版權(quán)均為本站所有,歡迎引用、轉(zhuǎn)載,請保持原文完整并注明來源及原文鏈接。禁止復(fù)制或仿造本網(wǎng)站,禁止在非www.sddonglingsh.com所屬的服務(wù)器上建立鏡像,否則將依法追究法律責(zé)任。本站部分內(nèi)容來源于網(wǎng)友推薦、互聯(lián)網(wǎng)收集整理而來,僅供學(xué)習(xí)參考,不代表本站立場,如有內(nèi)容涉嫌侵權(quán),請聯(lián)系alex-e#qq.com處理。