“鎖”是我們實際工作和面試中無法避開的話題之一,正確使用鎖可以保證高并發環境下程序的正確執行,也就是說只有使用鎖才能保證多人同時訪問時程序不會出現問題。
我們本課時的面試題是,什么是分布式鎖?如何實現分布式鎖?
#### 典型回答
第 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 實現分布式鎖的具體步驟以及實現代碼,還講了在實現過程中可能會遇到的一些問題以及解決方案。
#### 課后問答
* 1、如果業務就是會出現1%的超時呢?怎么處理?
講師回復: 要看 1% 的業務超出了多少時間,如果超出的不多就增加超時時長,否則就想辦法把耗時的業務代碼拎出來。
* 2、鎖超時那里,感覺圖和描述的不是很清楚,線程1的鎖因為超時被釋放了,線程2獲取到鎖開始執行,隨后因為線程1在線程2前完成了,所以線程1會去刪除鎖,所以這里產生了線程2的鎖被線程1誤刪的問題,不知道我這樣理解的對不
講師回復: 是這個意思。
* 3、老師,redis集群下是如何實現鎖的呢
講師回復: 實現方法都是一樣的
* 4、刪除鎖時如果判斷鎖和刪除鎖兩個操作不是原子性的,可能會出現什么問題?
講師回復: 一個先執行,一個后執行,中間執行過程可能被打斷,并且有可能把自己的執行權交由另一個線程執行,就會出現一些非安全性問題。
* 5、假如某個線程獲取到鎖后,執行時間大于過期時間,是不是執行時間到了才會刪除設置的鍵?還是把超時當成異常,然后直接刪除鍵釋放鎖?
講師回復: 過期時間到了也會刪除鎖,這是 Redis 層面執行的,程序線程執行完了也會刪除鎖,會有兩次刪除。正常來說要保證鎖的執行時間要盡量的短(不要出現超時的情況),第二,如果超時了要保證線程 A,不能誤刪線程 B 的鎖。
* 6、為什么是刪除鎖,而不是釋放鎖?
講師回復: 其實是一個意思,一個是物理刪除一個是邏輯刪除,可以這樣理解。
* 7、如果占用鎖的任務執行超時,任務會怎么處理?
講師回復: 可能會出現 Redis 已經把過期的鎖給刪除了,線程 A 執行完之后又把線程 B 的鎖給誤刪了,文章有解決方案哈。
- 前言
- 開篇詞
- 開篇詞:大廠技術面試“潛規則”
- 模塊一:Java 基礎
- 第01講:String 的特點是什么?它有哪些重要的方法?
- 第02講:HashMap 底層實現原理是什么?JDK8 做了哪些優化?
- 第03講:線程的狀態有哪些?它是如何工作的?
- 第04講:詳解 ThreadPoolExecutor 的參數含義及源碼執行流程?
- 第05講:synchronized 和 ReentrantLock 的實現原理是什么?它們有什么區別?
- 第06講:談談你對鎖的理解?如何手動模擬一個死鎖?
- 第07講:深克隆和淺克隆有什么區別?它的實現方式有哪些?
- 第08講:動態代理是如何實現的?JDK Proxy 和 CGLib 有什么區別?
- 第09講:如何實現本地緩存和分布式緩存?
- 第10講:如何手寫一個消息隊列和延遲消息隊列?
- 模塊二:熱門框架
- 第11講:底層源碼分析 Spring 的核心功能和執行流程?(上)
- 第12講:底層源碼分析 Spring 的核心功能和執行流程?(下)
- 第13講:MyBatis 使用了哪些設計模式?在源碼中是如何體現的?
- 第14講:SpringBoot 有哪些優點?它和 Spring 有什么區別?
- 第15講:MQ 有什么作用?你都用過哪些 MQ 中間件?
- 模塊三:數據庫相關
- 第16講:MySQL 的運行機制是什么?它有哪些引擎?
- 第17講:MySQL 的優化方案有哪些?
- 第18講:關系型數據和文檔型數據庫有什么區別?
- 第19講:Redis 的過期策略和內存淘汰機制有什么區別?
- 第20講:Redis 怎樣實現的分布式鎖?
- 第21講:Redis 中如何實現的消息隊列?實現的方式有幾種?
- 第22講:Redis 是如何實現高可用的?
- 模塊四:Java 進階
- 第23講:說一下 JVM 的內存布局和運行原理?
- 第24講:垃圾回收算法有哪些?
- 第25講:你用過哪些垃圾回收器?它們有什么區別?
- 第26講:生產環境如何排除和優化 JVM?
- 第27講:單例的實現方式有幾種?它們有什么優缺點?
- 第28講:你知道哪些設計模式?分別對應的應用場景有哪些?
- 第29講:紅黑樹和平衡二叉樹有什么區別?
- 第30講:你知道哪些算法?講一下它的內部實現過程?
- 模塊五:加分項
- 第31講:如何保證接口的冪等性?常見的實現方案有哪些?
- 第32講:TCP 為什么需要三次握手?
- 第33講:Nginx 的負載均衡模式有哪些?它的實現原理是什么?
- 第34講:Docker 有什么優點?使用時需要注意什么問題?
- 彩蛋
- 彩蛋:如何提高面試成功率?