使用Redis構建分布式鎖

2020-10-03 小小的平



經常聽到分布式這個名詞,覺得高大上又離我很遙遠,於是也沒有進行什麼深究。直到面試被反覆問道,我覺得有必要了解一下,以跟上這個時代的節奏

常見的分布式鎖實現,一個是ZooKeeper,另一個就是Redis了。Redis這麼熟悉,當然是拿它先開刀了。在進行鎖的實現的時候,有幾個基本概念,需要先說明一下。

第一個就是鎖的概念,這個鎖不像java或者jvm中那種複雜又難懂的鎖,你可以把鎖理解為一個值,根據這個值的存在與否,來決定是否獲取到鎖

在Redis中,有一種稱為樂觀鎖的東西,什麼叫樂觀鎖,意思就是這種策略是樂觀的,總是樂觀的認為別的客戶端連接不會修改該值,因此除了本客戶端連接可以讀取到某種資源(存儲在Redis上的鍵值對信息),其它客戶端連接也可以讀取到。那萬一其餘客戶端連接修改了資源呢?這個時候Redis Server會給加了樂觀鎖的客戶端連接發送消息,訴你該數據已經修改過了,這個時候本客戶端連接可以選擇循環重試或者直接退出。這個聽起來是不是像CAS的操作呢?我的感覺是像極了。具體涉及的命令就是watch unwatch multi exec,這些命令什麼意思,下文詳細說明

另一種鎖稱之為悲觀鎖。什麼是悲觀鎖呢?就是客戶端連接總是認為數據隨時都可能被其餘客戶端連接修改掉,所以加了悲觀鎖,其餘的客戶端連接在此刻是無法讀取到資源,自然也無法進行操作。當本客戶端連接釋放了鎖,其餘的客戶端連接才可以獲取鎖,進而進行操作。涉及的命令不僅包括watch unwatch multi exec,也包括setnx ttl expire等命令,同樣後文細說。

不像java中已經提供了各種鎖以及同步容器,並發工具,Redis本身它不提供任何鎖的實現,也沒有樂觀鎖、悲觀鎖,超時鎖等東西,這需要我們組合使用Redis的各種原子命令以及不同的命令特性,來自己實現鎖,同時在代碼層面一致的使用和釋放鎖。

再來說一下Redis客戶端。Redis是用c語言寫的,要麼使用自帶的redis-cli進行連接和交互,要麼使用根據通信協議封裝好的不同語言的客戶端庫。Redis的java客戶端庫很多,常用的有Jedis、Lettuce 。並且Spring SpringBoot默認的就是支持這兩種客戶端,所以學習了這兩個客戶端對以後也很有幫助。Lettuce 的功能更加強大,它基於netty,支持Redis的哨兵模式和集群模式。但是由於我知識淺薄,只能拿Jedis來作為演示啦。

由於樂觀鎖會導致無謂的修改循環重試,導致很少能夠修改成功,耗費資源。而悲觀鎖,雖然在獲取鎖時不斷重試,但對於修改資源,卻是一次就成功了,在資源競爭嚴重的時候,悲觀鎖策略性能更好,因此這裡主要選擇悲觀鎖這種思想來進行代碼演示。

首先我們設定一個鍵為鎖(Redis就是Key Value型存儲),並使用setnx命令,該命令的特點是,如果鍵存在,則設置定指定的值,並返回1,如果鍵存在,則什麼都不做,並返回0。對應到代碼中來就是,如果執行setnx命令返回了1,則說明獲取到了鎖,代碼可以繼續往下執行,如果執行setnx命令返回0,則說明沒有獲取到鎖,當前線程等待或者重試。

獲取到鎖,執行代碼完畢,需要釋放鎖。怎麼釋放呢,就是很簡單的執行del命令即可,即把鎖的key給刪除,這樣其餘的連接就可以獲取鎖了。
偽代碼如下:

while(con.setnx(lockKey,lockValue)==0){ //休眠 重試獲取鎖}// 執行業務代碼con.del(lockKey);

一切看起來很美好,不是嗎?但現實總是千變萬化的,一種可能是一段代碼的執行,需要在不同地方獲取不同的鎖,導致死鎖的發生,又或者是網絡故障或者客戶進程崩潰,造成鎖永遠無法釋放。這就會導致其餘的Redis連接一直無法獲取到鎖,因為一臺機器的代碼問題,網絡問題,機器故障等原因,導致所有的服務都變得不可用,這是讓人無法接受的,這違背了分布式的初衷。

怎麼解決這個問題呢?我們可以指定一個鎖的過期時間,比如10s後這個鎖會過期,並且極限條件下業務執行時間也不會超過10s。(在服務有互相依賴,複雜的服務調用中,調用鏈越長,超時時間越不好預估,但這個也是在解決問題和性能之間做一個平衡,超時時間設置太長,性能會大大降低,超時時間太短,會造成並發問題,因為一個連接中代碼還沒有執行完,鎖已經被刪除,同時另一個連接獲取到了鎖,執行業務代碼)。設置了鎖的過期時間,解決了鎖不釋放的問題,但是同時引入的新的問題,那就是可能會刪除其餘連接的鎖。比如A連接獲取到鎖key1,執行很長時間,此時鎖過期,被刪除,另一個連接B獲取到鎖key1,並執行對應的代碼,此時A連接執行結束,於是釋放鎖,但是此時,其實它的鎖已經被釋放了,在鎖過期的時候,現在它釋放的是B連接的鎖,那是不對的。假如此刻C連接進來,是能夠獲取到鎖的,那麼就意味著B C在同時執行業務代碼,違背了鎖當初設計的本意,因此絕對不能釋放其餘連接的鎖,而只能釋放自己的。

那麼如何解決這個問題?其實我們可以在連接獲取鎖的時候,設置一個只有當前連接知道的唯一值,釋放的時候會先取出鎖的值,進行比較,只有跟存入的值是一致的時候,才會釋放鎖,也就是刪除鍵,否則,什麼也不做。

分析了這麼多,我們可以看看獲取鎖的工具類代碼:

/** * 在指定的等待時限內獲取鎖 * @param jedis 連接 * @param lockName 鎖名稱 * @param timeOutMillionSeconds 獲取鎖超時時間 -1:一直等待,直到獲取到鎖 * @return 如果獲取到鎖,返回一個鎖標識符,否則返回null */public static String acquireLock(Jedis jedis,String lockName,long timeOutMillionSeconds){ // 略去各類校驗 String identifier = UUID.randomUUID().toString(); long timeEnd = timeOutMillionSeconds==-1?Long.MAX_VALUE:System.currentTimeMillis() + timeOutMillionSeconds; while(System.currentTimeMillis()<=timeEnd){ long result = jedis.setnx(lockName,identifier); if(result==1){// 等於1 說明沒有設置過,獲取鎖成功 return identifier; } try { TimeUnit.MILLISECONDS.sleep(100);// 線程休眠值 根據業務來定 }catch (InterruptedException e){//假設在本機沒有多線程編程 不會通知另一方線程中斷 根據具體業務來 throw new RuntimeException(e); } } return null;}

注意代碼中的jedis沒有close,根據需要,也可以在工具類中close掉。

以及超時鎖,為了防止setnx和expire之間,程序崩潰,造成超時時間沒有設置上,因此其餘的連接在獲取不到鎖的時候,會先判斷鎖有沒有過期時間,如果沒有,給鎖加上過期時間:

/** *在指定的等待時限內獲取鎖,該鎖自身帶有超時特性 * @param jedis 連接 * @param lockName 鎖名稱 * @param timeOutMillionSeconds 獲取鎖超時時間 -1:一直等待,直到獲取到鎖 * @param lockTimeOutSeconds 鎖的超時時間 * @return 如果獲取到鎖,返回一個鎖標識符,否則返回null */ public static String acquireLockTimeOut(Jedis jedis,String lockName,long timeOutMillionSeconds,int lockTimeOutSeconds){ // 略去各類校驗 String identifier = UUID.randomUUID().toString(); long timeEnd = timeOutMillionSeconds==-1?Long.MAX_VALUE:System.currentTimeMillis() + timeOutMillionSeconds; while(System.currentTimeMillis()<=timeEnd){ long result = jedis.setnx(lockName,identifier); if(result==1){// 等於1 說明沒有設置過,獲取鎖成功 jedis.expire(lockName,lockTimeOutSeconds); return identifier; }else{ if(jedis.ttl(lockName)==-1){ jedis.expire(lockName,lockTimeOutSeconds); } } try { TimeUnit.MILLISECONDS.sleep(100);// 線程休眠值 根據業務來定 }catch (InterruptedException e){//假設在本機沒有多線程編程 不會通知另一方線程中斷 根據具體業務來 throw new RuntimeException(e); } } return null; }

釋放鎖的代碼:

/** * 關於watch multi exec等命令,參考https://redis.io/topics/transactions 才能深刻理解 * @param jedis 連接 * @param lockName 鎖名稱 * @param identifier 鎖標識符 * @return 是否成功釋放鎖 僅作為參考 */ public static boolean releaseLock(Jedis jedis,String lockName,String identifier){ // 略去各類校驗 boolean releaseNormal = false;// 鎖是否正常釋放 jedis.watch(lockName); String identifierInRedis = jedis.get(lockName); if(identifier.equalsIgnoreCase(identifierInRedis)){// 如果標識符沒有改動,則說明可以解鎖 Transaction transaction = jedis.multi(); transaction.del(lockName); transaction.exec(); releaseNormal = true; }else{ jedis.unwatch(); releaseNormal = false; } return releaseNormal; }

這裡重點解釋一下釋放鎖的操作:只有現在取出的跟當時存入的值一致,才會進行刪除操作。但為了防止get 和del之間的某個時候,另一個連接修改了鎖的值,(為什麼會修改?是因為當前連接A在執行完get之後,鎖過期了,因此另一個連接B可以獲取到鎖,現在A執行刪除操作,就是刪除B連接獲取到的鎖),因此需要watch 操作,如果現在取出的值和當初存入的不一致,那麼直接執行unwatch並返回。為什麼要執行unwatch呢?因為為了安全,假如不執行unwatch就返回,在後續的代碼中執行multi和exec,那就有很大的問題,當鎖被刪除或者修改,就會打斷當前的事務,但是該事物跟鎖是沒有任何關係的,所以unwatch是一個需要執行的操作。另一個情況是假如當前連接取出的鎖的值,跟存入的一致,就需要執行刪除鎖的操作。可能有同學就會問了,Redis的所有操作都是原子操作,執行del和包裹在multi exec中執行del不是一樣的原子操作嗎?為何還要多此一舉,讓代碼變得不好理解。在這一點上,它們確實並無任何區別,但是重點是之前有一個watch命令。假如沒有執行watch multi del exec這樣的順序,就會有釋放掉其餘連接的鎖的風險,為什麼會這樣,上文已經做了分析。在get和del之間發生的事情,當前連接是不知道的,get del的執行不是原子性的。有了watch multi del exec這個順序,當前連接A get執行之後,鎖失效,且被另一個連接B獲取到鎖,也就是修改了鎖,因為watch(key),所以當前連接就知道了有人修改了。當執行exec的時候,就會丟棄掉del命令,因為watch的通知使得事務已經失效了,這保證了其餘連接的鎖不會被刪除。同時,當執行exec的時候,不論事務成功與否,都unwatch了。最終呢,釋放鎖的代碼看起來就是這樣了。

寫好了工具類,我們應該測試一下,看看是不是真的,我們可以使用線程池,放入200個任務,每個任務都是執行獲取Redis的某個鍵,並+1,再設置回Redis。在執行代碼前,進行鎖獲取,執行完畢,進行鎖釋放。為了等到所有線程執行完畢,便於獲取最終執行結果,使用CountDownLatch進行等待線程池所有任務的執行完畢。另外的一些部分是初始化動作,防止鎖已經設置了或者指定的鍵已經有值了。代碼如下:

public class LockTest { private static final Log log = LogFactory.getLog(LockTest.class); public static void main(String[] args)throws Exception { ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(4,8,10, TimeUnit.SECONDS,new LinkedBlockingDeque<Runnable>(1000),new ThreadPoolExecutor.CallerRunsPolicy()); JedisPoolConfig jedisPoolConfig = new JedisPoolConfig(); jedisPoolConfig.setMaxTotal(10); final JedisPool jedisPool = new JedisPool(jedisPoolConfig,"192.168.99.100"); final CountDownLatch countDownLatch = new CountDownLatch(200); final String lockName = "lock_a"; Jedis jedisInit = jedisPool.getResource(); jedisInit.del(lockName); final String testResource = "test_str"; jedisInit.set(testResource,"0"); jedisInit.close(); for(int i=0;i<200;i++){ threadPoolExecutor.execute(new Runnable() { public void run() { Jedis jedis = jedisPool.getResource(); String identifiler = RedisSetnxLock.acquireLock(jedis,lockName,-1); if(identifiler==null) { log.info("獲取鎖失敗"); countDownLatch.countDown(); jedis.close(); return; } try{ String value = jedis.get(testResource); Thread.sleep(200);// 故意休眠 jedis.set(testResource,(Integer.parseInt(value)+1)+""); }catch (Exception e){ e.printStackTrace(); }finally { RedisSetnxLock.releaseLock(jedis,lockName,identifiler); jedis.close(); countDownLatch.countDown(); } } }); } countDownLatch.await(); log.info(jedisPool.getResource().get(testResource)); threadPoolExecutor.shutdown(); }}

最後的執行結果符合預期。

為了演示連接掛掉或者執行超常任務的情形,可以執行下面的測試:

public class LockTest { private static final Log log = LogFactory.getLog(LockTest.class); public static void main(String[] args)throws Exception { ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(4,8,10, TimeUnit.SECONDS,new LinkedBlockingDeque<Runnable>(1000),new ThreadPoolExecutor.CallerRunsPolicy()); JedisPoolConfig jedisPoolConfig = new JedisPoolConfig(); jedisPoolConfig.setMaxTotal(10); final JedisPool jedisPool = new JedisPool(jedisPoolConfig,"192.168.99.100"); final CountDownLatch countDownLatch = new CountDownLatch(200); final String lockName = "lock_a"; Jedis jedisInit = jedisPool.getResource(); jedisInit.del(lockName); final String testResource = "test_str"; jedisInit.set(testResource,"0"); jedisInit.close(); for(int i=0;i<200;i++){ threadPoolExecutor.execute(new Runnable() { public void run() { Jedis jedis = jedisPool.getResource(); // 鎖的超時時間為1s String identifiler = RedisSetnxLock.acquireLockTimeOur(jedis,lockName,-1,1); if(identifiler==null) { log.info("獲取鎖失敗"); countDownLatch.countDown(); jedis.close(); return; } try{ String value = jedis.get(testResource); Thread.sleep(200); jedis.set(testResource,(Integer.parseInt(value)+1)+""); }catch (Exception e){ e.printStackTrace(); }finally { // 每次不釋放鎖,模擬執行超常任務或者進程掛掉的情形 //RedisSetnxLock.releaseLock(jedis,lockName,identifiler); jedis.close(); countDownLatch.countDown(); } } }); } countDownLatch.await(); log.info(jedisPool.getResource().get(testResource)); threadPoolExecutor.shutdown(); }}

執行結果同樣正確,只是執行時間變長了。

相關maven pom:

<!--jedis client--> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>3.2.0</version> </dependency> <!--jedis連接池--> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>2.8.0</version> </dependency> <dependency> <groupId>commons-logging</groupId> <artifactId>commons-logging</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-slf4j-impl</artifactId> <version>2.13.1</version> <scope>test</scope> </dependency> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>2.13.1</version> </dependency>

參考書籍:《Redis實戰》,一本非常棒的書。

相關焦點

  • 聊聊Redis分布式鎖
    設置一個KEY值,成功返回1說明成功獲得鎖,設置失敗返回0說明獲取鎖失敗②存在問題* 鎖時間不可控制,它無法續租期,因為如果一個線程獲得鎖,它在它的Expire_Time時間內還未執行完業務,另外一個線程就可以獲取它的鎖,這就會導致數據的錯亂;* 單點問題 (1)單實例存在進程一旦死掉,會徹底阻塞業務流程,還有存在一個線程獲得鎖,
  • 基於 Redis 實現的分布式鎖
    30毫秒 * * @param key 分布式鎖,redis key * @param expireTime 分布式鎖過期時間,單位秒 * @return * @author mingfei.zhang */ boolean lock(String key, long expireTime); /**
  • 阿里a面試題剖析 使用 Redis 如何設計分布式鎖?
    面試原題一般實現分布式鎖都有哪些方式?使用 redis 如何設計分布式鎖?使用 zk 來設計分布式鎖可以嗎?這兩種分布式鎖的實現方式哪種效率比較高?因為在分布式系統開發中,分布式鎖的使用場景還是很常見的。
  • 該如何一步步構建一個基於Redis的分布式鎖?
    一、介紹講介紹如何一步步構建一個基於Redis的分布式鎖。會從最原始的版本開始,然後根據問題進行調整,最後完成一個較為合理的分布式鎖。本篇文章會將分布式鎖的實現分為兩部分,一個是單機環境,另一個是集群環境下的Redis鎖實現。在介紹分布式鎖的實現之前,先來了解下分布式鎖的一些信息。
  • 使用 Redis 分布式鎖報錯:-READONLY
    使用 Redisson 提供的 Redis 分布式鎖時,編譯沒有問題,運行時報錯 (-READONLY You can't write against a read only replica):10月 11, 2020 2:18:18 上午 org.apache.catalina.core.StandardWrapperValve invoke嚴重: Servlet.service
  • 分布式鎖解決方案-Redis
    ## 為什麼要學習分布式鎖解決方案為了解決分布式架構帶來的數據準確性問題!我們用synchronized或者 ReentrantLock(瑞恩吹特) 能解決問題嗎?真實生產環境我們採用集群的方式去訪問秒殺商品(nginx為我們做了負載均衡)。
  • Spring Cloud基於Redis實現的分布式鎖
    基於Redis實現的分布式鎖Spring Cloud 分布式環境下,同一個服務都是部署在不同的機器上,這種情況無法像單體架構下數據一致性問題採用加鎖就實現數據一致性問題,在高並發情況下,對於分布式架構顯然是不合適的,針對這種情況我們就需要用到分布式鎖了。
  • java 從零實現屬於你的 redis 分布式鎖
    但是在分布式系統中,上面的鎖就統統沒用了。我們想要解決分布式系統中的並發問題,就需要引入分布式鎖的概念。>我們實現的 redis 分布鎖,繼承自上面的抽象類。當然這裡是為了簡化調用者的使用成本,開發在使用的時候只需要關心自己要加鎖的 key 即可。當然,甚至連加鎖的 key 都可以進一步抽象掉,比如封裝 @DistributedLock 放在方法上,即可實現分布式鎖。
  • 利用Redis實現分布式鎖
    為什麼需要分布式鎖?在傳統單體應用單機部署的情況下,可以使用Java並發相關的鎖,如ReentrantLcok或synchronized進行互斥控制。但是,隨著業務發展的需要,原單體單機部署的系統,漸漸的被部署在多機器多JVM上同時提供服務,這使得原單機部署情況下的並發控制鎖策略失效了,為了解決這個問題就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分布式鎖要解決的問題。
  • 實現一個Redis分布式鎖
    這一波操作明顯不符合原子性,如果代碼塊不加鎖,很容易因為並發導致超賣問題。咱們的系統如果是單體架構,那我們使用本地鎖就可以解決問題。如果是分布式架構,就需要使用分布式鎖。if (redis.call(&39;, KEYS[1], ARGV[1]) < 1) then return 0; end; redis.call(&39;, KEYS[1], tonumber(ARGV[2])); return 1; 通過這樣的方法,我們初步解決了競爭鎖的原子性問題
  • Go和Redis實現分布式鎖
    本文介紹分布式鎖的問題,以及分布式鎖實現的方法。為什麼要使用鎖,問題的引入?在 一文中我們介紹了進程和線程,從文章中能了解到線程共享進程的內存全局變量,那麼對於全局變量數據一致性的要求,需要在進程內對修改行為加鎖以創造臨界區。
  • C# Redis分布式鎖 - 單節點
    為什麼要用分布式鎖?先上一張截圖,這是在瀏覽別人的博客時看到的.但是進程鎖有一個前提,那就是需要多個進程在同一個系統中,如果多個進程不在同一個系統,那就只能使用分布式鎖來控制了.分布式鎖是控制分布式系統中不同系統之間訪問共享資源的一種鎖實現.它和線程鎖,進程鎖的作用都是一樣,只是範圍不一樣.
  • 手寫Redis分布式鎖
    分布式鎖使用場景現在的系統都是集群部署,每個服務都不是單節點的了。比如庫存服務,可能部署到3臺機器上分別命名為節點1,節點2,節點3。庫存服務需要扣減庫存,扣減庫存肯定需要鎖吧,如果使用Lock或者synchronized,只能鎖住自己的節點。
  • Redis分布式鎖三板斧,你真的會設計嗎?
    redis分布式鎖三板斧,獲取鎖、刪除鎖、鎖超時redis分布式的常規實現Redis是最常見的實現分布式鎖的方法之一,而很多人都了解使用了redis分布式鎖使用redis的SET key value [EX seconds] [PX milliseconds
  • 我猜你還沒明白如何利用好Redis使用實現分布式鎖?
    前言1.為什麼要使用分布式鎖使用分布式鎖的目的,無外乎就是保證同一時間只有一個客戶端可以對共享資源進行操作。3.基於Redis實現分布式鎖3.1 使用Redis命令實現分布式鎖3.1.1加鎖加鎖實際上就是在redis中,給Key鍵設置一個值
  • SpringBoot2.0實戰(22)整合Redis之實現分布式鎖
    >分布式鎖是控制分布式系統之間同步訪問共享資源的一種方式,在分布式系統中,如果不同的應用之間共享一個或一組資源,那麼訪問這些資源的時候,往往需要互斥來防止彼此幹擾來保證一致性,在這種情況下,便需要使用到分布式鎖。
  • 基於 Redis 的高容錯性分布式鎖架構設計方案 -01
    簡而言之,選擇Redis主要是因為如下3個原因:1、高性能;2、原子操作;3、實現方便,成本低;雖然使用Redis提供的SETNX命令就可以很輕鬆的實現一個分布式鎖效果,但這樣的做法卻存在很大問題。[1]) == 1) thenredis.call(『EXPIRE,ARGV[1]);return 1;end;return 0;基於上述Lua腳本我們確實可以實現一個較為完善的分布式鎖,但離投放生產使用卻仍然存在較大差距。
  • 分布式鎖沒那麼難,手把手教你實現 Redis 分布鎖!|保姆級教程
    那就先寫下最近在鼓搗一個東西,使用 Redis 實現可重入分布鎖。看到這裡,有的朋友可能會提出來使用 redisson 不香嗎,為什麼還要自己實現?哎,redisson 真的很香,但是現有項目中沒辦法使用,只好自己手擼一個可重入的分布式鎖了。
  • 手撕redis鎖,就這麼簡單
    因為mysql操作是需要IO的,IO的速度比內存速度慢,因此mysql如果在那種場景下使用的話是會存在系統瓶頸的。所以本篇就和小夥伴們分享基於內存操作的比較常用的分布式鎖——redis分布式鎖。手擼Redis分布式鎖實現原理redis分布式鎖實現原理其實也是比較簡單的,主要是依賴於redis的 set nx命令,我們來看一下完整的設置redis的命令:「Set resource_name my_random_value NX PX 30000」。看到這串命令,了解redis的小夥伴應該都看得懂這條命令是在redis中存入一個帶有過期時間的值。
  • 基於redis實現分布式鎖
    背景基於redis實現。使用主要是兩步1.獲取鎖2.釋放鎖代碼try{  獲取鎖;  處理業務;} finally{  釋放鎖;}>核心是基於redis的set命令。