“鎖”是我們實際工作和麪試中無法避開的話題之一,正確使用鎖可以保證高併發環境下程序的正確執行,也就是說只有使用鎖才能保證多人同時訪問時程序不會出現問題。
我們本課時的面試題是,什麼是分佈式鎖?如何實現分佈式鎖?
典型回答
第 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 實現分佈式鎖的具體步驟以及實現代碼,還講了在實現過程中可能會遇到的一些問題以及解決方案。