前言
針對共享資源的互斥訪問歷來是很多業務系統需要解決的問題。用到分布式鎖說明遇到了多個進程共同訪問同一個資源的問題。
一般是在兩個場景下會防止對同一個資源的重復訪問:
- 提高效率。比如多個節點計算同一批任務,如果某個任務已經有節點在計算了,那其他節點就不用重復計算了,以免浪費計算資源。不過重復計算也沒事,不會造成其他更大的損失。也就是允許偶爾的失敗。
- 保證正確性。這種情況對鎖的要求就很高了,如果重復計算,會對正確性造成影響。這種不允許失敗。
引入分布式鎖勢必要引入一個第三方的基礎設施,比如 MySQL,Redis,Zookeeper 等。本文聊聊各種實現的方案。
1. 單機鎖和分布式鎖
由此抽象一下分布式鎖的概念,首先分布式鎖需要是一個資源,這個資源能夠提供並發控制,並輸出一個排他性的狀態,也就是:
鎖 = 資源 + 並發控制 + 所有權展示
以常見的單機鎖為例:
- Spinlock = BOOL +CAS(樂觀鎖)
- Mutex = BOOL + CAS + 通知(悲觀鎖)
Spinlock 和 Mutex 都是一個 Bool 資源,通過原子的 CAS 指令:當現在為 0 設置為 1,成功的話持有鎖,失敗的話不持有鎖,如果不提供所有權的展示,例如 AtomicInteger,也是通過資源(Interger)+ CAS,但是不會明確的提示所有權,因此不會被視為一種鎖,當然,可以將“所有權展示”這個更多地視為某種服務提供形式的包裝。
單機環境下,內核具備“上帝視角”,能夠知道進程的存活,當進程掛掉的時候可以將該進程持有的鎖資源釋放,但發展到分布式環境,這就變成了一個挑戰,為了應對各種機器故障、宕機等,就需要給鎖提供了一個新的特性:可用性。
如下圖所示,任何提供三個特性的服務都可以提供分布式鎖的能力,資源可以是文件、KV 等,通過創建文件、KV 等原子操作,通過創建成功的結果來表明所有權的歸屬,同時通過 TTL 或者會話來保證鎖的可用性,通過超時釋放,可以避免死鎖。
2. 分布式鎖的系統分類
根據鎖資源本身的安全性,我們將分布式鎖分為兩個陣營:
-
基於異步復制的分布式系統,例如 mysql,tair,redis 等。
-
基於 paxos 協議的分布式一致性系統,例如 zookeeper,etcd,consul 等。
基於異步復制的分布式系統,存在數據丟失(丟鎖)的風險,不夠安全,往往通過 TTL 的機制承擔細粒度的鎖服務,該系統接入簡單,適用於對時間很敏感,期望設置一個較短的有效期,執行短期任務,丟鎖對業務影響相對可控的服務。
基於 paxos 協議的分布式系統,通過一致性協議保證數據的多副本,數據安全性高,往往通過租約(會話)的機制承擔粗粒度的鎖服務,該系統需要一定的門檻,適用於對安全性很敏感,希望長期持有鎖,不期望發生丟鎖現象的服務。
2.1 Redis 實現
Redis 客戶端加鎖也要根據Redis 部署情況來使用不同的加鎖方式。
2.1.1 單機Redis的分布式鎖
單機方式可根據lua腳本實現
思路大概是這樣的:在redis中設置一個值表示加了鎖,然后釋放鎖的時候就把這個key刪除。具體代碼是這樣的:
// 獲取鎖
// NX是指如果key不存在就成功,key存在返回false,PX可以指定過期時間
SET d_lock unique_value NX PX 30000
// 釋放鎖:通過執行一段lua腳本
// 釋放鎖涉及到兩條指令,這兩條指令不是原子性的
// 需要用到redis的lua腳本支持特性,redis執行lua腳本是原子性的
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
這種方式有兩大要點:
1)一定要用SET key value NX PXmilliseconds 命令
如果不用,先設置了值,再設置過期時間,這個不是原子性操作,有可能在設置過期時間之前宕機,會造成死鎖(key永久存在)。
2)value要具有唯一性
這個是為了在解鎖的時候,需要驗證value是和加鎖的一致才刪除key。
這是避免了一種情況:假設A獲取了鎖,過期時間30s,此時35s之后,鎖已經自動釋放了,A去釋放鎖,但是此時可能B獲取了鎖。A客戶端就不能刪除B的鎖了。
如果采用單機部署模式,會存在單點問題,只要redis故障了。加鎖就不行了。
2.1.2 集群分布式
redis 集群分布式集群方式有兩種
- master-slave + sentinel選舉模式
- redis cluster模式
集群使用redis鎖的問題:
采用master-slave模式,如果設置鎖之后,主機在傳輸到從機的時候掛掉了,從機還沒有加鎖信息,如何處理?即采用master-slave模式,加鎖的時候只對一個節點加鎖,即便通過sentinel做了高可用,但是如果master節點故障了,發生主從切換,此時就會有可能出現鎖丟失的問題。
redis cluster 模式下,edis的作者提出可依據RedLock算法:
這個算法的意思大概是這樣的:假設redis的部署模式是redis cluster,總共有6個master節點,通過以下步驟獲取一把鎖:
1.獲取當前時間戳,單位是毫秒;
2.輪流嘗試在每個master節點上創建鎖,過期時間設置較短,一般就幾十毫秒;
3.嘗試在大多數節點上建立一個鎖,比如5個節點就要求是3個節點(n / 2+1);
4.客戶端計算建立好鎖的時間,如果建立鎖的時間小於超時時間,就算建立成功了;
5.要是鎖建立失敗了,那么就依次刪除這個鎖;
6.只要別人建立了一把分布式鎖,你就得不斷輪詢去嘗試獲取鎖。
但是這樣的這種算法還是頗具爭議的,可能還會存在不少的問題,無法保證加鎖的過程一定正確。
基於Redission的實現:
Javaer都知道Jedis,Jedis是Redis的Java實現的客戶端,其API提供了比較全面的Redis命令的支持。Redission也是Redis的客戶端,相比於Jedis功能簡單。Jedis簡單使用阻塞的I/O和redis交互,Redission通過Netty支持非阻塞I/O。
Redission封裝了鎖的實現,其繼承了java.util.concurrent.locks.Lock的接口,讓我們像操作我們的本地Lock一樣去操作Redission的Lock,下面介紹一下其如何實現分布式鎖。
如果自己寫代碼來通過redis設置一個值,是通過下面這個命令設置的。
SET d_lock unique_value NX PX 30000
這里設置的超時時間是30s,假如我超過30s都還沒有完成業務邏輯的情況下,key會過期,其他線程有可能會獲取到鎖。這樣一來的話,第一個線程還沒執行完業務邏輯,第二個線程進來了也會出現線程安全問題。所以我們還需要額外的去維護這個過期時間,太麻煩了~我們來看看redisson是怎么實現的:
Config config = new Config();
config.useClusterServers()
.addNodeAddress("redis://192.168.1.101:7001")
.addNodeAddress("redis://192.168.1.101:7002")
.addNodeAddress("redis://192.168.1.101:7003")
.addNodeAddress("redis://192.168.1.102:7001")
.addNodeAddress("redis://192.168.1.102:7002")
.addNodeAddress("redis://192.168.1.102:7003");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("d_lock");
lock.lock();
lock.unlock();
我們只需要通過它的api中的lock和unlock即可完成分布式鎖,他幫我們考慮了很多細節:
-
redisson所有指令都通過lua腳本執行,redis支持lua腳本原子性執行
-
redisson設置一個key的默認過期時間為30s,如果某個客戶端持有一個鎖超過了30s怎么辦?
redisson中有一個watchdog的概念,翻譯過來就是看門狗,它會在你獲取鎖之后,每隔10秒幫你把key的超時時間設為30s
這樣的話,就算一直持有鎖也不會出現key過期了,其他線程獲取到鎖的問題了。
- redisson的“看門狗”邏輯保證了沒有死鎖發生
(如果機器宕機了,看門狗也就沒了。此時就不會延長key的過期時間,到了30s之后就會自動過期了,其他線程可以獲取到鎖)
Redis小結
優點: 對於Redis實現簡單,性能對比ZK和Mysql較好。如果不需要特別復雜的要求,那么自己就可以利用setNx進行實現,如果自己需要復雜的需求的話那么可以利用或者借鑒Redission。對於一些要求比較嚴格的場景來說的話可以使用RedLock。
缺點: 需要維護Redis集群,如果要實現RedLock那么需要維護更多的集群。
2.2 Zookeeper 實現
zk實現分布式鎖的落地方案:
-
使用zk的臨時節點和有序節點,每個線程獲取鎖就是在zk創建一個臨時有序的節點,比如在/lock/目錄下。
-
創建節點成功后,獲取/lock目錄下的所有臨時節點,再判斷當前線程創建的節點是否是所有的節點的序號最小的節點
-
如果當前線程創建的節點是所有節點序號最小的節點,則認為獲取鎖成功
-
如果當前線程創建的節點不是所有節點序號最小的節點,則對節點序號的前一個節點添加一個事件監聽。
比如當前線程獲取到的節點序號為/lock/003,然后所有的節點列表為:
[/lock/001,/lock/002,/lock/003],則對/lock/002這個節點添加一個事件監聽器。
如果鎖釋放了,會喚醒下一個序號的節點,然后重新執行第3步,判斷是否自己的節點序號是最小。
比如/lock/001釋放了,/lock/002監聽到時間,此時節點集合為[/lock/002,/lock/003],則/lock/002為最小序號節點,獲取到鎖。
整個過程如下:
Curator
Curator是一個zookeeper的開源客戶端,也提供了分布式鎖的實現。
他的使用方式也比較簡單:
InterProcessMutex ipm = new InterProcessMutex(client,"/d_lock");
ipm.acquire();
ipm.release();
其實現分布式鎖的核心源碼如下:
while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock ) {
// 獲取當前所有節點排序后的集合
List<String> children = getSortedChildren();
// 獲取當前節點的名稱
String sequenceNodeName = ourPath.substring(basePath.length() + 1);
// 判斷當前節點是否是最小的節點
PredicateResults predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
if ( predicateResults.getsTheLock() ) {
// 獲取到鎖
haveTheLock = true;
} else {
// 沒獲取到鎖,對當前節點的上一個節點注冊一個監聽器
……
}
}
2.3 基於數據庫實現
2.3.1 基於數據庫表的排它鎖
鎖信息存儲表結構如下:
CREATE TABLE `t_ms_lock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '描述',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存數據時間',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_name` (`name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';
當我們想要鎖住某個方法時,執行以下SQL:
insert into t_ms_lock(name,desc) values ('name','desc');
因為我們對name做了唯一性約束,這里如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,那么我們就可以認為操作成功的那個線程獲得了該方法的鎖,可以執行方法體內容。
當方法執行完畢之后,想要釋放鎖的話,需要執行以下Sql:
delete from t_ms_lock where name ='name';
上面這種簡單的實現有以下幾個問題:
-
這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用;
- 對於數據庫是單點可以搞兩個數據庫,數據之前雙向同步。一旦掛掉快速切換到備庫上;但切換也是單點?
-
這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖;
- 對於沒有失效時間,只要做一個定時任務,每隔一定時間把數據庫中的超時數據清理一遍;
-
這把鎖只能是非阻塞的,因為數據的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作;
- 對於非阻塞:搞一個while循環,直到insert成功再返回成功;
-
這把鎖是不可重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因為數據中數據已經存在了。
- 對於非重入, 在數據庫表中加個字段,記錄當前獲得鎖的機器的主機信息和線程信息,那么下次再獲取鎖的時候先查詢數據庫,如果當前機器的主機信息和線程信息在數據庫可以查到的話,直接把鎖分配給他就可以了。
2.3.2 基於數據庫的排它鎖
除了可以通過增刪操作數據表中的記錄以外,其實還可以借助數據中自帶的鎖來實現分布式的鎖。
基於MySql的InnoDB引擎,可以使用以下方法來實現加鎖操作:
public boolean lock(){
connection.setAutoCommit(false)
while(true){
result = select * from t_ms_lock where name=xxx for update;
if(result==null){
return true;
}
}
return false;
}
在查詢語句后面增加forupdate,數據庫會在查詢過程中給數據庫表增加排他鎖。當某條記錄被加上排他鎖之后,其他線程無法再在該行記錄上增加排他鎖。(這里再多提一句,InnoDB引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這里我們希望使用行級鎖,就要給method_name添加索引,值得注意的是,這個索引一定要創建成唯一索引,否則會出現多個重載方法之間無法同時被訪問的問題。重載方法的話建議把參數類型也加上)
我們可以認為獲得排它鎖的線程即可獲得分布式鎖,當獲取到鎖之后,可以執行方法的業務邏輯,執行完方法之后,再通過以下方法解鎖:
public void unlock(){
connection.commit();
}
這里還可能存在另外一個問題,雖然我們對name 使用了唯一索引,並且顯示使用for update來使用行級鎖。但是,MySql會對查詢進行優化,即便在條件中使用了索引字段,但是否使用索引來檢索數據是由MySQL 通過判斷不同執行計划的代價來決定的,如果 MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。如果發生這種情況就悲劇了。。。
還有一個問題,就是我們要使用排他鎖來進行分布式鎖的lock,那么一個排他鎖長時間不提交,就會占用數據庫連接。一旦類似的連接變得多了,就可能把數據庫連接池撐爆。
2.3.3 基於數據庫的樂觀鎖
大多數是基於數據版本(version)的記錄機制實現的。何謂數據版本號?即為數據增加一個版本標識,在基於數據庫表的版本解決方案中,一般是通過為數據庫表添加一個“version”字段來實現讀取出數據時,將此版本號一同讀出,之后更新時,對此版本號加1。在更新過程中,會對版本號進行比較,如果是一致的,沒有發生改變,則會成功執行本次操作;如果版本號不一致,則會更新失敗。
注意ABA 問題等。
3. 比較總結
對於redis的分布式鎖而言,它有以下缺點:
它獲取鎖的方式簡單粗暴,獲取不到鎖直接不斷嘗試獲取鎖,比較消耗性能。另外來說的話,redis的設計定位決定了它的數據並不是強一致性的,在某些極端情況下,可能會出現問題。鎖的模型不夠健壯,即便使用redlock算法來實現,在某些復雜場景下,也無法保證其實現100%沒有問題。但是另一方面使用redis實現分布式鎖在很多企業中非常常見,而且大部分情況下都不會遇到所謂的“極端復雜場景”,所以使用redis作為分布式鎖也不失為一種好的方案,最重要的一點是redis的性能很高,可以支撐高並發的獲取、釋放鎖操作。
zookeeper天生設計定位就是分布式協調,強一致性。鎖的模型健壯、簡單易用、適合做分布式鎖。如果獲取不到鎖,只需要添加一個監聽器就可以了,不用一直輪詢,性能消耗較小。
但是如果有較多的客戶端頻繁的申請加鎖、釋放鎖,對於zk集群的壓力會比較大。
我們介紹了幾種分布式鎖的實現方式,並進行了一些優缺點比較,哪種方式都無法做到完美。就像CAP一樣,在復雜性、可靠性、性能等方面無法同時滿足,所以需要根據不同的應用場景選擇最適合的方式。
比較角度 | 結果 |
---|---|
從理解的難易程度角度(從低到高) | 數據庫 > 緩存 > Zookeeper |
從實現的復雜性角度(從低到高) | Zookeeper >= 緩存 > 數據庫 |
從性能角度(從高到低) | 緩存 > Zookeeper >= 數據庫 |
從可靠性角度(從高到低) | Zookeeper > 緩存 > 數據庫 |