在分布式系統(tǒng)中,由于redis分布式鎖相對(duì)于更簡(jiǎn)單和高效,成為了分布式鎖的首先,被我們用到了很多實(shí)際業(yè)務(wù)場(chǎng)景當(dāng)中。
但不是說用了redis分布式鎖,就可以高枕無憂了,如果沒有用好或者用對(duì),也會(huì)引來一些意想不到的問題。
今天我們就一起聊聊redis分布式鎖的一些坑,給有需要的朋友一個(gè)參考。
一、非原子操作
使用redis的分布式鎖,我們首先想到的可能是setNx命令。
if (jedis.setnx(lockKey, val) == 1) { jedis.expire(lockKey, timeout); }
容易,三下五除二,我們就可以把代碼寫好。
這段代碼確實(shí)可以加鎖成功,但你有沒有發(fā)現(xiàn)什么問題?
加鎖操作和后面的設(shè)置超時(shí)時(shí)間是分開的,并非原子操作。
假如加鎖成功,但是設(shè)置超時(shí)時(shí)間失敗了,該lockKey就變成永不失效。假如在高并發(fā)場(chǎng)景中,有大量的lockKey加鎖成功了,但不會(huì)失效,有可能直接導(dǎo)致redis內(nèi)存空間不足。
那么,有沒有保證原子性的加鎖命令呢?
答案是:有,請(qǐng)看下面。
二、忘了釋放鎖
上面說到使用setNx命令加鎖操作和設(shè)置超時(shí)時(shí)間是分開的,并非原子操作。
而在redis中還有set命令,該命令可以指定多個(gè)參數(shù)。
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } return false;
其中:
- lockKey:鎖的標(biāo)識(shí)
- requestId:請(qǐng)求id
- NX:只在鍵不存在時(shí),才對(duì)鍵進(jìn)行設(shè)置操作。
- PX:設(shè)置鍵的過期時(shí)間為 millisecond 毫秒。
- expireTime:過期時(shí)間
set命令是原子操作,加鎖和設(shè)置超時(shí)時(shí)間,一個(gè)命令就能輕松搞定。
nice!
使用set命令加鎖,表面上看起來沒有問題。但如果仔細(xì)想想,加鎖之后,每次都要達(dá)到了超時(shí)時(shí)間才釋放鎖,會(huì)不會(huì)有點(diǎn)不合理?加鎖后,如果不及時(shí)釋放鎖,會(huì)有很多問題。
分布式鎖更合理的用法是:
- 手動(dòng)加鎖
- 業(yè)務(wù)操作
- 手動(dòng)釋放鎖
如果手動(dòng)釋放鎖失敗了,則達(dá)到超時(shí)時(shí)間,redis會(huì)自動(dòng)釋放鎖。
大致流程圖如下:
那么問題來了,如何釋放鎖呢?
偽代碼如下:
try{ String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } return false; } finally { unlock(lockKey); }
需要捕獲業(yè)務(wù)代碼的異常,然后在finally中釋放鎖。換句話說就是:無論代碼執(zhí)行成功或失敗了,都需要釋放鎖。
此時(shí),有些朋友可能會(huì)問:假如剛好在釋放鎖的時(shí)候,系統(tǒng)被重啟了,或者網(wǎng)絡(luò)斷線了,或者機(jī)房斷點(diǎn)了,不也會(huì)導(dǎo)致釋放鎖失???
這是一個(gè)好問題,因?yàn)檫@種小概率問題確實(shí)存在。
但還記得前面我們給鎖設(shè)置過超時(shí)時(shí)間嗎?即使出現(xiàn)異常情況造成釋放鎖失敗,但到了我們?cè)O(shè)定的超時(shí)時(shí)間,鎖還是會(huì)被redis自動(dòng)釋放。
但只在finally中釋放鎖,就夠了嗎?
三、釋放了別人的鎖
做人要厚道,先回答上面的問題:只在finally中釋放鎖,當(dāng)然是不夠的,因?yàn)獒尫沛i的姿勢(shì),還是不對(duì)。
哪里不對(duì)?
答:在多線程場(chǎng)景中,可能會(huì)出現(xiàn)釋放了別人的鎖的情況。
有些朋友可能會(huì)反駁:假設(shè)在多線程場(chǎng)景中,線程A獲取到了鎖,但如果線程A沒有釋放鎖,此時(shí),線程B是獲取不到鎖的,何來釋放了別人鎖之說?
答:假如線程A和線程B,都使用lockKey加鎖。線程A加鎖成功了,但是由于業(yè)務(wù)功能耗時(shí)時(shí)間很長(zhǎng),超過了設(shè)置的超時(shí)時(shí)間。這時(shí)候,redis會(huì)自動(dòng)釋放lockKey鎖。此時(shí),線程B就能給lockKey加鎖成功了,接下來執(zhí)行它的業(yè)務(wù)操作。恰好這個(gè)時(shí)候,線程A執(zhí)行完了業(yè)務(wù)功能,接下來,在finally方法中釋放了鎖lockKey。這不就出問題了,線程B的鎖,被線程A釋放了。
我想這個(gè)時(shí)候,線程B肯定哭暈在廁所里,并且嘴里還振振有詞。
那么,如何解決這個(gè)問題呢?
不知道你們注意到?jīng)]?在使用set命令加鎖時(shí),除了使用lockKey鎖標(biāo)識(shí),還多設(shè)置了一個(gè)參數(shù):requestId,為什么要需要記錄requestId呢?
答:requestId是在釋放鎖的時(shí)候用的。
偽代碼如下:
if (jedis.get(lockKey).equals(requestId)) { jedis.del(lockKey); return true; } return false;
在釋放鎖的時(shí)候,先獲取到該鎖的值(之前設(shè)置值就是requestId),然后判斷跟之前設(shè)置的值是否相同,如果相同才允許刪除鎖,返回成功。如果不同,則直接返回失敗。
換句話說就是:自己只能釋放自己加的鎖,不允許釋放別人加的鎖。
這里為什么要用requestId,用userId不行嗎?
答:如果用userId的話,對(duì)于請(qǐng)求來說并不唯一,多個(gè)不同的請(qǐng)求,可能使用同一個(gè)userId。而requestId是全局唯一的,不存在加鎖和釋放鎖亂掉的情況。
此外,使用lua腳本,也能解決釋放了別人的鎖的問題:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
lua腳本能保證查詢鎖是否存在和刪除鎖是原子操作,用它來釋放鎖效果更好一些。
說到lua腳本,其實(shí)加鎖操作也建議使用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框架的加鎖代碼,寫的不錯(cuò),大家可以借鑒一下。
有趣,下面還有哪些好玩的東西?
四、大量失敗請(qǐng)求
上面的加鎖方法看起來好像沒有問題,但如果你仔細(xì)想想,如果有1萬的請(qǐng)求同時(shí)去競(jìng)爭(zhēng)那把鎖,可能只有一個(gè)請(qǐng)求是成功的,其余的9999個(gè)請(qǐng)求都會(huì)失敗。
在秒殺場(chǎng)景下,會(huì)有什么問題?
答:每1萬個(gè)請(qǐng)求,有1個(gè)成功。再1萬個(gè)請(qǐng)求,有1個(gè)成功。如此下去,直到庫存不足。這就變成均勻分布的秒殺了,跟我們想象中的不一樣。
如何解決這個(gè)問題呢?
此外,還有一種場(chǎng)景:
比如,有兩個(gè)線程同時(shí)上傳文件到sftp,上傳文件前先要?jiǎng)?chuàng)建目錄。假設(shè)兩個(gè)線程需要?jiǎng)?chuàng)建的目錄名都是當(dāng)天的日期,比如:20210920,如果不做任何控制,直接并發(fā)的創(chuàng)建目錄,第二個(gè)線程必然會(huì)失敗。
這時(shí)候有些朋友可能會(huì)說:這還不容易,加一個(gè)redis分布式鎖就能解決問題了,此外再判斷一下,如果目錄已經(jīng)存在就不創(chuàng)建,只有目錄不存在才需要?jiǎng)?chuàng)建。
偽代碼如下:
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;
一切看似美好,但經(jīng)不起仔細(xì)推敲。
來自靈魂的一問:第二個(gè)請(qǐng)求如果加鎖失敗了,接下來,是返回失敗,還是返回成功呢?
主要流程圖如下:
顯然第二個(gè)請(qǐng)求,肯定是不能返回失敗的,如果返回失敗了,這個(gè)問題還是沒有被解決。 如果文件還沒有上傳成功,直接返回成功會(huì)有更大的問題。 頭疼,到底該如何解決呢?
答:使用自旋鎖。
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;
在規(guī)定的時(shí)間,比如500毫秒內(nèi),自旋不斷嘗試加鎖(說白了,就是在死循環(huán)中,不斷嘗試加鎖),如果成功則直接返回。如果失敗,則休眠50毫秒,再發(fā)起新一輪的嘗試。如果到了超時(shí)時(shí)間,還未加鎖成功,則直接返回失敗。
好吧,學(xué)到一招了,還有嗎?
五、鎖重入問題
我們都知道redis分布式鎖是互斥的。假如我們對(duì)某個(gè)key加鎖了,如果該key對(duì)應(yīng)的鎖還沒失效,再用相同key去加鎖,大概率會(huì)失敗。
沒錯(cuò),大部分場(chǎng)景是沒問題的。
為什么說是大部分場(chǎng)景呢?
因?yàn)檫€有這樣的場(chǎng)景:
假設(shè)在某個(gè)請(qǐng)求中,需要獲取一顆滿足條件的菜單樹或者分類樹。我們以菜單為例,這就需要在接口中從根節(jié)點(diǎn)開始,遞歸遍歷出所有滿足條件的子節(jié)點(diǎn),然后組裝成一顆菜單樹。
需要注意的是菜單不是一成不變的,在后臺(tái)系統(tǒng)中運(yùn)營(yíng)同學(xué)可以動(dòng)態(tài)添加、修改和刪除菜單。為了保證在并發(fā)的情況下,每次都可能獲取最新的數(shù)據(jù),這里可以加redis分布式鎖。
加redis分布式鎖的思路是對(duì)的。但接下來問題來了,在遞歸方法中遞歸遍歷多次,每次都是加的同一把鎖。遞歸第一層當(dāng)然是可以加鎖成功的,但遞歸第二層、第三層...第N層,不就會(huì)加鎖失敗了?
遞歸方法中加鎖的偽代碼如下:
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); } }
如果你直接這么用,看起來好像沒有問題。但最終執(zhí)行程序之后發(fā)現(xiàn),等待你的結(jié)果只有一個(gè):出現(xiàn)異常。
因?yàn)閺母?jié)點(diǎn)開始,第一層遞歸加鎖成功,還沒釋放鎖,就直接進(jìn)入第二層遞歸。因?yàn)殒i名為lockKey,并且值為requestId的鎖已經(jīng)存在,所以第二層遞歸大概率會(huì)加鎖失敗,然后返回到第一層。第一層接下來正常釋放鎖,然后整個(gè)遞歸方法直接返回了。
這下子,大家知道出現(xiàn)什么問題了吧?
沒錯(cuò),遞歸方法其實(shí)只執(zhí)行了第一層遞歸就返回了,其他層遞歸由于加鎖失敗,根本沒法執(zhí)行。
那么這個(gè)問題該如何解決呢?
答:使用可重入鎖。
我們以redisson框架為例,它的內(nèi)部實(shí)現(xiàn)了可重入鎖的功能。
古時(shí)候有句話說得好:為人不識(shí)陳近南,便稱英雄也枉然。
我說:分布式鎖不識(shí)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(); } }
上面的代碼也許并不完美,這里只是給了一個(gè)大致的思路,如果大家有這方面需求的話,以上代碼僅供參考。
接下來,聊聊redisson可重入鎖的實(shí)現(xiàn)原理。
加鎖主要是通過以下腳本實(shí)現(xiàn)的:
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]:過期時(shí)間
- ARGV[2]:uuid + ":" + threadId,可認(rèn)為是requestId
先判斷如果鎖名不存在,則加鎖。
接下來,判斷如果鎖名和requestId值都存在,則使用hincrby命令給該鎖名和requestId值計(jì)數(shù),每次都加1。注意一下,這里就是重入鎖的關(guān)鍵,鎖重入一次值就加1。
如果鎖名存在,但值不是requestId,則返回過期時(shí)間。
釋放鎖主要是通過以下腳本實(shí)現(xiàn)的:
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,說明還有引用,則重試設(shè)置過期時(shí)間。
- 如果減1后,重入鎖的value值還等于0,則可以刪除鎖,然后發(fā)消息通知等待線程搶鎖。
再次強(qiáng)調(diào)一下,如果你們系統(tǒng)可以容忍數(shù)據(jù)暫時(shí)不一致,有些場(chǎng)景不加鎖也行,我在這里只是舉個(gè)例子,本節(jié)內(nèi)容并不適用于所有場(chǎng)景。
六、鎖競(jìng)爭(zhēng)問題
如果有大量需要寫入數(shù)據(jù)的業(yè)務(wù)場(chǎng)景,使用普通的redis分布式鎖是沒有問題的。
但如果有些業(yè)務(wù)場(chǎng)景,寫入的操作比較少,反而有大量讀取的操作。這樣直接使用普通的redis分布式鎖,會(huì)不會(huì)有點(diǎn)浪費(fèi)性能?
我們都知道,鎖的粒度越粗,多個(gè)線程搶鎖時(shí)競(jìng)爭(zhēng)就越激烈,造成多個(gè)線程鎖等待的時(shí)間也就越長(zhǎng),性能也就越差。
所以,提升redis分布式鎖性能的第一步,就是要把鎖的粒度變細(xì)。
1、讀寫鎖
眾所周知,加鎖的目的是為了保證,在并發(fā)環(huán)境中讀寫數(shù)據(jù)的安全性,即不會(huì)出現(xiàn)數(shù)據(jù)錯(cuò)誤或者不一致的情況。
但在絕大多數(shù)實(shí)際業(yè)務(wù)場(chǎng)景中,一般是讀數(shù)據(jù)的頻率遠(yuǎn)遠(yuǎn)大于寫數(shù)據(jù)。而線程間的并發(fā)讀操作是并不涉及并發(fā)安全問題,我們沒有必要給讀操作加互斥鎖,只要保證讀寫、寫寫并發(fā)操作上鎖是互斥的就行,這樣可以提升系統(tǒng)的性能。
我們以redisson框架為例,它內(nèi)部已經(jīng)實(shí)現(xiàn)了讀寫鎖的功能。
讀鎖的偽代碼如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); RLock rLock = readWriteLock.readLock(); try { rLock.lock(); //業(yè)務(wù)操作 } catch (Exception e) { log.error(e); } finally { rLock.unlock(); }
寫鎖的偽代碼如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); RLock rLock = readWriteLock.writeLock(); try { rLock.lock(); //業(yè)務(wù)操作 } catch (InterruptedException e) { log.error(e); } finally { rLock.unlock(); }
將讀鎖和寫鎖分開,最大的好處是提升讀操作的性能,因?yàn)樽x和讀之間是共享的,不存在互斥性。而我們的實(shí)際業(yè)務(wù)場(chǎng)景中,絕大多數(shù)數(shù)據(jù)操作都是讀操作。所以,如果提升了讀操作的性能,也就會(huì)提升整個(gè)鎖的性能。
下面總結(jié)一個(gè)讀寫鎖的特點(diǎn):
- 讀與讀是共享的,不互斥
- 讀與寫互斥
- 寫與寫互斥
2、鎖分段
此外,為了減小鎖的粒度,比較常見的做法是將大鎖:分段。
在java中ConcurrentHashMap,就是將數(shù)據(jù)分為16段,每一段都有單獨(dú)的鎖,并且處于不同鎖段的數(shù)據(jù)互不干擾,以此來提升鎖的性能。
放在實(shí)際業(yè)務(wù)場(chǎng)景中,我們可以這樣做:
比如在秒殺扣庫存的場(chǎng)景中,現(xiàn)在的庫存中有2000個(gè)商品,用戶可以秒殺。為了防止出現(xiàn)超賣的情況,通常情況下,可以對(duì)庫存加鎖。如果有1W的用戶競(jìng)爭(zhēng)同一把鎖,顯然系統(tǒng)吞吐量會(huì)非常低。
為了提升系統(tǒng)性能,我們可以將庫存分段,比如:分為100段,這樣每段就有20個(gè)商品可以參與秒殺。
在秒殺的過程中,先把用戶id獲取hash值,然后除以100取模。模為1的用戶訪問第1段庫存,模為2的用戶訪問第2段庫存,模為3的用戶訪問第3段庫存,后面以此類推,到最后模為100的用戶訪問第100段庫存。
如此一來,在多線程環(huán)境中,可以大大的減少鎖的沖突。 以前多個(gè)線程只能同時(shí)競(jìng)爭(zhēng)1把鎖,尤其在秒殺的場(chǎng)景中,競(jìng)爭(zhēng)太激烈了,簡(jiǎn)直可以用慘絕人寰來形容,其后果是導(dǎo)致絕大數(shù)線程在鎖等待。 現(xiàn)在多個(gè)線程同時(shí)競(jìng)爭(zhēng)100把鎖,等待的線程變少了,從而系統(tǒng)吞吐量也就提升了。
需要注意的地方是:將鎖分段雖說可以提升系統(tǒng)的性能,但它也會(huì)讓系統(tǒng)的復(fù)雜度提升不少。因?yàn)樗枰腩~外的路由算法,跨段統(tǒng)計(jì)等功能。我們?cè)趯?shí)際業(yè)務(wù)場(chǎng)景中,需要綜合考慮,不是說一定要將鎖分段。
七、鎖超時(shí)問題
我在前面提到過,如果線程A加鎖成功了,但是由于業(yè)務(wù)功能耗時(shí)時(shí)間很長(zhǎng),超過了設(shè)置的超時(shí)時(shí)間,這時(shí)候redis會(huì)自動(dòng)釋放線程A加的鎖。
有些朋友可能會(huì)說:到了超時(shí)時(shí)間,鎖被釋放了就釋放了唄,對(duì)功能又沒啥影響。
答:錯(cuò),錯(cuò),錯(cuò)。對(duì)功能其實(shí)有影響。
通常我們加鎖的目的是:為了防止訪問臨界資源時(shí),出現(xiàn)數(shù)據(jù)異常的情況。比如:線程A在修改數(shù)據(jù)C的值,線程B也在修改數(shù)據(jù)C的值,如果不做控制,在并發(fā)情況下,數(shù)據(jù)C的值會(huì)出問題。
為了保證某個(gè)方法,或者段代碼的互斥性,即如果線程A執(zhí)行了某段代碼,是不允許其他線程在某一時(shí)刻同時(shí)執(zhí)行的,我們可以用synchronized關(guān)鍵字加鎖。
但這種鎖有很大的局限性,只能保證單個(gè)節(jié)點(diǎn)的互斥性。如果需要在多個(gè)節(jié)點(diǎn)中保持互斥性,就需要用redis分布式鎖。
做了這么多鋪墊,現(xiàn)在回到正題。
假設(shè)線程A加redis分布式鎖的代碼,包含代碼1和代碼2兩段代碼。
由于該線程要執(zhí)行的業(yè)務(wù)操作非常耗時(shí),程序在執(zhí)行完代碼1的時(shí),已經(jīng)到了設(shè)置的超時(shí)時(shí)間,redis自動(dòng)釋放了鎖。 而代碼2還沒來得及執(zhí)行。
此時(shí),代碼2相當(dāng)于裸奔的狀態(tài),無法保證互斥性。 假如它里面訪問了臨界資源,并且其他線程也訪問了該資源,可能就會(huì)出現(xiàn)數(shù)據(jù)異常的情況。 (PS: 我說的訪問臨界資源,不單單指讀取,還包含寫入)
那么,如何解決這個(gè)問題呢?
答:如果達(dá)到了超時(shí)時(shí)間,但業(yè)務(wù)代碼還沒執(zhí)行完,需要給鎖自動(dòng)續(xù)期。
我們可以使用TimerTask類,來實(shí)現(xiàn)自動(dòng)續(xù)期的功能:
Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { //自動(dòng)續(xù)期邏輯 } }, 10000, TimeUnit.MILLISECONDS);
獲取鎖之后,自動(dòng)開啟一個(gè)定時(shí)任務(wù),每隔10秒鐘,自動(dòng)刷新一次過期時(shí)間。這種機(jī)制在redisson框架中,有個(gè)比較霸氣的名字:watch dog,即傳說中的看門狗。
當(dāng)然自動(dòng)續(xù)期功能,我們還是優(yōu)先推薦使用lua腳本實(shí)現(xiàn),比如:
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;
需要注意的地方是:在實(shí)現(xiàn)自動(dòng)續(xù)期功能時(shí),還需要設(shè)置一個(gè)總的過期時(shí)間,可以跟redisson保持一致,設(shè)置成30秒。如果業(yè)務(wù)代碼到了這個(gè)總的過期時(shí)間,還沒有執(zhí)行完,就不再自動(dòng)續(xù)期了。
自動(dòng)續(xù)期的功能是獲取鎖之后開啟一個(gè)定時(shí)任務(wù),每隔10秒判斷一下鎖是否存在,如果存在,則刷新過期時(shí)間。如果續(xù)期3次,也就是30秒之后,業(yè)務(wù)方法還是沒有執(zhí)行完,就不再續(xù)期了。
八、主從復(fù)制的問題
上面花了這么多篇幅介紹的內(nèi)容,對(duì)單個(gè)redis實(shí)例是沒有問題的。
but,如果redis存在多個(gè)實(shí)例。比如:做了主從,或者使用了哨兵模式,基于redis的分布式鎖的功能,就會(huì)出現(xiàn)問題。
具體是什么問題?
假設(shè)redis現(xiàn)在用的主從模式,1個(gè)master節(jié)點(diǎn),3個(gè)slave節(jié)點(diǎn)。master節(jié)點(diǎn)負(fù)責(zé)寫數(shù)據(jù),slave節(jié)點(diǎn)負(fù)責(zé)讀數(shù)據(jù)。
本來是和諧共處,相安無事的。 redis加鎖操作,都在master上進(jìn)行,加鎖成功后,再異步同步給所有的slave。
突然有一天,master節(jié)點(diǎn)由于某些不可逆的原因,掛掉了。
這樣需要找一個(gè)slave升級(jí)為新的master節(jié)點(diǎn),假如slave1被選舉出來了。
如果有個(gè)鎖A比較悲催, 剛加鎖成功master就掛了,還沒來得及同步到slave1。
這樣會(huì)導(dǎo)致新master節(jié)點(diǎn)中的鎖A丟失了。后面,如果有新的線程,使用鎖A加鎖,依然可以成功,分布式鎖失效了。
那么,如何解決這個(gè)問題呢?
答:redisson框架為了解決這個(gè)問題,提供了一個(gè)專門的類:RedissonRedLock,使用了Redlock算法。
RedissonRedLock解決問題的思路如下:
- 需要搭建幾套相互獨(dú)立的redis環(huán)境,假如我們?cè)谶@里搭建了5套。
- 每套環(huán)境都有一個(gè)redisson node節(jié)點(diǎn)。
- 多個(gè)redisson node節(jié)點(diǎn)組成了RedissonRedLock。
- 環(huán)境包含:?jiǎn)螜C(jī)、主從、哨兵和集群模式,可以是一種或者多種混合。
在這里我們以主從為例,架構(gòu)圖如下:
RedissonRedLock加鎖過程如下:
- 獲取所有的redisson node節(jié)點(diǎn)信息,循環(huán)向所有的redisson node節(jié)點(diǎn)加鎖,假設(shè)節(jié)點(diǎn)數(shù)為N,例子中N等于5。
- 如果在N個(gè)節(jié)點(diǎn)當(dāng)中,有N/2 + 1個(gè)節(jié)點(diǎn)加鎖成功了,那么整個(gè)RedissonRedLock加鎖是成功的。
- 如果在N個(gè)節(jié)點(diǎn)當(dāng)中,小于N/2 + 1個(gè)節(jié)點(diǎn)加鎖成功,那么整個(gè)RedissonRedLock加鎖是失敗的。
- 如果中途發(fā)現(xiàn)各個(gè)節(jié)點(diǎn)加鎖的總耗時(shí),大于等于設(shè)置的最大等待時(shí)間,則直接返回失敗。
從上面可以看出,使用Redlock算法,確實(shí)能解決多實(shí)例場(chǎng)景中,假如master節(jié)點(diǎn)掛了,導(dǎo)致分布式鎖失效的問題。
但也引出了一些新問題,比如:
- 需要額外搭建多套環(huán)境,申請(qǐng)更多的資源,需要評(píng)估一下成本和性價(jià)比。
- 如果有N個(gè)redisson node節(jié)點(diǎn),需要加鎖N次,最少也需要加鎖N/2+1次,才知道redlock加鎖是否成功。顯然,增加了額外的時(shí)間成本,有點(diǎn)得不償失。
由此可見,在實(shí)際業(yè)務(wù)場(chǎng)景,尤其是高并發(fā)業(yè)務(wù)中,RedissonRedLock其實(shí)使用的并不多。
在分布式環(huán)境中,CAP是繞不過去的。
CAP指的是在一個(gè)分布式系統(tǒng)中:
- 一致性(Consistency)
- 可用性(Availability)
- 分區(qū)容錯(cuò)性(Partition tolerance)
這三個(gè)要素最多只能同時(shí)實(shí)現(xiàn)兩點(diǎn),不可能三者兼顧。
如果你的實(shí)際業(yè)務(wù)場(chǎng)景,更需要的是保證數(shù)據(jù)一致性。那么請(qǐng)使用CP類型的分布式鎖,比如:zookeeper,它是基于磁盤的,性能可能沒那么好,但數(shù)據(jù)一般不會(huì)丟。
如果你的實(shí)際業(yè)務(wù)場(chǎng)景,更需要的是保證數(shù)據(jù)高可用性。那么請(qǐng)使用AP類型的分布式鎖,比如:redis,它是基于內(nèi)存的,性能比較好,但有丟失數(shù)據(jù)的風(fēng)險(xiǎn)。
其實(shí),在我們絕大多數(shù)分布式業(yè)務(wù)場(chǎng)景中,使用redis分布式鎖就夠了,真的別太較真。因?yàn)閿?shù)據(jù)不一致問題,可以通過最終一致性方案解決。但如果系統(tǒng)不可用了,對(duì)用戶來說是暴擊一萬點(diǎn)傷害。
原文地址:https://mp.weixin.qq.com/s?__biz=MzkwOTIxNDQ3OA==&mid=2247567013&idx=1&sn=ded5a8d2ca7f385068a31f43a5cddbf8&utm_source=tuicool&utm_medium=referral