手把手教你實現一個基於Redis的分佈式鎖

作者:王天一
鏈接:https://www.wangtianyi.top

簡介

分佈式鎖在分佈式系統中非常常見,比如對公共資源進行操作,如賣車票,同一時刻只能有一個節點將某個特定座位的票賣出去;如避免緩存失效帶來的大量請求訪問數據庫的問題

設計

這非常像一道面試題:如何實現一個分佈式鎖?在簡介中,基本上已經對這個分佈式工具提出了一些需求,你可以不著急看下面的答案,自己思考一下分佈式鎖應該如何實現?

首先我們需要一個簡單的答題套路:需求分析、系統設計、實現方式、缺點不足

需求分析

  • 能夠在高併發的分佈式的系統中應用
  • 需要實現鎖的基本特性:一旦某個鎖被分配出去,那麼其他的節點無法再進入這個鎖所管轄範圍內的資源;失效機制避免無限時長的鎖與死鎖
  • 進一步實現鎖的高級特性和JUC併發工具類似功能更好:可重入、阻塞與非阻塞、公平與非公平、JUC的併發工具(Semaphore, CountDownLatch, CyclicBarrier)

系統設計

轉換成設計是如下幾個要求:

  • 對加鎖、解鎖的過程需要是高性能、原子性的
  • 需要在某個分佈式節點都能訪問到的公共平臺上進行鎖狀態的操作

所以,我們分析出系統的構成應該要有鎖狀態存儲模塊、連接存儲模塊的連接池模塊、鎖內部邏輯模塊

鎖狀態存儲模塊

分佈式鎖的存儲有三種常見實現,因為能滿足實現鎖的這些條件:高性能加鎖解鎖、操作的原子性、是分佈式系統中不同節點都可以訪問的公共平臺:

  • 數據庫(利用主鍵唯一規則、MySQL行鎖)
  • 基於Redis的NX、EX參數
  • Zookeeper臨時有序節點

由於鎖常常是在高併發的情況下才會使用到的分佈式控制工具,所以使用數據庫實現會對數據庫造成一定的壓力,連接池爆滿問題,所以不推薦數據庫實現;我們還需要維護Zookeeper集群,實現起來還是比較複雜的。

如果不是原有系統就依賴Zookeeper,同時壓力不大的情況下。一般不使用Zookeeper實現分佈式鎖。所以緩存實現分佈式鎖還是比較常見的,因為緩存比較輕量、緩存的響應快、吞吐高、還有自動失效的機制保證鎖一定能釋放。

連接池模塊

可使用JedisPool實現,如果後期性能不佳,可考慮參照HikariCP自己實現

鎖內部邏輯模塊

  • 基本功能:加鎖、解鎖、超時釋放
  • 高級功能:可重入、阻塞與非阻塞、公平與非公平、JUC併發工具功能

實現方式

存儲模塊使用Redis,連接池模塊暫時使用JedisPool,鎖的內部邏輯將從基本功能開始,逐步實現高級功能,下面就是各種功能實現的具體思路與代碼了。

加鎖、超時釋放

NX是Redis提供的一個原子操作,如果指定key存在,那麼NX失敗,如果不存在會進行set操作並返回成功。我們可以利用這個來實現一個分佈式的鎖,主要思路就是,set成功表示獲取鎖,set失敗表示獲取失敗,失敗後需要重試。再加上EX參數可以讓該key在超時之後自動刪除。

下面是一個阻塞鎖的加鎖操作,將循環去掉並返回執行結果就能寫出非阻塞鎖(就不粘出來了):

手把手教你實現一個基於Redis的分佈式鎖

但超時時間這個參數會引發一個問題,如果超過超時時間但是業務還沒執行完會導致併發問題,其他進程就會執行業務代碼,至於如何改進,下文會講到。

解鎖

最常見的解鎖代碼就是直接使用jedis.del()方法刪除鎖,這種不先判斷鎖的擁有者而直接解鎖的方式,會導致任何客戶端都可以隨時進行解鎖,即使這把鎖不是它的。

比如可能存在這樣的情況:客戶端A加鎖,一段時間之後客戶端A解鎖,在執行jedis.del()之前,鎖突然過期了,此時客戶端B嘗試加鎖成功,然後客戶端A再執行del()方法,則將客戶端B的鎖給解除了。

所以我們需要一個具有原子性的方法來解鎖,並且要同時判斷這把鎖是不是自己的。由於Lua腳本在Redis中執行是原子性的,所以可以寫成下面這樣:

public boolean unlock(String key, String value) {
Jedis jedis = jedisPool.getResource();
String/> Object result = jedis.eval(script, Collections.singletonList(LOCK_PREFIX + key), Collections.singletonList(value));
jedis.close();
return UNLOCK_MSG.equals(result);
}

來用測試梭一把

此時我們可以來寫個測試來試試有沒有達到我們想要的效果,上面的代碼都寫在src/main/java下的RedisLock裡,下面的測試代碼需要寫在src/test/java裡,因為單元測試只是測試代碼的邏輯,無法測試真實連接Redis之後的表現,也沒辦法體驗到被鎖住帶來的緊張又刺激的快感,所以本項目中主要以集成測試為主,如果你想試試帶Mock的單元測試,可以看看這篇文章。

crossoverjie.top/2018/03/29/distributed-lock/distributed-lock-redis/

那麼集成測試會需要依賴一個Redis實例,為了避免你在本地去裝個Redis來跑測試,我用到了一個嵌入式的Redis工具以及如下代碼來幫我們New一個Redis實例,盡情去連接吧 ~

代碼可參看EmbeddedRedis類。另外,集成測試使用到了Spring,是不是倍感親切?相當於也提供了一個集成Spring的例子。

手把手教你實現一個基於Redis的分佈式鎖

對於需要考慮併發的代碼下的測試是比較難且比較難以達到檢測代碼質量的目的的,因為測試用例會用到多線程的環境,不一定能百分百通過且難以重現,但本項目的分佈式鎖是一個比較簡單的併發場景,所以我會盡可能保證測試是有意義的。

我第一個測試用例是想測試一下鎖的互斥能力,能否在A拿到鎖之後,B就無法立即拿到鎖:

手把手教你實現一個基於Redis的分佈式鎖

但這僅僅測試了加鎖操作時候的互斥性,但是沒有測試解鎖是否會成功以及解鎖之後原來等待鎖的進程會繼續進行,所以你可以參看一下testLockAndUnlock方法是如何測試的。不要覺得寫測試很簡單,想清楚測試的各種情況,設計測試情景並實現並不容易。然而以後寫的測試不會單獨拿出來講,畢竟本文想關注的還是分佈式鎖的實現嘛。

超時釋放導致的併發問題

問題:如果A拿到鎖之後設置了超時時長,但是業務執行的時長超過了超時時長,導致A還在執行業務但是鎖已經被釋放,此時其他進程就會拿到鎖從而執行相同的業務,此時因為併發導致分佈式鎖失去了意義。

如果可以通過在key快要過期的時候判斷下任務有沒有執行完畢,如果還沒有那就自動延長過期時間,那麼確實可以解決併發的問題,但是超時時長也就失去了意義。所以個人認為最好的解決方式是在鎖超時的時候通知服務器去停掉超時任務,但是結合上Redis的消息通知機制不免有些過重了

所以這個問題上,分佈式鎖的Redis實現並不靠譜。本人在Redisson中也沒有找到解決方式。或者使用Zookepper將超時消息發送給客戶端去執行超時情況下的業務邏輯。

單點故障導致的併發問題

建立主從複製架構,但是還是會由於主節點掛掉導致某些數據還沒同步就已經丟失,所以推薦多主架構,有N個獨立的master服務器,客戶端會向所有的服務器發送獲取鎖的操作。

可以繼續優化的地方

實現類似JUC中的Semaphore、CountDownLatch、公平鎖非公平鎖、讀寫鎖功能,可參考Redisson的實現

github.com/redisson/redisson/wiki/8.-分佈式鎖和同步器

參考RedLock方案,提供多主配置方式與加鎖解鎖實現

使用訂閱解鎖消息與Semaphore代替Thread.sleep()避免時間浪費,可參考Redisson中RedissonLock的lockInterruptibly方法


分享到:


相關文章: