使用Redis實現(xiàn)分布式鎖的方法
Redis 中的分布式鎖如何使用
分布式鎖的使用場景
為了保證我們線上服務的并發(fā)性和安全性,目前我們的服務一般拋棄了單體應用,采用的都是擴展性很強的分布式架構(gòu)。
對于可變共享資源的訪問,同一時刻,只能由一個線程或者進程去訪問操作。這時候我們就需要做個標識,如果當前有線程或者進程在操作共享變量,我們就做個標記,標識當前資源正在被操作中, 其它的線程或者進程,就不能進行操作了。當前操作完成之后,刪除標記,這樣其他的線程或者進程,就能來申請共享變量的操作。通過上面的標記來保證同一時刻共享變量只能由一個線程或者進行持有。
對于單體應用:多個線程之間訪問可變共享變量,比較容易處理,可簡單使用內(nèi)存來存儲標示即可;
分布式應用:這種場景下比較麻煩,因為多個應用,部署的地址可能在不同的機房,一個在北京一個在上海。不能簡單的存儲標示在內(nèi)存中了,這時候需要使用公共內(nèi)存來記錄該標示,栗如 Redis,MySQL 。。。
使用 Redis 來實現(xiàn)分布式鎖
這里來聊聊如何使用 Redis 實現(xiàn)分布式鎖
Redis 中分布式鎖一般會用 set key value px milliseconds nx 或者 SETNX+Lua來實現(xiàn)。
因為 SETNX 命令,需要配合 EXPIRE 設置過期時間,Redis 中單命令的執(zhí)行是原子性的,組合命令就需要使用 Lua 才能保證原子性了。
看下如何實現(xiàn)
使用 set key value px milliseconds nx 實現(xiàn)
因為這個命令同時能夠設置鍵值和過期時間,同時Redis中的單命令都是原子性的,所以加鎖的時候使用這個命令即可
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 實現(xiàn)
如果使用 SETNX 命令,這個命令不能設置過期時間,需要配合 EXPIRE 命令來使用。
因為是用到了兩個命令,這時候兩個命令的組合使用是不能保障原子性的,在一些并發(fā)比較大的時候,需要配合使用 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
}
除了上面加鎖兩個命令的區(qū)別之外,在解鎖的時候需要注意下不能誤刪除別的線程持有的鎖
為什么會出現(xiàn)這種情況呢,這里來分析下
舉個栗子
1、線程1獲取了鎖,鎖的過期時間為1s;
2、線程1完成了業(yè)務操作,用時1.5s ,這時候線程1的鎖已經(jīng)被過期時間自動釋放了,這把鎖已經(jīng)被別的線程獲取了;
3、但是線程1不知道,接著去釋放鎖,這時候就會將別的線程的鎖,錯誤的釋放掉。

面對這種情況,其實也很好處理
1、設置 value 具有唯一性;
2、每次刪除鎖的時候,先去判斷下 value 的值是否能對的上,不相同就表示,鎖已經(jīng)被別的線程獲取了;
看下代碼實現(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
上面的這類鎖的最大缺點就是只作用在一個節(jié)點上,即使 Redis 通過 sentinel 保證高可用,如果這個 master 節(jié)點由于某些原因放生了主從切換,那么就會出現(xiàn)鎖丟失的情況:
1、在 Redis 的 master 節(jié)點上拿到了鎖;
2、但是這個加鎖的 key 還沒有同步到 slave 節(jié)點;
3、master 故障,發(fā)生了故障轉(zhuǎn)移,slave 節(jié)點升級為 master 節(jié)點;
4、導致鎖丟失。
針對這種情況如何處理呢,下面來聊聊 Redlock 算法
使用 Redlock 實現(xiàn)分布式鎖
在 Redis 的分布式環(huán)境中,我們假設有 N 個 Redis master。這些節(jié)點完全互相獨立,不存在主從復制或者其他集群協(xié)調(diào)機制。我們確保將在 N 個實例上使用與在 Redis 單實例下相同方法獲取和釋放鎖?,F(xiàn)在我們假設有 5 個 Redis master 節(jié)點,同時我們需要在5臺服務器上面運行這些 Redis 實例,這樣保證他們不會同時都宕掉。
為了取到鎖,客戶端營該執(zhí)行以下操作:
1、獲取當前Unix時間,以毫秒為單位。
2、依次嘗試從5個實例,使用相同的key和具有唯一性的 value(例如UUID)獲取鎖。當向 Redis 請求獲取鎖時,客戶端應該設置一個網(wǎng)絡連接和響應超時時間,這個超時時間應該小于鎖的失效時間。例如你的鎖自動失效時間為10秒,則超時時間應該在 5-50 毫秒之間。這樣可以避免服務器端 Redis 已經(jīng)掛掉的情況下,客戶端還在死死地等待響應結(jié)果。如果服務器端沒有在規(guī)定時間內(nèi)響應,客戶端應該盡快嘗試去另外一個 Redis 實例請求獲取鎖;
3、客戶端使用當前時間減去開始獲取鎖時間(步驟1記錄的時間)就得到獲取鎖使用的時間。當且僅當從大多數(shù)(N/2+1,這里是3個節(jié)點)的 Redis 節(jié)點都取到鎖,并且使用的時間小于鎖失效時間時,鎖才算獲取成功;
4、如果取到了鎖,key 的真正有效時間等于有效時間減去獲取鎖所使用的時間(步驟3計算的結(jié)果);
5、如果因為某些原因,獲取鎖失?。]有在至少N/2+1個 Redis 實例取到鎖或者取鎖時間已經(jīng)超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖(即便某些 Redis 實例根本就沒有加鎖成功,防止某些節(jié)點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖)。
根據(jù)官方的推薦,go 版本中 Redsync 實現(xiàn)了這一算法,這里看下具體的實現(xiàn)過程
// 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é)點中加鎖
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é)點書沒有達到的設定的數(shù)目
// 或者鍵值的過期時間已經(jīng)到了
// 在所有的節(jié)點中解鎖
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é)點,并且在每個節(jié)點中執(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
// 計算執(zhí)行成功的節(jié)點數(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é)點,然后嘗試在所有的節(jié)點中執(zhí)行加鎖的操作;
2、收集加鎖成功的節(jié)點數(shù),如果沒有達到指定的數(shù)目,釋放剛剛添加的鎖;
關于 Redlock 的缺點可參見
鎖的續(xù)租
Redis 中分布式鎖還有一個問題就是鎖的續(xù)租問題,當鎖的過期時間到了,但是業(yè)務的執(zhí)行時間還沒有完成,這時候就需要對鎖進行續(xù)租了
續(xù)租的流程
1、當客戶端加鎖成功后,可以啟動一個定時的任務,每隔一段時間,檢查業(yè)務是否完成,未完成,增加 key 的過期時間;
2、這里判斷業(yè)務是否完成的依據(jù)是:
- 1、這個 key 是否存在,如果 key 不存在了,就表示業(yè)務已經(jīng)執(zhí)行完成了,也就不需要進行續(xù)租操作了;
- 2、同時需要校驗下 value 值,如果 value 對應的值和之前寫入的值不同了,說明當前鎖已經(jīng)被別的線程獲取了;
看下 redsync 中續(xù)租的實現(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é)點中加鎖
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
}
// 判斷下鎖的過期時間
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, `
// 需要先比較下當前的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)聽和操作,啟動一個定時器,固定時間來調(diào)用續(xù)租函數(shù)給鎖續(xù)租;
2、每次續(xù)租操作的時候需要匹配下當前的 value 值,因為鎖可能已經(jīng)被當前的線程釋放了,當前的持有者可能是別的線程;
看看 SETEX 的源碼
SETEX 能保證只有在 key 不存在時設置 key 的值,那么這里來看看,源碼中是如何實現(xiàn)的呢
// https://github.com/redis/redis/blob/7.0/src/t_string.c#L78
// setGenericCommand()函數(shù)是以下命令: SET, SETEX, PSETEX, SETNX.的最底層實現(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 實現(xiàn)的重點
// 如果nx,并且在數(shù)據(jù)庫中找到了這個值就返回
// 如果是 xx,并且在數(shù)據(jù)庫中沒有找到鍵值就會返回
// 因為 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、命令的實現(xiàn)里面加入了鍵值是否存在的判斷,來保證 NX 只有在 key 不存在時設置 key 的值;
2、因為 Redis 中總是一個線程處理命令的執(zhí)行,單命令是能夠保證原子性,不會出現(xiàn)并發(fā)的問題。
為什么 Redis 可以用來做分布式鎖
分布式鎖需要滿足的特性
- 互斥性:在任意時刻,對于同一個鎖,只有一個客戶端能持有,從而保證一個共享資源同一時間只能被一個客戶端操作;
- 安全性:即不會形成死鎖,當一個客戶端在持有鎖的期間崩潰而沒有主動解鎖的情況下,其持有的鎖也能夠被正確釋放,并保證后續(xù)其它客戶端能加鎖;
- 可用性:當提供鎖服務的節(jié)點發(fā)生宕機等不可恢復性故障時,“熱備” 節(jié)點能夠接替故障的節(jié)點繼續(xù)提供服務,并保證自身持有的數(shù)據(jù)與故障節(jié)點一致。
- 對稱性:對于任意一個鎖,其加鎖和解鎖必須是同一個客戶端,即客戶端 A 不能把客戶端 B 加的鎖給解了。
那么 Redis 對上面的特性是如何支持的呢?
1、Redis 中命令的執(zhí)行都是單線程的,雖然在 Redis6.0 的版本中,引入了多線程來處理 IO 任務,但是命令的執(zhí)行依舊是單線程處理的;
2、單線程的特點,能夠保證命令的執(zhí)行的是不存在并發(fā)的問題,同時命令執(zhí)行的原子性也能得到保證;
3、Redis 中提供了針對 SETNX 這樣的命令,能夠保證同一時刻是只會有一個請求執(zhí)行成功,提供互斥性的保障;
4、Redis 中也提供了 EXPIRE 超時釋放的命令,可以實現(xiàn)鎖的超時釋放,避免死鎖的出現(xiàn);
5、高可用,針對如果發(fā)生主從切換,數(shù)據(jù)丟失的情況,Redis 引入了 RedLock 算法,保證了 Redis 中主要大部分節(jié)點正常運行,鎖就可以正常運行;
6、Redis 中本身沒有對鎖提供續(xù)期的操作,不過一些第三方的實現(xiàn)中實現(xiàn)了 Redis 中鎖的續(xù)期,類似 使用 java 實現(xiàn)的 Redisson,使用 go 實現(xiàn)的 redsync,當然自己實現(xiàn)也不是很難,實現(xiàn)過程可參見上文。
總體來說,Redis 中對分布式鎖的一些特性都提供了支持,使用 Redis 實現(xiàn)分布式鎖,是一個不錯的選擇。
分布式鎖如何選擇
1、如果業(yè)務規(guī)模不大,qps 很小,使用 Redis,etcd,ZooKeeper 去實現(xiàn)分布式鎖都不會有問題,就看公司了基礎架構(gòu)了,如果有現(xiàn)成的 Redis,etcd,ZooKeeper 直接用就可以了;
2、Redis 中分布式鎖有一定的安全隱患,如果業(yè)務中對安全性要求很高,那么 Redis 可能就不適合了,etcd 或者 ZooKeeper 就比較合適了;
3、如果系統(tǒng) qps 很大,但是可以容忍一些錯誤,那么 Redis 可能就更合適了,畢竟 etcd或者ZooKeeper 背面往往都是較低的吞吐量和較高的延遲。
總結(jié)
1、在分布式的場景下,使用分布式鎖是我們經(jīng)常遇到的一種場景;
2、使用 Redis 實現(xiàn)鎖是個不錯的選擇,Redis 的單命令的執(zhí)行是原子性的同時借助于 Lua 也可以很容易的實現(xiàn)組合命令的原子性;
3、針對分布式場景下主從切換,數(shù)據(jù)同步不及時的情況,redis 中引入了 redLock 來處理分布式鎖;
4、根據(jù) martin 的描述,redLock 是繁重的,且存在安全性,不過我們可以根據(jù)自己的業(yè)務場景做出判斷;
5、需要注意的是在設置分布式鎖的時候需要設置 value 的唯一性,并且每次主動刪除鎖的時候需要匹配下 value 的正確性,避免誤刪除其他線程的鎖;
參考
【Redis核心技術與實戰(zhàn)】https://time.geekbang.org/column/intro/100056701
【Redis設計與實現(xiàn)】https://book.douban.com/subject/25900156/
【Redis 的學習筆記】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 實現(xiàn)分布式鎖】https://www.cnblogs.com/ricklz/p/15033193.html#分布式鎖
【Redis中的原子操作(3)-使用Redis實現(xiàn)分布式鎖】https://boilingfrog.github.io/2022/06/15/ Redis中的原子操作 (3)-使用Redis實現(xiàn)分布式鎖/
到此這篇關于使用Redis實現(xiàn)分布式鎖的方法的文章就介紹到這了,更多相關Redis分布式鎖內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
NoSQL和Redis簡介及Redis在Windows下的安裝和使用教程
這篇文章主要介紹了NoSQL和Redis簡介及Redis在Windows下的安裝和使用教程,本文同時講解了python操作redis,并給出了操作實例,需要的朋友可以參考下2015-01-01
Redis increment 函數(shù)處理并發(fā)序列號案例
這篇文章主要介紹了Redis increment 函數(shù)處理并發(fā)序列號案例,本文通過實例代碼給大家介紹的非常詳細,感興趣的朋友跟隨小編一起看看吧2024-08-08
Redis通用命令介紹以及key的層級結(jié)構(gòu)講解
這篇文章主要介紹了Redis通用命令以及key的層級結(jié)構(gòu),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習吧2022-12-12
Redis優(yōu)化經(jīng)驗總結(jié)(必看篇)
下面小編就為大家?guī)硪黄猂edis優(yōu)化經(jīng)驗總結(jié)(必看篇)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-03-03

