功能03-優惠券秒殺03 4.功能03-優惠券秒殺 4.6Redisson的分散式鎖 Redis分散式鎖—Redisson+RLock可重入鎖實現篇 4.6.1基於setnx實現的分散式鎖問題 我們在4.5自己實現的分散式鎖,主要使用的是redis的setnx命令,它仍存在如下問題: 4.6.2Re ...
功能03-優惠券秒殺03
4.功能03-優惠券秒殺
4.6Redisson的分散式鎖
4.6.1基於setnx實現的分散式鎖問題
我們在4.5自己實現的分散式鎖,主要使用的是redis的setnx命令,它仍存在如下問題:
4.6.2Redisson基本介紹
Redisson是一個在Redis基礎上實現的Java駐記憶體數據網格(In-Memory Data Grid)。它不僅提供了一系列的分散式的Java常用對象,還提供了許多分散式服務,其中就包括了各種分散式鎖的實現。
一句話:Redisson是一個在Redis基礎上實現的分散式工具的集合。
據Redisson官網的介紹,Redisson是一個Java Redis客戶端,與Spring 提供給我們的 RedisTemplate 工具沒有本質的區別,可以把它看做是一個功能更強大的客戶端
官網地址: https://redisson.org
GitHub地址: https://github.com/redisson/redisson
中文文檔:目錄 · redisson/redisson Wiki (github.com)
4.6.3Redisson快速入門
代碼實現
(1)修改pom.xml,添加依賴
<!--redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
(2)配置Redisson
package com.hmdp.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author 李
* @version 1.0
*/
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
//配置
Config config = new Config();
//redis單節點模式,設置redis伺服器的地址,埠,密碼
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("123456");
//創建RedissonClient對象
return Redisson.create(config);
}
}
配置了之後,就可以在任意地方去使用Redisson了:比如說去改造之前的業務,使用Redisson的分散式鎖
(3)修改VoucherOrderServiceImpl.java,使用Redisson的分散式鎖
註入RedissonClient對象:
使用RedissonClient提供的鎖:
(4)使用jemeter測試
分別向埠為8081、8082的伺服器發送200個請求(使用同一個用戶的token)
資料庫中只下了一單:
說明解決了集群下的一人一單問題。
4.6.4Redisson可重入鎖原理(Reentrant Lock)
可重入鎖:字面意思是“可以重新進入的鎖”,即允許同一個線程多次獲取同一把鎖。
比如一個遞歸函數里有加鎖操作,遞歸過程中這個鎖會阻塞自己嗎?如果不會,那麼這個鎖就是可重入鎖(因為這個原因可重入鎖也叫做遞歸鎖)。
Lock鎖藉助於底層一個voaltile的state變數來記錄重入狀態。如果當前沒有線程持有這把鎖,那麼state=0,假如有線程持有這把鎖,那麼state=1,如果持有這把鎖的線程再次持有這把鎖,那麼state就會+1 。
對於synchronized而言,它在c語言代碼中會有一個count,原理和state類似,也是重入一次就加一,釋放一次就減一 ,直到減少成零時,表示當前這把鎖沒有被人持有。
Redisson也支持可重入鎖。Redisson在分散式鎖中,採用redis的hash結構用來存儲鎖,其中key表示這把鎖是否存在,用field表示當前這把鎖被哪個線程持有,value記錄重入的次數(鎖計數)。當獲取鎖的線程釋放鎖前,先對鎖計數-1,然後判斷鎖計數0,如果是0,就釋放鎖。
使用Redis的string類型的setnx命令,可以實現互斥性,ex可以設置過期時間。但如果使用hash結構,該結構中沒有類似的組合命令,因此只能將之前的邏輯拆開。先判斷是否存在,然後手動設置過期時間,邏輯如下:
可以看到,無論是獲取鎖還是釋放鎖,都比使用setnx實現的分散式鎖複雜得多,而且實現需要有多個步驟。
因此,需要採用lua腳本來確保獲取鎖和釋放鎖的原子性:
- 獲取鎖的lua腳本
local key = KEYS[1]; -- 鎖的key
local threadId = ARGV[1]; -- 線程唯一標識
local releaseTime = ARGV[2]; -- 鎖的自動釋放時間
-- 判斷是否存在
-- 鎖不存在
if(redis.call('exists', key) == 0) then
-- 不存在, 獲取鎖
redis.call('hset', key, threadId, '1');
-- 設置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回結果
end;
-- 鎖已經存在,判斷threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
-- 如果是自己, 獲取鎖,重入次數+1
redis.call('hincrby', key, threadId, '1'); -- hincrby命令是對哈希表指定的field對應的value增長指定步長
-- 重新設置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回結果
end;
return 0; -- 代碼走到這裡,說明獲取鎖的不是自己,獲取鎖失敗
- 釋放鎖的腳本
local key = KEYS[1]; -- 鎖的key
local threadId = ARGV[1]; -- 線程唯一標識
local releaseTime = ARGV[2]; -- 鎖的自動釋放時間
-- 判斷當前鎖是否還是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
return nil; -- 如果已經不是自己,則直接返回,不進行操作
end;
-- 是自己的鎖,則重入次數-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 然後判斷重入次數是否已經為0
if (count > 0) then
-- 大於0,說明不能釋放鎖,重置有效期然後返回
redis.call('EXPIRE', key, releaseTime);
return nil;
else -- 等於0,說明可以釋放鎖,直接刪除
redis.call('DEL', key);
return nil;
end;
代碼測試
我們來測試一下Redisson的可重入鎖:
package com.hmdp;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.boot.test.context.SpringBootTest;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* @author 李
* @version 1.0
*/
@Slf4j
@SpringBootTest
class RedissonTest {
@Resource
private RedissonClient redissonClient;
private RLock lock;
@BeforeEach
void setUp() {
lock = redissonClient.getLock("order");
}
@Test
void method1() throws InterruptedException {
// 嘗試獲取鎖
boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
if (!isLock) {
log.error("獲取鎖失敗 .... 1");
return;
}
try {
log.info("獲取鎖成功 .... 1");
method2();
log.info("開始執行業務 ... 1");
} finally {
log.warn("準備釋放鎖 .... 1");
lock.unlock();
}
}
void method2() {
// 嘗試獲取鎖
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("獲取鎖失敗 .... 2");
return;
}
try {
log.info("獲取鎖成功 .... 2");
log.info("開始執行業務 ... 2");
} finally {
log.warn("準備釋放鎖 .... 2");
lock.unlock();
}
}
}
在method1()的boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
旁打上斷點:
點擊step over,顯示獲取鎖成功:
打開redis,可以看到對應的hash數據,value記錄的是線程重入鎖的次數,此時value=1:
當前線程在method1()中調用method2()後,在method2()中重新獲取鎖,此時value記錄的次數+1,value=2:
當method2()釋放鎖的時候,鎖重入次數-1,value=1:
當執行到method1()釋放鎖的時候,鎖重入次數-1,此時發現鎖重入次數value=0,因此刪除對應的key,真正釋放鎖。
我們進入RedissonLock的源碼,發現裡面也寫了相關的lua腳本,這裡的腳本和上面我們寫的基本一致:
獲取鎖的腳本:
釋放鎖的腳本:
4.6.5Redisson的鎖重試和WatchDog機制
(1)為什麼需要WatchDog機制?
如果拿到分散式鎖的節點宕機,且這個鎖正好處於鎖住的狀態時,就會出現死鎖問題。為了避免這種情況的發生,我們通常都會給鎖設置一個過期時間。但隨之而來又產生了新的問題:假如一個線程拿到了鎖並設置了30s超時,但在30s後這個線程的業務沒有執行完畢,鎖已經超時釋放了。可能會導致其他線程搶到鎖,然後出現多線程併發的問題。
為瞭解決這種兩難的境地:Redisson提供了watch dog 自動延期機制。
(2)WatchDog的自動延期機制
Redisson提供了一個監控鎖的看門狗,它的作用是在Redisson實例被關閉前,不斷的延長鎖的有效期。也就是說,如果一個拿到鎖的線程一直沒有完成邏輯,那麼看門狗會幫助線程不斷的延長鎖超時時間,鎖不會因為超時而被釋放。如果獲取到分散式鎖的節點宕機了,看門狗就無法延長鎖的有效期,也避免了死鎖的可能。
watchDog 只有在未指定加鎖時間(leaseTime)時才會生效
預設情況下,看門狗的續期時間是30s,也可以通過修改Config.lockWatchdogTimeout來另行指定。另外Redisson 還提供了可以指定leaseTime參數的加鎖方法來指定加鎖的時間。超過這個時間後鎖便自動解開了,不會延長鎖的有效期。
(3)鎖重試機制:利用信號量和PubSub功能實現等待、喚醒,獲取鎖失敗的重試機制
(4)Redisson實例獲取鎖和釋放鎖的流程
(4.1)獲取鎖的邏輯:
- 首先去嘗試獲取鎖,如果返回的ttl為null,則說明成功獲取鎖,然後需要判斷是否走看門狗機制:
- 如果我們自己設置了leaseTime,就不會開啟watchDog機制,直接返回true;
- 如果設置的leaseTime=-1,則開啟watchDog,不停地更新有效期,然後返回true
- 如果返回的ttl不為null,說明獲取鎖失敗。需要重試獲取,在重試之前要先判斷線程剩餘的等待時間:
- 如果剩餘等待時間<=0,說明該線程沒有機會獲取鎖了,直接返回false;
- 如果如果剩餘時間>0,就可以去嘗試重新獲取鎖了。但是不是立即吃重試獲取,需要去等待鎖釋放的信號:
- 如果在等待中,等待時間大於了剩餘等待時間,則直接返回false;
- 如果收到了釋放鎖的信號,並且如果等待時間小於剩餘等待時間,就重新開始嘗試獲取鎖
重覆上述所有步驟。最終線程要麼成功獲取鎖,要麼超時返回。
(4.2)釋放鎖的邏輯
嘗試釋放鎖:
- 如果失敗,記錄異常,結束
- 如果成功,向等待的其他線程發送釋放鎖信息。然後取消watchDog機制,結束
4.6.6Redisson分散式鎖總結
Redisson分散式鎖原理:
- 可重入:利用hash結構記錄線程id和重入次數
- 可重試:利用信號量和PubSub功能實現等待、喚醒,獲取鎖失敗的重試機制
- 超時續約:利用watchDog,每隔一段時間(releaseTime/3),重置超時時間
4.6.7Redisson的聯鎖原理(multiLock)
上面我們已經介紹了Redisson分散式鎖如何實現鎖的可重入,鎖獲取時的重試,以及鎖釋放時間的自動續約。
現在來分析一下Redisson怎麼解決主從一致性問題。要解決這個問題,主從一致性問題產生:
(1)主從一致性問題
為避免單節點的redis服務宕機,從而影響依賴於redis的業務的執行(如分散式鎖),在實際開發中,我們往往會搭建Redis的主從模式。
什麼叫做Redis的主從模式?
有多台Redis,將其中一臺Redis伺服器作為主節點,其他的作為從節點。一般主節點負責寫入數據,從節點負責讀取數據,當主節點伺服器寫入數據時會同步到從節點的伺服器上。
但主從節點畢竟不是在同一臺機器上,它們之間的數據同步會有一定的延時,主從一致性問題正是由於這樣的延時而導致的:
假設有一個Java應用現在要來獲取鎖,它向主節點間發送了一個寫命令:set lock thread1 nx ex 10
,主節點上保存了這個鎖的標識,然後主節點向從節點同步數據,但就在這時主節點宕機了。也就是說同步未完成,但主節點已經宕機了。
redis中的哨兵監控著整個集群的狀態,它發現主節點宕機之後,首先斷開與客戶端的連接,然後在Redis Slave中選擇一個當做新的主節點。
但是由於之前的主從同步未完成——也就是說鎖已經丟失了。所以,此時我們的Java應用再來訪問這個新的主節點時就會發現,鎖已經沒有了(鎖失效了)。那麼此時再有其他線程來獲取鎖也能獲取成功,因此就會出現線程的併發安全問題——這就是主從一致性問題導致的鎖失效問題。
(2)MultiLock鎖
既然主從關係是一致性問題發生的原因,那麼就不要使用主從節點了。我們將所有的節點都變為獨立的redis節點,相互之間沒有任何關係,都可以去做讀寫,每個節點的地位都是一樣的。
此時我們獲取鎖的方式就改變了:獲取鎖時,要把加鎖的邏輯寫入到每一個獨立的Redis節點上,只有所有的伺服器都寫入成功,此時才是加鎖成功。
-
假設現在某個節點掛了,那麼去獲得鎖的時候,只要有一個節點拿不到,都不能算是加鎖成功,保證了加鎖的可靠性。
-
因為沒有主從節點,也就不會出現一致性問題;其次,隨著redis節點的增多,redis可用性也提高了。
-
為了提高可用性,我們也可以對所有獨立的Redis Node分別建立主從關係,讓它們去做主從同步。
那麼獨立的Redis Node的主從關係會不會導致鎖失效呢?
我們假設此時有一個Redis Node宕機了,並且它的數據沒有同步到它的從節點。這時如果有其他線程想去獲取鎖,因為在其他Redis Node上不能拿到鎖,因此不算是獲取鎖成功。也就是說,只要有任意一個節點在存活著,那麼其他線程就不能趁機拿到鎖,解決了鎖失效問題。
這樣的方案保留了既主從同步機制,確保了Redis集群高可用的特性,同時也避免了主從一致引發的鎖失效問題。這套方案在Redisson中被稱為MultiLock鎖(聯鎖):redisson中的MultiLock,可以把一組鎖當作一個鎖來加鎖和釋放。
那麼MutiLock 加鎖原理是什麼呢?筆者畫了一幅圖來說明
當我們去設置了多個鎖時,redission會將多個鎖添加到一個集合中,然後用while迴圈去不停去嘗試拿鎖,但是會有一個總共的加鎖時間,這個時間是用需要加鎖的個數 * 1500ms ,假設有3個鎖,那麼時間就是4500ms,假設在這4500ms內,所有的鎖都加鎖成功, 那麼此時才算是加鎖成功,如果在4500ms有線程加鎖失敗,則會再次去進行重試