背景
分布式鎖,是指在分布式的部署環(huán)境下涮总,通過鎖機(jī)制來讓多客戶端互斥的對(duì)共享資源進(jìn)行訪問胸囱。
分布式鎖要滿足哪些要求呢?
排他性:在同一時(shí)間只會(huì)有一個(gè)客戶端能獲取到鎖瀑梗,其它客戶端無法同時(shí)獲取
避免死鎖:這把鎖在一段有限的時(shí)間之后旺矾,一定會(huì)被釋放(正常釋放或異常釋放)
高可用:獲取或釋放鎖的機(jī)制必須高可用且性能佳
分布式鎖的實(shí)現(xiàn)方式
目前主流的有三種,從實(shí)現(xiàn)的復(fù)雜度上來看夺克,從上往下難度依次增加:
基于數(shù)據(jù)庫實(shí)現(xiàn)
基于Redis實(shí)現(xiàn)
基于ZooKeeper實(shí)現(xiàn)
無論哪種方式,其實(shí)都不完美嚎朽,依舊要根據(jù)咱們業(yè)務(wù)的實(shí)際場(chǎng)景來選擇铺纽。
為什么用分布式鎖?
在討論這個(gè)問題之前哟忍,我們先來看一個(gè)業(yè)務(wù)場(chǎng)景:
系統(tǒng)A是一個(gè)電商系統(tǒng)狡门,目前是一臺(tái)機(jī)器部署,系統(tǒng)中有一個(gè)用戶下訂單的接口锅很,該接口的流程是用戶下訂單之前一定要去檢查一下庫存其馏,確保庫存足夠了才會(huì)給用戶下單。
由于系統(tǒng)有一定的并發(fā)爆安,所以會(huì)預(yù)先將商品的庫存保存在redis中叛复,用戶下單的時(shí)候會(huì)更新redis的庫存。
此時(shí)系統(tǒng)架構(gòu)如下:
但是這樣一來會(huì)產(chǎn)生一個(gè)問題:假如某個(gè)時(shí)刻扔仓,redis里面的某個(gè)商品庫存為1褐奥,此時(shí)兩個(gè)請(qǐng)求同時(shí)到來,其中一個(gè)請(qǐng)求執(zhí)行到上圖的第3步翘簇,更新數(shù)據(jù)庫的庫存為0撬码,但是第4步還沒有執(zhí)行。
而另外一個(gè)請(qǐng)求執(zhí)行到了第2步版保,發(fā)現(xiàn)庫存還是1呜笑,就繼續(xù)執(zhí)行第3步夫否。
這樣的結(jié)果,是導(dǎo)致賣出了2個(gè)商品叫胁,然而其實(shí)庫存只有1個(gè)凰慈。
很明顯不對(duì)啊曹抬!這就是典型的問題溉瓶,產(chǎn)生這種問題實(shí)質(zhì)是2 3 4步驟不是原子操作
此時(shí),我們很容易想到解決方案:用鎖把2谤民、3堰酿、4步鎖住(其實(shí)是把2步驟鎖渍抛恪)触创,讓他們執(zhí)行完4步驟之后把鎖解開,另一個(gè)線程才能進(jìn)來執(zhí)行第2步为牍。這里又會(huì)涉及到另一個(gè)問題哼绑,數(shù)據(jù)庫和緩存一致性的問題,即3 4步驟碉咆,這里不做討論抖韩。
按照上面的圖,在執(zhí)行第2步時(shí)疫铜,使用Java提供的synchronized或者ReentrantLock來鎖住茂浮,然后在第4步執(zhí)行完之后才釋放鎖。
這樣一來壳咕,2席揽、3、4 這3個(gè)步驟就被“鎖”住了谓厘,多個(gè)線程之間只能串行化執(zhí)行幌羞。
但是好景不長(zhǎng),整個(gè)系統(tǒng)的并發(fā)飆升竟稳,一臺(tái)機(jī)器扛不住了∈翳耄現(xiàn)在要增加一臺(tái)機(jī)器,如下圖:
增加機(jī)器之后住练,系統(tǒng)變成上圖所示地啰,我的天!
假設(shè)此時(shí)兩個(gè)用戶的請(qǐng)求同時(shí)到來讲逛,但是落在了不同的機(jī)器上亏吝,那么這兩個(gè)請(qǐng)求是可以同時(shí)執(zhí)行了,還是會(huì)出現(xiàn)庫存超賣的問題盏混。
為什么呢蔚鸥?因?yàn)樯蠄D中的兩個(gè)A系統(tǒng)惜论,運(yùn)行在兩個(gè)不同的JVM里面,他們加的鎖只對(duì)屬于自己JVM里面的線程有效止喷,對(duì)于其他JVM的線程是無效的馆类。
因此,這里的問題是:Java提供的原生鎖機(jī)制在多機(jī)部署場(chǎng)景下失效了
這是因?yàn)閮膳_(tái)機(jī)器加的鎖不是同一個(gè)鎖(兩個(gè)鎖在不同的JVM里面)弹谁。
那么乾巧,我們只要保證兩臺(tái)機(jī)器加的鎖是同一個(gè)鎖,問題不就解決了嗎预愤?
此時(shí)沟于,就該分布式鎖隆重登場(chǎng)了,分布式鎖的思路是:
在整個(gè)系統(tǒng)提供一個(gè)全局植康、唯一的獲取鎖的“東西”旷太,然后每個(gè)系統(tǒng)在需要加鎖時(shí),都去問這個(gè)“東西”拿到一把鎖销睁,這樣不同的系統(tǒng)拿到的就可以認(rèn)為是同一把鎖供璧。
至于這個(gè)“東西”,可以是Redis冻记、Zookeeper睡毒,也可以是數(shù)據(jù)庫。
文字描述不太直觀冗栗,我們來看下圖:
通過上面的分析吕嘀,我們知道了庫存超賣場(chǎng)景在分布式部署系統(tǒng)的情況下使用Java原生的鎖機(jī)制無法保證線程安全,所以我們需要用到分布式鎖的方案贞瞒。
那么,如何實(shí)現(xiàn)分布式鎖呢趁曼?接著往下看军浆!
基于數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖(悲觀鎖)
拿換領(lǐng)碼換領(lǐng)優(yōu)惠券按理來說
- 查該碼狀態(tài)是否已經(jīng)被換取,如果未換取執(zhí)行下一步
- 換取優(yōu)惠券
- 更新該碼狀態(tài)
看似邏輯很正常挡闰,但如果用戶同一時(shí)間并發(fā)提交換領(lǐng)碼乒融,在沒有加鎖限制的情況下,用戶則可以使用同一個(gè)換領(lǐng)碼同時(shí)兌換到多張優(yōu)惠券摄悯。比如用戶A在執(zhí)行完第二步但還沒執(zhí)行第三步時(shí)赞季,用戶B也使用該碼進(jìn)行兌換,在第一步是查詢到的是該碼未被兌換奢驯。
所以在第一步的時(shí)候就要加鎖申钩。為代碼如下
start transaction;
//加排他鎖,有別的線程并發(fā)讀的時(shí)候瘪阁,此處就處于加鎖狀態(tài)(阻塞狀態(tài))
select state from coupon where number='11111143' for update;
....
換取優(yōu)惠券
....
//更新狀態(tài)碼狀態(tài)
update coupon set state=1 where number='1111143';
這樣在第一步查詢時(shí)就加鎖撒遣,這樣就可以防止同一碼被多次兌換邮偎。如果使用正常的select state from coupon where number='11111143',在mvcc中讀是不加鎖的。如果使用共享鎖select state from coupon where number='11111143' lock in share mode的話义黎,并發(fā)時(shí)其他線程也是可以獲得該共享鎖的禾进。所以這里只能用排他鎖。
總結(jié):如果第一步查詢時(shí)是使用redis查詢的話廉涕,就使用redis來實(shí)現(xiàn)分布式鎖泻云,如果是數(shù)據(jù)庫查詢時(shí)就可以使用數(shù)據(jù)庫鎖來實(shí)現(xiàn)。
基于Redis實(shí)現(xiàn)分布式鎖
上面分析為啥要使用分布式鎖了狐蜕,這里我們來具體看看分布式鎖落地的時(shí)候應(yīng)該怎么樣處理宠纯。
最常見的一種方案就是使用Redis做分布式鎖
使用Redis做分布式鎖的思路大概是這樣的:在redis中設(shè)置一個(gè)值表示加了鎖缅叠,然后釋放鎖的時(shí)候就把這個(gè)key刪除渡紫。
具體代碼是這樣的:
這種方式有幾大要點(diǎn):
-
一定要用使用SET key value NX PX milliseconds 命令(原子操作)
如果不用這種方式奕翔,采用先設(shè)置了值降盹,再設(shè)置過期時(shí)間护戳,這個(gè)不是原子性操作拴疤,有可能在設(shè)置過期時(shí)間之前宕機(jī)迈套,會(huì)造成死鎖(key永久存在)甲脏。
這種方式一定要設(shè)置過期時(shí)間治力,同樣也是避免死鎖蒙秒。如果沒設(shè)置過期時(shí)間的情況下,機(jī)器(不是redis服務(wù)器宵统,指api服務(wù)器)獲取鎖成功晕讲,但是刪除鎖時(shí)機(jī)器宕機(jī),這是造成死鎖马澈,其他線程將一直獲取不到該鎖瓢省。
-
value要具有唯一性
這個(gè)是為了在解鎖的時(shí)候,需要驗(yàn)證value是和加鎖的一致才刪除key痊班。
這是避免了一種情況:假設(shè)A獲取了鎖勤婚,過期時(shí)間30s,此時(shí)35s之后涤伐,鎖已經(jīng)自動(dòng)釋放了馒胆,A去釋放鎖,但是此時(shí)可能B獲取了鎖凝果。A客戶端就不能刪除B的鎖了祝迂。
如果真出現(xiàn)A超過過期時(shí)間還沒有執(zhí)行完的情況,這里也會(huì)出現(xiàn)一個(gè)很嚴(yán)重的問題器净,也就是線程安全問題型雳,同樣會(huì)出現(xiàn)上述線程超賣的情況。所以要使用這種方式的話,過期時(shí)間一定要合理控制四啰。
除了要考慮客戶端要怎么實(shí)現(xiàn)分布式鎖之外宁玫,還需要考慮redis的部署問題。
redis有3種部署方式:
單機(jī)模式
master-slave + sentinel選舉模式
redis cluster模式
使用redis做分布式鎖的缺點(diǎn)在于:如果采用單機(jī)部署模式柑晒,會(huì)存在單點(diǎn)問題欧瘪,只要redis故障了。加鎖就不行了匙赞。
采用master-slave模式佛掖,加鎖的時(shí)候只對(duì)一個(gè)節(jié)點(diǎn)加鎖,即便通過sentinel做了高可用涌庭,但是如果master節(jié)點(diǎn)故障了芥被,發(fā)生主從切換,此時(shí)就會(huì)有可能出現(xiàn)鎖丟失的問題坐榆。
如果采用redis cluster模式拴魄,同樣會(huì)有上述問題,加鎖會(huì)先對(duì)集群中的某個(gè)主節(jié)點(diǎn)加鎖席镀,然后異步復(fù)制到其備節(jié)點(diǎn)匹中。如果還沒復(fù)制到備節(jié)點(diǎn)時(shí),主節(jié)點(diǎn)宕機(jī)豪诲,這時(shí)備節(jié)點(diǎn)上選舉為主節(jié)點(diǎn)顶捷。這把鎖已經(jīng)丟失。
所以這種方式有以下倆個(gè)缺點(diǎn):
- 會(huì)有鎖丟失的問題
- set key value nx px 3000 如果3000毫秒之后業(yè)務(wù)邏輯還沒執(zhí)行完屎篱,key過期服赎,鎖釋放。其他線程獲取到鎖交播。這樣一來也會(huì)造成線程安全問題重虑。
php代碼實(shí)現(xiàn)如下:模擬下單間庫存,避免庫存超賣現(xiàn)象
public function lock(Request $request, Response $response)
{
$data = $this->validate($request, [
'shop_id' => 'required',
'num' => 'required'
]);
$lockState = false;
$lockValue = microtime(true);
while (!$lockState) {
//加鎖 set aaa 2324242 px 60000 nx
$lockState = Redis::set($data['shop_id'] . "lock", $lockValue, 'px', 60000, 'nx');
//避免重復(fù)無用循環(huán)秦士,浪費(fèi)cpu等資源
sleep(1)
}
//獲取庫存緩存,要預(yù)熱嚎尤,或者后臺(tái)添加商品時(shí)添加緩存
$inventory = Redis::get($data['shop_id'] . "sku");
if ($inventory <= 0) {
//庫存為0 已經(jīng)賣完
return $response->setStatusCode(401);
}
// 下單
//修改庫存,涉及到雙寫一致問題。
//本次雙寫使用的先更新數(shù)據(jù)庫在更新緩存的策略伍宦,這種策略通過需要加事務(wù)來避免數(shù)據(jù)不一致性。
//但是代碼上面已經(jīng)加上了分布式鎖乏梁,也就是整個(gè)過程其實(shí)只能有一個(gè)線程來執(zhí)行了次洼,不會(huì)出現(xiàn)上面提到的數(shù)據(jù)不一致性問題了,所以這里不加事務(wù)也是可以的遇骑。
try {
//加事務(wù)
DB::beginTransaction();
//修改數(shù)據(jù)庫庫存
$updateInventory = Shop::where('id', $data['shop_id'])->decrement('inventory', $data['num']);
if ($updateInventory) {
//庫存存入
Redis::decrby($data['shop_id'] . "sku", $data['num'])卖毁;
}
DB::commit();
//解鎖 用到了get和delete操作 使用lua腳本
Redis::eval(file_get_contents(storage_path("app/lock.lua")), 1, $data['shop_id'] . "lock", $lockValue);
} catch (\Exception $e) {
//解鎖 用到了get和delete操作 使用lua腳本
Redis::eval(file_get_contents(storage_path("app/lock.lua")), 1, $data['shop_id'] . "lock", $lockValue);
$this->addError('sku error', ['error' => $e->getMessage()]);
DB::rollBack();
}
}
lua腳本如下
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
要注意:獲取鎖是一個(gè)阻塞的過程,如果鎖被線程A占用,這時(shí)線程B來獲取時(shí)會(huì)一直處于阻塞狀態(tài)亥啦,直到線程A釋放鎖時(shí)線程B才會(huì)獲取鎖炭剪。
使用這種方法需要注意幾個(gè)問題:
1. 鎖必須要設(shè)置一個(gè)過期時(shí)間
。
否則的話翔脱,當(dāng)一個(gè)客戶端獲取鎖成功之后奴拦,假如它崩潰了,或者由于發(fā)生了網(wǎng)絡(luò)分割(network partition)導(dǎo)致它再也無法和Redis節(jié)點(diǎn)通信了届吁,那么它就會(huì)一直持有這個(gè)鎖错妖,而其它客戶端永遠(yuǎn)無法獲得鎖了。antirez在后面的分析中也特別強(qiáng)調(diào)了這一點(diǎn)疚沐,而且把這個(gè)過期時(shí)間稱為鎖的有效時(shí)間(lock validity time)暂氯。獲得鎖的客戶端必須在這個(gè)時(shí)間之內(nèi)完成對(duì)共享資源的訪問。
分布式鎖的過期時(shí)間的長(zhǎng)短非常重要亮蛔,設(shè)置太長(zhǎng)影響系統(tǒng)性能痴施,設(shè)置太短會(huì)導(dǎo)致鎖提前釋放,整體加鎖失敗究流。
那么如果在鎖馬上要釋放時(shí)辣吃,邏輯還沒有執(zhí)行完,對(duì)于這種情況該怎么應(yīng)對(duì)梯嗽?答案是續(xù)鎖
齿尽。
其實(shí)設(shè)置的加鎖時(shí)間是整體邏輯的最大耗時(shí)了,比如邏輯正常執(zhí)行完平均時(shí)間是3s灯节,那么我們一般會(huì)設(shè)置加鎖時(shí)間大于3s循头,設(shè)置為5s或者6s。
但是不排除特殊情況下導(dǎo)致整體邏輯耗時(shí)加長(zhǎng)炎疆,導(dǎo)致邏輯執(zhí)行時(shí)間大于設(shè)置的過期時(shí)間了卡骂。
此時(shí)看下續(xù)鎖
的操作是如何進(jìn)行的,假設(shè)鎖的過期時(shí)間設(shè)置為5s
,正常情況下邏輯執(zhí)行完為3s
形入。
2. 設(shè)置鎖和設(shè)置鎖的過期時(shí)間必須時(shí)原子性的全跨。
第一步獲取鎖的操作,網(wǎng)上不少文章把它實(shí)現(xiàn)成了兩個(gè)Redis命令:
SETNX resource_name my_random_value
EXPIRE resource_name 30
雖然這兩個(gè)命令和前面算法描述中的一個(gè)SET
命令執(zhí)行效果相同亿遂,但卻不是原子的浓若。如果客戶端在執(zhí)行完SETNX
后崩潰了,那么就沒有機(jī)會(huì)執(zhí)行EXPIRE
了蛇数,導(dǎo)致它一直持有這個(gè)鎖挪钓。
3. 設(shè)置鎖的值時(shí)的唯一性是很有必要的。
它保證了一個(gè)客戶端釋放的鎖必須是自己持有的那個(gè)鎖耳舅。假如獲取鎖時(shí)SET
的不是一個(gè)隨機(jī)字符串碌上,而是一個(gè)固定值,那么可能會(huì)發(fā)生下面的執(zhí)行序列:
客戶端1獲取鎖成功。
客戶端1在某個(gè)操作上阻塞了很長(zhǎng)時(shí)間馏予。
過期時(shí)間到了天梧,鎖自動(dòng)釋放了。
客戶端2獲取到了對(duì)應(yīng)同一個(gè)資源的鎖霞丧。
客戶端1從阻塞中恢復(fù)過來呢岗,釋放掉了客戶端2持有的鎖。
之后蚯妇,客戶端2在訪問共享資源的時(shí)候敷燎,就沒有鎖為它提供保護(hù)了。
4. 釋放鎖的操作必須使用Lua腳本來實(shí)現(xiàn)箩言。
釋放鎖其實(shí)包含三步操作:'GET'硬贯、判斷和'DEL',用Lua腳本來實(shí)現(xiàn)能保證這三步的原子性陨收。否則饭豹,如果把這三步操作放到客戶端邏輯中去執(zhí)行的話,就有可能發(fā)生與前面第三個(gè)問題類似的執(zhí)行序列:
- 客戶端1獲取鎖成功务漩。
客戶端1訪問共享資源拄衰。
客戶端1為了釋放鎖,先執(zhí)行'GET'操作獲取隨機(jī)字符串的值饵骨。
客戶端1判斷隨機(jī)字符串的值翘悉,與預(yù)期的值相等。
客戶端1由于某個(gè)原因阻塞住了很長(zhǎng)時(shí)間居触。
過期時(shí)間到了妖混,鎖自動(dòng)釋放了。
客戶端2獲取到了對(duì)應(yīng)同一個(gè)資源的鎖轮洋。
*客戶端1從阻塞中恢復(fù)過來制市,執(zhí)行DEL
操縱,釋放掉了客戶端2持有的鎖弊予。
實(shí)際上祥楣,在上述第三個(gè)問題和第四個(gè)問題的分析中,如果不是客戶端阻塞住了汉柒,而是出現(xiàn)了大的網(wǎng)絡(luò)延遲误褪,也有可能導(dǎo)致類似的執(zhí)行序列發(fā)生。
基于以上的考慮碾褂,其實(shí)redis的作者也考慮到這個(gè)問題兽间,他提出了一個(gè)RedLock算法
假設(shè)redis的部署模式是redis cluster,總共有5個(gè)master節(jié)點(diǎn)斋扰,通過以下步驟獲取一把鎖:
獲取當(dāng)前時(shí)間戳,單位是毫秒
它嘗試按順序獲取所有5個(gè)實(shí)例中的鎖,在所有實(shí)例中使用相同的鍵名和隨機(jī)值传货,并設(shè)置一個(gè)鎖的過期時(shí)間屎鳍。在步驟2期間,客戶端使用與鎖的過期時(shí)間相比較小的時(shí)間以獲取鎖问裕,這個(gè)時(shí)間叫做的鎖的獲取時(shí)間逮壁。例如,如果過期時(shí)間是10秒粮宛,則鎖的獲取時(shí)間可以在~5-50毫秒范圍內(nèi)(如果超時(shí)則獲取失敗窥淆,從而嘗試獲取下個(gè)鎖)。這可以防止客戶端長(zhǎng)時(shí)間保持阻塞巍杈,試圖與Redis節(jié)點(diǎn)進(jìn)行通信忧饭,如果實(shí)例不可用,我們應(yīng)該嘗試盡快與下一個(gè)實(shí)例通話筷畦。
客戶端通過從當(dāng)前時(shí)間中減去在步驟1中獲得的時(shí)間戳來計(jì)算鎖的獲取時(shí)間词裤。當(dāng)且僅當(dāng)客戶端能夠在大多數(shù)實(shí)例中獲取鎖時(shí)(至少3個(gè))并且獲取鎖時(shí)間小于鎖定鎖的過期時(shí)間,認(rèn)為鎖定被獲取鳖宾。
如果獲得了鎖吼砂,則其有效時(shí)間被認(rèn)為是鎖的過期時(shí)間減去獲取鎖時(shí)間,如步驟3中計(jì)算的鼎文。
如果客戶端由于某種原因(無法鎖定5 / 2 + 1實(shí)例或有效時(shí)間為負(fù))未能獲取鎖定渔肩,它將嘗試解鎖所有實(shí)例(即使它認(rèn)為不是能夠鎖定)。
只要?jiǎng)e人建立了一把分布式鎖拇惋,你就得不斷輪詢?nèi)L試獲取鎖
當(dāng)然周偎,上面描述的只是獲取鎖的過程,而釋放鎖的過程比較簡(jiǎn)單:客戶端向所有Redis節(jié)點(diǎn)發(fā)起釋放鎖的操作蚤假,不管這些節(jié)點(diǎn)當(dāng)時(shí)在獲取鎖的時(shí)候成功與否栏饮。
具體步驟可參考redis 官網(wǎng)
但是這樣的這種算法還是頗具爭(zhēng)議的,可能還會(huì)存在不少的問題磷仰,無法保證加鎖的過程一定正確袍嬉。
redLock鎖真的萬無一失嗎?下面是需要思考的幾個(gè)問題
未解決執(zhí)行時(shí)間大于過期時(shí)間問題
單節(jié)點(diǎn)時(shí)存在如果線程A的執(zhí)行時(shí)間或者發(fā)生阻塞以至于大于鎖的加鎖時(shí)間灶平,這時(shí)線程A還沒有執(zhí)行完伺通,由于鎖已經(jīng)自動(dòng)釋放,這時(shí)線程B獲得鎖逢享。同一資源被倆線程執(zhí)行罐监,出現(xiàn)安全問題。redLock同樣未解決該問題瞒爬,所以鎖的過期時(shí)間時(shí)一個(gè)很關(guān)鍵的問題弓柱。-
RedLock性能及崩潰恢復(fù)的相關(guān)解決方法
假設(shè)一共有5個(gè)Redis節(jié)點(diǎn):A, B, C, D, E沟堡。設(shè)想發(fā)生了如下的事件序列:客戶端1成功鎖住了A, B, C,獲取鎖成功(但D和E沒有鎖资缚铡)航罗。
節(jié)點(diǎn)C崩潰重啟了,但客戶端1在C上加的鎖沒有持久化下來屁药,丟失了粥血。
節(jié)點(diǎn)C重啟后,客戶端2鎖住了C, D, E酿箭,獲取鎖成功复亏。
這樣,客戶端1和客戶端2同時(shí)獲得了鎖(針對(duì)同一資源)缭嫡。在默認(rèn)情況下缔御,Redis的AOF持久化方式是每秒寫一次磁盤(即執(zhí)行fsync),因此最壞情況下可能丟失1秒的數(shù)據(jù)械巡。為了盡可能不丟數(shù)據(jù)刹淌,Redis允許設(shè)置成每次修改數(shù)據(jù)都進(jìn)行fsync,但這會(huì)降低性能讥耗。當(dāng)然有勾,即使執(zhí)行了fsync也仍然有可能丟失數(shù)據(jù)(這取決于系統(tǒng)而不是Redis的實(shí)現(xiàn))。所以古程,上面分析的由于節(jié)點(diǎn)重啟引發(fā)的鎖失效問題蔼卡,總是有可能出現(xiàn)的。為了應(yīng)對(duì)這一問題挣磨,antirez又提出了
延遲重啟
(delayed restarts)的概念雇逞。也就是說,一個(gè)節(jié)點(diǎn)崩潰后茁裙,先不立即重啟它塘砸,而是等待一段時(shí)間再重啟,這段時(shí)間應(yīng)該大于鎖的有效時(shí)間(lock validity time)晤锥。這樣的話掉蔬,這個(gè)節(jié)點(diǎn)在重啟前所參與的鎖都會(huì)過期,它在重啟后就不會(huì)對(duì)現(xiàn)有的鎖造成影響矾瘾。
-
基于故障轉(zhuǎn)移實(shí)現(xiàn)的redis主從無法真正實(shí)現(xiàn)Redlock
假設(shè)redis cluster同樣有5個(gè)master節(jié)點(diǎn)女轿,A, B, C, D, E。設(shè)想發(fā)生了如下的事件序列:
客戶端1成功鎖住了A, B, C壕翩,獲取鎖成功(但D和E沒有鎖昨燃!)。
節(jié)點(diǎn)C崩潰,并且崩潰前鎖未同步到slave節(jié)點(diǎn)鎖放妈,slave節(jié)點(diǎn)升級(jí)為master節(jié)點(diǎn)北救,這時(shí)該master節(jié)點(diǎn)并沒有加鎖荐操。
這時(shí)客戶端2鎖住了C, D, E,獲取鎖成功珍策。
這樣淀零,客戶端1和客戶端2同時(shí)獲得了鎖(針對(duì)同一資源),導(dǎo)致資源共享膛壹,沒有進(jìn)行互斥。這個(gè)失敗的原因是因?yàn)閺膔edis立刻升級(jí)為主redis唉堪,如果能夠過TTL時(shí)間再升級(jí)為主redis
(延遲升級(jí))
后模聋,或者立刻升級(jí)為主redis的節(jié)點(diǎn)只有過TTL的時(shí)間后再執(zhí)行獲取鎖的任務(wù),就能成功產(chǎn)生互斥效果唠亚;是不是這樣就能實(shí)現(xiàn)基于redis主從的Redlock了
RedLock算法是否是異步算法链方?
可以看成是同步算法;因?yàn)?即使進(jìn)程間(多個(gè)電腦間)沒有同步時(shí)鐘灶搜,但是每個(gè)進(jìn)程時(shí)間流速大致相同祟蚀;并且時(shí)鐘漂移相對(duì)于TTL叫小,可以忽略割卖,所以可以看成同步算法前酿;(不夠嚴(yán)謹(jǐn),算法上要算上時(shí)鐘漂移鹏溯,因?yàn)槿绻麅蓚€(gè)電腦在地球兩端罢维,則時(shí)鐘漂移非常大)
RedLock失敗重試
當(dāng)client不能獲取鎖時(shí),應(yīng)該在隨機(jī)時(shí)間后重試獲取鎖丙挽;并且最好在同一時(shí)刻并發(fā)的把set命令發(fā)送給所有redis實(shí)例肺孵;而且對(duì)于已經(jīng)獲取鎖的client在完成任務(wù)后要及時(shí)釋放鎖,這是為了節(jié)省時(shí)間颜阐;
RedLock釋放鎖
由于釋放鎖時(shí)會(huì)判斷這個(gè)鎖的value是不是自己設(shè)置的平窘,如果是才刪除;所以在釋放鎖時(shí)非常簡(jiǎn)單凳怨,只要向所有實(shí)例都發(fā)出釋放鎖的命令瑰艘,不用考慮能否成功釋放鎖;
RedLock注意點(diǎn)(Safety arguments):
1.先假設(shè)client獲取所有實(shí)例猿棉,所有實(shí)例包含相同的key和過期時(shí)間(TTL) ,但每個(gè)實(shí)例set命令時(shí)間不同導(dǎo)致不能同時(shí)過期磅叛,第一個(gè)set命令之前是T1,最后一個(gè)set命令后為T2,則此client有效獲取鎖的最小時(shí)間為TTL-(T2-T1)-時(shí)鐘漂移;
2.對(duì)于以N/2+ 1(也就是一半以 上)的方式判斷獲取鎖成功,是因?yàn)槿绻∮谝话肱袛酁槌晒Φ脑捜蓿锌赡艹霈F(xiàn)多個(gè)client都成功獲取鎖的情況弊琴, 從而使鎖失效
3.一個(gè)client鎖定大多數(shù)事例耗費(fèi)的時(shí)間大于或接近鎖的過期時(shí)間,就認(rèn)為鎖無效杖爽,并且解鎖這個(gè)redis實(shí)例(不執(zhí)行業(yè)務(wù)) ;只要在TTL時(shí)間內(nèi)成功獲取一半以上的鎖便是有效鎖;否則無效
總結(jié)
1.TTL時(shí)長(zhǎng) 要大于正常業(yè)務(wù)執(zhí)行的時(shí)間+獲取所有redis服務(wù)消耗時(shí)間+時(shí)鐘漂移
2.獲取redis所有服務(wù)消耗時(shí)間要 遠(yuǎn)小于TTL時(shí)間敲董,并且獲取成功的鎖個(gè)數(shù)要 在總數(shù)的一般以上:N/2+1
3.嘗試獲取每個(gè)redis實(shí)例鎖時(shí)的時(shí)間要 遠(yuǎn)小于TTL時(shí)間
4.嘗試獲取所有鎖失敗后 重新嘗試一定要有一定次數(shù)限制
5.在redis崩潰后(無論一個(gè)還是所有)紫皇,要延遲TTL時(shí)間重啟redis
6.在實(shí)現(xiàn)多redis節(jié)點(diǎn)時(shí)要結(jié)合單節(jié)點(diǎn)分布式鎖算法 共同實(shí)現(xiàn)
最后,Martin得出了如下的結(jié)論:
如果是為了效率(efficiency)而使用分布式鎖腋寨,允許鎖的偶爾失效聪铺,那么使用單Redis節(jié)點(diǎn)的鎖方案就足夠了,簡(jiǎn)單而且效率高萄窜。Redlock則是個(gè)過重的實(shí)現(xiàn)(heavyweight)铃剔。
如果是為了正確性(correctness)在很嚴(yán)肅的場(chǎng)合使用分布式鎖,那么不要使用Redlock查刻。它不是建立在異步模型上的一個(gè)足夠強(qiáng)的算法键兜,它對(duì)于系統(tǒng)模型的假設(shè)中包含很多危險(xiǎn)的成分(對(duì)于timing)。而且穗泵,它沒有一個(gè)機(jī)制能夠提供fencing token普气。那應(yīng)該使用什么技術(shù)呢?Martin認(rèn)為佃延,應(yīng)該考慮類似Zookeeper的方案现诀,或者支持事務(wù)的數(shù)據(jù)庫。
另一種方式:Redisson
此外履肃,實(shí)現(xiàn)Redis的分布式鎖仔沿,除了自己基于redis client原生api來實(shí)現(xiàn)之外,還可以使用開源框架:Redission
Redisson是一個(gè)企業(yè)級(jí)的開源Redis Client尺棋,也提供了分布式鎖的支持于未。我也非常推薦大家使用,為什么呢陡鹃?
回想一下上面說的烘浦,如果自己寫代碼來通過redis設(shè)置一個(gè)值,是通過下面這個(gè)命令設(shè)置的萍鲸。
- SET anyLock unique_value NX PX 30000
這里設(shè)置的超時(shí)時(shí)間是30s闷叉,假如我超過30s都還沒有完成業(yè)務(wù)邏輯的情況下,key會(huì)過期脊阴,其他線程有可能會(huì)獲取到鎖握侧。
這樣一來的話,第一個(gè)線程還沒執(zhí)行完業(yè)務(wù)邏輯嘿期,第二個(gè)線程進(jìn)來了也會(huì)出現(xiàn)線程安全問題品擎。所以我們還需要額外的去維護(hù)這個(gè)過期時(shí)間,太麻煩了~
我們來看看redisson是怎么實(shí)現(xiàn)的备徐?先感受一下使用redission的爽:
Config config = new Config();
config . useClusterServers()
. addNodeAddress("redis://192.168.31.101:7001")
. addNodeAddress("redis://192.168.31.101:7002")
. addNodeAddress("redis://192.168.31.101:7003")
. addNodeAddress("redis://192.168.31.101:7004")
. addNodeAddress("redis://192.168.31.101:7005")
. addNodeAddress("redis://192.168.31.101:7006")
RedissonClient redisson = Redisson.create(config)
RLock lock=redisson.getLock("anyLock");
lock.lock();
lock.unlock();
就是這么簡(jiǎn)單萄传,我們只需要通過它的api中的lock和unlock即可完成分布式鎖,他幫我們考慮了很多細(xì)節(jié):
redisson所有指令都通過lua腳本執(zhí)行蜜猾,redis支持lua腳本原子性執(zhí)行
-
redisson設(shè)置一個(gè)key的默認(rèn)過期時(shí)間為30s,如果某個(gè)客戶端持有一個(gè)鎖超過了30s怎么辦秀菱?
redisson中有一個(gè)
watchdog
的概念振诬,翻譯過來就是看門狗,它會(huì)在你獲取鎖之后衍菱,每隔10秒幫你把key的超時(shí)時(shí)間重新延后30s這樣的話赶么,就不會(huì)出現(xiàn)業(yè)務(wù)沒執(zhí)行完而key過期了,其他線程獲取到鎖的問題了脊串。
-
redisson的“看門狗”邏輯保證了沒有死鎖發(fā)生辫呻。
(如果機(jī)器宕機(jī)了,看門狗也就沒了琼锋。此時(shí)就不會(huì)延長(zhǎng)key的過期時(shí)間印屁,到了30s之后就會(huì)自動(dòng)過期了,其他線程可以獲取到鎖)
這里稍微貼出來其實(shí)現(xiàn)代碼:
這里的代碼請(qǐng)看下面參考連接中的
另外斩例,redisson還提供了對(duì)redlock算法的支持,
它的用法也很簡(jiǎn)單:
這里的代碼請(qǐng)看下面參考連接中的
小結(jié):
本節(jié)分析了使用redis作為分布式鎖的具體落地方案
以及其一些局限性
然后介紹了一個(gè)redis的客戶端框架redisson,
這也是我推薦大家使用的从橘,
比自己寫代碼實(shí)現(xiàn)會(huì)少care很多細(xì)節(jié)念赶。
基于zookeeper實(shí)現(xiàn)分布式鎖
常見的分布式鎖實(shí)現(xiàn)方案里面,除了使用redis來實(shí)現(xiàn)之外恰力,使用zookeeper也可以實(shí)現(xiàn)分布式鎖叉谜。
在介紹zookeeper(下文用zk代替)實(shí)現(xiàn)分布式鎖的機(jī)制之前,先粗略介紹一下zk是什么東西:
Zookeeper是一種提供配置管理踩萎、分布式協(xié)同以及命名的中心化服務(wù)停局。
zk的模型是這樣的:zk包含一系列的節(jié)點(diǎn),叫做znode香府,就好像文件系統(tǒng)一樣每個(gè)znode表示一個(gè)目錄董栽,然后znode有一些特性:
-
有序節(jié)點(diǎn):假如當(dāng)前有一個(gè)父節(jié)點(diǎn)為
/lock
,我們可以在這個(gè)父節(jié)點(diǎn)下面創(chuàng)建子節(jié)點(diǎn)企孩;zookeeper提供了一個(gè)可選的有序特性锭碳,例如我們可以創(chuàng)建子節(jié)點(diǎn)“/lock/node-”并且指明有序,那么zookeeper在生成子節(jié)點(diǎn)時(shí)會(huì)根據(jù)當(dāng)前的子節(jié)點(diǎn)數(shù)量自動(dòng)添加整數(shù)序號(hào)
也就是說勿璃,如果是第一個(gè)創(chuàng)建的子節(jié)點(diǎn)擒抛,那么生成的子節(jié)點(diǎn)為
/lock/node-0000000000
,下一個(gè)節(jié)點(diǎn)則為/lock/node-0000000001
补疑,依次類推歧沪。 臨時(shí)節(jié)點(diǎn):客戶端可以建立一個(gè)臨時(shí)節(jié)點(diǎn),在會(huì)話結(jié)束或者會(huì)話超時(shí)后莲组,zookeeper會(huì)自動(dòng)刪除該節(jié)點(diǎn)诊胞。
事件監(jiān)聽:在讀取數(shù)據(jù)時(shí),我們可以同時(shí)對(duì)節(jié)點(diǎn)設(shè)置事件監(jiān)聽锹杈,當(dāng)節(jié)點(diǎn)數(shù)據(jù)或結(jié)構(gòu)變化時(shí)厢钧,zookeeper會(huì)通知客戶端鳞尔。當(dāng)前zookeeper有如下四種事件:
節(jié)點(diǎn)創(chuàng)建
節(jié)點(diǎn)刪除
節(jié)點(diǎn)數(shù)據(jù)修改
子節(jié)點(diǎn)變更
基于以上的一些zk的特性,我們很容易得出使用zk實(shí)現(xiàn)分布式鎖的落地方案:
使用zk的臨時(shí)節(jié)點(diǎn)和有序節(jié)點(diǎn)早直,每個(gè)線程獲取鎖就是在zk創(chuàng)建一個(gè)臨時(shí)有序的節(jié)點(diǎn)寥假,比如在/lock/目錄下。
創(chuàng)建節(jié)點(diǎn)成功后霞扬,獲取/lock目錄下的所有臨時(shí)節(jié)點(diǎn)糕韧,再判斷當(dāng)前線程創(chuàng)建的節(jié)點(diǎn)是否是所有的節(jié)點(diǎn)的序號(hào)最小的節(jié)點(diǎn)
如果當(dāng)前線程創(chuàng)建的節(jié)點(diǎn)是所有節(jié)點(diǎn)序號(hào)最小的節(jié)點(diǎn),則認(rèn)為獲取鎖成功喻圃。
-
如果當(dāng)前線程創(chuàng)建的節(jié)點(diǎn)不是所有節(jié)點(diǎn)序號(hào)最小的節(jié)點(diǎn)萤彩,則對(duì)節(jié)點(diǎn)序號(hào)的前一個(gè)節(jié)點(diǎn)添加一個(gè)事件監(jiān)聽。
比如當(dāng)前線程獲取到的節(jié)點(diǎn)序號(hào)為
/lock/003
,然后所有的節(jié)點(diǎn)列表為[/lock/001,/lock/002,/lock/003]
,則對(duì)/lock/002
這個(gè)節(jié)點(diǎn)添加一個(gè)事件監(jiān)聽器斧拍。
如果鎖釋放了雀扶,會(huì)喚醒下一個(gè)序號(hào)的節(jié)點(diǎn),然后重新執(zhí)行第3步肆汹,判斷是否自己的節(jié)點(diǎn)序號(hào)是最小愚墓。
比如/lock/001
釋放了,/lock/002
監(jiān)聽到時(shí)間昂勉,此時(shí)節(jié)點(diǎn)集合為[/lock/002,/lock/003]
,則/lock/002
為最小序號(hào)節(jié)點(diǎn)浪册,獲取到鎖。
整個(gè)過程如下:
具體的實(shí)現(xiàn)思路就是這樣岗照,至于代碼怎么寫,這里比較復(fù)雜就不貼出來了攒至。
Curator介紹
Curator是一個(gè)zookeeper的開源客戶端厚者,也提供了分布式鎖的實(shí)現(xiàn)。
他的使用方式也比較簡(jiǎn)單:
InterProcessMutex interProcessMutex = new InterProcessMutex(client,"/anyLock");`
interProcessMutex.acquire();`
interProcessMutex.release();`
其實(shí)現(xiàn)分布式鎖的核心源碼如下:
這里的代碼請(qǐng)看下下面的連接
其實(shí)curator實(shí)現(xiàn)分布式鎖的底層原理和上面分析的是差不多的迫吐。這里我們用一張圖詳細(xì)描述其原理:
小結(jié):
本節(jié)介紹了zookeeperr實(shí)現(xiàn)分布式鎖的方案以及zk的開源客戶端的基本使用籍救,簡(jiǎn)要的介紹了其實(shí)現(xiàn)原理。
兩種方案的優(yōu)缺點(diǎn)比較
學(xué)完了兩種分布式鎖的實(shí)現(xiàn)方案之后渠抹,本節(jié)需要討論的是redis和zk的實(shí)現(xiàn)方案中各自的優(yōu)缺點(diǎn)蝙昙。
對(duì)于redis的分布式鎖而言,它有以下缺點(diǎn):
它獲取鎖的方式簡(jiǎn)單粗暴梧却,獲取不到鎖直接不斷嘗試獲取鎖奇颠,比較消耗性能。
另外來說的話放航,redis的設(shè)計(jì)定位決定了它的數(shù)據(jù)并不是強(qiáng)一致性的烈拒,在某些極端情況下,可能會(huì)出現(xiàn)問題。鎖的模型不夠健壯
即便使用redlock算法來實(shí)現(xiàn)荆几,在某些復(fù)雜場(chǎng)景下吓妆,也無法保證其實(shí)現(xiàn)100%沒有問題,關(guān)于redlock的討論可以看How to do distributed locking
redis分布式鎖吨铸,其實(shí)需要自己不斷去嘗試獲取鎖行拢,比較消耗性能。
但是另一方面使用redis實(shí)現(xiàn)分布式鎖在很多企業(yè)中非常常見诞吱,而且大部分情況下都不會(huì)遇到所謂的“極端復(fù)雜場(chǎng)景”
所以使用redis作為分布式鎖也不失為一種好的方案舟奠,最重要的一點(diǎn)是redis的性能很高,可以支撐高并發(fā)的獲取房维、釋放鎖操作沼瘫。
對(duì)于zk分布式鎖而言:
zookeeper天生設(shè)計(jì)定位就是分布式協(xié)調(diào),強(qiáng)一致性咙俩。鎖的模型健壯耿戚、簡(jiǎn)單易用、適合做分布式鎖阿趁。
如果獲取不到鎖膜蛔,只需要添加一個(gè)監(jiān)聽器就可以了,不用一直輪詢歌焦,性能消耗較小。
但是zk也有其缺點(diǎn):如果有較多的客戶端頻繁的申請(qǐng)加鎖砚哆、釋放鎖独撇,對(duì)于zk集群的壓力會(huì)比較大。
小結(jié):
綜上所述躁锁,redis和zookeeper都有其優(yōu)缺點(diǎn)纷铣。我們?cè)谧黾夹g(shù)選型的時(shí)候可以根據(jù)這些問題作為參考因素。
作者的一些建議
通過前面的分析战转,實(shí)現(xiàn)分布式鎖的兩種常見方案:redis和zookeeper搜立,他們各有千秋。應(yīng)該如何選型呢槐秧?
就個(gè)人而言的話啄踊,我比較推崇zk實(shí)現(xiàn)的鎖:
因?yàn)閞edis是有可能存在隱患的,可能會(huì)導(dǎo)致數(shù)據(jù)不對(duì)的情況刁标。但是颠通,怎么選用要看具體在公司的場(chǎng)景了。
如果公司里面有zk集群條件膀懈,優(yōu)先選用zk實(shí)現(xiàn)顿锰,但是如果說公司里面只有redis集群,沒有條件搭建zk集群。
那么其實(shí)用redis來實(shí)現(xiàn)也可以硼控,另外還可能是系統(tǒng)設(shè)計(jì)者考慮到了系統(tǒng)已經(jīng)有redis刘陶,但是又不希望再次引入一些外部依賴的情況下,可以選用redis牢撼。
這個(gè)是要系統(tǒng)設(shè)計(jì)者基于架構(gòu)的考慮了
思考
加鎖是為了資源不能同時(shí)被共享匙隔,那么隊(duì)列是單個(gè)消費(fèi)的,能保證資源不被共享浪默。 那么能使用隊(duì)列代替嗎牡直?