淺談Redis分布式鎖的幾個(gè)坑
在分布式系統(tǒng)中,多個(gè)節(jié)點(diǎn)對(duì)共享資源的并發(fā)訪問(wèn)是常見(jiàn)場(chǎng)景,而分布式鎖是保證資源訪問(wèn)互斥性、維護(hù)數(shù)據(jù)一致性的核心機(jī)制。Redis 憑借其高性能、高可用的特性,成為實(shí)現(xiàn)分布式鎖的主流選擇。但在實(shí)際使用中,若忽略 Redis 的特性和分布式場(chǎng)景的復(fù)雜性,很容易踩入各種坑,導(dǎo)致鎖失效、數(shù)據(jù)錯(cuò)亂等嚴(yán)重問(wèn)題。
本文將詳細(xì)拆解 Redis 分布式鎖的 3 個(gè)核心坑點(diǎn),并通過(guò)Go 語(yǔ)言實(shí)戰(zhàn)代碼,演示如何正確實(shí)現(xiàn)健壯的 Redis 分布式鎖,同時(shí)深入講解背后的原理和設(shè)計(jì)思路。
一、Redis 分布式鎖的基本原理
在深入坑點(diǎn)之前,我們先明確 Redis 分布式鎖的核心實(shí)現(xiàn)邏輯,這是后續(xù)避坑的基礎(chǔ)。
Redis 分布式鎖的實(shí)現(xiàn)主要依賴兩個(gè)核心特性:
SETNX命令(SET if Not Exists) :僅當(dāng)指定 key 不存在時(shí),才會(huì)設(shè)置 key 的值,返回成功;若 key 已存在,直接返回失敗。該命令保證了鎖的互斥性,同一時(shí)間只有一個(gè)客戶端能獲取到鎖。- Key 的過(guò)期時(shí)間:為鎖設(shè)置過(guò)期時(shí)間,防止鎖的持有者因宕機(jī)、崩潰等異常情況無(wú)法主動(dòng)釋放鎖,導(dǎo)致其他客戶端永遠(yuǎn)無(wú)法獲取鎖(即死鎖)。
在 Go 語(yǔ)言中,我們通常不直接使用SETNX命令(單獨(dú)使用SETNX+EXPIRE存在原子性問(wèn)題),而是使用 Redis 的SET命令的組合參數(shù),實(shí)現(xiàn)「原子性設(shè)置鎖 + 過(guò)期時(shí)間」,核心參數(shù)如下:
NX:等價(jià)于SETNX,僅當(dāng) key 不存在時(shí)設(shè)置PX/EX:設(shè)置 key 的過(guò)期時(shí)間,PX單位為毫秒,EX單位為秒
Go 語(yǔ)言中(以redis-v6客戶端為例),原子性獲取鎖的核心調(diào)用如下:
// 原子性設(shè)置鎖:key不存在才設(shè)置,同時(shí)設(shè)置過(guò)期時(shí)間,返回是否成功 ok, err := redisClient.SetNX(ctx, lockKey, requestID, expireTime) // 或更靈活的SET命令(支持更多參數(shù)) status, err := redisClient.Do(ctx, "SET", lockKey, requestID, "NX", "PX", expireTime.Milliseconds())
二、坑 1:鎖的持有時(shí)間超過(guò)過(guò)期時(shí)間(鎖提前失效)
問(wèn)題描述
這是 Redis 分布式鎖最常見(jiàn)的坑點(diǎn)。假設(shè)我們給鎖設(shè)置了 30 秒過(guò)期時(shí)間,但客戶端的業(yè)務(wù)邏輯執(zhí)行了 40 秒(比如復(fù)雜計(jì)算、遠(yuǎn)程調(diào)用超時(shí)),那么在第 30 秒時(shí),鎖會(huì)自動(dòng)過(guò)期釋放,而此時(shí)客戶端的業(yè)務(wù)邏輯還在執(zhí)行。
此時(shí)其他客戶端就可以獲取到同一把鎖,多個(gè)客戶端同時(shí)操作共享資源,破壞了鎖的互斥性,可能導(dǎo)致數(shù)據(jù)錯(cuò)亂、重復(fù)執(zhí)行等問(wèn)題。
根本原因
- 業(yè)務(wù)邏輯執(zhí)行時(shí)間不可控,無(wú)法精準(zhǔn)預(yù)估鎖的持有時(shí)間
- 鎖的過(guò)期時(shí)間是「靜態(tài)」的,一旦設(shè)置無(wú)法自動(dòng)延長(zhǎng)
- 客戶端未實(shí)現(xiàn)鎖的「續(xù)約機(jī)制」,無(wú)法在業(yè)務(wù)邏輯未執(zhí)行完成時(shí)延長(zhǎng)鎖的過(guò)期時(shí)間
解決方案:實(shí)現(xiàn)鎖的自動(dòng)續(xù)約(看門狗機(jī)制)
核心思路:客戶端獲取鎖成功后,啟動(dòng)一個(gè)后臺(tái)協(xié)程(看門狗),定期檢查鎖是否仍被當(dāng)前客戶端持有,若持有且即將過(guò)期,就延長(zhǎng)鎖的過(guò)期時(shí)間,直到業(yè)務(wù)邏輯執(zhí)行完成或客戶端異常退出。
Go 實(shí)戰(zhàn)代碼實(shí)現(xiàn)
package redislock
import (
"context"
"errors"
"fmt"
"time"
redisv6 "code.byted.org/kv/redis-v6"
"github.com/google/uuid"
)
// RedisLock Redis分布式鎖結(jié)構(gòu)體
type RedisLock struct {
redisClient redisv6.Client // Redis客戶端
lockKey string // 鎖的Key
requestID string // 唯一請(qǐng)求ID,用于標(biāo)識(shí)鎖的持有者
expireTime time.Duration // 鎖的過(guò)期時(shí)間
renewalTime time.Duration // 鎖的續(xù)約間隔時(shí)間
renewalTicker *time.Ticker // 續(xù)約定時(shí)器
stopChan chan struct{} // 停止續(xù)約的信號(hào)通道
ctx context.Context // 上下文
cancel context.CancelFunc// 上下文取消函數(shù)
}
// NewRedisLock 創(chuàng)建Redis分布式鎖實(shí)例
// lockKey:鎖的唯一標(biāo)識(shí)
// expireTime:鎖的過(guò)期時(shí)間(建議30秒左右)
// renewalTime:續(xù)約間隔時(shí)間(建議為過(guò)期時(shí)間的1/3,如10秒)
func NewRedisLock(client redisv6.Client, lockKey string, expireTime, renewalTime time.Duration) *RedisLock {
// 生成唯一請(qǐng)求ID(用于標(biāo)識(shí)當(dāng)前鎖持有者,避免誤刪其他客戶端的鎖)
requestID := fmt.Sprintf("lock-%s-%s", uuid.New().String(), time.Now().Format("20060102150405"))
ctx, cancel := context.WithCancel(context.Background())
return &RedisLock{
redisClient: client,
lockKey: lockKey,
requestID: requestID,
expireTime: expireTime,
renewalTime: renewalTime,
stopChan: make(chan struct{}, 1),
ctx: ctx,
cancel: cancel,
}
}
// Acquire 嘗試獲取分布式鎖,并啟動(dòng)自動(dòng)續(xù)約
func (l *RedisLock) Acquire() (bool, error) {
// 原子性獲取鎖:SET NX PX(key不存在時(shí)設(shè)置,同時(shí)設(shè)置毫秒級(jí)過(guò)期時(shí)間)
ok, err := l.redisClient.SetNX(l.ctx, l.lockKey, l.requestID, l.expireTime)
if err != nil {
return false, fmt.Errorf("獲取鎖失?。?w", err)
}
if !ok {
// 鎖已被其他客戶端持有
return false, nil
}
// 獲取鎖成功,啟動(dòng)自動(dòng)續(xù)約協(xié)程(看門狗)
l.startRenewal()
return true, nil
}
// startRenewal 啟動(dòng)鎖的自動(dòng)續(xù)約機(jī)制
func (l *RedisLock) startRenewal() {
// 初始化定時(shí)器,每隔renewalTime執(zhí)行一次續(xù)約
l.renewalTicker = time.NewTicker(l.renewalTime)
go func() {
defer l.renewalTicker.Stop()
for {
select {
case <-l.ctx.Done():
// 上下文取消,停止續(xù)約
return
case <-l.stopChan:
// 主動(dòng)停止續(xù)約
return
case <-l.renewalTicker.C:
// 執(zhí)行續(xù)約操作
renewed, err := l.renewLock()
if err != nil {
fmt.Printf("鎖續(xù)約失?。?v\n", err)
return
}
if !renewed {
fmt.Printf("鎖已失效或被其他客戶端持有,停止續(xù)約\n")
return
}
fmt.Printf("鎖續(xù)約成功,過(guò)期時(shí)間延長(zhǎng)至%v\n", l.expireTime)
}
}
}()
}
// renewLock 延長(zhǎng)鎖的過(guò)期時(shí)間(原子操作)
func (l *RedisLock) renewLock() (bool, error) {
// 使用Lua腳本保證續(xù)約的原子性:僅當(dāng)鎖的持有者是當(dāng)前客戶端時(shí),才延長(zhǎng)過(guò)期時(shí)間
renewScript := `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end
`
// 執(zhí)行Lua腳本
result, err := l.redisClient.Eval(
l.ctx,
renewScript,
[]string{l.lockKey}, // KEYS[1]:鎖的Key
l.requestID, // ARGV[1]:當(dāng)前客戶端的請(qǐng)求ID
l.expireTime.Milliseconds(), // ARGV[2]:新的過(guò)期時(shí)間(毫秒)
)
if err != nil {
return false, fmt.Errorf("執(zhí)行續(xù)約Lua腳本失?。?w", err)
}
// 腳本返回1表示續(xù)約成功,0表示續(xù)約失?。ㄦi已失效或被其他客戶端持有)
resultInt, ok := result.(int64)
if !ok {
return false, errors.New("續(xù)約腳本返回結(jié)果格式異常")
}
return resultInt == 1, nil
}
關(guān)鍵說(shuō)明
- 續(xù)約間隔時(shí)間建議設(shè)置為鎖過(guò)期時(shí)間的 1/3(如過(guò)期時(shí)間 30 秒,續(xù)約間隔 10 秒),避免因網(wǎng)絡(luò)延遲導(dǎo)致續(xù)約不及時(shí)。
- 續(xù)約操作使用 Lua 腳本保證原子性,先校驗(yàn)鎖的持有者是否為當(dāng)前客戶端,再延長(zhǎng)過(guò)期時(shí)間,防止給其他客戶端的鎖續(xù)約。
- 通過(guò)
Ticker和chan實(shí)現(xiàn)優(yōu)雅的續(xù)約啟停,避免協(xié)程泄露。
三、坑 2:解鎖失敗導(dǎo)致死鎖(解鎖操作非原子性)
問(wèn)題描述
解鎖操作看似簡(jiǎn)單,只需刪除鎖的 Key 即可,但如果忽略分布式場(chǎng)景的并發(fā)特性,很容易導(dǎo)致解鎖失敗,進(jìn)而引發(fā)死鎖。
最常見(jiàn)的錯(cuò)誤解鎖邏輯如下(偽代碼):
// 錯(cuò)誤示例:兩步操作非原子性
func (l *RedisLock) WrongRelease() error {
// 1. 先獲取鎖的當(dāng)前值,判斷是否為當(dāng)前客戶端的requestID
lockValue, err := l.redisClient.Get(l.ctx, l.lockKey)
if err != nil {
return err
}
if lockValue != l.requestID {
return errors.New("鎖非當(dāng)前客戶端持有")
}
// 2. 再刪除鎖的Key
return l.redisClient.Del(l.ctx, l.lockKey)
}
上述代碼存在嚴(yán)重的原子性問(wèn)題:假設(shè)客戶端執(zhí)行完第一步判斷(確認(rèn)鎖是自己的)后,鎖恰好過(guò)期,此時(shí)其他客戶端已經(jīng)獲取到了這把鎖,而當(dāng)前客戶端繼續(xù)執(zhí)行第二步刪除操作,就會(huì)導(dǎo)致誤刪其他客戶端的鎖,同時(shí)如果當(dāng)前客戶端刪除失?。ㄈ缇W(wǎng)絡(luò)異常),鎖會(huì)一直存在,導(dǎo)致后續(xù)客戶端無(wú)法獲取鎖,引發(fā)死鎖。
根本原因
解鎖操作的「校驗(yàn)持有者」和「刪除 Key」是兩個(gè)獨(dú)立的 Redis 命令,無(wú)法保證原子性,中間可能被其他操作打斷(如鎖過(guò)期、網(wǎng)絡(luò)延遲)。
解決方案:使用 Lua 腳本保證解鎖操作的原子性
Lua 腳本可以在 Redis 服務(wù)器端原子性地執(zhí)行多個(gè)命令,將「校驗(yàn)持有者」和「刪除 Key」合并為一個(gè)原子操作,避免中間被打斷的問(wèn)題。
Go 實(shí)戰(zhàn)代碼實(shí)現(xiàn)
// 繼續(xù)在RedisLock結(jié)構(gòu)體中補(bǔ)充解鎖方法
// Release 安全釋放分布式鎖(原子操作)
func (l *RedisLock) Release() error {
// 停止自動(dòng)續(xù)約
l.stopChan <- struct{}{}
l.cancel()
// 解鎖Lua腳本:先校驗(yàn)鎖的持有者,再刪除鎖,保證原子性
unlockScript := `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`
// 執(zhí)行Lua腳本
result, err := l.redisClient.Eval(
l.ctx,
unlockScript,
[]string{l.lockKey}, // KEYS[1]:鎖的Key
l.requestID, // ARGV[1]:當(dāng)前客戶端的請(qǐng)求ID
)
if err != nil {
return fmt.Errorf("執(zhí)行解鎖Lua腳本失?。?w", err)
}
// 解析腳本返回結(jié)果
resultInt, ok := result.(int64)
if !ok {
return errors.New("解鎖腳本返回結(jié)果格式異常")
}
if resultInt == 0 {
return errors.New("解鎖失?。烘i已失效或非當(dāng)前客戶端持有")
}
fmt.Printf("鎖釋放成功\n")
return nil
}
關(guān)鍵說(shuō)明
- 解鎖前先停止自動(dòng)續(xù)約,避免續(xù)約協(xié)程繼續(xù)操作已釋放的鎖。
- Lua 腳本的邏輯清晰:僅當(dāng)
GET到的鎖值與當(dāng)前客戶端的requestID一致時(shí),才執(zhí)行DEL命令刪除鎖,否則返回 0 表示解鎖失敗。 - 原子性操作保證了即使在高并發(fā)場(chǎng)景下,也不會(huì)出現(xiàn)誤解鎖或解鎖失敗的問(wèn)題,從根本上避免死鎖。
四、坑 3:鎖的誤刪(未標(biāo)識(shí)鎖的持有者)
問(wèn)題描述
如果所有客戶端使用相同的鎖值(如固定字符串"locked"),那么任何一個(gè)客戶端都可以刪除其他客戶端持有的鎖,這就是「鎖的誤刪」問(wèn)題。
舉個(gè)例子:
- 客戶端 A 獲取鎖成功,設(shè)置鎖值為
"locked",過(guò)期時(shí)間 30 秒。 - 客戶端 A 的業(yè)務(wù)邏輯執(zhí)行超時(shí),鎖在 30 秒后自動(dòng)過(guò)期。
- 客戶端 B 獲取到同一把鎖,設(shè)置鎖值仍為
"locked"。 - 客戶端 A 此時(shí)業(yè)務(wù)邏輯執(zhí)行完成,執(zhí)行解鎖操作,刪除了客戶端 B 持有的鎖。
最終導(dǎo)致客戶端 B 的鎖被誤刪,后續(xù)其他客戶端可以繼續(xù)獲取鎖,破壞了互斥性。
根本原因
未為每個(gè)客戶端分配唯一的標(biāo)識(shí),無(wú)法區(qū)分鎖的持有者,導(dǎo)致任何客戶端都可以隨意刪除鎖。
解決方案:為每個(gè)客戶端生成唯一請(qǐng)求 ID
核心思路:
- 每個(gè)客戶端在獲取鎖時(shí),生成一個(gè)唯一的
requestID(如 UUID + 時(shí)間戳),作為鎖的值存入 Redis。 - 解鎖時(shí),僅當(dāng)鎖的值與當(dāng)前客戶端的
requestID一致時(shí),才執(zhí)行解鎖操作。 - 這個(gè)
requestID就是鎖的「持有者標(biāo)識(shí)」,確保只有鎖的持有者才能釋放鎖,從根本上避免誤刪。
Go 實(shí)戰(zhàn)代碼強(qiáng)化(已集成在前面的示例中)
我們?cè)谇懊娴拇a中,已經(jīng)實(shí)現(xiàn)了唯一requestID的生成和校驗(yàn),這里重點(diǎn)強(qiáng)調(diào)幾個(gè)關(guān)鍵細(xì)節(jié):
- 唯一
requestID的生成:
// 采用UUID+時(shí)間戳的方式,保證全局唯一性
requestID := fmt.Sprintf("lock-%s-%s", uuid.New().String(), time.Now().Format("20060102150405"))
UUID 保證了不同客戶端、不同時(shí)間的請(qǐng)求唯一性,時(shí)間戳便于問(wèn)題排查和日志分析。
- 獲取鎖時(shí)存儲(chǔ)
requestID:
// 原子性設(shè)置鎖時(shí),將requestID作為鎖的值存入Redis ok, err := l.redisClient.SetNX(l.ctx, l.lockKey, l.requestID, l.expireTime)
- 解鎖時(shí)校驗(yàn)
requestID:
// 在Lua腳本中,通過(guò)ARGV[1]傳入requestID,與鎖的值進(jìn)行比對(duì)
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
額外補(bǔ)充:避免重復(fù)生成requestID
每個(gè)RedisLock實(shí)例對(duì)應(yīng)一個(gè)唯一的requestID,在實(shí)例創(chuàng)建時(shí)生成,避免多次獲取鎖時(shí)生成不同的requestID,導(dǎo)致無(wú)法解鎖自身持有的鎖。
到此這篇關(guān)于淺談Redis分布式鎖的幾個(gè)坑的文章就介紹到這了,更多相關(guān)Redis分布式鎖坑內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- Redis實(shí)現(xiàn)分布式鎖的幾種方法總結(jié)
- 基于Redis實(shí)現(xiàn)分布式鎖以及任務(wù)隊(duì)列
- Redis分布式鎖的實(shí)現(xiàn)方式(redis面試題)
- Redis實(shí)現(xiàn)分布式鎖的五種方法詳解
- 淺談Redis分布式鎖的正確實(shí)現(xiàn)方式
- Redis分布式鎖實(shí)現(xiàn)方式及超時(shí)問(wèn)題解決
- redis分布式鎖及會(huì)出現(xiàn)的問(wèn)題解決
- Redis分布式鎖及4種常見(jiàn)實(shí)現(xiàn)方法
- Redis分布式鎖如何實(shí)現(xiàn)續(xù)期
- Redis分布式鎖的使用和實(shí)現(xiàn)原理詳解
相關(guān)文章
在Centos?8.0中安裝Redis服務(wù)器的教程詳解
由于考慮到linux服務(wù)器的性能,所以經(jīng)常需要把一些中間件安裝在linux服務(wù)上,今天通過(guò)本文給大家介紹下在Centos?8.0中安裝Redis服務(wù)器的詳細(xì)過(guò)程,感興趣的朋友一起看看吧2022-03-03
基于Redis實(shí)現(xiàn)延時(shí)隊(duì)列的優(yōu)化方案小結(jié)
本文主要介紹了基于Redis實(shí)現(xiàn)延時(shí)隊(duì)列的優(yōu)化方案小結(jié),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2022-07-07
redis的三種啟動(dòng)實(shí)現(xiàn)方式(后臺(tái)運(yùn)行)
文章介紹了Redis的三種啟動(dòng)方式:直接運(yùn)行、通過(guò)配置文件啟動(dòng)及使用啟動(dòng)腳本設(shè)置開(kāi)機(jī)自啟,啟動(dòng)腳本需復(fù)制到/etc/init.d并重命名為redisd,同時(shí)添加運(yùn)行級(jí)別注釋以解決chkconfig報(bào)錯(cuò)問(wèn)題,確保服務(wù)可開(kāi)機(jī)自動(dòng)啟動(dòng)2025-07-07
redis和mysql同步(雙寫一致性)的實(shí)現(xiàn)
Redis 和 MySQL 的同步是現(xiàn)代應(yīng)用中常見(jiàn)的需求,正確的同步方案能夠提高系統(tǒng)的性能和數(shù)據(jù)一致性,本文介紹了幾種常見(jiàn)的同步方式,下面就來(lái)詳細(xì)的介紹一下,感興趣的可以了解一下2025-09-09
Redis 對(duì)比 Memcached 并在 CentOS 下進(jìn)行安裝配置詳解
Redis 是一個(gè)開(kāi)源、支持網(wǎng)絡(luò)、基于內(nèi)存、鍵值對(duì)的 Key-Value 數(shù)據(jù)庫(kù),本篇文章主要介紹了Redis 對(duì)比 Memcached 并在 CentOS 下進(jìn)行安裝配置詳解,有興趣的可以了解一下。2016-11-11

