分布式鎖實(shí)現(xiàn)匯總-詳述基于Redis實(shí)現(xiàn)的那些細(xì)節(jié)
為了保證同一時(shí)間只有一個(gè)線程訪問(wèn)某一代碼塊,Java中可以使用synchronized語(yǔ)法和ReentrantLock等本地鎖的方式。但是在分布式環(huán)境下,需要使用分布式鎖來(lái)保證不同節(jié)點(diǎn)的線程同步執(zhí)行。
常用的分布式鎖實(shí)現(xiàn)包括以下幾種:
- 基于數(shù)據(jù)庫(kù)的分布式鎖:使用數(shù)據(jù)庫(kù)的事務(wù)和行級(jí)鎖來(lái)實(shí)現(xiàn)分布式鎖,通過(guò)在數(shù)據(jù)庫(kù)中創(chuàng)建一張鎖表來(lái)記錄鎖的狀態(tài)。
- 基于Redis的分布式鎖:利用Redis的原子操作和過(guò)期時(shí)間特性,使用SETNX命令來(lái)獲取鎖,使用DEL命令來(lái)釋放鎖。
- 基于Zookeeper的分布式鎖:利用Zookeeper的有序節(jié)點(diǎn)和watch機(jī)制,通過(guò)創(chuàng)建臨時(shí)有序節(jié)點(diǎn)來(lái)實(shí)現(xiàn)鎖的競(jìng)爭(zhēng)和釋放。
三種分布式鎖對(duì)比
優(yōu)點(diǎn) | 缺點(diǎn) | |
數(shù)據(jù)庫(kù) | 簡(jiǎn)單,使用方便,不需要引入Redis、zookeeper等中間件 | 不適合高并發(fā)的場(chǎng)景 db操作性能較差,有鎖表的風(fēng)險(xiǎn) |
redis | 性能好,適合高并發(fā)場(chǎng)景 較輕量級(jí) 較好的框架支持,如Redisson | 過(guò)期時(shí)間不好控制。 需要考慮鎖被別的線程誤刪場(chǎng)景 |
zookeeper | 有較好的性能和可靠性。 有封裝較好的框架,如Curator | 性能不如redis實(shí)現(xiàn)的分布式鎖 比較重的分布式鎖 |
【基于Redis實(shí)現(xiàn)的分布式鎖】
早期版本實(shí)現(xiàn)
目前Redis版本已經(jīng)發(fā)布到7.x,生產(chǎn)項(xiàng)目應(yīng)該不會(huì)再使用2.x的版本,這里主要是為了更好的理解各種情況。
在redis2.6.12之前,是通過(guò)setnx與expire兩個(gè)命令配合使用來(lái)實(shí)現(xiàn)的。setNX命令代表當(dāng)key不存在時(shí)返回成功,否則返回失敗,即鎖已被其他線程占用。
setnx(key,value);
expire(key,seconds)
這種實(shí)現(xiàn)方式把加鎖和設(shè)置過(guò)期時(shí)間的步驟分成兩步,并不是原子操作,如果加鎖成功之后程序崩潰、服務(wù)宕機(jī)等異常情況,導(dǎo)致沒(méi)有設(shè)置過(guò)期時(shí)間,那么就會(huì)導(dǎo)致死鎖的問(wèn)題,其他線程永遠(yuǎn)都無(wú)法獲取這個(gè)鎖。
如何避免上述問(wèn)題呢?早期redis版本,可以使用lua腳本,之后的版本,則也可以利用redis命令的擴(kuò)展參數(shù)來(lái)實(shí)現(xiàn)。繼續(xù)...
Lua腳本
可以使用Lua腳本來(lái)保證原子性(包含setnx和expire兩條指令),lua腳本如下:
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then redis.call('expire',KEYS[1],ARGV[2])
return 1
else
return 0
end
加鎖代碼如下:
// 使用lua腳本 保證原子性
public boolean tryLock_with_lua(String key, String UniqueId, int seconds) {
String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
"redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
List<String> keys = new ArrayList<>();
List<String> values = new ArrayList<>();
keys.add(key);
values.add(UniqueId);
values.add(String.valueOf(seconds));
Object result = jedis.eval(lua_scripts, keys, values);
return result.equals(1L);
}
SET的擴(kuò)展命令(SET EX PX NX)
除了使用,使用Lua腳本,保證SETNX + EXPIRE兩條指令的原子性,我們還可以使用redis的SET指令擴(kuò)展參數(shù),它也是原子性的!
SET key value[EX seconds][PX milliseconds][NX|XX]
- EX seconds: 設(shè)定過(guò)期時(shí)間,單位為秒。
- PX milliseconds: 設(shè)定過(guò)期時(shí)間,單位為毫秒。
- NX: 表示key不存在的時(shí)候,才能set成功,也即保證只有第一個(gè)客戶端請(qǐng)求才能獲得鎖,而其他客戶端請(qǐng)求只能等其釋放鎖,才能獲取。
- XX: 僅當(dāng)key存在時(shí)設(shè)置值。
代碼如下:
public boolean tryLock_with_set(String key, String UniqueId, int seconds) {
return "OK".equals(jedis.set(key, UniqueId, "NX", "EX", seconds));
}
value必須要具有唯一性,可以用UUID來(lái)做,設(shè)置隨機(jī)字符串保證唯一性。為什么要保證唯一呢?繼續(xù)...
釋放鎖
雖然我們?cè)诩渔i的時(shí)候,設(shè)置了默認(rèn)過(guò)期時(shí)間,但我們肯定不能等著過(guò)期再釋放鎖。當(dāng)前線程執(zhí)行完任務(wù)之后,需要手動(dòng)刪除key,即釋放鎖。
// 錯(cuò)誤的解鎖方法—直接刪除key
public void unlock_with_del(Jedis jedis,String key) {
jedis.del(key);
}
通過(guò)del命令直接刪除,是否可行呢?結(jié)合下圖我們來(lái)分析一下:
線程A加鎖同時(shí)設(shè)置超時(shí)時(shí)間5秒,結(jié)果5s之后程序邏輯還沒(méi)有執(zhí)行完成,鎖已經(jīng)釋放。線程B此時(shí)也來(lái)嘗試加鎖并獲得了鎖,這時(shí)線程A業(yè)務(wù)執(zhí)行完成,釋放鎖,結(jié)果釋放了線程B持有的鎖。也就是說(shuō)鎖被別的線程誤刪了。如何解決呢?這里就用到了前面提到的UUID,給value值設(shè)置一個(gè)標(biāo)記當(dāng)前線程唯一的隨機(jī)數(shù),在刪除的時(shí)候,校驗(yàn)一下。同時(shí),判斷是不是當(dāng)前線程加的鎖和釋放鎖也要保證原子性。
// 使用Lua腳本進(jìn)行解鎖操縱,解鎖的時(shí)候驗(yàn)證value值
public boolean unlock(Jedis jedis,String key,String value) {
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) else return 0 end";
return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);
}
完成了鎖的獲取、鎖的釋放,這就OK了嘛?然而,并非如此。繼續(xù)...
Redission
(1)超時(shí)釋放
在分析鎖誤刪問(wèn)題時(shí)提到,線程A設(shè)置了5秒超時(shí),但5秒內(nèi),業(yè)務(wù)并未執(zhí)行完成,而鎖已超時(shí)釋放,從而導(dǎo)致了線程A和B同時(shí)持有了鎖。如何解決呢?把鎖過(guò)期時(shí)間設(shè)置長(zhǎng)一些,算是一種解決方案,有沒(méi)有更好的呢?其實(shí)我們?cè)O(shè)想一下,是否可以給獲得鎖的線程,開啟一個(gè)定時(shí)守護(hù)線程,每隔一段時(shí)間檢查鎖是否還存在,存在則對(duì)鎖的過(guò)期時(shí)間延長(zhǎng),防止鎖過(guò)期提前釋放。這其實(shí)就是開源框架Redission采用的思路。
redission watchdog (源于網(wǎng)絡(luò))
只要線程一加鎖成功,就會(huì)啟動(dòng)一個(gè)watch dog看門狗,它是一個(gè)后臺(tái)線程,會(huì)每隔10秒檢查一下,如果線程1還持有鎖,那么就會(huì)不斷的延長(zhǎng)鎖key的生存時(shí)間。因此,Redisson就是使用watch dog解決了鎖過(guò)期釋放,業(yè)務(wù)沒(méi)執(zhí)行完問(wèn)題。
(2)可重入
另外還有一個(gè)問(wèn)題,上述實(shí)現(xiàn)并非可重入鎖。所謂可重入鎖,即當(dāng)線程在持有鎖的情況下再次請(qǐng)求加鎖,如果一個(gè)鎖支持一個(gè)線程多次加鎖,那么這個(gè)鎖就是可重入的。如果一個(gè)不可重入鎖被再次加鎖,由于該鎖已經(jīng)被持有,再次加鎖會(huì)失敗。Redis 可通過(guò)對(duì)鎖進(jìn)行重入計(jì)數(shù),加鎖時(shí)加 1,解鎖時(shí)減 1,當(dāng)計(jì)數(shù)歸 0 時(shí)釋放鎖。Redission中也有相關(guān)的實(shí)現(xiàn)。
如果key不存在,通過(guò)hash的方式保存,同時(shí)設(shè)置過(guò)期時(shí)間,反之如果存在就是+1。對(duì)應(yīng)的就是'hincrby', KEYS[1], ARGV[2], 1這段命令,對(duì)hash結(jié)構(gòu)的鎖重入次數(shù)+1。
Redlock+Redisson
如果線程一在Redis的master節(jié)點(diǎn)上拿到了鎖,但是加鎖的key還沒(méi)同步到slave節(jié)點(diǎn)。恰好這時(shí),master節(jié)點(diǎn)發(fā)生故障,一個(gè)slave節(jié)點(diǎn)就會(huì)升級(jí)為master節(jié)點(diǎn)。線程二就可以獲取同個(gè)key的鎖啦,但線程一也已經(jīng)拿到鎖了,鎖的安全性就沒(méi)了。
為了解決這個(gè)問(wèn)題,Redis作者 antirez提出一種高級(jí)的分布式鎖算法:Redlock(Redission中也有相關(guān)的實(shí)現(xiàn))。Redlock核心思想是這樣的:
搞多個(gè)Redis master部署,以保證它們不會(huì)同時(shí)宕掉。并且這些master節(jié)點(diǎn)是完全相互獨(dú)立的,相互之間不存在數(shù)據(jù)同步。同時(shí),需要確保在這多個(gè)master實(shí)例上,是與在Redis單實(shí)例,使用相同方法來(lái)獲取和釋放鎖。
假設(shè)當(dāng)前有5個(gè)Redis master節(jié)點(diǎn),在5臺(tái)服務(wù)器上面運(yùn)行這些Redis實(shí)例。
RedLock的實(shí)現(xiàn)步驟如下:
- 獲取當(dāng)前Unix時(shí)間,以毫秒為單位。
- 依次嘗試從5個(gè)實(shí)例,使用相同的key和具有唯一性的value(例如UUID)獲取鎖。當(dāng)向Redis請(qǐng)求獲取鎖時(shí),客戶端應(yīng)該設(shè)置一個(gè)網(wǎng)絡(luò)連接和響應(yīng)超時(shí)時(shí)間,這個(gè)超時(shí)時(shí)間應(yīng)該小于鎖的失效時(shí)間。例如你的鎖自動(dòng)失效時(shí)間為10秒,則超時(shí)時(shí)間應(yīng)該在5-50毫秒之間。這樣可以避免服務(wù)器端Redis已經(jīng)掛掉的情況下,客戶端還在死死地等待響應(yīng)結(jié)果。如果服務(wù)器端沒(méi)有在規(guī)定時(shí)間內(nèi)響應(yīng),客戶端應(yīng)該盡快嘗試去另外一個(gè)Redis實(shí)例請(qǐng)求獲取鎖。
- 客戶端使用當(dāng)前時(shí)間減去開始獲取鎖時(shí)間(步驟1記錄的時(shí)間)就得到獲取鎖使用的時(shí)間。當(dāng)且僅當(dāng)從大多數(shù)(N/2+1,這里是3個(gè)節(jié)點(diǎn))的Redis節(jié)點(diǎn)都取到鎖,并且使用的時(shí)間小于鎖失效時(shí)間時(shí),鎖才算獲取成功。
- 如果取到了鎖,key的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間(步驟3計(jì)算的結(jié)果)。
- 如果因?yàn)槟承┰?,獲取鎖失?。](méi)有在至少N/2+1個(gè)Redis實(shí)例取到鎖或者取鎖時(shí)間已經(jīng)超過(guò)了有效時(shí)間),客戶端應(yīng)該在所有的Redis實(shí)例上進(jìn)行解鎖(即便某些Redis實(shí)例根本就沒(méi)有加鎖成功,防止某些節(jié)點(diǎn)獲取到鎖但是客戶端沒(méi)有得到響應(yīng)而導(dǎo)致接下來(lái)的一段時(shí)間不能被重新獲取鎖)