為什么
在傳統(tǒng)的單體服務中,我們經(jīng)常會遇到多線程對于單一資源的搶占導致的線程安全問題以及對數(shù)據(jù)庫數(shù)據(jù)操作的一致性問題,如果是在單體系統(tǒng)中,我們可以很方便的使用編程語言提供的鎖以及數(shù)據(jù)庫事務來解決這些問題。
一旦單體系統(tǒng)轉(zhuǎn)為分布式架構(gòu),那么本地事務和線程鎖就無法滿足跨進程的鎖效果;分布式鎖則是用于進程間同步訪問共享資源的一種方式,通過全局共享來實現(xiàn)全局鎖的效果,保證數(shù)據(jù)的一致性。
總的來說,在分布式系統(tǒng)中,當我們期望一個操作(一個請求、一個方法、一個數(shù)據(jù)庫操作...)在整個系統(tǒng)中同一時間只能有一個線程執(zhí)行,那我們就需要用到分布式鎖; 抽象來看就是兩個場景:
- 單一資源的數(shù)據(jù)變更:比如對共享存儲數(shù)據(jù)(數(shù)據(jù)庫、緩存...)進行修改,多線程的互斥
- access token:對于多個資源的原子性操作,期望整個業(yè)務邏輯就是單一線程執(zhí)行保持一致性,在入口處就鎖住
分布式鎖應該具備的特性:
- 原子性:在分布式系統(tǒng)中,一個方法在同一時間只能被一個線程執(zhí)行
- 阻塞性:在沒獲取到鎖時可以進行阻塞也可以返回失敗
- 高可用:能夠正確的獲取鎖和釋放鎖,且具備鎖失效的能力
- 高性能:獲取鎖與釋放鎖的性能保障
- 可重入:能夠具備可重入特性
實現(xiàn)方式
基于數(shù)據(jù)庫實現(xiàn)
樂觀鎖實現(xiàn)
先去干,能不能干,能不能干成先不管,這就是樂觀心態(tài)。在開發(fā)過程中,樂觀鎖用的非常多,比如典型的 CAS ;在不加鎖的情況下保證數(shù)據(jù)的一致性。
使用方式也很簡單,只需要在表中添加一個版本號的字段,每次對數(shù)據(jù)進行修改的時候,通過版本來確定是否能夠更新 update xx set version = OLD_VERSION+1 where id = ID and version = OLD_VERSION
, 如果更新不成功,客戶端可以選擇是否重試。當然,需要加上索引。
可見這種方式的優(yōu)勢其實很明顯,不加鎖,使用簡單。但也有一些局限性
- 只能支持單數(shù)據(jù)更新的一致性(對于數(shù)據(jù)的插入可以通過唯一索引來解決
- 由于是樂觀鎖(先干,在檢查),也就意味著可能活干完咯,發(fā)現(xiàn)更新不了,浪費了計算資源
- 無法支持 access token
悲觀鎖實現(xiàn)
先自我審查自己能不能干,能不能干成,如果答案是no,那么就等著(阻塞)或先溜(返回),這就是悲觀心態(tài)。悲觀鎖在 access token 模式更加適用。
使用方式同樣很好理解(這只是基于數(shù)據(jù)庫的悲觀鎖的一種實現(xiàn)方式)
- 有一張 資源鎖 表,表中包含 鎖 字段,并需要加上唯一索引
- 當有線程想要獲取某個鎖時,只需要在 資源鎖 表中插入一條數(shù)據(jù)
- 如果插入成功,表示獲取鎖成功,插入失敗則表示鎖已經(jīng)被占用
- 業(yè)務執(zhí)行完釋放鎖,刪除對應的鎖記錄即可
// 1. 創(chuàng)建資源鎖表
CREATE TABLE `resource_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`lock_name` varchar(64) NOT NULL COMMENT '鎖名',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`lock_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
//2. 獲取鎖(插入成本表示獲取到鎖
INSERT INTO resource_lock (lock_name) VALUES ('lockName');
//3. 釋放鎖
delete from resource_lock where lock_name ='lockName';
乍一看好像很簡單,如果程序一直保證正確執(zhí)行,這種方式好像也行,但沒如果... 對于一個分布式系統(tǒng),服務宕機是會出現(xiàn)的,所以還需要考慮一些新的可能發(fā)生的問題
- 沒有失效機制:持有鎖的線程所在的服務宕機了,還沒來的及釋放鎖怎么辦? 可以通過在表中新增過期時間,寫一個定時任務定期刪除過期鎖
- 不可重入:需要在表中新增線程信息,重入的時候先查詢是否存在鎖
- 不支持鎖阻塞:需要編寫相應的邏輯
- 基于數(shù)據(jù)庫實現(xiàn),那么數(shù)據(jù)庫的可用性就需要得到保證,而且在并發(fā)大的時候,對于數(shù)據(jù)庫的性能的影響問題
這么一分析...為了確保悲觀鎖的功能完整性,實現(xiàn)也會越來越復雜...以至于既然要用存儲去實現(xiàn),為撒不直接用緩存,性能至少有保障。
基于Redis :AP架構(gòu)
既然想到存儲用緩存來做,那必然想到的第一個就是 Redis 了,Redis 也很給力,可以很好的支撐分布式鎖的能力,提供了比較好用的命令
-
setnx
: 當且僅當 key 不存在,將 key 的值設(shè)為 value ,并返回1;若給定的 key 已經(jīng)存在,則 SETNX 不做任何動作,并返回0。 -
expire
: 為key設(shè)置一個超時時間,單位為second,超過這個時間鎖會自動釋放,避免死鎖。
大致的流程如下圖
- client A 和 client B 同時執(zhí)行
setnx("lock",UUID)
嘗試獲取到鎖,Redis 的實現(xiàn)保證了只會有一個 client 成功,假如 client A 運氣好成功了 - client A 緊接著馬上設(shè)置一個過期時間
expire("lock",10)
- client A 繼續(xù)執(zhí)行業(yè)務邏輯
- 執(zhí)行完業(yè)務邏輯后釋放鎖
如果程序能夠正常走,好像也沒什么問題...但我們知道分布式架構(gòu)中,網(wǎng)絡(luò)是不可靠的,如果在設(shè)置過期時間前 client B 掛掉咯,那就 GG 了,因為沒有設(shè)置過期時間,那就成死鎖了.. 就像下面這樣
所以我們需要保證
setnx
和 expire
的原子性。在 Redis 2.6.12 之后增強了 setnx
命令,可以同時設(shè)置過期時間,從而保證原子性。解決了死鎖問題,再來看看過期時間的問題,我們?nèi)绾闻袛辔覀儜撛O(shè)置多長時間的過期時間?
- 設(shè)置短了,業(yè)務邏輯可能還沒執(zhí)行完,鎖被釋放了,被其它線程獲取執(zhí)行
- 設(shè)置長了,需要業(yè)務邏輯處理完了自己釋放鎖(同樣會存在線程掛掉的情況)
其實我們想要到達一種效果,如果能夠自動續(xù)期,鎖快要過期了,但是業(yè)務操作還沒有處理完,就自動對鎖進行續(xù)期。Java 中的 Redisson 客戶端就通過 watch dog 機制(守護線程)來支持這個功能。
通過 Redisson 客戶端獲取鎖時會創(chuàng)建一個守護線程,通過守護線程來定期 check 過期時間,如果業(yè)務邏輯還在運行,那么就會續(xù)時。如果程序宕機,那么守護線程也會一起掛掉,redis 中的鎖也將不會再次續(xù)時,最后過期。從而自動實現(xiàn)續(xù)期且不會出現(xiàn)死鎖的問題。
簡單回顧一下,我們解決了
- 獲取鎖和設(shè)置過期時間的原子性問題
- 過期時間自動續(xù)時的問題
在單機模式下看起來已經(jīng)沒什么問題了。而在生產(chǎn)環(huán)境下一般都會是集群模式,比如哨兵模式。得益于 Redis 的 AP 架構(gòu),選擇了可用性,使得其性能非常好,但也正是因為AP架構(gòu),可能會導致數(shù)據(jù)丟失的情況。
- client A 獲取鎖成功
- master 節(jié)點在同步鎖信息到 slave 節(jié)點時,master 宕機,信息沒有向 slave 節(jié)點同步成功
- slave 節(jié)點通過選舉成為 master 節(jié)點
- client B 再次獲取相同的鎖,發(fā)現(xiàn) slave 節(jié)點上并沒有其它線程占用,所以也獲取到了鎖
- client A 和 client B 獲取到了相同的鎖
當然,這個是非常極端的情況下會出現(xiàn)的問題;雖然 Redis 之父 Antirez 提出來了分布式鎖的一種 「健壯」 的實現(xiàn)算法 RedLock,但依舊還是會有新的問題,比如節(jié)點奔潰重啟、時鐘跳躍...
總的來看,基于 Redis 實現(xiàn)分布式鎖是很常用的,性能也比較高,滿足絕大部分業(yè)務場景,如果我們能夠接受非常極端情況下帶來的鎖丟失問題,Redis 分布式鎖是個很好的選擇。
基于Zookeeper :CP 架構(gòu)
Zookeeper 是一種提供「分布式服務協(xié)調(diào)」的中心化服務,是以 Paxos 算法為基礎(chǔ)實現(xiàn)的。Zookeeper 采用的是 CP 架構(gòu),選擇了強一致性,這也就意味著不會像 Redis 那樣出現(xiàn)數(shù)據(jù)丟失的情況(主從切換時),但為了實現(xiàn)強一致性,那么性能肯定是要比 Redis 差一些。
使用 Zookeeper 來實現(xiàn)分布式鎖是比較簡單的
- client 會在 Zookeeper 中創(chuàng)建一個臨時節(jié)點,比如`/zk/lock
- 如果獲取成功,那么 client 會創(chuàng)建一個 session 保持和 Zookeeper 臨時節(jié)點的關(guān)聯(lián)
- client 處理業(yè)務邏輯
- client 處理完業(yè)務邏輯后刪除 臨時節(jié)點,關(guān)閉 session
如果 client 宕機,那么 session 就會結(jié)束,臨時節(jié)點也會自動刪除,其它 client 就可以創(chuàng)建 lock
節(jié)點。
session 的維護是依賴于 client 的定時心跳來維護的,也就是說,如果 client 沒有及時的給 Zookeeper 發(fā)送心跳檢查,那么 Zookeeper 就會認為這個 session 已經(jīng)過期了,就會刪除調(diào)臨時節(jié)點。比如出現(xiàn)長時間的 GC 或者長時間的網(wǎng)絡(luò)延遲,都可能會導致臨時節(jié)點被刪除的可能。
對于 Zookeeper 來說,實現(xiàn)分布式鎖從使用者角度來看比較簡單,不需要考慮太多的東西,比如過期時間的設(shè)置。但維護成本會比較高,性能相對 Redis 也會差一些,以及可能會出現(xiàn)長時間失聯(lián)導致的節(jié)點數(shù)據(jù)丟失的問題。
總結(jié)
- 優(yōu)先使用基于數(shù)據(jù)庫的樂觀鎖
- 如果期望更高的性能且能夠接受極少數(shù)情況的鎖丟失,那么優(yōu)先選擇 Redis
- 如果期望盡可能的避免鎖丟失,優(yōu)先選擇 Zookeeper,且考慮 GC 時間和 心跳檢查的設(shè)置
- 在分布式系統(tǒng)中極端情況下,分布式鎖都不太可靠,所以需要我們在業(yè)務層面的入口也相應的隔離,在真的發(fā)生了鎖丟失導致的數(shù)據(jù)不一致的情況做對應的補償