1 介紹
這篇博文講介紹如何一步步構(gòu)建一個(gè)基于Redis的分布式鎖醒叁。會(huì)從最原始的版本開始,然后根據(jù)問題進(jìn)行調(diào)整,最后完成一個(gè)較為合理的分布式鎖。
本篇文章會(huì)將分布式鎖的實(shí)現(xiàn)分為兩部分寨躁,一個(gè)是單機(jī)環(huán)境,另一個(gè)是集群環(huán)境下的Redis鎖實(shí)現(xiàn)牙勘。在介紹分布式鎖的實(shí)現(xiàn)之前职恳,先來了解下分布式鎖的一些信息。
2 分布式鎖
2.1 什么是分布式鎖方面?
分布式鎖是控制分布式系統(tǒng)或不同系統(tǒng)之間共同訪問共享資源的一種鎖實(shí)現(xiàn)话肖,如果不同的系統(tǒng)或同一個(gè)系統(tǒng)的不同主機(jī)之間共享了某個(gè)資源時(shí),往往需要互斥來防止彼此干擾來保證一致性葡幸。
2.2 分布式鎖需要具備哪些條件
互斥性:在任意一個(gè)時(shí)刻最筒,只有一個(gè)客戶端持有鎖。
無死鎖:即便持有鎖的客戶端崩潰或者其他意外事件蔚叨,鎖仍然可以被獲取床蜘。
容錯(cuò):只要大部分Redis節(jié)點(diǎn)都活著,客戶端就可以獲取和釋放鎖
2.4 分布式鎖的實(shí)現(xiàn)有哪些蔑水?
數(shù)據(jù)庫
Memcached(add命令)
Redis(setnx命令)
Zookeeper(臨時(shí)節(jié)點(diǎn))
等等
3 單機(jī)Redis的分布式鎖
3.1 準(zhǔn)備工作
3.1.1 定義常量類
publicclassLockConstants{publicstaticfinalString OK ="OK";/** NX|XX, NX -- Only set the key if it does not already exist. XX -- Only set the key if it already exist. **/publicstaticfinalString NOT_EXIST ="NX";publicstaticfinalString EXIST ="XX";/** expx EX|PX, expire time units: EX = seconds; PX = milliseconds **/publicstaticfinalString SECONDS ="EX";publicstaticfinalString MILLISECONDS ="PX";privateLockConstants(){}}復(fù)制代碼
3.1.2 定義鎖的抽象類
抽象類RedisLock實(shí)現(xiàn)java.util.concurrent包下的Lock接口邢锯,然后對(duì)一些方法提供默認(rèn)實(shí)現(xiàn),子類只需實(shí)現(xiàn)lock方法和unlock方法即可搀别。代碼如下
publicabstractclassRedisLockimplementsLock{protectedJedis jedis;protectedString lockKey;publicRedisLock(Jedis jedis,String lockKey){this(jedis, lockKey);? ? }publicvoidsleepBySencond(intsencond){try{? ? ? ? ? ? Thread.sleep(sencond*1000);? ? ? ? }catch(InterruptedException e) {? ? ? ? ? ? e.printStackTrace();? ? ? ? }? ? }@OverridepublicvoidlockInterruptibly(){}@OverridepublicConditionnewCondition(){returnnull;? ? }@OverridepublicbooleantryLock(){returnfalse;? ? }@OverridepublicbooleantryLock(longtime, TimeUnit unit){returnfalse;? ? }}復(fù)制代碼
3.2 最基礎(chǔ)的版本1
先來一個(gè)最基礎(chǔ)的版本丹擎,代碼如下
publicclassLockCase1extendsRedisLock{publicLockCase1(Jedis jedis, String name){super(jedis, name);? ? }@Overridepublicvoidlock(){while(true){? ? ? ? ? ? String result = jedis.set(lockKey,"value", NOT_EXIST);if(OK.equals(result)){? ? ? ? ? ? ? ? System.out.println(Thread.currentThread().getId()+"加鎖成功!");break;? ? ? ? ? ? }? ? ? ? }? ? }@Overridepublicvoidunlock(){? ? ? ? jedis.del(lockKey);? ? }}復(fù)制代碼
LockCase1類提供了lock和unlock方法。
其中l(wèi)ock方法也就是在reids客戶端執(zhí)行如下命令
SET lockKey value NX復(fù)制代碼
而unlock方法就是調(diào)用DEL命令將鍵刪除歇父。
好了蒂培,方法介紹完了。現(xiàn)在來想想這其中會(huì)有什么問題榜苫?
假設(shè)有兩個(gè)客戶端A和B护戳,A獲取到分布式的鎖。A執(zhí)行了一會(huì)垂睬,突然A所在的服務(wù)器斷電了(或者其他什么的)媳荒,也就是客戶端A掛了抗悍。這時(shí)出現(xiàn)一個(gè)問題,這個(gè)鎖一直存在钳枕,且不會(huì)被釋放缴渊,其他客戶端永遠(yuǎn)獲取不到鎖。如下示意圖
>need-to-insert-img
可以通過設(shè)置過期時(shí)間來解決這個(gè)問題
3.3 版本2-設(shè)置鎖的過期時(shí)間
publicvoidlock(){while(true){? ? ? ? String result = jedis.set(lockKey,"value", NOT_EXIST,SECONDS,30);if(OK.equals(result)){? ? ? ? ? ? System.out.println(Thread.currentThread().getId()+"加鎖成功!");break;? ? ? ? }? ? }}復(fù)制代碼
類似的Redis命令如下
SET lockKey value NX EX 30復(fù)制代碼
注:要保證設(shè)置過期時(shí)間和設(shè)置鎖具有原子性
這時(shí)又出現(xiàn)一個(gè)問題鱼炒,問題出現(xiàn)的步驟如下
客戶端A獲取鎖成功疟暖,過期時(shí)間30秒。
客戶端A在某個(gè)操作上阻塞了50秒田柔。
30秒時(shí)間到了俐巴,鎖自動(dòng)釋放了。
客戶端B獲取到了對(duì)應(yīng)同一個(gè)資源的鎖硬爆。
客戶端A從阻塞中恢復(fù)過來欣舵,釋放掉了客戶端B持有的鎖。
示意圖如下
>need-to-insert-img
這時(shí)會(huì)有兩個(gè)問題
過期時(shí)間如何保證大于業(yè)務(wù)執(zhí)行時(shí)間?
如何保證鎖不會(huì)被誤刪除?
先來解決如何保證鎖不會(huì)被誤刪除這個(gè)問題缀磕。
這個(gè)問題可以通過設(shè)置value為當(dāng)前客戶端生成的一個(gè)隨機(jī)字符串缘圈,且保證在足夠長(zhǎng)的一段時(shí)間內(nèi)在所有客戶端的所有獲取鎖的請(qǐng)求中都是唯一的。
版本2的完整代碼:Github地址
3.4 版本3-設(shè)置鎖的value
抽象類RedisLock增加lockValue字段袜蚕,lockValue字段的默認(rèn)值為UUID隨機(jī)值假設(shè)當(dāng)前線程ID糟把。
public abstract class RedisLock implements Lock {? ? //...? ? protected String lockValue;? ? public RedisLock(Jedis jedis,String lockKey) {? ? ? ? this(jedis, lockKey, UUID.randomUUID().toString()+Thread.currentThread().getId());? ? }? ? public RedisLock(Jedis jedis, String lockKey, String lockValue) {? ? ? ? this.jedis = jedis;? ? ? ? this.lockKey = lockKey;? ? ? ? this.lockValue = lockValue;? ? }? ? //...}復(fù)制代碼
加鎖代碼
public voidlock() {while(true){? ? ? ? String result = jedis.set(lockKey, lockValue, NOT_EXIST,SECONDS,30);if(OK.equals(result)){? ? ? ? ? ? System.out.println(Thread.currentThread().getId()+"加鎖成功!");break;? ? ? ? }? ? }}復(fù)制代碼
解鎖代碼
public voidunlock() {? ? String lockValue = jedis.get(lockKey);if(lockValue.equals(lockValue)){? ? ? ? jedis.del(lockKey);? ? }}復(fù)制代碼
這時(shí)看看加鎖代碼,好像沒有什么問題啊牲剃。
再來看看解鎖的代碼遣疯,這里的解鎖操作包含三步操作:獲取值、判斷和刪除鎖凿傅。這時(shí)你有沒有想到在多線程環(huán)境下的i++操作?
3.4.1 i++問題
i++操作也可分為三個(gè)步驟:讀i的值缠犀,進(jìn)行i+1,設(shè)置i的值聪舒。
如果兩個(gè)線程同時(shí)對(duì)i進(jìn)行i++操作辨液,會(huì)出現(xiàn)如下情況
i設(shè)置值為0
線程A讀到i的值為0
線程B也讀到i的值為0
線程A執(zhí)行了+1操作,將結(jié)果值1寫入到內(nèi)存
線程B執(zhí)行了+1操作箱残,將結(jié)果值1寫入到內(nèi)存
此時(shí)i進(jìn)行了兩次i++操作滔迈,但是結(jié)果卻為1
在多線程環(huán)境下有什么方式可以避免這類情況發(fā)生?
解決方式有很多種,例如用AtomicInteger被辑、CAS燎悍、synchronized等等。
這些解決方式的目的都是要確保i++操作的原子性敷待。那么回過頭來看看解鎖间涵,同理我們也是要確保解鎖的原子性仁热。我們可以利用Redis的lua腳本來實(shí)現(xiàn)解鎖操作的原子性榜揖。
版本3的完整代碼:Github地址
3.5 版本4-具有原子性的釋放鎖
lua腳本內(nèi)容如下
ifredis.call("get",KEYS[1]) == ARGV[1]thenreturnredis.call("del",KEYS[1])elsereturn0end復(fù)制代碼
這段Lua腳本在執(zhí)行的時(shí)候要把的lockValue作為ARGV[1]的值傳進(jìn)去勾哩,把lockKey作為KEYS[1]的值傳進(jìn)去。現(xiàn)在來看看解鎖的java代碼
public voidunlock() {? ? // 使用lua腳本進(jìn)行原子刪除操作? ? String checkAndDelScript ="if redis.call('get', KEYS[1]) == ARGV[1] then "+"return redis.call('del', KEYS[1]) "+"else "+"return 0 "+"end";? ? jedis.eval(checkAndDelScript, 1, lockKey, lockValue);}復(fù)制代碼
好了举哟,解鎖操作也確保了原子性了思劳,那么是不是單機(jī)Redis環(huán)境的分布式鎖到此就完成了?
別忘了版本2-設(shè)置鎖的過期時(shí)間還有一個(gè),過期時(shí)間如何保證大于業(yè)務(wù)執(zhí)行時(shí)間問題沒有解決妨猩。
版本4的完整代碼:Github地址
3.6 版本5-確保過期時(shí)間大于業(yè)務(wù)執(zhí)行時(shí)間
抽象類RedisLock增加一個(gè)boolean類型的屬性isOpenExpirationRenewal潜叛,用來標(biāo)識(shí)是否開啟定時(shí)刷新過期時(shí)間。
在增加一個(gè)scheduleExpirationRenewal方法用于開啟刷新過期時(shí)間的線程壶硅。
public abstract class RedisLock implements Lock {//...? ? protected volatile boolean isOpenExpirationRenewal =true;? ? /**? ? * 開啟定時(shí)刷新? ? */? ? protected voidscheduleExpirationRenewal(){? ? ? ? Thread renewalThread = new Thread(new ExpirationRenewal());? ? ? ? renewalThread.start();? ? }? ? /**? ? * 刷新key的過期時(shí)間? ? */? ? private class ExpirationRenewal implements Runnable{? ? ? ? @Override? ? ? ? public voidrun() {while(isOpenExpirationRenewal){? ? ? ? ? ? ? ? System.out.println("執(zhí)行延遲失效時(shí)間中...");? ? ? ? ? ? ? ? String checkAndExpireScript ="if redis.call('get', KEYS[1]) == ARGV[1] then "+"return redis.call('expire',KEYS[1],ARGV[2]) "+"else "+"return 0 end";? ? ? ? ? ? ? ? jedis.eval(checkAndExpireScript, 1, lockKey, lockValue,"30");? ? ? ? ? ? ? ? //休眠10秒? ? ? ? ? ? ? ? sleepBySencond(10);? ? ? ? ? ? }? ? ? ? }? ? }}復(fù)制代碼
加鎖代碼在獲取鎖成功后將isOpenExpirationRenewal置為true威兜,并且調(diào)用scheduleExpirationRenewal方法,開啟刷新過期時(shí)間的線程庐椒。
publicvoidlock(){while(true) {? ? ? ? String result = jedis.set(lockKey, lockValue, NOT_EXIST, SECONDS,30);if(OK.equals(result)) {? ? ? ? ? ? System.out.println("線程id:"+Thread.currentThread().getId() +"加鎖成功!時(shí)間:"+LocalTime.now());//開啟定時(shí)刷新過期時(shí)間isOpenExpirationRenewal =true;? ? ? ? ? ? scheduleExpirationRenewal();break;? ? ? ? }? ? ? ? System.out.println("線程id:"+Thread.currentThread().getId() +"獲取鎖失敗椒舵,休眠10秒!時(shí)間:"+LocalTime.now());//休眠10秒sleepBySencond(10);? ? }}復(fù)制代碼
解鎖代碼增加一行代碼,將isOpenExpirationRenewal屬性置為false约谈,停止刷新過期時(shí)間的線程輪詢笔宿。
publicvoidunlock(){//...isOpenExpirationRenewal =false;}復(fù)制代碼
版本5的完整代碼:Github地址
3.7 測(cè)試
測(cè)試代碼如下
publicvoidtestLockCase5(){//定義線程池ThreadPoolExecutor pool =newThreadPoolExecutor(0,10,1, TimeUnit.SECONDS,newSynchronousQueue<>());//添加10個(gè)線程獲取鎖for(inti =0; i <10; i++) {? ? ? ? pool.submit(() -> {try{? ? ? ? ? ? ? ? Jedis jedis =newJedis("localhost");? ? ? ? ? ? ? ? LockCase5 lock =newLockCase5(jedis, lockName);? ? ? ? ? ? ? ? lock.lock();//模擬業(yè)務(wù)執(zhí)行15秒lock.sleepBySencond(15);? ? ? ? ? ? ? ? lock.unlock();? ? ? ? ? ? }catch(Exception e){? ? ? ? ? ? ? ? e.printStackTrace();? ? ? ? ? ? }? ? ? ? });? ? }//當(dāng)線程池中的線程數(shù)為0時(shí),退出while(pool.getPoolSize() !=0) {}}復(fù)制代碼
測(cè)試結(jié)果
或許到這里基于單機(jī)Redis環(huán)境的分布式就介紹完了棱诱。但是使用java的同學(xué)有沒有發(fā)現(xiàn)一個(gè)鎖的重要特性
那就是鎖的重入泼橘,那么分布式鎖的重入該如何實(shí)現(xiàn)呢?這里就留一個(gè)坑了
4 集群Redis的分布式鎖
在Redis的分布式環(huán)境中迈勋,Redis 的作者提供了RedLock 的算法來實(shí)現(xiàn)一個(gè)分布式鎖炬灭。
4.1 加鎖
RedLock算法加鎖步驟如下
獲取當(dāng)前Unix時(shí)間,以毫秒為單位靡菇。
依次嘗試從N個(gè)實(shí)例担败,使用相同的key和隨機(jī)值獲取鎖。在步驟2镰官,當(dāng)向Redis設(shè)置鎖時(shí),客戶端應(yīng)該設(shè)置一個(gè)網(wǎng)絡(luò)連接和響應(yīng)超時(shí)時(shí)間提前,這個(gè)超時(shí)時(shí)間應(yīng)該小于鎖的失效時(shí)間。例如你的鎖自動(dòng)失效時(shí)間為10秒泳唠,則超時(shí)時(shí)間應(yīng)該在5-50毫秒之間狈网。這樣可以避免服務(wù)器端Redis已經(jīng)掛掉的情況下,客戶端還在死死地等待響應(yīng)結(jié)果笨腥。如果服務(wù)器端沒有在規(guī)定時(shí)間內(nèi)響應(yīng)拓哺,客戶端應(yīng)該盡快嘗試另外一個(gè)Redis實(shí)例。
客戶端使用當(dāng)前時(shí)間減去開始獲取鎖時(shí)間(步驟1記錄的時(shí)間)就得到獲取鎖使用的時(shí)間脖母。當(dāng)且僅當(dāng)從大多數(shù)(這里是3個(gè)節(jié)點(diǎn))的Redis節(jié)點(diǎn)都取到鎖士鸥,并且使用的時(shí)間小于鎖失效時(shí)間時(shí),鎖才算獲取成功谆级。
如果取到了鎖烤礁,key的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間(步驟3計(jì)算的結(jié)果)讼积。
如果因?yàn)槟承┰颍@取鎖失斀抛小(沒有在至少N/2+1個(gè)Redis實(shí)例取到鎖或者取鎖時(shí)間已經(jīng)超過了有效時(shí)間)勤众,客戶端應(yīng)該在所有的Redis實(shí)例上進(jìn)行解鎖(即便某些Redis實(shí)例根本就沒有加鎖成功)。
4.2 解鎖
向所有的Redis實(shí)例發(fā)送釋放鎖命令即可鲤脏,不用關(guān)心之前有沒有從Redis實(shí)例成功獲取到鎖.
關(guān)于RedLock算法们颜,還有一個(gè)小插曲,就是Martin Kleppmann 和?RedLock 作者 antirez的對(duì)RedLock算法的互懟猎醇。 官網(wǎng)原話如下
Martin Kleppmannanalyzed Redlock here. I disagree with the analysis and postedmy reply to his analysis here.
更多關(guān)于RedLock算法這里就不在說明窥突,有興趣的可以到官網(wǎng)閱讀相關(guān)文章。
5 總結(jié)
這篇文章講述了一個(gè)基于Redis的分布式鎖的編寫過程及解決問題的思路硫嘶,但是本篇文章實(shí)現(xiàn)的分布式鎖并不適合用于生產(chǎn)環(huán)境波岛。java環(huán)境有Redisson可用于生產(chǎn)環(huán)境,但是分布式鎖還是Zookeeper會(huì)比較好一些
Java高架構(gòu)師音半、分布式架構(gòu)则拷、高可擴(kuò)展、高性能曹鸠、高并發(fā)煌茬、性能優(yōu)化、Spring boot彻桃、Redis坛善、ActiveMQ、Nginx邻眷、Mycat眠屎、Netty、Jvm大型分布式項(xiàng)目實(shí)戰(zhàn)學(xué)習(xí)架構(gòu)師視頻免費(fèi)學(xué)習(xí)加群:835638062 點(diǎn)擊鏈接加入群聊【Java高級(jí)架構(gòu)】:https://jq.qq.com/?_wv=1027&k=5S3kL3v