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