分布式技術(shù)原理與實(shí)戰(zhàn)45講--第10講:分布式鎖有哪些應(yīng)用場(chǎng)景和實(shí)現(xiàn)
電商網(wǎng)站都會(huì)遇到秒殺、特價(jià)之類的活動(dòng),大促活動(dòng)有一個(gè)共同特點(diǎn)就是訪問量激增,在高并發(fā)下會(huì)出現(xiàn)成千上萬人搶購一個(gè)商品的場(chǎng)景。雖然在系統(tǒng)設(shè)計(jì)時(shí)會(huì)通過限流、異步、排隊(duì)等方式優(yōu)化,但整體的并發(fā)還是平時(shí)的數(shù)倍以上,參加活動(dòng)的商品一般都是限量庫存,如何防止庫存超賣,避免并發(fā)問題呢?分布式鎖就是一個(gè)解決方案。
如何理解分布式鎖
我們都知道,在業(yè)務(wù)開發(fā)中,為了保證在多線程下處理共享數(shù)據(jù)的安全性,需要保證同一時(shí)刻只有一個(gè)線程能處理共享數(shù)據(jù)。
Java 語言給我們提供了線程鎖,開放了處理鎖機(jī)制的 API,比如 Synchronized、Lock 等。當(dāng)一個(gè)鎖被某個(gè)線程持有的時(shí)候,另一個(gè)線程嘗試去獲取這個(gè)鎖會(huì)失敗或者阻塞,直到持有鎖的線程釋放了該鎖。
在單臺(tái)服務(wù)器內(nèi)部,可以通過線程加鎖的方式來同步,避免并發(fā)問題,那么在分布式場(chǎng)景下呢?

分布式場(chǎng)景下解決并發(fā)問題,需要應(yīng)用分布式鎖技術(shù)。如上圖所示,分布式鎖的目的是保證在分布式部署的應(yīng)用集群中,多個(gè)服務(wù)在請(qǐng)求同一個(gè)方法或者同一個(gè)業(yè)務(wù)操作的情況下,對(duì)應(yīng)業(yè)務(wù)邏輯只能被一臺(tái)機(jī)器上的一個(gè)線程執(zhí)行,避免出現(xiàn)并發(fā)問題。
分布式鎖的常用實(shí)現(xiàn)
實(shí)現(xiàn)分布式鎖目前有三種流行方案,即基于數(shù)據(jù)庫、Redis、ZooKeeper 的方案。
基于關(guān)系型數(shù)據(jù)庫
基于關(guān)系型數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖,是依賴數(shù)據(jù)庫的唯一性來實(shí)現(xiàn)資源鎖定,比如主鍵和唯一索引等。
以唯一索引為例,創(chuàng)建一張鎖表,定義方法或者資源名、失效時(shí)間等字段,同時(shí)針對(duì)加鎖的信息添加唯一索引,比如方法名,當(dāng)要鎖住某個(gè)方法或資源時(shí),就在該表中插入對(duì)應(yīng)方法的一條記錄,插入成功表示獲取了鎖,想要釋放鎖的時(shí)候就刪除這條記錄。
下面創(chuàng)建一張基于數(shù)據(jù)庫的分布式鎖表:
CREATE TABLE `methodLock` (`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法或者資源',PRIMARY KEY (`id`),UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='對(duì)方法加鎖';
當(dāng)希望對(duì)某個(gè)方法加鎖時(shí),執(zhí)行以下 SQL 語句:
insert into methodLock(method_name) values ('method_name');
在數(shù)據(jù)表定義中,我們對(duì) method_name 做了唯一性約束,如果有多個(gè)請(qǐng)求同時(shí)提交到數(shù)據(jù)庫的話,數(shù)據(jù)庫會(huì)保證只有一個(gè)操作可以成功,那么就可以認(rèn)為操作成功的那個(gè)線程獲得了該方法的鎖,可以執(zhí)行后面的業(yè)務(wù)邏輯。
當(dāng)方法執(zhí)行完畢之后,想要釋放鎖的話,在數(shù)據(jù)庫中刪除對(duì)應(yīng)的記錄即可。
基于數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖操作簡(jiǎn)單,但是并不是一個(gè)可以落地的方案,有很多地方需要優(yōu)化。
存在單點(diǎn)故障風(fēng)險(xiǎn)
數(shù)據(jù)庫實(shí)現(xiàn)方式強(qiáng)依賴數(shù)據(jù)庫的可用性,一旦數(shù)據(jù)庫掛掉,則會(huì)導(dǎo)致業(yè)務(wù)系統(tǒng)不可用,為了解決這個(gè)問題,需要配置數(shù)據(jù)庫主從機(jī)器,防止單點(diǎn)故障。
超時(shí)無法失效
如果一旦解鎖操作失敗,則會(huì)導(dǎo)致鎖記錄一直在數(shù)據(jù)庫中,其他線程無法再獲得鎖,解決這個(gè)問題,可以添加獨(dú)立的定時(shí)任務(wù),通過時(shí)間戳對(duì)比等方式,刪除超時(shí)數(shù)據(jù)。
不可重入
可重入性是鎖的一個(gè)重要特性,以 Java 語言為例,常見的 Synchronize、Lock 等都支持可重入。在數(shù)據(jù)庫實(shí)現(xiàn)方式中,同一個(gè)線程在沒有釋放鎖之前無法再次獲得該鎖,因?yàn)閿?shù)據(jù)已經(jīng)存在,再次插入會(huì)失敗。實(shí)現(xiàn)可重入,需要改造加鎖方法,額外存儲(chǔ)和判斷線程信息,不阻塞獲得鎖的線程再次請(qǐng)求加鎖。
無法實(shí)現(xiàn)阻塞
其他線程在請(qǐng)求對(duì)應(yīng)方法時(shí),插入數(shù)據(jù)失敗會(huì)直接返回,不會(huì)阻塞線程,如果需要阻塞其他線程,需要不斷的重試 insert 操作,直到數(shù)據(jù)插入成功,這個(gè)操作是服務(wù)器和數(shù)據(jù)庫資源的極大浪費(fèi)。
可以看到,借助數(shù)據(jù)庫實(shí)現(xiàn)一個(gè)完備的分布式鎖,存在很多問題,并且讀寫數(shù)據(jù)庫需要一定的性能,可能會(huì)影響業(yè)務(wù)執(zhí)行的耗時(shí)。
下面我們來看下應(yīng)用緩存如何實(shí)現(xiàn)。
應(yīng)用 Redis 緩存
相比基于數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖,緩存的性能更好,并且各種緩存組件也提供了多種集群方案,可以解決單點(diǎn)問題。
常見的開源緩存組件都支持分布式鎖,包括 Redis、Memcached 及 Tair。以常見的 Redis 為例,應(yīng)用 Redis 實(shí)現(xiàn)分布式鎖,最直接的想法是利用 setnx 和 expire 命令實(shí)現(xiàn)加鎖。
在 Redis 中,setnx 是「set if not exists」如果不存在,則 SET 的意思,當(dāng)一個(gè)線程執(zhí)行 setnx 返回 1,說明 key 不存在,該線程獲得鎖;當(dāng)一個(gè)線程執(zhí)行 setnx 返回 0,說明 key 已經(jīng)存在,那么獲取鎖失敗,expire 就是給鎖加一個(gè)過期時(shí)間。
偽代碼如下:
if(setnx(key,value)==1){ ? ? expire(key,expireTime) ? ? try{ ? ? ? ?//業(yè)務(wù)處理 ? ? }finally{ ? ? ? //釋放鎖 ? ? ? del(key) ? ? }}
使用 setnx 和 expire 有一個(gè)問題,這兩條命令可能不會(huì)同時(shí)失敗,不具備原子性,如果一個(gè)線程在執(zhí)行完 setnx 之后突然崩潰,導(dǎo)致鎖沒有設(shè)置過期時(shí)間,那么這個(gè)鎖就會(huì)一直存在,無法被其他線程獲取。
為了解決這個(gè)問題,在 Redis 2.8 版本中,添加了 SETEX 命令,SETEX 支持 setnx 和 expire 指令組合的原子操作,解決了加鎖過程中失敗的問題。
添加 SETEX 命令, 就是一個(gè)完善的分布式鎖嗎?在下一課時(shí)的內(nèi)容中我會(huì)詳細(xì)分享。
基于 ZooKeeper 實(shí)現(xiàn)
ZooKeeper 有四種節(jié)點(diǎn)類型,包括持久節(jié)點(diǎn)、持久順序節(jié)點(diǎn)、臨時(shí)節(jié)點(diǎn)和臨時(shí)順序節(jié)點(diǎn),利用 ZooKeeper 支持臨時(shí)順序節(jié)點(diǎn)的特性,可以實(shí)現(xiàn)分布式鎖。
當(dāng)客戶端對(duì)某個(gè)方法加鎖時(shí),在 ZooKeeper 中該方法對(duì)應(yīng)的指定節(jié)點(diǎn)目錄下,生成一個(gè)唯一的臨時(shí)有序節(jié)點(diǎn)。

判斷是否獲取鎖,只需要判斷持有的節(jié)點(diǎn)是否是有序節(jié)點(diǎn)中序號(hào)最小的一個(gè),當(dāng)釋放鎖的時(shí)候,將這個(gè)臨時(shí)節(jié)點(diǎn)刪除即可,這種方式可以避免服務(wù)宕機(jī)導(dǎo)致的鎖無法釋放而產(chǎn)生的死鎖問題。
下面描述使用 ZooKeeper 實(shí)現(xiàn)分布式鎖的算法流程,根節(jié)點(diǎn)為 /lock:
客戶端連接 ZooKeeper,并在 /lock 下創(chuàng)建臨時(shí)有序子節(jié)點(diǎn),第一個(gè)客戶端對(duì)應(yīng)的子節(jié)點(diǎn)為 /lock/lock01/00000001,第二個(gè)為 /lock/lock01/00000002;
其他客戶端獲取 /lock01 下的子節(jié)點(diǎn)列表,判斷自己創(chuàng)建的子節(jié)點(diǎn)是否為當(dāng)前列表中序號(hào)最小的子節(jié)點(diǎn);
如果是則認(rèn)為獲得鎖,執(zhí)行業(yè)務(wù)代碼,否則通過 watch 事件監(jiān)聽 /lock01 的子節(jié)點(diǎn)變更消息,獲得變更通知后重復(fù)此步驟直至獲得鎖;
完成業(yè)務(wù)流程后,刪除對(duì)應(yīng)的子節(jié)點(diǎn),釋放分布式鎖。
在實(shí)際開發(fā)中,可以應(yīng)用 Apache Curator 來快速實(shí)現(xiàn)分布式鎖,Curator 是 Netflix 公司開源的一個(gè) ZooKeeper 客戶端,對(duì) ZooKeeper 原生 API 做了抽象和封裝,若感興趣可自行查詢資料了解。
總結(jié)
這一課時(shí)分享了分布式鎖的應(yīng)用場(chǎng)景和幾種實(shí)現(xiàn),包括分布式鎖的概念,使用數(shù)據(jù)庫方式、緩存和 ZooKeeper 實(shí)現(xiàn)分布式鎖等。