前言
分布式鎖一般有三種實(shí)現(xiàn)方式:1. 數(shù)據(jù)庫(kù)樂(lè)觀鎖渗磅;2. 基于Redis的分布式鎖;3. 基于ZooKeeper的分布式鎖冀痕。本篇博客將介紹第二種方式币狠,基于Redis實(shí)現(xiàn)分布式鎖听绳。雖然網(wǎng)上已經(jīng)有各種介紹Redis分布式鎖實(shí)現(xiàn)的博客,然而他們的實(shí)現(xiàn)卻有著各種各樣的問(wèn)題,為了避免誤人子弟,本篇博客將詳細(xì)介紹如何正確地實(shí)現(xiàn)Redis分布式鎖帜羊。
可靠性
首先咒程,為了確保分布式鎖可用鸠天,我們至少要確保鎖的實(shí)現(xiàn)同時(shí)滿足以下四個(gè)條件:
互斥性。在任意時(shí)刻帐姻,只有一個(gè)客戶端能持有鎖稠集。
不會(huì)發(fā)生死鎖。即使有一個(gè)客戶端在持有鎖的期間崩潰而沒(méi)有主動(dòng)解鎖饥瓷,也能保證后續(xù)其他客戶端能加鎖剥纷。
具有容錯(cuò)性。只要大部分的Redis節(jié)點(diǎn)正常運(yùn)行呢铆,客戶端就可以加鎖和解鎖晦鞋。
解鈴還須系鈴人。加鎖和解鎖必須是同一個(gè)客戶端,客戶端自己不能把別人加的鎖給解了悠垛。
代碼實(shí)現(xiàn)
組件依賴
首先我們要通過(guò)Maven引入?Jedis?開(kāi)源組件线定,在?pom.xml?文件加入下面的代碼:
redis.clientsjedis2.9.0
加鎖代碼
正確姿勢(shì)
Talk is cheap, show me the code。先展示代碼确买,再帶大家慢慢解釋為什么這樣實(shí)現(xiàn):
publicclassRedisTool{privatestaticfinalString LOCK_SUCCESS ="OK";privatestaticfinalString SET_IF_NOT_EXIST ="NX";privatestaticfinalString SET_WITH_EXPIRE_TIME ="PX";/**? ? * 嘗試獲取分布式鎖? ? *@paramjedis Redis客戶端? ? *@paramlockKey 鎖? ? *@paramrequestId 請(qǐng)求標(biāo)識(shí)? ? *@paramexpireTime 超期時(shí)間? ? *@return是否獲取成功? ? */publicstaticbooleantryGetDistributedLock(Jedis jedis, String lockKey, String requestId,intexpireTime){? ? ? ? String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);if(LOCK_SUCCESS.equals(result)) {returntrue;? ? ? ? }returnfalse;? ? }}
可以看到斤讥,我們加鎖就一行代碼:?jedis.set(String key, String value, String nxxx, String expx, int time)?,這個(gè)set()方法一共有五個(gè)形參:
第一個(gè)為key湾趾,我們使用key來(lái)當(dāng)鎖芭商,因?yàn)閗ey是唯一的。
第二個(gè)為value搀缠,我們傳的是requestId铛楣,很多童鞋可能不明白,有key作為鎖不就夠了嗎胡嘿,為什么還要用到value蛉艾?原因就是我們?cè)谏厦嬷v到可靠性時(shí),分布式鎖要滿足第四個(gè)條件解鈴還須系鈴人衷敌,通過(guò)給value賦值為requestId勿侯,我們就知道這把鎖是哪個(gè)請(qǐng)求加的了,在解鎖的時(shí)候就可以有依據(jù)缴罗。requestId可以使用?UUID.randomUUID().toString()?方法生成助琐。
第三個(gè)為nxxx,這個(gè)參數(shù)我們填的是NX面氓,意思是SET IF NOT EXIST兵钮,即當(dāng)key不存在時(shí),我們進(jìn)行set操作舌界;若key已經(jīng)存在掘譬,則不做任何操作;
第四個(gè)為expx呻拌,這個(gè)參數(shù)我們傳的是PX葱轩,意思是我們要給這個(gè)key加一個(gè)過(guò)期的設(shè)置,具體時(shí)間由第五個(gè)參數(shù)決定藐握。
第五個(gè)為time靴拱,與第四個(gè)參數(shù)相呼應(yīng),代表key的過(guò)期時(shí)間猾普。
總的來(lái)說(shuō)袜炕,執(zhí)行上面的set()方法就只會(huì)導(dǎo)致兩種結(jié)果:1. 當(dāng)前沒(méi)有鎖(key不存在),那么就進(jìn)行加鎖操作初家,并對(duì)鎖設(shè)置個(gè)有效期偎窘,同時(shí)value表示加鎖的客戶端乌助。2. 已有鎖存在,不做任何操作陌知。
心細(xì)的童鞋就會(huì)發(fā)現(xiàn)了眷茁,我們的加鎖代碼滿足我們可靠性里描述的三個(gè)條件。首先纵诞,set()加入了NX參數(shù)上祈,可以保證如果已有key存在,則函數(shù)不會(huì)調(diào)用成功浙芙,也就是只有一個(gè)客戶端能持有鎖登刺,滿足互斥性。其次嗡呼,由于我們對(duì)鎖設(shè)置了過(guò)期時(shí)間纸俭,即使鎖的持有者后續(xù)發(fā)生崩潰而沒(méi)有解鎖,鎖也會(huì)因?yàn)榈搅诉^(guò)期時(shí)間而自動(dòng)解鎖(即key被刪除)南窗,不會(huì)發(fā)生死鎖揍很。最后,因?yàn)槲覀儗alue賦值為requestId万伤,代表加鎖的客戶端請(qǐng)求標(biāo)識(shí)窒悔,那么在客戶端在解鎖的時(shí)候就可以進(jìn)行校驗(yàn)是否是同一個(gè)客戶端。由于我們只考慮Redis單機(jī)部署的場(chǎng)景敌买,所以容錯(cuò)性我們暫不考慮简珠。
錯(cuò)誤示例1
比較常見(jiàn)的錯(cuò)誤示例就是使用?jedis.setnx()?和?jedis.expire()?組合實(shí)現(xiàn)加鎖,代碼如下:
publicstaticvoidwrongGetLock1(Jedis jedis,StringlockKey,StringrequestId,intexpireTime) {? ? Long result = jedis.setnx(lockKey, requestId);if(result ==1) {// 若在這里程序突然崩潰虹钮,則無(wú)法設(shè)置過(guò)期時(shí)間聋庵,將發(fā)生死鎖jedis.expire(lockKey, expireTime);? ? }}
setnx()方法作用就是SET IF NOT EXIST,expire()方法就是給鎖加一個(gè)過(guò)期時(shí)間芙粱。乍一看好像和前面的set()方法結(jié)果一樣祭玉,然而由于這是兩條Redis命令,不具有原子性春畔,如果程序在執(zhí)行完setnx()之后突然崩潰脱货,導(dǎo)致鎖沒(méi)有設(shè)置過(guò)期時(shí)間。那么將會(huì)發(fā)生死鎖拐迁。網(wǎng)上之所以有人這樣實(shí)現(xiàn)蹭劈,是因?yàn)榈桶姹镜膉edis并不支持多參數(shù)的set()方法疗绣。
錯(cuò)誤示例2
publicstaticboolean wrongGetLock2(Jedis jedis,StringlockKey,intexpireTime) {? ? long expires = System.currentTimeMillis() + expireTime;StringexpiresStr =String.valueOf(expires);// 如果當(dāng)前鎖不存在线召,返回加鎖成功if(jedis.setnx(lockKey, expiresStr) ==1) {returntrue;? ? }// 如果鎖存在,獲取鎖的過(guò)期時(shí)間StringcurrentValueStr = jedis.get(lockKey);if(currentValueStr !=null&& Long.parseLong(currentValueStr) < System.currentTimeMillis()) {// 鎖已過(guò)期多矮,獲取上一個(gè)鎖的過(guò)期時(shí)間缓淹,并設(shè)置現(xiàn)在鎖的過(guò)期時(shí)間StringoldValueStr = jedis.getSet(lockKey, expiresStr);if(oldValueStr !=null&& oldValueStr.equals(currentValueStr)) {// 考慮多線程并發(fā)的情況哈打,只有一個(gè)線程的設(shè)置值和當(dāng)前值相同,它才有權(quán)利加鎖returntrue;? ? ? ? }? ? }// 其他情況讯壶,一律返回加鎖失敗returnfalse;}
這一種錯(cuò)誤示例就比較難以發(fā)現(xiàn)問(wèn)題料仗,而且實(shí)現(xiàn)也比較復(fù)雜。實(shí)現(xiàn)思路:使用?jedis.setnx()命令實(shí)現(xiàn)加鎖伏蚊,其中key是鎖立轧,value是鎖的過(guò)期時(shí)間。執(zhí)行過(guò)程:1. 通過(guò)setnx()方法嘗試加鎖躏吊,如果當(dāng)前鎖不存在氛改,返回加鎖成功。2. 如果鎖已經(jīng)存在則獲取鎖的過(guò)期時(shí)間比伏,和當(dāng)前時(shí)間比較胜卤,如果鎖已經(jīng)過(guò)期,則設(shè)置新的過(guò)期時(shí)間赁项,返回加鎖成功葛躏。代碼如下:
那么這段代碼問(wèn)題在哪里?1. 由于是客戶端自己生成過(guò)期時(shí)間悠菜,所以需要強(qiáng)制要求分布式下每個(gè)客戶端的時(shí)間必須同步舰攒。 2. 當(dāng)鎖過(guò)期的時(shí)候,如果多個(gè)客戶端同時(shí)執(zhí)行?jedis.getSet()?方法悔醋,那么雖然最終只有一個(gè)客戶端可以加鎖芒率,但是這個(gè)客戶端的鎖的過(guò)期時(shí)間可能被其他客戶端覆蓋。3. 鎖不具備擁有者標(biāo)識(shí)篙顺,即任何客戶端都可以解鎖偶芍。
解鎖代碼
正確姿勢(shì)
還是先展示代碼,再帶大家慢慢解釋為什么這樣實(shí)現(xiàn):
publicclassRedisTool{privatestaticfinalLong RELEASE_SUCCESS =1L;/**? ? * 釋放分布式鎖? ? *@paramjedis Redis客戶端? ? *@paramlockKey 鎖? ? *@paramrequestId 請(qǐng)求標(biāo)識(shí)? ? *@return是否釋放成功? ? */publicstaticboolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {? ? ? ? String script ="if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";? ? ? ? Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));if(RELEASE_SUCCESS.equals(result)) {returntrue;? ? ? ? }returnfalse;? ? }}
可以看到德玫,我們解鎖只需要兩行代碼就搞定了匪蟀!第一行代碼,我們寫(xiě)了一個(gè)簡(jiǎn)單的Lua腳本代碼宰僧,上一次見(jiàn)到這個(gè)編程語(yǔ)言還是在《黑客與畫(huà)家》里材彪,沒(méi)想到這次居然用上了。第二行代碼琴儿,我們將Lua代碼傳到?jedis.eval()?方法里段化,并使參數(shù)KEYS[1]賦值為lockKey,ARGV[1]賦值為requestId造成。eval()方法是將Lua代碼交給Redis服務(wù)端執(zhí)行显熏。
那么這段Lua代碼的功能是什么呢?其實(shí)很簡(jiǎn)單晒屎,首先獲取鎖對(duì)應(yīng)的value值喘蟆,檢查是否與requestId相等缓升,如果相等則刪除鎖(解鎖)。那么為什么要使用Lua語(yǔ)言來(lái)實(shí)現(xiàn)呢蕴轨?因?yàn)橐_保上述操作是原子性的港谊。關(guān)于非原子性會(huì)帶來(lái)什么問(wèn)題,可以閱讀?【解鎖代碼-錯(cuò)誤示例2】?橙弱。那么為什么執(zhí)行eval()方法可以確保原子性歧寺,源于Redis的特性,下面是官網(wǎng)對(duì)eval命令的部分解釋:
簡(jiǎn)單來(lái)說(shuō)棘脐,就是在eval命令執(zhí)行Lua代碼的時(shí)候成福,Lua代碼將被當(dāng)成一個(gè)命令去執(zhí)行,并且直到eval命令執(zhí)行完成荆残,Redis才會(huì)執(zhí)行其他命令奴艾。
錯(cuò)誤示例1
最常見(jiàn)的解鎖代碼就是直接使用?jedis.del()?方法刪除鎖,這種不先判斷鎖的擁有者而直接解鎖的方式内斯,會(huì)導(dǎo)致任何客戶端都可以隨時(shí)進(jìn)行解鎖蕴潦,即使這把鎖不是它的。
publicstaticvoidwrongReleaseLock1(Jedis jedis, String lockKey){? ? jedis.del(lockKey);}
錯(cuò)誤示例2
這種解鎖代碼乍一看也是沒(méi)問(wèn)題俘闯,甚至我之前也差點(diǎn)這樣實(shí)現(xiàn)潭苞,與正確姿勢(shì)差不多,唯一區(qū)別的是分成兩條命令去執(zhí)行真朗,代碼如下:
publicstaticvoidwrongReleaseLock2(Jedis jedis, String lockKey, String requestId){// 判斷加鎖與解鎖是不是同一個(gè)客戶端if(requestId.equals(jedis.get(lockKey))) {// 若在此時(shí)此疹,這把鎖突然不是這個(gè)客戶端的,則會(huì)誤解鎖jedis.del(lockKey);? ? }}
如代碼注釋遮婶,問(wèn)題在于如果調(diào)用?jedis.del()?方法的時(shí)候蝗碎,這把鎖已經(jīng)不屬于當(dāng)前客戶端的時(shí)候會(huì)解除他人加的鎖。那么是否真的有這種場(chǎng)景旗扑?答案是肯定的蹦骑,比如客戶端A加鎖,一段時(shí)間之后客戶端A解鎖臀防,在執(zhí)行?jedis.del()?之前眠菇,鎖突然過(guò)期了,此時(shí)客戶端B嘗試加鎖成功袱衷,然后客戶端A再執(zhí)行del()方法捎废,則將客戶端B的鎖給解除了。
總結(jié)
本文主要介紹了如何使用Java代碼正確實(shí)現(xiàn)Redis分布式鎖致燥,對(duì)于加鎖和解鎖也分別給出了兩個(gè)比較經(jīng)典的錯(cuò)誤示例登疗。其實(shí)想要通過(guò)Redis實(shí)現(xiàn)分布式鎖并不難,只要保證能滿足可靠性里的四個(gè)條件篡悟∶仗荆互聯(lián)網(wǎng)雖然給我們帶來(lái)了方便,只要有問(wèn)題就可以google搬葬,然而網(wǎng)上的答案一定是對(duì)的嗎荷腊?其實(shí)不然,所以我們更應(yīng)該時(shí)刻保持著質(zhì)疑精神急凰,多想多驗(yàn)證女仰。
如果你的項(xiàng)目中Redis是多機(jī)部署的,那么可以嘗試使用?Redisson?實(shí)現(xiàn)分布式鎖抡锈。
想要了解更多分布式知識(shí)點(diǎn)的疾忍,可以關(guān)注我一下,我后續(xù)也會(huì)整理更多關(guān)于分布式架構(gòu)這一塊的知識(shí)點(diǎn)分享出來(lái)床三,另外順便給大家推薦一個(gè)交流學(xué)習(xí)群:650385180一罩,里面會(huì)分享一些資深架構(gòu)師錄制的視頻錄像:有Spring,MyBatis撇簿,Netty源碼分析聂渊,高并發(fā)、高性能四瘫、分布式汉嗽、微服務(wù)架構(gòu)的原理,JVM性能優(yōu)化這些成為架構(gòu)師必備的知識(shí)體系找蜜。還能領(lǐng)取免費(fèi)的學(xué)習(xí)資源饼暑,目前受益良多,以下的課程體系圖也是在群里獲取洗做。