在單實例JVM中捡絮,常見的處理并發(fā)問題的方法有很多尸疆,比如synchronized關(guān)鍵字進行訪問控制、volatile關(guān)鍵字允粤、ReentrantLock等常用方法萍聊。但是在分布式環(huán)境中问芬,上述方法卻不能在跨JVM場景中用于處理并發(fā)問題,當業(yè)務(wù)場景需要對分布式環(huán)境中的并發(fā)問題進行處理時寿桨,需要使用分布式鎖來實現(xiàn)此衅。
分布式鎖,是指在分布式的部署環(huán)境下亭螟,通過鎖機制來讓多客戶端互斥的對共享資源進行訪問挡鞍。
目前比較常見的分布式鎖實現(xiàn)方案有以下幾種:
- 基于數(shù)據(jù)庫,如MySQL
- 基于緩存预烙,如Redis
- 基于Zookeeper墨微、etcd等。
在上一篇《基于數(shù)據(jù)庫實現(xiàn)分布式鎖》中介紹了如何基于數(shù)據(jù)庫實現(xiàn)分布式鎖扁掸,這里介紹一下如何使用緩存(Redis)實現(xiàn)分布式鎖翘县。
使用Redis實現(xiàn)分布式鎖最簡單的方案是使用命令SETNX。SETNX(SET if Not eXist)的使用方式為:SETNX key value谴分,只在鍵key不存在的情況下锈麸,將鍵key的值設(shè)置為value,若鍵key存在狸剃,則SETNX不做任何動作掐隐。SETNX在設(shè)置成功時返回,設(shè)置失敗時返回0钞馁。當要獲取鎖時虑省,直接使用SETNX獲取鎖,當要釋放鎖時僧凰,使用DEL命令刪除掉對應(yīng)的鍵key即可探颈。
上面這種方案有一個致命問題,就是某個線程在獲取鎖之后由于某些異常因素(比如宕機)而不能正常的執(zhí)行解鎖操作训措,那么這個鎖就永遠釋放不掉了伪节。為此光羞,我們可以為這個鎖加上一個超時時間。第一時間我們會聯(lián)想到Redis的EXPIRE命令(EXPIRE key seconds)怀大。但是這里我們不能使用EXPIRE來實現(xiàn)分布式鎖纱兑,因為它與SETNX一起是兩個操作,在這兩個操作之間可能會發(fā)生異常化借,從而還是達不到預(yù)期的結(jié)果潜慎,示例如下:
// STEP 1
SETNX key value
// 若在這里(STEP1和STEP2之間)程序突然崩潰,則無法設(shè)置過期時間蓖康,將有可能無法釋放鎖
// STEP 2
EXPIRE key expireTime
對此铐炫,正確的姿勢應(yīng)該是使用“SET key value [EX seconds] [PX milliseconds] [NX|XX]”這個命令。
從 Redis 2.6.12 版本開始蒜焊, SET 命令的行為可以通過一系列參數(shù)來修改:
- EX seconds : 將鍵的過期時間設(shè)置為 seconds 秒倒信。 執(zhí)行 SET key value EX seconds 的效果等同于執(zhí)行 SETEX key seconds value 。
- PX milliseconds : 將鍵的過期時間設(shè)置為 milliseconds 毫秒泳梆。 執(zhí)行 SET key value PX milliseconds 的效果等同于執(zhí)行 PSETEX key milliseconds value 鳖悠。
- NX : 只在鍵不存在時, 才對鍵進行設(shè)置操作鸭丛。 執(zhí)行 SET key value NX 的效果等同于執(zhí)行 SETNX key value 竞穷。
- XX : 只在鍵已經(jīng)存在時唐责, 才對鍵進行設(shè)置操作鳞溉。
舉例,我們需要創(chuàng)建一個分布式鎖鼠哥,并且設(shè)置過期時間為10s熟菲,那么可以執(zhí)行以下命令:
SET lockKey lockValue EX 10 NX
或者
SET lockKey lockValue PX 10000 NX
注意EX和PX不能同時使用,否則會報錯:ERR syntax error朴恳。
解鎖的時候還是使用DEL命令來解鎖抄罕。
修改之后的方案看上去很完美,但實際上還是會有問題于颖。試想一下呆贿,某線程A獲取了鎖并且設(shè)置了過期時間為10s,然后在執(zhí)行業(yè)務(wù)邏輯的時候耗費了15s森渐,此時線程A獲取的鎖早已被Redis的過期機制自動釋放了做入。在線程A獲取鎖并經(jīng)過10s之后,改鎖可能已經(jīng)被其它線程獲取到了同衣。當線程A執(zhí)行完業(yè)務(wù)邏輯準備解鎖(DEL key)的時候竟块,有可能刪除掉的是其它線程已經(jīng)獲取到的鎖。
所以最好的方式是在解鎖時判斷鎖是否是自己的耐齐。我們可以在設(shè)置key的時候?qū)alue設(shè)置為一個唯一值uniqueValue(可以是隨機值浪秘、UUID蒋情、或者機器號+線程號的組合、簽名等)耸携。當解鎖時棵癣,也就是刪除key的時候先判斷一下key對應(yīng)的value是否等于先前設(shè)置的值,如果相等才能刪除key夺衍,偽代碼示例如下:
if uniqueKey == GET(key) {
DEL key
}
這里我們一眼就可以看出問題來:GET和DEL是兩個分開的操作浙巫,在GET執(zhí)行之后且在DEL執(zhí)行之前的間隙是可能會發(fā)生異常的。如果我們只要保證解鎖的代碼是原子性的就能解決問題了刷后。這里我們引入了一種新的方式的畴,就是Lua腳本,示例如下:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
其中ARGV[1]表示設(shè)置key時指定的唯一值尝胆。
由于Lua腳本的原子性丧裁,在Redis執(zhí)行該腳本的過程中,其他客戶端的命令都需要等待該Lua腳本執(zhí)行完才能執(zhí)行含衔。
下面我們使用Jedis來演示一下獲取鎖和解鎖的實現(xiàn)煎娇,具體如下:
public boolean lock(String lockKey, String uniqueValue, int seconds){
SetParams params = new SetParams();
params.nx().ex(seconds);
String result = jedis.set(lockKey, uniqueValue, params);
if ("OK".equals(result)) {
return true;
}
return false;
}
public boolean unlock(String lockKey, String uniqueValue){
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(uniqueValue));
if (result.equals(1)) {
return true;
}
return false;
}
如此就萬無一失了嗎?顯然不是!
表面來看贪染,這個方法似乎很管用缓呛,但是這里存在一個問題:在我們的系統(tǒng)架構(gòu)里存在一個單點故障,如果Redis的master節(jié)點宕機了怎么辦呢杭隙?有人可能會說:加一個slave節(jié)點哟绊!在master宕機時用slave就行了!
但是其實這個方案明顯是不可行的痰憎,因為Redis的復(fù)制是異步的票髓。舉例來說:
- 線程A在master節(jié)點拿到了鎖。
- master節(jié)點在把A創(chuàng)建的key寫入slave之前宕機了铣耘。
- slave變成了master節(jié)點洽沟。
- 線程B也得到了和A還持有的相同的鎖。(因為原來的slave里面還沒有A持有鎖的信息)
當然蜗细,在某些場景下這個方案沒有什么問題裆操,比如業(yè)務(wù)模型允許同時持有鎖的情況,那么使用這種方案也未嘗不可炉媒。
舉例說明踪区,某個服務(wù)有2個服務(wù)實例:A和B,初始情況下A獲取了鎖然后對資源進行操作(可以假設(shè)這個操作很耗費資源)橱野,B沒有獲取到鎖而不執(zhí)行任何操作朽缴,此時B可以看做是A的熱備。當A出現(xiàn)異常時水援,B可以“轉(zhuǎn)正”密强。當鎖出現(xiàn)異常時茅郎,比如Redis master宕機,那么B可能會同時持有鎖并且對資源進行操作或渤,如果操作的結(jié)果是冪等的(或者其它情況)系冗,那么也可以使用這種方案。這里引入分布式鎖可以讓服務(wù)在正常情況下避免重復(fù)計算而造成資源的浪費薪鹦。
為了應(yīng)對這種情況掌敬,antriez提出了Redlock算法。Redlock算法的主要思想是:假設(shè)我們有N個Redis master節(jié)點池磁,這些節(jié)點都是完全獨立的奔害,我們可以運用前面的方案來對前面單個的Redis master節(jié)點來獲取鎖和解鎖,如果我們總體上能在合理的范圍內(nèi)或者N/2+1個鎖地熄,那么我們就可以認為成功獲得了鎖华临,反之則沒有獲取鎖(可類比Quorum模型)。雖然Redlock的原理很好理解端考,但是其內(nèi)部的實現(xiàn)細節(jié)很是復(fù)雜雅潭,要考慮很多因素,具體內(nèi)容可以參考:https://redis.io/topics/distlock却特。 有關(guān)Redlock的具體使用方式可以參考我之前轉(zhuǎn)載的兩篇文章《Redis分布式鎖最牛逼的實現(xiàn)》和《Redission實現(xiàn)Redis分布式鎖的N種姿勢》扶供。
Redlock算法也并非是“銀彈”,他除了條件有點苛刻外裂明,其算法本身也被質(zhì)疑椿浓。關(guān)于Redis分布式鎖的安全性問題,在分布式系統(tǒng)專家Martin Kleppmann和Redis的作者antirez之間就發(fā)生過一場爭論漾岳。這場爭論的內(nèi)容大致如下:
Martin Kleppmann發(fā)表了一篇blog轰绵,名字叫”How to do distributed locking “,地址為:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html尼荆。 Martin在這篇文章中談及了分布式系統(tǒng)的很多基礎(chǔ)性的問題(特別是分布式計算的異步模型),對分布式系統(tǒng)的從業(yè)者來說非常值得一讀唧垦。
Martin的那篇文章是在2016-02-08這一天發(fā)表的捅儒,但據(jù)Martin說,他在公開發(fā)表文章的一星期之前就把草稿發(fā)給了antirez進行review振亮,而且他們之間通過email進行了討論巧还。不知道Martin有沒有意料到,antirez對于此事的反應(yīng)很快坊秸,就在Martin的文章發(fā)表出來的第二天麸祷,antirez就在他的博客上貼出了他對于此事的反駁文章,名字叫”Is Redlock safe?”褒搔,地址為http://antirez.com/news/101阶牍。
這是高手之間的過招喷面。antirez這篇文章也條例非常清晰,并且中間涉及到大量的細節(jié)走孽。antirez認為惧辈,Martin的文章對于Redlock的批評可以概括為兩個方面(與Martin文章的前后兩部分對應(yīng)):
- 帶有自動過期功能的分布式鎖,必須提供某種fencing機制來保證對共享資源的真正的互斥保護磕瓷。Redlock提供不了這樣一種機制盒齿。
- Redlock構(gòu)建在一個不夠安全的系統(tǒng)模型之上。它對于系統(tǒng)的記時假設(shè)(timing assumption)有比較強的要求困食,而這些要求在現(xiàn)實的系統(tǒng)中是無法保證的边翁。
antirez對這兩方面分別進行了反駁。
首先硕盹,關(guān)于fencing機制倒彰。antirez對于Martin的這種論證方式提出了質(zhì)疑:既然在鎖失效的情況下已經(jīng)存在一種fencing機制能繼續(xù)保持資源的互斥訪問了,那為什么還要使用一個分布式鎖并且還要求它提供那么強的安全性保證呢莱睁?即使退一步講待讳,Redlock雖然提供不了Martin所講的遞增的fencing token,但利用Redlock產(chǎn)生的隨機字符串(my_random_value)可以達到同樣的效果仰剿。這個隨機字符串雖然不是遞增的创淡,但卻是唯一的,可以稱之為unique token南吮。
然后琳彩,antirez的反駁就集中在第二個方面上:關(guān)于算法在記時(timing)方面的模型假設(shè)。在我們前面分析Martin的文章時也提到過部凑,Martin認為Redlock會失效的情況主要有三種:1. 時鐘發(fā)生跳躍露乏;2. 長時間的GC pause;3. 長時間的網(wǎng)絡(luò)延遲涂邀。
antirez肯定意識到了這三種情況對Redlock最致命的其實是第一點:時鐘發(fā)生跳躍瘟仿。這種情況一旦發(fā)生,Redlock是沒法正常工作的比勉。而對于后兩種情況來說劳较,Redlock在當初設(shè)計的時候已經(jīng)考慮到了,對它們引起的后果有一定的免疫力浩聋。所以观蜗,antirez接下來集中精力來說明通過恰當?shù)倪\維,完全可以避免時鐘發(fā)生大的跳動衣洁,而Redlock對于時鐘的要求在現(xiàn)實系統(tǒng)中是完全可以滿足的墓捻。
神仙打架,我們站旁邊看看就好坊夫。拋開這個層面而言砖第,在理解Redlock算法時要理解“各個節(jié)點完全獨立”這個概念撤卢。Redis本身有幾種部署模式:單機模式、主從模式厂画、哨兵模式凸丸、集群模式。比如采用集群模式部署袱院,如果需要5個節(jié)點屎慢,那么就需要部署5個Redis Cluster集群。很顯然忽洛,這種要求每個master節(jié)點都獨立的Redlock算法條件有點苛刻腻惠,使用它所需要耗費的資源比較多,而且對每個節(jié)點都請求一次鎖所帶來的額外開銷也不可忽視欲虚。除非有實實在在的業(yè)務(wù)應(yīng)用需求集灌,或者有資源可以復(fù)用。
使用Redis分布式鎖并不能做到萬無一失复哆。一般而言欣喧,Redis分布式鎖的優(yōu)勢在于性能,而如果要考慮到可靠性梯找,那么Zookeeper唆阿、etcd這類的組件會比Redis要高。當然锈锤,在合適的環(huán)境下使用基于數(shù)據(jù)庫實現(xiàn)的分布式鎖會更合適驯鳖,參考《基于數(shù)據(jù)庫實現(xiàn)分布式鎖》。
不過就以可靠性而言久免,沒有任何組件是完全可靠的浅辙,程序員的價值不僅僅在于表象地如何靈活運用這些組件,而在于如何基于這些不可靠的組件構(gòu)建一個可靠的系統(tǒng)阎姥。
還是那句老話记舆,選擇何種方案,合適最重要丁寄。
參考資料:
- https://redis.io/topics/distlock
- http://www.reibang.com/p/7e47a4503b87
- http://ifeve.com/redis-lock/
- https://www.cnblogs.com/linjiqin/p/8003838.html
- http://zhangtielei.com/posts/blog-redlock-reasoning.html
- http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html
橫圖
歡迎支持筆者新作:《深入理解Kafka:核心設(shè)計與實踐原理》和《RabbitMQ實戰(zhàn)指南》氨淌,同時歡迎關(guān)注筆者的微信公眾號:朱小廝的博客。