面試官:如何保證分布式鎖的高可用和高性能?
現(xiàn)在基本上所有的后端都是分布式系統(tǒng),數(shù)據(jù)一致性是我們所有后端工程師必須面對(duì)的核心挑戰(zhàn)。而要保證一致性,鎖就是我們繞不開(kāi)的工具。從單體應(yīng)用中的 synchronized 或 ReentrantLock,到分布式環(huán)境下的分布式鎖,其本質(zhì)一脈相承,即確保在多線程或多節(jié)點(diǎn)環(huán)境下,某個(gè)關(guān)鍵資源在同一時(shí)刻只能被一個(gè)執(zhí)行單元訪問(wèn)。
分布式鎖和分布式事務(wù),堪稱分布式系統(tǒng)中的兩大“攔路虎”,理論深?yuàn)W,實(shí)踐中又極易出錯(cuò)。在面試中,分布式鎖更是高階崗位的必考題。然而,還是有很多同學(xué)對(duì)分布式鎖的理解僅僅停留在 SETNX 和“設(shè)置一個(gè)過(guò)期時(shí)間”的表層。當(dāng)被進(jìn)一步追問(wèn),比如“如何優(yōu)雅地等待鎖?”、“加鎖超時(shí)了怎么辦?”、“業(yè)務(wù)執(zhí)行時(shí)間超過(guò)了鎖的過(guò)期時(shí)間又該如何?”時(shí),就開(kāi)始不知道怎么回答了。
一個(gè)工業(yè)級(jí)的分布式鎖,需要考慮的遠(yuǎn)不止于此。今天,我們就以 Redis 為例,從零開(kāi)始,層層深入,一步步構(gòu)建一個(gè)真正高可用、高性能的分布式鎖方案,確保你在這個(gè)話題下?lián)碛凶銐虻纳疃取?/span>
1. 加鎖
首先要明確,并非只有 Redis 才能實(shí)現(xiàn)分布式鎖。廣義上講,任何支持“排他性操作”的中間件都可以。比如ZooKeeper、Nacos 甚至關(guān)系型數(shù)據(jù)庫(kù)(如利用 MySQL 的 SELECT ... FOR UPDATE 語(yǔ)法)。但鑒于 Redis 極高的性能、廣泛的應(yīng)用基礎(chǔ)以及其提供的豐富指令,它成為了實(shí)現(xiàn)分布式鎖的最主流選擇。
要用 Redis 實(shí)現(xiàn)鎖,我們首先要理解鎖的本質(zhì)——排他性。我們只需要在 Redis 中找到一種排他性的操作即可。SETNX(SET if Not eXists)命令完美契合了這個(gè)需求。此命令只在 key 不存在時(shí)才會(huì)設(shè)置成功,并返回1;如果 key 已存在,則設(shè)置失敗,返回0。因此,一個(gè)最簡(jiǎn)單的分布式鎖模型就誕生了:
- 加鎖:執(zhí)行
SETNX lock_key any_value。如果返回1,代表加鎖成功。 - 釋放鎖:執(zhí)行
DEL lock_key。
1
如上圖所示,線程1執(zhí)行 SETNX key1=value1 成功,獲得了鎖。隨后線程2執(zhí)行 SETNX key1=value2 失敗,進(jìn)入等待。這個(gè)模型雖然簡(jiǎn)單,但在實(shí)際應(yīng)用中卻有很多漏洞,根本無(wú)法投入生產(chǎn)。我們來(lái)逐一看看它暴露出的問(wèn)題。
1.1 等待與重試
當(dāng) SETNX 失敗時(shí),客戶端不應(yīng)立即返回失敗,而應(yīng)進(jìn)入等待”段。但如何等待,以及在等待過(guò)程中遇到網(wǎng)絡(luò)超時(shí),都大有講究。
1.1.1 如何等待鎖
當(dāng)加鎖失敗,我們有兩種主流的等待策略:
- 循環(huán)輪詢(Spin Lock)
這是最簡(jiǎn)單粗暴的方案。比如,加鎖失敗后,線程 sleep 100毫秒,然后再次嘗試 SETNX,直到加鎖成功或超出總的等待超時(shí)時(shí)間。這個(gè)“總的等待超時(shí)時(shí)間”該如何設(shè)定?這需要根據(jù)業(yè)務(wù)場(chǎng)景來(lái)定。我們應(yīng)該盡可能確保在等待時(shí)間內(nèi)能拿到鎖。因此,這個(gè)時(shí)間應(yīng)該約等于一個(gè)鎖的平均持有時(shí)間。例如,如果我們通過(guò)監(jiān)控統(tǒng)計(jì),發(fā)現(xiàn)99%的業(yè)務(wù)執(zhí)行時(shí)間都在800毫秒內(nèi),那么將總等待時(shí)間設(shè)為1秒就是個(gè)合理的選擇。
這種方式的優(yōu)點(diǎn)是實(shí)現(xiàn)簡(jiǎn)單,缺點(diǎn)是會(huì)帶來(lái)不必要的 sleep(可能鎖剛被釋放,但線程還在睡眠)和頻繁的 Redis 查詢(無(wú)效輪詢),但是實(shí)時(shí)性不好且空耗資源。
- 事件監(jiān)聽(tīng)
輪詢方式顯然不夠優(yōu)雅。更高級(jí)的方式是利用 Redis 的發(fā)布訂閱(Pub/Sub)機(jī)制,或者更高階的 Keyspace Notifications(鍵空間通知)。加鎖失敗的客戶端可以立即“訂閱”lock_key 的“刪除事件”(DEL 事件)。當(dāng)鎖被釋放(DEL)時(shí),Redis 會(huì)主動(dòng)通知所有訂閱者,它們?cè)僦匦聡L試加鎖(SETNX)。這種方式實(shí)時(shí)性更好,也更節(jié)省客戶端和服務(wù)端資源,但實(shí)現(xiàn)起來(lái)也相對(duì)復(fù)雜。
2
注意:上圖只是一個(gè)示意。在實(shí)際工程中,"檢測(cè)到鎖存在" 和 "發(fā)起訂閱" 必須是一個(gè)原子化或有嚴(yán)密邏輯先后的操作,否則可能在客戶端檢測(cè)到鎖存在(SETNX 失敗)和發(fā)起訂閱的這個(gè)微小間隙,鎖恰好被釋放了(DEL),導(dǎo)致客戶端錯(cuò)過(guò)通知,陷入永久等待。
1.1.2 如何重試加鎖?
分布式環(huán)境中,網(wǎng)絡(luò)抖動(dòng)是常態(tài)。如果一個(gè)客戶端發(fā)起 SETNX 命令后,收到了一個(gè)超時(shí)響應(yīng),這時(shí)它會(huì)陷入薛定諤的鎖狀態(tài):我到底加上鎖了嗎?
如果貿(mào)然重試,可能會(huì)覆蓋一個(gè)已經(jīng)加鎖成功的狀態(tài)。解決這個(gè)問(wèn)題的核心在于保證操作的冪等性。我們必須讓鎖的持有者能夠“識(shí)別”自己的鎖。具體做法是,在加鎖時(shí),value 不再是任意值,而是一個(gè)全局唯一的ID,例如 UUID 或是唯一的請(qǐng)求ID(Request ID)。
假設(shè)我們?yōu)?nbsp;lock:order:555 這把鎖生成了唯一值 uuid-client-A。當(dāng)重試時(shí),邏輯如下:
客戶端發(fā)起 SETNX lock:order:555 uuid-client-A,不幸超時(shí)。客戶端發(fā)起重試。此時(shí)它不能直接 SETNX,而是應(yīng)該先 GET lock:order:555。
- 情況A:GET 返回 nil(key不存在)。
這說(shuō)明上一次的 SETNX 命令沒(méi)有到達(dá) Redis 或執(zhí)行失敗。客戶端此時(shí)可以安全地再次執(zhí)行 SETNX lock:order:555 uuid-client-A。
3
- 情況B:GET 返回 uuid-client-A。
這說(shuō)明上一次的 SETNX 執(zhí)行成功了!只是響應(yīng)包在路上丟了。此時(shí)客戶端已經(jīng)持有了鎖,它需要做的只是重置一下這把鎖的過(guò)期時(shí)間(我們稍后會(huì)講到),然后心滿意足地返回“加鎖成功”。
4
- 情況C:GET 返回 uuid-client-B。
這說(shuō)明在客戶端A超時(shí)和重試的間隙,鎖被客戶端B拿走了(可能是A上次加鎖失敗,B加鎖成功)。此時(shí)客戶端A重試失敗,應(yīng)進(jìn)入上述的等待邏輯。
5
在分布式鎖的設(shè)計(jì)中,如果要支持 重試機(jī)制,關(guān)鍵是要能夠判斷上一次加鎖是否成功。因此,在加鎖時(shí),可以為每個(gè)鎖的值設(shè)置一個(gè)唯一標(biāo)識(shí)(例如一個(gè)隨機(jī)生成的 UUID),用于標(biāo)識(shí)鎖的持有者。
當(dāng)客戶端因超時(shí)等原因需要重試時(shí),可以根據(jù)以下邏輯進(jìn)行判斷:
- 查詢鎖是否存在:如果 Redis 中不存在該 key,說(shuō)明上次加鎖沒(méi)有成功,可以直接重新嘗試加鎖。
- 校驗(yàn)鎖的歸屬:如果 key 存在,并且其 value 與本次請(qǐng)求攜帶的 UUID 相同,說(shuō)明上次加鎖其實(shí)已經(jīng)成功了。此時(shí),為了防止鎖過(guò)期失效,可以重新設(shè)置鎖的過(guò)期時(shí)間。
- 鎖已被他人持有:如果 key 存在,但 value 與本次 UUID 不同,說(shuō)明鎖已經(jīng)被其他客戶端持有,本次加鎖應(yīng)視為失敗。
在實(shí)踐中,由于 Redis 性能極高,這種加鎖超時(shí)的情況很少見(jiàn),一般重試一兩次足矣。但是,如果我們從理論上分析,萬(wàn)一重試也一直超時(shí)呢?
其實(shí)也無(wú)需額外處理。如果之前加鎖已經(jīng)成功了(情況B),那么無(wú)非就是后續(xù)的重置過(guò)期時(shí)間失敗,等鎖到了過(guò)期時(shí)間,自然就失效了。如果之前沒(méi)加鎖成功(情況A或C),那就更沒(méi)關(guān)系了,別的線程需要時(shí)自然可以拿到鎖。這也就自然而然地引出了下一個(gè)關(guān)鍵概念:過(guò)期時(shí)間。
1.2 鎖過(guò)期與續(xù)約
我們剛才的簡(jiǎn)單模型(SETNX + DEL)還有一個(gè)致命缺陷:如果一個(gè)客戶端加鎖成功后,業(yè)務(wù)還沒(méi)執(zhí)行完就宕機(jī)了——比如機(jī)器斷電、進(jìn)程崩潰——它將永遠(yuǎn)無(wú)法執(zhí)行 DEL。這把鎖就成了“死鎖”,其他所有客戶端都將永遠(yuǎn)等待下去,導(dǎo)致整個(gè)業(yè)務(wù)線癱瘓。
6
1.2.1 為什么需要過(guò)期時(shí)間?
為了防止持有者宕機(jī)導(dǎo)致死鎖,我們必須引入一道保險(xiǎn)——鎖過(guò)期時(shí)間。我們必須在加鎖的同時(shí),給鎖設(shè)置一個(gè)過(guò)期時(shí)間(TTL)。這里要強(qiáng)調(diào)一下,加鎖(SETNX)和設(shè)置過(guò)期時(shí)間(EXPIRE)必須是一個(gè)原子操作。否則,如果在 SETNX 成功和 EXPIRE 之間客戶端宕機(jī),依然會(huì)產(chǎn)生死鎖。
這里我們Redis 提供了原子命令:
SET lock_key unique_value EX 30 NX這條命令(SET ... EX ... NX)等價(jià)于 SETNX + EXPIRE,完美解決了原子性問(wèn)題。這樣,即便持有鎖的客戶端宕機(jī),鎖也會(huì)在30秒后自動(dòng)釋放,其他客戶端得以繼續(xù)工作。
7
1.2.2 過(guò)期時(shí)間設(shè)多長(zhǎng)?
只要引入了過(guò)期時(shí)間,面試官幾乎必然會(huì)問(wèn):這個(gè)時(shí)間應(yīng)該設(shè)置成多長(zhǎng)?這又是一個(gè)經(jīng)典問(wèn)題。答案還是:根據(jù)業(yè)務(wù)場(chǎng)景。
你需要評(píng)估業(yè)務(wù)邏輯的執(zhí)行耗時(shí),比如取 99.9% 分位線(P999),然后再加一個(gè)合理的緩沖(Buffer)。例如,99.9% 的請(qǐng)求在5秒內(nèi)完成,你可以把過(guò)期時(shí)間設(shè)為10秒甚至20秒。
這里還有一個(gè)重要觀點(diǎn):過(guò)期時(shí)間的主要目的是為了防止宕機(jī)導(dǎo)致的死鎖。而在絕大多數(shù)(99.99%)情況下,鎖都應(yīng)該由客戶端在業(yè)務(wù)執(zhí)行完畢后主動(dòng)釋放(
DEL)。因此,把過(guò)期時(shí)間設(shè)得長(zhǎng)一些,比如30秒、1分鐘,通常是安全的,也是合理的。
其實(shí)很多公司的分布式鎖實(shí)現(xiàn),也就到這一步為止了。但我們還可以繼續(xù)深挖。
1.2.3 鎖續(xù)約機(jī)制
無(wú)論你把過(guò)期時(shí)間設(shè)為30秒還是1分鐘,總有可能遇到“天選之子”——某個(gè)請(qǐng)求因?yàn)镚C停頓、網(wǎng)絡(luò)延遲或極其復(fù)雜的計(jì)算,執(zhí)行時(shí)間真的超過(guò)了鎖的過(guò)期時(shí)間。這時(shí),會(huì)發(fā)生什么?
- 客戶端A持有鎖,業(yè)務(wù)正在執(zhí)行。
- 鎖到達(dá)1分鐘過(guò)期時(shí)間,被 Redis 自動(dòng)釋放。
- 另一個(gè)客戶端B立即通過(guò)
SETNX拿到了這把鎖,開(kāi)始執(zhí)行業(yè)務(wù)。 - 緊接著,客戶端A的業(yè)務(wù)終于執(zhí)行完畢,它會(huì)(錯(cuò)誤地)去釋放鎖(我們稍后會(huì)講如何避免)。
- 這就造成了數(shù)據(jù)混亂,兩個(gè)客戶端可能同時(shí)操作了資源。
8
為了解決這個(gè)問(wèn)題,我們需要引入鎖續(xù)約(Renewal)機(jī)制,也常被稱為“看門狗(Watchdog)”機(jī)制。原理是:客戶端在加鎖成功后,啟動(dòng)一個(gè)后臺(tái)線程(或定時(shí)任務(wù))。這個(gè)線程會(huì)周期性地檢查客戶端是否還持有鎖。如果還持有(即業(yè)務(wù)尚未執(zhí)行完),就去“續(xù)”一下鎖的過(guò)期時(shí)間,比如重新 EXPIRE lock_key 60。
舉個(gè)例子(按原文):我們?cè)O(shè)置鎖的過(guò)期時(shí)間是1分鐘。續(xù)約線程可以設(shè)置在50秒的時(shí)候啟動(dòng)檢查。
- 在第50秒,續(xù)約線程檢查到業(yè)務(wù)還在運(yùn)行,于是重置過(guò)期時(shí)間為1分鐘。
- 在第100秒(1分40秒),續(xù)約線程再次檢查,業(yè)務(wù)還在,再次重置為1分鐘。
- 重復(fù)上述檢查重置過(guò)程......
- 在第130秒(2分10秒),業(yè)務(wù)執(zhí)行完畢,客戶端主動(dòng)釋放鎖。
- 在第150秒(2分30秒),續(xù)約線程檢查到鎖已被釋放,退出。
理論上,只要確保在剩余過(guò)期時(shí)間內(nèi)能夠續(xù)約成功就可以了。比如這里預(yù)留了10秒(60秒-50秒)的窗口期,就算第一次續(xù)約失敗,也有足夠的時(shí)間進(jìn)行重試。
9
這樣,只要客戶端A沒(méi)有宕機(jī),它就可以在業(yè)務(wù)執(zhí)行期間內(nèi),一直持有這把鎖。
1.2.4 續(xù)約失敗策略
續(xù)約機(jī)制也不是萬(wàn)能的。如果續(xù)約線程在嘗試 EXPIRE 時(shí),因?yàn)榫W(wǎng)絡(luò)問(wèn)題或 Redis 故障而連續(xù)失敗,直到鎖過(guò)期了都沒(méi)成功,該怎么辦?此時(shí),鎖已經(jīng)(或即將)被其他客戶端拿到。原客戶端A的業(yè)務(wù)如果繼續(xù)執(zhí)行,將導(dǎo)致兩個(gè)客戶端同時(shí)處理業(yè)務(wù),破壞了排他性。
這時(shí)我們面臨兩種策略:
- 保守策略(推薦):續(xù)約失敗意味著鎖的歸屬權(quán)已經(jīng)丟失。業(yè)務(wù)邏輯必須立即中斷并回滾,向上層拋出異常。這是對(duì)數(shù)據(jù)一致性最嚴(yán)格的保障。
- 激進(jìn)策略:假設(shè)續(xù)約失敗是小概率事件,業(yè)務(wù)邏輯繼續(xù)執(zhí)行。這可能會(huì)導(dǎo)致數(shù)據(jù)不一致,但系統(tǒng)“可用性”更高。此策略慎用。
這里提到了中斷業(yè)務(wù),這又是一個(gè)可以在面試的時(shí)候跟面試官深聊的亮點(diǎn)。
1.2.5 業(yè)務(wù)中斷策略
在分布式鎖出了問(wèn)題(如續(xù)約失敗)時(shí),如何中斷業(yè)務(wù)?這其實(shí)是個(gè)很困難的事情。分布式鎖框架(如 Redisson)并不能直接幫你中斷業(yè)務(wù),它能做的,只是在續(xù)約失敗時(shí),給業(yè)務(wù)代碼發(fā)一個(gè)“中斷信號(hào)”(比如設(shè)置一個(gè) volatile 標(biāo)志位,或者調(diào)用線程的 interrupt() 方法)。是否中斷,以及如何中斷,完全取決于你的業(yè)務(wù)代碼是如何實(shí)現(xiàn)的。
- 如果你的業(yè)務(wù)是一個(gè)大循環(huán),那么你可以在每個(gè)循環(huán)開(kāi)始的時(shí)候,檢測(cè)一下中斷信號(hào):
// 偽代碼:在循環(huán)中檢測(cè)中斷信號(hào)
for (int i = 0; i < data.size(); i++) {
// 鎖框架在續(xù)約失敗時(shí),會(huì)設(shè)置一個(gè)中斷標(biāo)志
if (lock.isInterrupted()) {
// 中斷業(yè)務(wù),執(zhí)行回滾
break;
}
// 你的業(yè)務(wù)邏輯
DoSomething(data.get(i));
}- 如果你的業(yè)務(wù)沒(méi)有循環(huán),而是由多個(gè)步驟構(gòu)成,那么你可以在每一個(gè)關(guān)鍵步驟之后都檢測(cè)一下:
// 偽代碼:在關(guān)鍵步驟間檢測(cè)中斷信號(hào)
step1();
if (lock.isInterrupted()) {
// 中斷并返回
return;
}
step2();
if (lock.isInterrupted()) {
// 中斷并返回
return;
}
step3();最后可以總結(jié)拔高一下:這種中斷業(yè)務(wù)的難題,在微服務(wù)超時(shí)控制里也會(huì)遇到,目前業(yè)界也沒(méi)有銀彈,它強(qiáng)依賴于業(yè)務(wù)代碼的主動(dòng)配合與檢測(cè)。
2. 釋放鎖
我們前面提到,加鎖時(shí)要用唯一ID作為 value。這個(gè)ID在釋放鎖時(shí)同樣至關(guān)重要。正常來(lái)說(shuō),釋放鎖(DEL)不會(huì)有問(wèn)題。但在一些特殊場(chǎng)景下(比如Redis宕機(jī)恢復(fù),或者業(yè)務(wù)執(zhí)行時(shí)間超過(guò)了鎖過(guò)期時(shí)間),釋放鎖也可能出大問(wèn)題。還設(shè)有這樣一個(gè)場(chǎng)景:
- 客戶端A加鎖 key1成功(
value=vaule1),過(guò)期時(shí)間30秒。 - 客戶端A遭遇了長(zhǎng)時(shí)間的 Full GC,卡頓了35秒,鎖續(xù)約失效。
- 在第30秒時(shí),鎖自動(dòng)過(guò)期釋放。
- 在第31秒時(shí),客戶端B加鎖 key1成功(
value=value2)。 - 在第35秒時(shí),客戶端A從 GC 中蘇醒,它的業(yè)務(wù)邏輯執(zhí)行完畢,發(fā)起
DEL key1。 - 災(zāi)難發(fā)生:客戶端A“釋放”了客戶端B的鎖。
10
這個(gè)場(chǎng)景的根源在于,客戶端A釋放了“不屬于自己”的鎖。解決方案就是:釋放鎖時(shí),必須檢查鎖是不是自己的。
客戶端在釋放鎖時(shí),不能簡(jiǎn)單粗暴地 DEL。它必須執(zhí)行一個(gè)“復(fù)合操作”:“檢查 value 是否匹配,如果匹配才刪除”。這必須是一個(gè)原子操作,否則在 GET 和 DEL 之間鎖可能又過(guò)期了,產(chǎn)生新的競(jìng)態(tài)條件。
實(shí)現(xiàn)原子化“查刪”的最佳方式是使用 Lua 腳本,因?yàn)?Redis 執(zhí)行 Lua 腳本是原子的。
-- 釋放鎖的 Lua 腳本
-- KEYS[1] 是鎖的 key
-- ARGV[1] 是客戶端的唯一ID (比如 uuid-A)
if redis.call("get", KEYS[1]) == ARGV[1] then
-- 檢查鎖的 value 是不是自己的 ID,如果是,才刪除
return redis.call("del", KEYS[1])
else
-- 如果鎖不存在,或者鎖的 value 不是自己的 ID,則不刪除
return 0
end這個(gè)腳本會(huì)先 get 鎖的 value,判斷它是否等于客戶端傳入的唯一ID。如果是,才執(zhí)行 del。這完美解決了誤刪問(wèn)題。

3. 高階方案
3.1 Redlock
至此,我們的分布式鎖似乎已經(jīng)很健壯了。但它依然依賴一個(gè)單實(shí)例的 Redis。如果這個(gè) Redis 實(shí)例宕機(jī)了,整個(gè)分布式鎖服務(wù)就癱瘓了。
你可能會(huì)說(shuō):“用 Redis 主從(Master-Slave)復(fù)制和哨兵(Sentinel)來(lái)保證高可用啊!”,但這不行。Redis 的主從復(fù)制是異步的。這會(huì)導(dǎo)致一個(gè)致命缺陷:
- 客戶端A在 Master 節(jié)點(diǎn)加鎖成功。
- Master 還沒(méi)來(lái)得及把這個(gè)
key異步復(fù)制給 Slave,就宕機(jī)了。 - 哨兵(Sentinel)將 Slave 提升為新的 Master。
- 客戶端B在新 Master 上嘗試加鎖,由于新 Master 上根本沒(méi)有這個(gè)
key,B也加鎖成功了。 - 災(zāi)難發(fā)生:系統(tǒng)中有兩個(gè)客戶端同時(shí)持有了鎖,排他性被打破。
12
為了解決 Redis 單點(diǎn)和主從異步復(fù)制的缺陷,Redis 的作者 Antirez 提出了 Redlock(紅鎖)算法。
Redlock 的思想是“多數(shù)派原則”。它不再依賴單個(gè) Redis 實(shí)例,而是部署 N 個(gè)。當(dāng)大多數(shù)節(jié)點(diǎn)都告訴你加鎖成功的時(shí)候,就說(shuō)明加鎖成功了。比如你同時(shí)在 5 個(gè)節(jié)點(diǎn)上加鎖,那么大多數(shù)就意味著至少 3 個(gè)節(jié)點(diǎn)成功才算加鎖成功。
- 加鎖流程:
客戶端記錄當(dāng)前時(shí)間戳。
依次嘗試在所有5個(gè)實(shí)例上加鎖(使用我們前面完善的 SET ... EX ... NX 命令),并且為每個(gè)實(shí)例設(shè)置一個(gè)很短的連接和響應(yīng)超時(shí)(例如50毫秒),防止在某個(gè)宕機(jī)節(jié)點(diǎn)上浪費(fèi)太多時(shí)間。
統(tǒng)計(jì)加鎖成功的實(shí)例數(shù)量。如果超過(guò)半數(shù)(例如 5個(gè)中的3個(gè))成功,并且總耗時(shí)小于鎖的有效時(shí)間(過(guò)期時(shí)間 - 加鎖耗時(shí)),則認(rèn)為加鎖成功。
鎖的真正有效時(shí)間 = 初始設(shè)置的過(guò)期時(shí)間 - 加鎖總耗時(shí)。
13
- 釋放鎖流程:
客戶端必須向所有5個(gè)實(shí)例發(fā)起釋放鎖(前面提到的Lua腳本)的操作,無(wú)論加鎖時(shí)該實(shí)例是否成功。這樣做是為了清理可能存在的“僵尸鎖”。
Redlock 通過(guò)“多數(shù)派”機(jī)制,極大地提升了分布式鎖的可用性。即使有1-2個(gè) Redis 實(shí)例宕機(jī),鎖服務(wù)依然可用。
但它也并非銀彈。Redlock 的成本更高,實(shí)現(xiàn)更復(fù)雜,且加鎖的性能開(kāi)銷也更大(需要請(qǐng)求多次)。因此,在實(shí)際選型中,很多公司會(huì)評(píng)估后也會(huì)認(rèn)為這種方案過(guò)于復(fù)雜,單實(shí)例 Redis 帶來(lái)的風(fēng)險(xiǎn)(如宕機(jī)、主從切換)是可接受的,從而選擇繼續(xù)使用我們前面討論的、基于單實(shí)例的健壯方案。
3.2 鎖的性能優(yōu)化
分布式鎖雖然解決了問(wèn)題,但它本身是有開(kāi)銷的:每一次加鎖、續(xù)約、釋放鎖,都是一次(甚至多次)網(wǎng)絡(luò)IO。在高并發(fā)場(chǎng)景下,鎖的競(jìng)爭(zhēng)會(huì)成為性能瓶頸。
其實(shí)分布式鎖能做的優(yōu)化不多。一個(gè)思路是優(yōu)化 Redis本身的性能(比如啟用單獨(dú)的Redis集群,防止被其他業(yè)務(wù)影響),另一個(gè)思路就是減少分布式鎖的競(jìng)爭(zhēng)。
3.2.1 Singleflight 模式
在高并發(fā)下,可能一個(gè)服務(wù)實(shí)例內(nèi)的幾十個(gè)線程,和另外幾十個(gè)實(shí)例的幾百個(gè)線程,都在同一時(shí)刻競(jìng)爭(zhēng)同一把鎖。
我們可以借鑒 singleflight 模式(在Go中很常用,Guava中也有類似實(shí)現(xiàn)):針對(duì)同一個(gè) key 的加鎖請(qǐng)求,在單個(gè)實(shí)例內(nèi)部,只允許一個(gè)線程去 Redis 競(jìng)爭(zhēng)分布式鎖。其他線程則在本地等待這個(gè)代表的結(jié)果。
14
假設(shè)有2個(gè)實(shí)例,每個(gè)實(shí)例上各有10個(gè)線程要去獲得 key1 上的分布式鎖。
- 無(wú)優(yōu)化:總共有 2 * 10 = 20 個(gè)線程會(huì)涌向 Redis 競(jìng)爭(zhēng)鎖。
- Singleflight:實(shí)例A內(nèi)部先選出1個(gè)線程,實(shí)例B內(nèi)部也選出1個(gè)線程。最終只有2個(gè)線程去 Redis 競(jìng)爭(zhēng)分布式鎖。
競(jìng)爭(zhēng)壓力驟減,性能顯著提升。競(jìng)爭(zhēng)越激烈,這種方案的效果越好。如果沒(méi)什么并發(fā),那就基本沒(méi)什么效果。
3.2.2 本地鎖交接
這里還有一種更加激進(jìn)的優(yōu)化方案。當(dāng)實(shí)例A的線程T1拿到了分布式鎖并執(zhí)行完業(yè)務(wù)后,它在釋放鎖(DEL)之前,先檢查一下本地(即實(shí)例A的內(nèi)存中)是否還有其他線程(如T2、T3)正在等待這把鎖。
如果有(比如T2在等),T1可以直接在內(nèi)存中把“鎖憑證”(那個(gè)唯一的UUID)轉(zhuǎn)交給T2,并通知T2“你現(xiàn)在可以執(zhí)行了”。T1自己則不去 DEL 鎖,轉(zhuǎn)而由T2在未來(lái)去釋放。
這種本地接力完全省去了一次 DEL 和一次 SETNX 的網(wǎng)絡(luò)開(kāi)銷,在高競(jìng)爭(zhēng)下效果還是非常不錯(cuò)的。
15
雖然這種方案開(kāi)起來(lái)性能確實(shí)得到了極大的提升,但是實(shí)際生產(chǎn)環(huán)境中這種方式一般用的較少,主要有以下幾個(gè)點(diǎn):
- 復(fù)雜度高,容錯(cuò)性差,這種方案引入了本地狀態(tài)依賴(比如本機(jī)的等待隊(duì)列和鎖持有狀態(tài)),一旦實(shí)例 A 崩潰或重啟,這個(gè)“接力關(guān)系”就斷了,但在 Redis 看來(lái)鎖還沒(méi)釋放,造成鎖泄漏
- 失去了分布式鎖的原本意義,Redis 分布式鎖設(shè)計(jì)的初衷就是鎖的狀態(tài)由 Redis 統(tǒng)一仲裁,不依賴于任何單節(jié)點(diǎn)的本地狀態(tài)。但這種方法把部分鎖語(yǔ)義搬回了節(jié)點(diǎn)內(nèi)存。這意味著鎖的持有狀態(tài),不再是單一數(shù)據(jù)源,而是 Redis + 本地協(xié)作,這就導(dǎo)致一致性邊界模糊,違背集中鎖仲裁的設(shè)計(jì)哲學(xué)
3.2.3 分布式鎖替換
針對(duì)這種排他場(chǎng)景,還可以進(jìn)一步優(yōu)化。就是不用分布式鎖。分布式鎖是解決并發(fā)的其實(shí)性能消耗還是不小的,如果能換個(gè)思路,也許根本不需要它。嚴(yán)格來(lái)說(shuō),是原本這些場(chǎng)景就不該用分布式鎖。
- 數(shù)據(jù)庫(kù)樂(lè)觀鎖
很多場(chǎng)景使用分布式鎖,是為了保護(hù)一個(gè)“讀取數(shù)據(jù) -> 計(jì)算 -> 寫回?cái)?shù)據(jù)”的流程。比如扣減庫(kù)存:SETNX -> SELECT -> 業(yè)務(wù)計(jì)算 -> UPDATE -> DEL。這個(gè)流程完全可以用樂(lè)觀鎖替代。給庫(kù)存表加一個(gè) version 字段:
SELECT stock, version FROM inventory WHERE sku_id = 's101'- 在內(nèi)存中計(jì)算新庫(kù)存
new_stock = stock - 1 UPDATE inventory SET stock = new_stock, version = version + 1 WHERE sku_id = 's101' AND version = (第1步查到的version)
如果 UPDATE 的返回行數(shù)為0,說(shuō)明 version 已被他人修改(并發(fā)沖突)。此時(shí)客戶端只需從第1步開(kāi)始重試即可。全程無(wú)鎖,性能極高。缺點(diǎn)是可能會(huì)有多個(gè)線程在做重復(fù)計(jì)算,但只要最終更新數(shù)據(jù)庫(kù)時(shí)控制住了并發(fā),就沒(méi)關(guān)系。
- 一致性哈希負(fù)載均衡
分布式鎖的出現(xiàn),是因?yàn)橥粋€(gè)業(yè)務(wù)請(qǐng)求(如處理訂單 order_id=555)可能被負(fù)載均衡打到任何一個(gè)實(shí)例上。
如果我們能通過(guò)一致性哈希等手段,將特定ID的請(qǐng)求(如按 order_id 哈希)固定路由到同一個(gè)實(shí)例上,那么問(wèn)題就從“分布式”退化成了“單機(jī)”。我們只需要在那個(gè)實(shí)例內(nèi)部使用本地鎖(如 Java 的 ReentrantLock)或者用單機(jī) Singleflight 模式就可以,完全規(guī)避了重量級(jí)的分布式鎖。
16
4. 小節(jié)
從最初的 SETNX 到完善的續(xù)約、原子化釋放、再到 Redlock 以及各種性能優(yōu)化手段,我們可以看到,分布式鎖的核心始終圍繞兩個(gè)關(guān)鍵詞展開(kāi):可靠性與性能。可靠性確保鎖語(yǔ)義不被破壞,性能則決定方案能否真正落地。工程實(shí)踐中,鎖并非萬(wàn)能,很多場(chǎng)景完全可以用樂(lè)觀鎖或一致性哈希等思路替代,從根源上消除“鎖”的需求。掌握這些設(shè)計(jì)背后的取舍邏輯,遠(yuǎn)比死記實(shí)現(xiàn)細(xì)節(jié)更重要。分布式鎖不是終點(diǎn),而是理解分布式一致性與系統(tǒng)取舍的起點(diǎn)。



































