天天看點

一文詳解如何用MySQL/Redis/ZooKeeper實作分布式鎖

一個挺着啤酒肚,身穿格子衫,發際線嚴重後移的中年男子,手拿着保溫杯,胳膊夾着MacBook向你走來,看樣子是架構師級别。
一文詳解如何用MySQL/Redis/ZooKeeper實作分布式鎖

面試開始, 直入正題。

面試官: 你有沒有參與過秒殺系統的設計?

我: 沒有,我平時都是開發背景管理系統、OA辦公系統、内部管理系統,從來沒有開發過秒殺系統。

面試官: 嗯...,小夥子很實誠。今天就先到這裡吧,後面有消息會主動聯系你。

後面還可能有消息嗎?你們啥時候主動聯系過我?

實話實說的被拒,八股文背的溜反而被錄取。

好吧,等我看看一燈怎麼總結的秒殺系統的八股文。

我: 參與過秒殺系統,并獨立負責過秒殺系統的架構設計(【狗頭】是的,都是我設計的)。

面試官: 這樣才對,這樣我才能接着往下問。你在設計秒殺系統的時候,怎麼防止商品超賣?比如活動中隻有一台iPhone,最終賣出100台,肯定不行,平台要虧錢。

我: 肯定要加鎖,不過由于秒殺系統請求量較大,一般使用分布式叢集。而Java自帶Synchronized、ReentrantLock鎖隻能用在單機系統中,這時候就需要用到分布式鎖。

面試官: 你提到分布式鎖,分布式鎖都有哪些作用?

八股文這就開始了。

我:我覺得分布式鎖主要有兩個作用:

保證資料的正确性:

比如:秒殺的時候防止商品超賣,表單重複送出,接口幂等性。

避免資料重複處理:

比如:排程任務在多台機器重複執行,緩存過期所有請求都去加載資料庫。

總結八股文,還得是一燈。

面試官: 小夥子總結的挺全,你知道設計一個分布式鎖,要具有哪些特性?

我: 我覺得分布式鎖要具有以下這些特性:

互斥:同一時刻隻能有一個線程獲得鎖。

可重入:當一個線程擷取鎖後,還可以再次擷取這個鎖,避免死鎖發生。

高可用:當小部分節點挂掉後,仍然能夠對外提供服務。

高性能:要做到高并發、低延遲。

支援阻塞和非阻塞:Synchronized是阻塞的,ReentrantLock.tryLock()就是非阻塞的

支援公平鎖和非公平鎖:Synchronized是非公平鎖,ReentrantLock(boolean fair)可以建立公平鎖

面試官: 小夥子,有點東西。你是怎麼設計一個分布式鎖?

我: 有幾種常用的工具都可以實作分布式鎖。

比如:關系型資料庫(例如:MySQL)、分布式資料庫(例如:Redis)、分布式協調服務架構(例如:zookeeper)

使用MySQL實作分布式鎖比較簡單,建一張表:

CREATE TABLE `distributed_lock` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
  `resource_name` varchar(200) NOT NULL DEFAULT '' COMMENT '資源名稱(唯一索引)',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_resource_name` (`resource_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='分布式鎖';
           

擷取鎖的時候,就插入一條記錄。插入成功就代表擷取到鎖,插入失敗就代表擷取鎖失敗。

INSERT INTO distributed_lock (`resource_name`) VALUES ('資源1');
           

釋放鎖的時候,就删除這條記錄。

DELETE FROM distributed_lock WHERE resource_name = '資源1';
           

實作比較簡單,不過還不能用于實際生産中,有幾個問題沒有解決:

  1. 這把鎖不支援阻塞,insert失敗立即就傳回了。當然可以用while循環直到插入成功,不過自旋也會占用CPU。
  2. 這把鎖不是可重入的,已經擷取到鎖的線程再次插入也會失敗,我們可以增加兩列,一列記錄擷取到鎖的節點和線程,另一列記錄加鎖次數。擷取鎖,次數加一,釋放鎖,次數減一,次數為零就删除這把鎖。
  3. 這把鎖沒有過期時間,如果業務處理失敗或者機器當機,導緻沒有釋放鎖,鎖就會一直存在,其他線程也無法擷取到鎖。我們可以增加一列鎖過期時間,再啟動一個異步任務掃描過期時間大于目前時間的鎖就删除。

就是這麼麻煩,我們看一下優化之後的鎖變成什麼樣了:

CREATE TABLE `distributed_lock` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
  `resource_name` varchar(200) NOT NULL DEFAULT '' COMMENT '資源名稱(唯一索引)',
  `owner` varchar(200) NOT NULL DEFAULT '' COMMENT '鎖持有者(機器碼+線程名稱)',
  `lock_count` int NOT NULL DEFAULT '0' COMMENT '加鎖次數',
  `expire_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '鎖過期時間',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_resource_name` (`resource_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='分布式鎖';
           

這下應該完美了吧?不行,還有個問題:

業務邏輯沒處理完,鎖過期了怎麼辦?

假如我們設定鎖過期時間是6秒,正常情況下業務邏輯可以在6秒内處理完成,但是當JVM發生FullGC或者調用第三方服務出現網絡延遲,業務邏輯還沒處理完,鎖已經過期,被删掉,然後被其他線程擷取到鎖,豈不是要出問題?

這就引入了另一個知識點“鎖續期”:

擷取鎖的同時,啟動一個異步任務,每當業務執行到三分之一時間,也就是6秒中的第2秒的時候,就自動延長鎖過期時間,繼續延長到6秒,這樣就能保證業務邏輯處理完成之前鎖不會過期。

面試官: 小夥子,分布式鎖算是讓你玩明白了。我還想繼續問,生産中一般很少用MySQL做分布式鎖,因為MySQL并發性能跟不上。剛才提到Redis也可以實作分布式鎖,你知道該怎麼實作嗎?

我當然知道,八股文就要背全套。

我: 使用Redis實作分布式鎖,跟使用MySQL類似,也需要解決實作過程中遇到的各種問題,不過解決方案稍有不同。

最簡單的擷取鎖方式:

// 1. 擷取鎖
redis.setnx('resource_name1', 'owner1')
// 2. 釋放鎖
redis.del('resource_name1')
           

當“resource_name1”不存在時,set成功,也就是擷取鎖成功。

不過還需要加上過期時間,防止沒有釋放鎖。

// 1. 擷取鎖
redis.setnx('resource_name1', 'owner1')
// 2. 增加鎖過期時間
redis.exprire('resource_name1', 6, TimeUnit.SECONDS)
           

又引入新問題了,兩條指令不是原子的,可能擷取鎖之後還沒來得及設定過期時間就當機了,這該怎麼辦?

好辦,在Redis 2.6.12之後,提供一條複合指令:

redis.set('resource_name1', 'owner1',"NX" "EX", 6)
           

還有一個問題,釋放鎖的時候,并沒有判斷鎖的持有者,有可能把其他線程持有的鎖給釋放了,這可不行,可以這樣做:

// 釋放鎖
if ('owner1'.equals(redis.get('resource_name1'))){
	redis.del('resource_name1')
}
           

這樣行不行呢?還不行,因為get和del兩條指令不是原子操作,需要引入Lua腳本把兩條指令打包成一條發給Redis執行:

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redis.eval(script, Collections.singletonList('resource_name1'), Collections.singletonList('owner1'))
           

這樣總行了吧?還不行,還有個“鎖續期”的問題沒有解決。

更簡單了,Redis用戶端Redisson已經幫我們實作續期的功能,叫“WatchDog”(看門狗),在我們調用lock自動喚醒“看門狗”。

面試官: 小夥子,你可真行啊。你再講一下使用zookeeper怎麼實作分布式鎖?

我: zookeeper采用樹形節點,類似Linux目錄檔案結構,同一目錄下的節點名稱不能重複。

一文詳解如何用MySQL/Redis/ZooKeeper實作分布式鎖

節點有分為四種類型:

持久節點: 一旦建立,永久存儲在伺服器上,除非手動删除。

臨時節點: 生命周期與用戶端綁定,用戶端斷開連接配接,節點就被自動删除。

持久順序節點: 特性同持久節點,隻是在節點名稱後面追加自增有序數字。

臨時順序節點: 特性同臨時節點,隻是在節點名稱後面追加自增有序數字。

zookeeper還有個監聽-通知機制,用戶端可以在資源節點上建立watch事件。當節點發生變化,會通知用戶端,用戶端可以根據變化做相應的業務處理。

我們可以利用臨時順序節點的特性建立分布式鎖,分以下三步:

  1. 在資源/resource1目錄下建立臨時順序節點node
  2. 擷取/resource1目錄下的所有節點,如果目前節點序号最小,代表加鎖成功
  3. 如果不是,就是watch監聽序号最小的節點

實作邏輯很簡單,我們來分析一下zookeeper實作分布式鎖的優點:

  1. 由于建立的臨時節點,斷開連接配接後自動删除,是以無需設定鎖逾時時間,也就不用考慮不釋放和鎖續期
  2. 由于節點上存儲的建立人資訊,鎖也就支援可重入
  3. 由于可以監聽節點,也就實作了可阻塞

面試官: 小夥子,更新加薪的機會就是留給你這樣的人。薪資double,明天就來上班吧。

總結:

關于分布式鎖的所有知識點,雖然很多,但都已經總結在這張圖上了,歡迎點贊收藏轉發評論。

一文詳解如何用MySQL/Redis/ZooKeeper實作分布式鎖