20-Redis 怎樣實現的分佈式鎖?

“鎖”是我們實際工作和麪試中無法避開的話題之一,正確使用鎖可以保證高併發環境下程序的正確執行,也就是說只有使用鎖才能保證多人同時訪問時程序不會出現問題。

我們本課時的面試題是,什麼是分佈式鎖?如何實現分佈式鎖?

典型回答

第 06 課時講了單機鎖的一些知識,包括悲觀鎖、樂觀鎖、可重入鎖、共享鎖和獨佔鎖等內容,但它們都屬於單機鎖也就是程序級別的鎖,如果在分佈式環境下使用就會出現鎖不生效的問題,因此我們需要使用分佈式鎖來解決這個問題。

分佈式鎖是控制分佈式系統之間同步訪問共享資源的一種方式。是爲了解決分佈式系統中,不同的系統或是同一個系統的不同主機共享同一個資源的問題,它通常會採用互斥來保證程序的一致性,這就是分佈式鎖的用途以及執行原理。

分佈式鎖示意圖,如下圖所示:
在這裏插入圖片描述
分佈式鎖的常見實現方式有四種:

  • 基於 MySQL 的悲觀鎖來實現分佈式鎖,這種方式使用的最少,因爲這種實現方式的性能不好,且容易造成死鎖;
  • 基於 Memcached 實現分佈式鎖,可使用 add 方法來實現,如果添加成功了則表示分佈式鎖創建成功;
  • 基於 Redis 實現分佈式鎖,這也是本課時要介紹的重點,可以使用 setnx 方法來實現;
  • 基於 ZooKeeper 實現分佈式鎖,利用 ZooKeeper 順序臨時節點來實現。

由於 MySQL 的執行效率問題和死鎖問題,所以這種實現方式會被我們先排除掉,而 Memcached 和 Redis 的實現方式比較類似,但因爲 Redis 技術比較普及,所以會優先使用 Redis 來實現分佈式鎖,而 ZooKeeper 確實可以很好的實現分佈式鎖。但此技術在中小型公司的普及率不高,尤其是非 Java 技術棧的公司使用的較少,如果只是爲了實現分佈式鎖而重新搭建一套 ZooKeeper 集羣,顯然實現成本和維護成本太高,所以綜合以上因素,我們本文會採用 Redis 來實現分佈式鎖。

之所以可以使用以上四種方式來實現分佈式鎖,是因爲以上四種方式都屬於程序調用的“外部系統”,而分佈式的程序是需要共享“外部系統”的,這就是分佈式鎖得以實現的基本前提。

考點分析

分佈式鎖的問題看似簡單,但卻有很多細節需要注意,比如,需要考慮分佈式鎖的超時問題,如果不設置超時時間的話,可能會導致死鎖的產生,所以在對待這個“鎖”的問題上,一定不能馬虎。和此知識點相關的面試還有以下這些:

  • 單機鎖有哪些?它爲什麼不能在分佈式環境下使用?
  • Redis 是如何實現分佈式鎖的?可能會遇到什麼問題?
  • 分佈式鎖超時的話會有什麼問題?如何解決?

知識擴展

單機鎖

程序中使用的鎖叫單機鎖,我們日常中所說的“鎖”都泛指單機鎖,其分類有很多,大體可分爲以下幾類:

  • 悲觀鎖,是數據對外界的修改採取保守策略,它認爲線程很容易把數據修改掉,因此在整個數據被修改的過程中都會採取鎖定狀態,直到一個線程使用完,其他線程纔可以繼續使用,典型應用是 synchronized;
  • 樂觀鎖,和悲觀鎖的概念恰好相反,樂觀鎖認爲一般情況下數據在修改時不會出現衝突,所以在數據訪問之前不會加鎖,只是在數據提交更改時,纔會對數據進行檢測,典型應用是 ReadWriteLock 讀寫鎖;
  • 可重入鎖,也叫遞歸鎖,指的是同一個線程在外面的函數獲取了鎖之後,那麼內層的函數也可以繼續獲得此鎖,在 Java 語言中 ReentrantLock 和 synchronized 都是可重入鎖;
  • 獨佔鎖和共享鎖,只能被單線程持有的鎖叫做獨佔鎖,可以被多線程持有的鎖叫共享鎖,獨佔鎖指的是在任何時候最多隻能有一個線程持有該鎖,比如 ReentrantLock 就是獨佔鎖;而 ReadWriteLock 讀寫鎖允許同一時間內有多個線程進行讀操作,它就屬於共享鎖。

單機鎖之所以不能應用在分佈式系統中是因爲,在分佈式系統中,每次請求可能會被分配在不同的服務器上,而單機鎖是在單臺服務器上生效的。如果是多臺服務器就會導致請求分發到不同的服務器,從而導致鎖代碼不能生效,因此會造成很多異常的問題,那麼單機鎖就不能應用在分佈式系統中了。

使用 Redis 實現分佈式鎖

使用 Redis 實現分佈式鎖主要需要使用 setnx 方法,也就是 set if not exists(不存在則創建),具體的實現代碼如下:

127.0.0.1:6379> setnx lock true
(integer) 1 #創建鎖成功
#邏輯業務處理...
127.0.0.1:6379> del lock
(integer) 1 #釋放鎖

當執行 setnx 命令之後返回值爲 1 的話,則表示創建鎖成功,否則就是失敗。釋放鎖使用 del 刪除即可,當其他程序 setnx 失敗時,則表示此鎖正在使用中,這樣就可以實現簡單的分佈式鎖了。

但是以上代碼有一個問題,就是沒有設置鎖的超時時間,因此如果出現異常情況,會導致鎖未被釋放,而其他線程又在排隊等待此鎖就會導致程序不可用。

有人可能會想到使用 expire 來設置鍵值的過期時間來解決這個問題,例如以下代碼:

127.0.0.1:6379> setnx lock true
(integer) 1 #創建鎖成功
127.0.0.1:6379> expire lock 30 #設置鎖的(過期)超時時間爲 30s
(integer) 1 
#邏輯業務處理...
127.0.0.1:6379> del lock
(integer) 1 #釋放鎖

但這樣執行仍然會有問題,因爲 setnx lock true 和 expire lock 30 命令是非原子的,也就是一個執行完另一個才能執行。但如果在 setnx 命令執行完之後,發生了異常情況,那麼就會導致 expire 命令不會執行,因此依然沒有解決死鎖的問題。

這個問題在 Redis 2.6.12 之前一直沒有得到有效的處理,當時的解決方案是在客戶端進行原子合併操作,於是就誕生了很多客戶端類庫來解決此原子問題,不過這樣就增加了使用的成本。因爲你不但要添加 Redis 的客戶端,還要爲了解決鎖的超時問題,需額外的增加新的類庫,這樣就增加了使用成本,但這個問題在 Redis 2.6.12 版本中得到了有效的處理。

在 Redis 2.6.12 中我們可以使用一條 set 命令來執行鍵值存儲,並且可以判斷鍵是否存在以及設置超時時間了,如下代碼所示:

127.0.0.1:6379> set lock true ex 30 nx
OK #創建鎖成功

其中,ex 是用來設置超時時間的,而 nx 是 not exists 的意思,用來判斷鍵是否存在。如果返回的結果爲“OK”則表示創建鎖成功,否則表示此鎖有人在使用。

鎖超時

從上面的內容可以看出,使用 set 命令之後好像一切問題都解決了,但在這裏我要告訴你,其實並沒有。例如,我們給鎖設置了超時時間爲 10s,但程序的執行需要使用 15s,那麼在第 10s 時此鎖因爲超時就會被釋放,這時候線程二在執行 set 命令時正常獲取到了鎖,於是在很短的時間內 2s 之後刪除了此鎖,這就造成了鎖被誤刪的情況,如下圖所示:

在這裏插入圖片描述
鎖被誤刪的解決方案是在使用 set 命令創建鎖時,給 value 值設置一個歸屬標識。例如,在 value 中插入一個 UUID,每次在刪除之前先要判斷 UUID 是不是屬於當前的線程,如果屬於再刪除,這樣就避免了鎖被誤刪的問題。

注意:在鎖的歸屬判斷和刪除的過程中,不能先判斷鎖再刪除鎖,如下代碼所示:

if(uuid.equals(uuid)){ // 判斷是否是自己的鎖
	del(luck); // 刪除鎖
}

應該把判斷和刪除放到一個原子單元中去執行,因此需要藉助 Lua 腳本來執行,在 Redis 中執行 Lua 腳本可以保證這批命令的原子性,它的實現代碼如下:

/**
 * 釋放分佈式鎖
 * @param jedis Redis客戶端
 * @param lockKey 鎖的 key
 * @param flagId 鎖歸屬標識
 * @return 是否釋放成功
 */
public static boolean unLock(Jedis jedis, String lockKey, String flagId) {
    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(flagId));
    if ("1L".equals(result)) { // 判斷執行結果
        return true;
    }
    return false;
}

其中,Collections.singletonList() 方法是將 String 轉成 List,因爲 jedis.eval() 最後兩個參數要求必須是 List 類型。

鎖超時可以通過兩種方案來解決:

  • 把執行耗時的方法從鎖中剔除,減少鎖中代碼的執行時間,保證鎖在超時之前,代碼一定可以執行完;
  • 把鎖的超時時間設置的長一些,正常情況下我們在使用完鎖之後,會調用刪除的方法手動刪除鎖,因此可以把超時時間設置的稍微長一些。

小結

本課時我們講了分佈式鎖的四種實現方式,即 MySQL、Memcached、Redis 和 ZooKeeper,因爲 Redis 的普及率比較高,因此對於很多公司來說使用 Redis 實現分佈式鎖是最優的選擇。本課時我們還講了使用 Redis 實現分佈式鎖的具體步驟以及實現代碼,還講了在實現過程中可能會遇到的一些問題以及解決方案。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章