<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
在分散式系統中,由於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
:請求idNX
:只在鍵不存在時,才對鍵進行設定操作。PX
:設定鍵的過期時間為 millisecond 毫秒。expireTime
:過期時間set
命令是原子操作,加鎖和設定超時時間,一個命令就能輕鬆搞定。
使用set
命令加鎖,表面上看起來沒有問題。但如果仔細想想,加鎖之後,每次都要達到了超時時間才釋放鎖,會不會有點不合理?加鎖後,如果不及時釋放鎖,會有很多問題。
分散式鎖更合理的用法是:
大致流程圖如下:
那麼問題來了,如何釋放鎖呢?
虛擬碼如下:
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]);
其中:
釋放鎖主要是通過以下指令碼實現的:
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
再次強調一下,如果你們系統可以容忍資料暫時不一致,有些場景不加鎖也行,我在這裡只是舉個例子,本節內容並不適用於所有場景。
如果有大量需要寫入資料的業務場景,使用普通的redis分散式鎖是沒有問題的。
但如果有些業務場景,寫入的操作比較少,反而有大量讀取的操作。這樣直接使用普通的redis分散式鎖,會不會有點浪費效能?
我們都知道,鎖的粒度越粗,多個執行緒搶鎖時競爭就越激烈,造成多個執行緒鎖等待的時間也就越長,效能也就越差。
所以,提升redis分散式鎖效能的第一步,就是要把鎖的粒度變細。
眾所周知,加鎖的目的是為了保證,在並行環境中讀寫資料的安全性,即不會出現資料錯誤或者不一致的情況。
但在絕大多數實際業務場景中,一般是讀資料的頻率遠遠大於寫資料。而執行緒間的並行讀操作是並不涉及並行安全問題,我們沒有必要給讀操作加互斥鎖,只要保證讀寫、寫寫並行操作上鎖是互斥的就行,這樣可以提升系統的效能。
我們以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(); }
將讀鎖和寫鎖分開,最大的好處是提升讀操作的效能,因為讀和讀之間是共用的,不存在互斥性。而我們的實際業務場景中,絕大多數資料操作都是讀操作。所以,如果提升了讀操作的效能,也就會提升整個鎖的效能。
下面總結一個讀寫鎖的特點:
此外,為了減小鎖的粒度,比較常見的做法是將大鎖:分段
。
在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解決問題的思路如下:
在這裡我們以主從為例,架構圖如下:
RedissonRedLock加鎖過程如下:
從上面可以看出,使用Redlock演演算法,確實能解決多範例場景中,假如master節點掛了,導致分散式鎖失效的問題。
但也引出了一些新問題,比如:
由此可見,在實際業務場景,尤其是高並行業務中,RedissonRedLock其實使用的並不多。
在分散式環境中,CAP是繞不過去的。
CAP指的是在一個分散式系統中:
這三個要素最多隻能同時實現兩點,不可能三者兼顧。
如果你的實際業務場景,更需要的是保證資料一致性。那麼請使用CP型別的分散式鎖,比如:zookeeper,它是基於磁碟的,效能可能沒那麼好,但資料一般不會丟。
如果你的實際業務場景,更需要的是保證資料高可用性。那麼請使用AP型別的分散式鎖,比如:redis,它是基於記憶體的,效能比較好,但有丟失資料的風險。
其實,在我們絕大多數分散式業務場景中,使用redis分散式鎖就夠了,真的別太較真。因為資料不一致問題,可以通過最終一致性方案解決。但如果系統不可用了,對使用者來說是暴擊一萬點傷害。
到此這篇關於redis分散式鎖的8大坑總結梳理的文章就介紹到這了,更多相關redis分散式鎖坑內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45