分布式鎖是為了解決在分布式環(huán)境里,當(dāng)多個(gè)節(jié)點(diǎn)同時(shí)競爭訪問同一個(gè)資源帶來數(shù)據(jù)不一致的問題,分布式鎖除了要滿足互斥、無死鎖、可重入等特性外,還需要考慮高可用、高性能等問題。分布式鎖比較流行的實(shí)現(xiàn)方式有三種:

  1. 數(shù)據(jù)庫
  2. Redis
  3. Zookeeper

本文主要介紹使用Redis時(shí)如何實(shí)現(xiàn)分布式鎖,使用Redis實(shí)現(xiàn)分布式鎖的最低要求需要滿足以下三點(diǎn):

  1. 互斥,在任何給定時(shí)刻,只有一個(gè)客戶端可以持有鎖。
  2. 無死鎖,即使鎖定資源的客戶端崩潰或分區(qū),也始終可以獲取鎖定。
  3. 容錯(cuò)能力,只要大多數(shù)Redis節(jié)點(diǎn)都處于運(yùn)行狀態(tài),客戶端就可以獲取和釋放鎖。

互斥和無死鎖可以可以使用Redis的setnx指令保證只能有一個(gè)節(jié)點(diǎn)設(shè)置鎖,同時(shí)設(shè)置一個(gè)超時(shí)時(shí)間保證在極端情況下能自動(dòng)釋放鎖,容錯(cuò)能力可部署Redis集群。

基于故障轉(zhuǎn)移Redis分布式鎖的實(shí)施

使用Redis實(shí)施分布式鎖最簡單的方法是在實(shí)例在創(chuàng)建一個(gè)key,使用Redis的超時(shí)功能,在有限的時(shí)間內(nèi)保持key的可用性,以便最終將其釋放。最后當(dāng)客戶端處理完成后刪除key。

一看使用Redis實(shí)施分布式鎖還蠻容易,不需要過多的步驟,但是在單實(shí)例里面如果實(shí)例發(fā)生宕機(jī)或網(wǎng)絡(luò)不可用時(shí)會(huì)發(fā)怎么辦呢?這個(gè)也蠻好解決我們可以加個(gè)奴隸使用一主一從,如果主服務(wù)器不可用還可以使用它。這樣會(huì)帶來另外一個(gè)問題,你們發(fā)現(xiàn)這樣實(shí)施后鎖沒法保證互斥性了,比如發(fā)生以下的情況:

  1. 客戶端A獲取Master節(jié)點(diǎn)中的鎖
  2. Slave節(jié)點(diǎn)同步key之前,Master宕機(jī)
  3. Slave節(jié)點(diǎn)升級(jí)為Master節(jié)點(diǎn)
  4. 客戶端再次去獲取同樣的鎖,獲取鎖成功。安全違規(guī)!

畫個(gè)圖比較清楚一點(diǎn):

Redis分布式鎖實(shí)施方案

單個(gè)實(shí)例實(shí)施分布式鎖

在解決單實(shí)例Redis分布式鎖的限制之前,先看一下如果正確的使用Redis在單實(shí)例中實(shí)施分布式鎖,要獲取分布式鎖,必遵循循以下方法:

SET resource_name my_random_value NX PX 30000

該命令只有在key不存在時(shí)才設(shè)置key(NX選項(xiàng)),并且會(huì)設(shè)置到期時(shí)間為30000ms(PX選項(xiàng)),key的值為一個(gè)隨機(jī)值,唯一的限制是必需保持該值在所有的鎖定請(qǐng)求中必需唯一。

使用隨機(jī)值是為了更安全的釋放鎖,只有只有key中存放的值是期望的值時(shí)才釋放鎖了,為了保證判斷值是否一致與刪除key的原子性,使用Lua腳本來完成:

if redis.call("get",KEYS[1]) == ARGV[1] then
 return redis.call("del",KEYS[1])
else
 return 0
end

ReidsLock算法

在分布式環(huán)境中,如何保證Redis鎖的高可用呢?假設(shè)有N個(gè)Master節(jié)點(diǎn),所有節(jié)點(diǎn)相互獨(dú)立,不采用任務(wù)復(fù)制或其他協(xié)調(diào)系統(tǒng)。比如我們有5個(gè)Redis主服務(wù)器,為了獲取鎖客戶端相當(dāng)做如下操作:

  1. 可以以毫秒為單位獲取當(dāng)前時(shí)間
  2. 生成key和隨機(jī)值和超時(shí)時(shí)間
  3. 發(fā)送Redis命令的超時(shí)時(shí)間盡可能的小,這樣當(dāng)節(jié)點(diǎn)故障時(shí)能快速的訪問下一個(gè)節(jié)點(diǎn)
  4. 順序發(fā)送獲取鎖命令
  5. 客戶使用當(dāng)前時(shí)間減去第一步中獲取的時(shí)間得到鎖的有效時(shí)間,當(dāng)有至少3個(gè)節(jié)點(diǎn)獲取鎖功能,并且鎖的有效時(shí)間小于鎖的超時(shí)時(shí)間才認(rèn)為成功獲取到鎖
  6. 如果客戶端由于某種原因(無法鎖定N / 2 + 1個(gè)實(shí)例或有效時(shí)間為負(fù)),則認(rèn)為獲取鎖失敗,它將嘗試解鎖所有實(shí)例。
Redis分布式鎖實(shí)施方案

上圖簡單畫出5臺(tái)Redis獲取鎖的過程,客戶端A成功在Redis 1、Redis 2、Redis 4中獲取到鎖。偽代碼大概是這樣子:

long timestamp = System.currentTimeMillis();
String lockKey = xxx
String value = xxx
long timeout = 5000
int n = 5
int successCount = 0;
for (i = 0; i < n; i++){
 if(
 redis[i].setnx(lockKey, value, timeout) 
 ){
 //設(shè)置成功
 successCount++;
 }
 
 if(successCount == (N / 2 + 1) ) 
 break;
}
//獲取鎖總花費(fèi)時(shí)間
long spent = System.currentTimeMillis() - timestamp 
//獲取成功的數(shù)量等于 (N / 2 + 1)并且,有交時(shí)間小時(shí)超時(shí)時(shí)間
if(successCount == (N / 2 + 1) && 
 (timeout - spent) > 0
){
 //成功獲取到鎖
}else{
 //獲取鎖失敗,需要釋放鎖
}

使用此算法面臨的問題

  1. 因?yàn)橛卸嗯_(tái)Redis服務(wù)器,發(fā)送命令時(shí)間和過期時(shí)間多少會(huì)有一些延遲
  2. 某臺(tái)機(jī)器崩潰鎖丟失,重啟后其他客戶端可以再次鎖定
  3. 使用持久化解決鎖的丟失,如果停電key未寫入磁盤
  4. ...

如何解決超時(shí)時(shí)間延遲的問題?

只要保證使用鎖的時(shí)間小時(shí)有效時(shí)間,在鎖過期之前釋放鎖,就可以忽略這些延遲,實(shí)事上這個(gè)很難保證。

如何解決機(jī)器崩潰問題?

使用持久化方案,將鎖信息保存到磁盤,因?yàn)镽edis過期是從語義上實(shí)現(xiàn)的,故障期間Redis服務(wù)器時(shí)間仍然流逝,所以當(dāng)Redis服務(wù)器重啟后,過期時(shí)間還是可以保持一致的。

鎖數(shù)據(jù)未持久化到磁盤如何處理?

如果鎖未持久化入磁盤,那會(huì)跟問題2一樣,重啟后其他客戶端可再次獲取鎖,解決這個(gè)問題可以將持久性設(shè)置中始終啟用fsync = always,反過來這將完全破壞性能。

為了保證這一點(diǎn),我們只需要使一個(gè)實(shí)例在崩潰后至少不可用超過我們使用的最大TTL(即實(shí)例崩潰時(shí)存在的所有鎖的所有鍵)所需的時(shí)間即可。無效并自動(dòng)釋放。

使用延遲重新啟動(dòng),即使沒有任何種類的Redis持久性,也基本上可以實(shí)現(xiàn)安全性,但是請(qǐng)注意,這可能會(huì)轉(zhuǎn)化為可用性損失。例如,如果大多數(shù)實(shí)例崩潰,則系統(tǒng)將無法在全局范圍內(nèi)使用TTL(此處,全局范圍是指在此期間根本沒有資源可鎖定)。