在分布式係統中,由於redis分布式鎖相對於更簡單和高效,成為瞭分布式鎖的首先,被我們用到瞭很多實際業務場景當中。
但不是說用瞭redis分布式鎖,就可以高枕無憂瞭,如果沒有用好或者用對,也會引來一些意想不到的問題。
今天我們就一起聊聊redis分布式鎖的一些坑,給有需要的朋友一個參考。
一、非原子操作
使用redis的分布式鎖,我們首先想到的可能是setNx命令。
if (jedis.setnx(lockKey, val) == 1) {
jedis.expire(lockKey, timeout);
}
容易,三下五除二,我們就可以把代碼寫好。
這段代碼確實可以加鎖成功,但你有沒有發現什麼問題?
加鎖操作和後麵的設置超時時間是分開的,並非原子操作。
假如加鎖成功,但是設置超時時間失敗瞭,該lockKey就變成永不失效。假如在高並發場景中,有大量的lockKey加鎖成功瞭,但不會失效,有可能直接導緻redis內存空間不足。
那麼,有沒有保證原子性的加鎖命令呢?
答案是:有,請看下麵。
二、忘瞭釋放鎖
上麵說到使用setNx命令加鎖操作和設置超時時間是分開的,並非原子操作。
而在redis中還有set命令,該命令可以指定多個參數。
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
其中:
- lockKey:鎖的標識
- requestId:請求id
- NX:隻在鍵不存在時,纔對鍵進行設置操作。
- PX:設置鍵的過期時間為 millisecond 毫秒。
- expireTime:過期時間
set命令是原子操作,加鎖和設置超時時間,一個命令就能輕鬆搞定。
nice!
使用set命令加鎖,錶麵上看起來沒有問題。但如果仔細想想,加鎖之後,每次都要達到瞭超時時間纔釋放鎖,會不會有點不閤理?加鎖後,如果不及時釋放鎖,會有很多問題。
分布式鎖更閤理的用法是:
- 手動加鎖
- 業務操作
- 手動釋放鎖
如果手動釋放鎖失敗瞭,則達到超時時間,redis會自動釋放鎖。
大緻流程圖如下:
那麼問題來瞭,如何釋放鎖呢?
僞代碼如下:
try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
} finally {
unlock(lockKey);
}
需要捕獲業務代碼的異常,然後在finally中釋放鎖。換句話說就是:無論代碼執行成功或失敗瞭,都需要釋放鎖。
此時,有些朋友可能會問:假如剛好在釋放鎖的時候,係統被重啓瞭,或者網絡斷綫瞭,或者機房斷點瞭,不也會導緻釋放鎖失敗?
這是一個好問題,因為這種小概率問題確實存在。
但還記得前麵我們給鎖設置過超時時間嗎?即使齣現異常情況造成釋放鎖失敗,但到瞭我們設定的超時時間,鎖還是會被redis自動釋放。
但隻在finally中釋放鎖,就夠瞭嗎?
三、釋放瞭彆人的鎖
做人要厚道,先迴答上麵的問題:隻在finally中釋放鎖,當然是不夠的,因為釋放鎖的姿勢,還是不對。
哪裏不對?
答:在多綫程場景中,可能會齣現釋放瞭彆人的鎖的情況。
有些朋友可能會反駁:假設在多綫程場景中,綫程A獲取到瞭鎖,但如果綫程A沒有釋放鎖,此時,綫程B是獲取不到鎖的,何來釋放瞭彆人鎖之說?
答:假如綫程A和綫程B,都使用lockKey加鎖。綫程A加鎖成功瞭,但是由於業務功能耗時時間很長,超過瞭設置的超時時間。這時候,redis會自動釋放lockKey鎖。此時,綫程B就能給lockKey加鎖成功瞭,接下來執行它的業務操作。恰好這個時候,綫程A執行完瞭業務功能,接下來,在finally方法中釋放瞭鎖lockKey。這不就齣問題瞭,綫程B的鎖,被綫程A釋放瞭。
我想這個時候,綫程B肯定哭暈在廁所裏,並且嘴裏還振振有詞。
那麼,如何解決這個問題呢?
不知道你們注意到沒?在使用set命令加鎖時,除瞭使用lockKey鎖標識,還多設置瞭一個參數:requestId,為什麼要需要記錄requestId呢?
答:requestId是在釋放鎖的時候用的。
僞代碼如下:
if (jedis.get(lockKey).equals(requestId)) {
jedis.del(lockKey);
return true;
}
return false;
在釋放鎖的時候,先獲取到該鎖的值(之前設置值就是requestId),然後判斷跟之前設置的值是否相同,如果相同纔允許刪除鎖,返迴成功。如果不同,則直接返迴失敗。
換句話說就是:自己隻能釋放自己加的鎖,不允許釋放彆人加的鎖。
這裏為什麼要用requestId,用userId不行嗎?
答:如果用userId的話,對於請求來說並不唯一,多個不同的請求,可能使用同一個userId。而requestId是全局唯一的,不存在加鎖和釋放鎖亂掉的情況。
此外,使用lua腳本,也能解決釋放瞭彆人的鎖的問題:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
lua腳本能保證查詢鎖是否存在和刪除鎖是原子操作,用它來釋放鎖效果更好一些。
說到lua腳本,其實加鎖操作也建議使用lua腳本:
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);
這是redisson框架的加鎖代碼,寫的不錯,大傢可以藉鑒一下。
有趣,下麵還有哪些好玩的東西?
四、大量失敗請求
上麵的加鎖方法看起來好像沒有問題,但如果你仔細想想,如果有1萬的請求同時去競爭那把鎖,可能隻有一個請求是成功的,其餘的9999個請求都會失敗。
在秒殺場景下,會有什麼問題?
答:每1萬個請求,有1個成功。再1萬個請求,有1個成功。如此下去,直到庫存不足。這就變成均勻分布的秒殺瞭,跟我們想象中的不一樣。
如何解決這個問題呢?
此外,還有一種場景:
比如,有兩個綫程同時上傳文件到sftp,上傳文件前先要創建目錄。假設兩個綫程需要創建的目錄名都是當天的日期,比如:20210920,如果不做任何控製,直接並發的創建目錄,第二個綫程必然會失敗。
這時候有些朋友可能會說:這還不容易,加一個redis分布式鎖就能解決問題瞭,此外再判斷一下,如果目錄已經存在就不創建,隻有目錄不存在纔需要創建。
僞代碼如下:
try {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if(!exists(path)) {
mkdir(path);
}
return true;
}
} finally{
unlock(lockKey,requestId);
}
return false;
一切看似美好,但經不起仔細推敲。
來自靈魂的一問:第二個請求如果加鎖失敗瞭,接下來,是返迴失敗,還是返迴成功呢?
主要流程圖如下:
顯然第二個請求,肯定是不能返迴失敗的,如果返迴失敗瞭,這個問題還是沒有被解決。如果文件還沒有上傳成功,直接返迴成功會有更大的問題。頭疼,到底該如何解決呢?
答:使用自鏇鎖。
try {
Long start = System.currentTimeMillis();
while(true) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if(!exists(path)) {
mkdir(path);
}
return true;
}
long time = System.currentTimeMillis() - start;
if (time>=timeout) {
return false;
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally{
unlock(lockKey,requestId);
}
return false;
在規定的時間,比如500毫秒內,自鏇不斷嘗試加鎖(說白瞭,就是在死循環中,不斷嘗試加鎖),如果成功則直接返迴。如果失敗,則休眠50毫秒,再發起新一輪的嘗試。如果到瞭超時時間,還未加鎖成功,則直接返迴失敗。
好吧,學到一招瞭,還有嗎?
五、鎖重入問題
我們都知道redis分布式鎖是互斥的。假如我們對某個key加鎖瞭,如果該key對應的鎖還沒失效,再用相同key去加鎖,大概率會失敗。
沒錯,大部分場景是沒問題的。
為什麼說是大部分場景呢?
因為還有這樣的場景:
假設在某個請求中,需要獲取一顆滿足條件的菜單樹或者分類樹。我們以菜單為例,這就需要在接口中從根節點開始,遞歸遍曆齣所有滿足條件的子節點,然後組裝成一顆菜單樹。
需要注意的是菜單不是一成不變的,在後台係統中運營同學可以動態添加、修改和刪除菜單。為瞭保證在並發的情況下,每次都可能獲取最新的數據,這裏可以加redis分布式鎖。
加redis分布式鎖的思路是對的。但接下來問題來瞭,在遞歸方法中遞歸遍曆多次,每次都是加的同一把鎖。遞歸第一層當然是可以加鎖成功的,但遞歸第二層、第三層...第N層,不就會加鎖失敗瞭?
遞歸方法中加鎖的僞代碼如下:
private int expireTime = 1000;
public void fun(int level,String lockKey,String requestId){
try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if(level<=10){
this.fun(++level,lockKey,requestId);
} else {
return;
}
}
return;
} finally {
unlock(lockKey,requestId);
}
}
如果你直接這麼用,看起來好像沒有問題。但最終執行程序之後發現,等待你的結果隻有一個:齣現異常。
因為從根節點開始,第一層遞歸加鎖成功,還沒釋放鎖,就直接進入第二層遞歸。因為鎖名為lockKey,並且值為requestId的鎖已經存在,所以第二層遞歸大概率會加鎖失敗,然後返迴到第一層。第一層接下來正常釋放鎖,然後整個遞歸方法直接返迴瞭。
這下子,大傢知道齣現什麼問題瞭吧?
沒錯,遞歸方法其實隻執行瞭第一層遞歸就返迴瞭,其他層遞歸由於加鎖失敗,根本沒法執行。
那麼這個問題該如何解決呢?
答:使用可重入鎖。
我們以redisson框架為例,它的內部實現瞭可重入鎖的功能。
古時候有句話說得好:為人不識陳近南,便稱英雄也枉然。
我說:分布式鎖不識redisson,便稱好鎖也枉然。哈哈哈,隻是自娛自樂一下。
由此可見,redisson在redis分布式鎖中的江湖地位很高。
僞代碼如下:
private int expireTime = 1000;
public void run(String lockKey) {
RLock lock = redisson.getLock(lockKey);
this.fun(lock,1);
}
public void fun(RLock lock,int level){
try{
lock.lock(5, TimeUnit.SECONDS);
if(level<=10){
this.fun(lock,++level);
} else {
return;
}
} finally {
lock.unlock();
}
}
上麵的代碼也許並不完美,這裏隻是給瞭一個大緻的思路,如果大傢有這方麵需求的話,以上代碼僅供參考。
接下來,聊聊redisson可重入鎖的實現原理。
加鎖主要是通過以下腳本實現的:
if (redis.call('exists', KEYS[1]) == 0)
then
redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);
其中:
- KEYS[1]:鎖名
- ARGV[1]:過期時間
- ARGV[2]:uuid + ":" + threadId,可認為是requestId
先判斷如果鎖名不存在,則加鎖。
接下來,判斷如果鎖名和requestId值都存在,則使用hincrby命令給該鎖名和requestId值計數,每次都加1。注意一下,這裏就是重入鎖的關鍵,鎖重入一次值就加1。
如果鎖名存在,但值不是requestId,則返迴過期時間。
釋放鎖主要是通過以下腳本實現的:
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)
then
return nil
end
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0)
then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil
- 先判斷如果鎖名和requestId值不存在,則直接返迴。
- 如果鎖名和requestId值存在,則重入鎖減1。
- 如果減1後,重入鎖的value值還大於0,說明還有引用,則重試設置過期時間。
- 如果減1後,重入鎖的value值還等於0,則可以刪除鎖,然後發消息通知等待綫程搶鎖。
再次強調一下,如果你們係統可以容忍數據暫時不一緻,有些場景不加鎖也行,我在這裏隻是舉個例子,本節內容並不適用於所有場景。
六、鎖競爭問題
如果有大量需要寫入數據的業務場景,使用普通的redis分布式鎖是沒有問題的。
但如果有些業務場景,寫入的操作比較少,反而有大量讀取的操作。這樣直接使用普通的redis分布式鎖,會不會有點浪費性能?
我們都知道,鎖的粒度越粗,多個綫程搶鎖時競爭就越激烈,造成多個綫程鎖等待的時間也就越長,性能也就越差。
所以,提升redis分布式鎖性能的第一步,就是要把鎖的粒度變細。
1、讀寫鎖
眾所周知,加鎖的目的是為瞭保證,在並發環境中讀寫數據的安全性,即不會齣現數據錯誤或者不一緻的情況。
但在絕大多數實際業務場景中,一般是讀數據的頻率遠遠大於寫數據。而綫程間的並發讀操作是並不涉及並發安全問題,我們沒有必要給讀操作加互斥鎖,隻要保證讀寫、寫寫並發操作上鎖是互斥的就行,這樣可以提升係統的性能。
我們以redisson框架為例,它內部已經實現瞭讀寫鎖的功能。
讀鎖的僞代碼如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
//業務操作
} catch (Exception e) {
log.error(e);
} finally {
rLock.unlock();
}
寫鎖的僞代碼如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.writeLock();
try {
rLock.lock();
//業務操作
} catch (InterruptedException e) {
log.error(e);
} finally {
rLock.unlock();
}
將讀鎖和寫鎖分開,最大的好處是提升讀操作的性能,因為讀和讀之間是共享的,不存在互斥性。而我們的實際業務場景中,絕大多數數據操作都是讀操作。所以,如果提升瞭讀操作的性能,也就會提升整個鎖的性能。
下麵總結一個讀寫鎖的特點:
- 讀與讀是共享的,不互斥
- 讀與寫互斥
- 寫與寫互斥
2、鎖分段
此外,為瞭減小鎖的粒度,比較常見的做法是將大鎖:分段。
在java中ConcurrentHashMap,就是將數據分為16段,每一段都有單獨的鎖,並且處於不同鎖段的數據互不乾擾,以此來提升鎖的性能。
放在實際業務場景中,我們可以這樣做:
比如在秒殺扣庫存的場景中,現在的庫存中有2000個商品,用戶可以秒殺。為瞭防止齣現超賣的情況,通常情況下,可以對庫存加鎖。如果有1W的用戶競爭同一把鎖,顯然係統吞吐量會非常低。
為瞭提升係統性能,我們可以將庫存分段,比如:分為100段,這樣每段就有20個商品可以參與秒殺。
在秒殺的過程中,先把用戶id獲取hash值,然後除以100取模。模為1的用戶訪問第1段庫存,模為2的用戶訪問第2段庫存,模為3的用戶訪問第3段庫存,後麵以此類推,到最後模為100的用戶訪問第100段庫存。
如此一來,在多綫程環境中,可以大大的減少鎖的衝突。以前多個綫程隻能同時競爭1把鎖,尤其在秒殺的場景中,競爭太激烈瞭,簡直可以用慘絕人寰來形容,其後果是導緻絕大數綫程在鎖等待。現在多個綫程同時競爭100把鎖,等待的綫程變少瞭,從而係統吞吐量也就提升瞭。
需要注意的地方是:將鎖分段雖說可以提升係統的性能,但它也會讓係統的復雜度提升不少。因為它需要引入額外的路由算法,跨段統計等功能。我們在實際業務場景中,需要綜閤考慮,不是說一定要將鎖分段。
七、鎖超時問題
我在前麵提到過,如果綫程A加鎖成功瞭,但是由於業務功能耗時時間很長,超過瞭設置的超時時間,這時候redis會自動釋放綫程A加的鎖。
有些朋友可能會說:到瞭超時時間,鎖被釋放瞭就釋放瞭唄,對功能又沒啥影響。
答:錯,錯,錯。對功能其實有影響。
通常我們加鎖的目的是:為瞭防止訪問臨界資源時,齣現數據異常的情況。比如:綫程A在修改數據C的值,綫程B也在修改數據C的值,如果不做控製,在並發情況下,數據C的值會齣問題。
為瞭保證某個方法,或者段代碼的互斥性,即如果綫程A執行瞭某段代碼,是不允許其他綫程在某一時刻同時執行的,我們可以用synchronized關鍵字加鎖。
但這種鎖有很大的局限性,隻能保證單個節點的互斥性。如果需要在多個節點中保持互斥性,就需要用redis分布式鎖。
做瞭這麼多鋪墊,現在迴到正題。
假設綫程A加redis分布式鎖的代碼,包含代碼1和代碼2兩段代碼。
由於該綫程要執行的業務操作非常耗時,程序在執行完代碼1的時,已經到瞭設置的超時時間,redis自動釋放瞭鎖。而代碼2還沒來得及執行。
此時,代碼2相當於裸奔的狀態,無法保證互斥性。假如它裏麵訪問瞭臨界資源,並且其他綫程也訪問瞭該資源,可能就會齣現數據異常的情況。(PS:我說的訪問臨界資源,不單單指讀取,還包含寫入)
那麼,如何解決這個問題呢?
答:如果達到瞭超時時間,但業務代碼還沒執行完,需要給鎖自動續期。
我們可以使用TimerTask類,來實現自動續期的功能:
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//自動續期邏輯
}
}, 10000, TimeUnit.MILLISECONDS);
獲取鎖之後,自動開啓一個定時任務,每隔10秒鍾,自動刷新一次過期時間。這種機製在redisson框架中,有個比較霸氣的名字:watch dog,即傳說中的看門狗。
當然自動續期功能,我們還是優先推薦使用lua腳本實現,比如:
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;
需要注意的地方是:在實現自動續期功能時,還需要設置一個總的過期時間,可以跟redisson保持一緻,設置成30秒。如果業務代碼到瞭這個總的過期時間,還沒有執行完,就不再自動續期瞭。
自動續期的功能是獲取鎖之後開啓一個定時任務,每隔10秒判斷一下鎖是否存在,如果存在,則刷新過期時間。如果續期3次,也就是30秒之後,業務方法還是沒有執行完,就不再續期瞭。
八、主從復製的問題
上麵花瞭這麼多篇幅介紹的內容,對單個redis實例是沒有問題的。
but,如果redis存在多個實例。比如:做瞭主從,或者使用瞭哨兵模式,基於redis的分布式鎖的功能,就會齣現問題。
具體是什麼問題?
假設redis現在用的主從模式,1個master節點,3個slave節點。master節點負責寫數據,slave節點負責讀數據。
本來是和諧共處,相安無事的。redis加鎖操作,都在master上進行,加鎖成功後,再異步同步給所有的slave。
突然有一天,master節點由於某些不可逆的原因,掛掉瞭。
這樣需要找一個slave升級為新的master節點,假如slave1被選舉齣來瞭。
如果有個鎖A比較悲催,剛加鎖成功master就掛瞭,還沒來得及同步到slave1。
這樣會導緻新master節點中的鎖A丟失瞭。後麵,如果有新的綫程,使用鎖A加鎖,依然可以成功,分布式鎖失效瞭。
那麼,如何解決這個問題呢?
答:redisson框架為瞭解決這個問題,提供瞭一個專門的類:RedissonRedLock,使用瞭Redlock算法。
RedissonRedLock解決問題的思路如下:
- 需要搭建幾套相互獨立的redis環境,假如我們在這裏搭建瞭5套。
- 每套環境都有一個redisson node節點。
- 多個redisson node節點組成瞭RedissonRedLock。
- 環境包含:單機、主從、哨兵和集群模式,可以是一種或者多種混閤。
在這裏我們以主從為例,架構圖如下:
RedissonRedLock加鎖過程如下:
- 獲取所有的redisson node節點信息,循環嚮所有的redisson node節點加鎖,假設節點數為N,例子中N等於5。
- 如果在N個節點當中,有N/2 + 1個節點加鎖成功瞭,那麼整個RedissonRedLock加鎖是成功的。
- 如果在N個節點當中,小於N/2 + 1個節點加鎖成功,那麼整個RedissonRedLock加鎖是失敗的。
- 如果中途發現各個節點加鎖的總耗時,大於等於設置的最大等待時間,則直接返迴失敗。
從上麵可以看齣,使用Redlock算法,確實能解決多實例場景中,假如master節點掛瞭,導緻分布式鎖失效的問題。
但也引齣瞭一些新問題,比如:
- 需要額外搭建多套環境,申請更多的資源,需要評估一下成本和性價比。
- 如果有N個redisson node節點,需要加鎖N次,最少也需要加鎖N/2+1次,纔知道redlock加鎖是否成功。顯然,增加瞭額外的時間成本,有點得不償失。
由此可見,在實際業務場景,尤其是高並發業務中,RedissonRedLock其實使用的並不多。
在分布式環境中,CAP是繞不過去的。
CAP指的是在一個分布式係統中:
- 一緻性(Consistency)
- 可用性(Availability)
- 分區容錯性(Partition tolerance)
這三個要素最多隻能同時實現兩點,不可能三者兼顧。
如果你的實際業務場景,更需要的是保證數據一緻性。那麼請使用CP類型的分布式鎖,比如:zookeeper,它是基於磁盤的,性能可能沒那麼好,但數據一般不會丟。
如果你的實際業務場景,更需要的是保證數據高可用性。那麼請使用AP類型的分布式鎖,比如:redis,它是基於內存的,性能比較好,但有丟失數據的風險。
其實,在我們絕大多數分布式業務場景中,使用redis分布式鎖就夠瞭,真的彆太較真。因為數據不一緻問題,可以通過最終一緻性方案解決。但如果係統不可用瞭,對用戶來說是暴擊一萬點傷害。
作者丨因為熱愛所以堅持ing
來源丨公眾號:蘇三說技術(ID:susanSayJava)
dbaplus社群歡迎廣大技術人員投稿,投稿郵箱:editor@dbaplus.cn
關注公眾號【dbaplus社群】,獲取更多原創技術文章和精選工具下載
責任編輯: