隨著分布式系統的流行,分布式鎖的需求也越來越強。網上很多基于Redis實現的分布式鎖,但是大大小小都有些問題。本文基于Redis給出實現及一些問題的分析。
基于Redis單節點(主從架構)的實現
獲取鎖
SET key_name random_value NX PX expire_time
public boolean lock(String key, long expireTime, TimeUnit timeUnit) {
String lockKey = LOCK_PREFIX + key;
return redisTemplate.execute(
(RedisCallback<Boolean>) connection ->
connection.set(lockKey.getBytes(), UUID.randomUUID().toString().replaceAll("-", "").getBytes(), Expiration.from(expireTime, timeUnit), RedisStringCommands.SetOption.SET_IF_ABSENT)
);
}
- key_name:鎖的名稱
- random_value:客戶端生成的隨機字符串
- NX:只有當不存在此key_name時才能操作成功
- PX:設置過期時間
- expire_time:過期時間值,單位:毫秒
執行業務代碼
執行業務的具體處理操作。
釋放鎖
釋放鎖采用lua腳本去執行。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
- KEYS[1]:就是前面獲取鎖時的key_name
- ARGV[1:前面獲取鎖時的random_value
Redis單節點實現分布式鎖注意事項
- 鎖要加上過期時間,防止成功獲取鎖的客戶端由于各種原因導致無法與Redis進行通信,無法釋放鎖,導致其他客戶端也無法獲取到鎖,產生了死鎖。
- set NX 操作與 PX 操作要在同一條命令里執行,避免set NX后由于其他原因,導致無法執行PX操作,無法給鎖設置過期時間。
- 必須給鎖設置一個隨機字符串,它保證了客戶端在釋放鎖時,釋放的一定是自己獲得的那把鎖。
- 釋放鎖的操作必須使用lua腳本實現。釋放鎖其實包含三步,'GET'、判斷和'DEL'。使用lua腳本可以保證這三個操作的原子性,客戶端自己操作這三個步驟不具有原子性。
這里解釋下第三點為什么要這么做,考慮的是可能會出現以下情況:
- 客戶端1獲取鎖成功
- 客戶端1在成功獲取鎖后,執行業務邏輯時,在某個地方阻塞了(比如說IO操作)很長一段時間
- 鎖的過期時間到了,鎖自動釋放了
- 客戶端2獲取到了同一個鎖的資源
- 客戶端1從阻塞中恢復過來,并且釋放掉了客戶端2持有的鎖。
使用random_value,客戶端會判斷redis保存的那把鎖還是不是自己持有的那把鎖,如果是則釋放鎖,不是,則釋放失敗。
以上幾個問題在使用時只要稍加注意,還是可以避免掉的。但是有一個問題,由于Redis單節點無法解決的。
failover(故障轉移)引起的問題,以下簡述一下發生的過程。
- 客戶端1從master節點獲取了鎖
- master宕機了,并且存儲的key尚未復制到slaver節點
- slaver升級為master
- 客戶端2從新的master節點獲得了同一個鎖
由于Redis單節點存在一些問題,而且實際生產過程中,一般采用Redis集群保證高可用。Redis作者提出了Redlock的算法來實現Redis多節點下的分布式鎖。
Redis集群下的分布式鎖
獲取鎖
- 獲取當前系統時間(毫秒數)
- 按順序向Redis所有節點執行獲取鎖的操作,這個獲取鎖的操作和單節點時一致,包含隨機字符串、過期時間等。為了保證不受某個不可用節點的影響,Redis還增加了一個超時時間,它遠小于鎖的有效時間(幾十毫秒級)。客戶端向某個節點獲取鎖失敗,應立即向其他節點獲取鎖
- 計算整個獲取鎖的過程總共消耗了多少時間,計算方法是用當前時間減去第一步獲取的時間,如果客戶端從大多數節點(>N/2+1)都獲取到了鎖,并且獲取鎖的總消耗時間小于鎖的有效時間,這時才認為獲取鎖成功,否則認為失敗
- 如果獲取鎖成功了,那么這個鎖的有效時間需要重新計算,它等于最初的鎖的有效時間減去獲取鎖的過程消耗時間
- 如果鎖最終獲取失敗了,那么客戶端應該向所有節點發送釋放鎖的操作
執行客戶端代碼
釋放鎖
客戶端向所有節點發送釋放鎖的操作,包括獲取鎖失敗的節點。