引言
在前面的大部分文章中,我們反復(fù)圍繞著線程安全相關(guān)問題在對(duì)Java的并發(fā)編程進(jìn)行闡述,但前敘的文章中都是基于單體架構(gòu)的Java程序進(jìn)行分析的,而如今單體的程序遠(yuǎn)不足以滿足日益漸增的用戶需求,所以一般目前Java程序都是通過多機(jī)器、分布式的架構(gòu)模式進(jìn)行部署。那么在多部署環(huán)境下,之前我們分析的CAS無鎖、隱式鎖、顯式鎖等方案是否還有效呢?答案是無效。
一、單體架構(gòu)下的鎖遷移分布式架構(gòu)分析
在前面關(guān)于Synchronized關(guān)鍵字原理剖析以及AQS與ReetrantLock原理分析兩篇文章中,曾得知這兩種都是屬于可以解決線程安全問題的鎖,一種是Java原生的關(guān)鍵字,通過Java對(duì)象進(jìn)行加鎖的隱式鎖,另一種則是JUC提供的顯式鎖方案。在開發(fā)過程中使用它們都能夠保證多線程的安全問題。但把它們放在多機(jī)器/多應(yīng)用部署的環(huán)境下,也能保證相同的效果嗎?先來看一個(gè)案例:
兩個(gè)服務(wù):[訂單服務(wù):8000端口]、[商品服務(wù):8001、8002端口]
數(shù)據(jù)庫(kù):訂單庫(kù)[db_order]、商品庫(kù)[db_shopping]
訂單服務(wù)中提供了一個(gè)下單接口,用戶下單后會(huì)調(diào)用它,調(diào)用下單接口后,會(huì)通過restTemplate進(jìn)行RPC調(diào)用商品服務(wù)的扣庫(kù)存接口,實(shí)現(xiàn)庫(kù)存扣減下單操作。
源碼如下:
// 訂單服務(wù)
@RestController
@RequestMapping("/order")
public class OrderApi{
// 庫(kù)存服務(wù)的RPC前綴
private static final String REST_URL_PREFIX =
"http://SHOPPING/inventory";
@Autowired
private OrderService orderService;
@Autowired
private RestTemplate restTemplate;
// 下單接口
@RequestMapping("/placeAnOrder")
public String placeAnOrder(){
// 模擬商品ID
String inventoryId = "82391173-9dbc-49b6-821b-746a11dbbe5e";
// 生成一個(gè)訂單ID(分布式架構(gòu)中要使用分布式ID生成策略,此處是模擬)
String orderId = UUID.randomUUID().toString();
// 模擬生成訂單記錄
Order order = new
Order(orderId,"黃金竹子","88888.88",inventoryId);
// RPC調(diào)用庫(kù)存接口
String responseResult = restTemplate.getForObject(
REST_URL_PREFIX + "/minusInventory?inventoryId="
+ inventoryId, String.class);
System.out.println("調(diào)用后庫(kù)存接口結(jié)果:" + responseResult);
Integer n = orderService.insertSelective(order);
if (n > 0)
return "下單成功....";
return "下單失敗....";
}
}
// 庫(kù)存服務(wù)
@RestController
@RequestMapping("/inventory")
public class InventoryApi{
@Autowired
private InventoryService inventoryService;
@Value("${server.port}")
private String port;
// 扣庫(kù)存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
// 查詢庫(kù)存信息
Inventory inventoryResult =
inventoryService.selectByPrimaryKey(inventory.getInventoryId());
if (inventoryResult.getShopCount() <= 0) {
return "庫(kù)存不足,請(qǐng)聯(lián)系賣家....";
}
// 扣減庫(kù)存
inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
int n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
if (n > 0)
return "端口-" + port + ",庫(kù)存扣減成功?。?!";
return "端口-" + port + ",庫(kù)存扣減失敗?。?!";
}
}
觀察上述源碼,存在什么問題?線程安全問題。按照之前的做法我們會(huì)對(duì)扣庫(kù)存的接口使用Synchronized或ReetrantLock
加鎖,如下:
// 庫(kù)存服務(wù) → 扣庫(kù)存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
int n;
synchronized(InventoryApi.class){
// 查詢庫(kù)存信息
Inventory inventoryResult =
inventoryService.selectByPrimaryKey(inventory.getInventoryId());
if (inventoryResult.getShopCount() <= 0) {
return "庫(kù)存不足,請(qǐng)聯(lián)系賣家....";
}
// 扣減庫(kù)存
inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
System.out.println("庫(kù)存信息-剩余庫(kù)存數(shù)量:" +
inventoryResult.getShopCount());
}
if (n > 0)
return "端口:" + port + ",庫(kù)存扣減成功?。?!";
return "端口:" + port + ",庫(kù)存扣減失敗?。。?;
}
是不是感覺沒問題了?看測(cè)試:
通過JMeter壓測(cè)工具,1秒內(nèi)對(duì)下單接口進(jìn)行八百次調(diào)用,此時(shí)出現(xiàn)了一個(gè)比較有意思的現(xiàn)象,測(cè)試結(jié)果如下:
訂單服務(wù)控制臺(tái)日志:[端口:8000]
......
調(diào)用后庫(kù)存接口結(jié)果:端口-8001,庫(kù)存扣減成功!??!
調(diào)用后庫(kù)存接口結(jié)果:端口-8002,庫(kù)存扣減成功?。?!
......
調(diào)用后庫(kù)存接口結(jié)果:端口-8001,庫(kù)存扣減成功!!!
調(diào)用后庫(kù)存接口結(jié)果:端口-8001,庫(kù)存扣減成功?。。? 調(diào)用后庫(kù)存接口結(jié)果:端口-8002,庫(kù)存扣減成功!??!
......
商品服務(wù)控制臺(tái)日志:[端口:8001]
......
庫(kù)存信息-剩余庫(kù)存數(shù)量:999
......
庫(kù)存信息-剩余庫(kù)存數(shù)量:788
庫(kù)存信息-剩余庫(kù)存數(shù)量:787
.....
商品服務(wù)控制臺(tái)日志:[端口:8002]
......
庫(kù)存信息-剩余庫(kù)存數(shù)量:998
庫(kù)存信息-剩余庫(kù)存數(shù)量:996
庫(kù)存信息-剩余庫(kù)存數(shù)量:993
......
庫(kù)存信息-剩余庫(kù)存數(shù)量:788
.....
注意觀察如上日志,在兩個(gè)商品服務(wù)[8001/8002]中都出現(xiàn)了庫(kù)存信息-剩余庫(kù)存數(shù)量:788
這么一條日志記錄,這代表第799個(gè)商品被賣了兩次,還是出現(xiàn)了線程安全問題,導(dǎo)致了庫(kù)存超賣。可我們不是已經(jīng)通過Class
對(duì)象加鎖了嗎?為什么還會(huì)有這樣的問題出現(xiàn)呢?下面我們來依次分析。
問題分析
在關(guān)于Synchronized關(guān)鍵字原理剖析的文章中已經(jīng)得知:Synchronized關(guān)鍵字是依賴于對(duì)象做為鎖資源進(jìn)行加鎖操作的,每個(gè)對(duì)象都會(huì)存在一個(gè)伴生的Monitor
監(jiān)視器,Synchronized
則是通過它進(jìn)行上鎖,加鎖過程如下:
OK~,有了上述基礎(chǔ)知識(shí)之后,再接著分析前面的問題。為什么會(huì)出現(xiàn)線程安全問題?
實(shí)際上這個(gè)問題也不難理解,前面的案例中,我們是通過
InventoryApi.class
類對(duì)象進(jìn)行上鎖的,如果在單體程序中確實(shí)沒有任何問題,因?yàn)?code>Class對(duì)象是唯一的,當(dāng)多條線程搶占一把Class
鎖時(shí),同一時(shí)刻只會(huì)有一條線程獲取鎖成功,這樣自然而然也不存在線程安全問題了。但目前的問題就出在這里,InventoryApi.class
對(duì)象在單體程序中確實(shí)只存在一個(gè),但目前商品服務(wù)是屬于多應(yīng)用/多機(jī)器部署的,目前商品服務(wù)有兩個(gè)進(jìn)程,那這就代表著存在兩個(gè)不同的Java堆,在兩個(gè)不同的堆空間中,都存在各自的InventoryApi.class
對(duì)象,也就是代表著“此時(shí)Class
對(duì)象并不是唯一的,此刻出現(xiàn)了兩把鎖”。而正是因?yàn)檫@個(gè)問題才最終導(dǎo)致了線程安全問題的出現(xiàn)。如下圖:
還是回到最開始敘述線程安全的起點(diǎn),同一時(shí)刻滿足三要素:“多線程”、“共享資源”以及“非原子性操作”便會(huì)產(chǎn)生線程安全問題,而
Synchronized
或ReetrantLock
都是從破壞了第一個(gè)要素的角度出發(fā),以此解決了線程安全問題。但目前在分布式架構(gòu)中,Synchronized
或ReetrantLock
因?yàn)槎噙M(jìn)程導(dǎo)致的多個(gè)Java空間堆出現(xiàn),所以不管是Synchronized
還是ReetrantLock
都已經(jīng)無法保證同一時(shí)刻只有一條線程對(duì)共享資源進(jìn)行非原子性操作,最終線程安全問題的出現(xiàn)已經(jīng)無法避免。
二、分布式鎖思想及其實(shí)現(xiàn)過程推導(dǎo)
經(jīng)過前面的分析,已經(jīng)得知了分布式架構(gòu)下的線程安全問題產(chǎn)生的根本原因,那目前又該如何解決這個(gè)問題呢?可能學(xué)習(xí)過分布式鎖的小伙伴會(huì)直接回答:分布式鎖。但此刻請(qǐng)你拋下之前的記憶,我們完全從零開始對(duì)分布式鎖進(jìn)行推導(dǎo)。
大家可以先代入一個(gè)角色:假設(shè)市面上還沒有成熟的解決方案,你是第一位遇到這個(gè)問題的人,那么你又該如何去解決這類問題呢?OK~,接下來我們一起逐步推導(dǎo)。
先思考思考前面的Synchronized、ReetrantLock
是如何解決單體程序的線程安全問題的呢?
- Synchronized:
- 依賴堆中對(duì)象的Monitor監(jiān)視器
- 通過操作監(jiān)視器中的_count字段實(shí)現(xiàn)互斥
- ReetrantLock:
- 依賴于AQS同步器
- 通過操作AQS中volatile修飾的state成員實(shí)現(xiàn)互斥
它們有什么共同點(diǎn)呢?都存在一個(gè)互斥量,并且互斥量都是所有線程可見的。
OK~,明白了這兩點(diǎn)之后,再反過來思考一下,分布式環(huán)境中又是因?yàn)槭裁丛驅(qū)е碌陌踩珕栴}復(fù)發(fā)了呢?答案是:互斥量所在的區(qū)域?qū)τ谄渌M(jìn)程中的線程來說是不可見的。比如Synchronized關(guān)鍵字通過某個(gè)Class對(duì)象作為鎖對(duì)象,一個(gè)堆空間中的Class對(duì)象對(duì)于當(dāng)前進(jìn)程中的所有線程來說是可見的,但是對(duì)于其他進(jìn)程的線程是不可見的。ReetrantLock也是同理,volatile修飾的state變量,對(duì)于當(dāng)前進(jìn)程中的所有線程可見,但對(duì)于另外進(jìn)程中的線程是不可見的。
那么此時(shí)想要解決分布式情況下的線程安全問題的思路是不是明了啦?
我們只需要找一個(gè)多個(gè)進(jìn)程之間所有線程可見的區(qū)域?qū)崿F(xiàn)這個(gè)互斥量即可。
比如:在一臺(tái)服務(wù)器的同一路徑下創(chuàng)建一個(gè)文件夾。獲取鎖操作則是創(chuàng)建文件夾,反之,釋放鎖的邏輯則是刪除文件夾,這樣可以很好的實(shí)現(xiàn)一把分布式鎖,因?yàn)镺S特性規(guī)定,在同一路徑下,相同名字的文件夾只能創(chuàng)建一個(gè)。所以當(dāng)兩條線程同時(shí)執(zhí)行獲取鎖邏輯時(shí),永遠(yuǎn)只會(huì)有一條線程創(chuàng)建成功,成功創(chuàng)建文件夾的那條線程則代表獲取鎖成功,那么可以去執(zhí)行業(yè)務(wù)邏輯。當(dāng)這條線程執(zhí)行完業(yè)務(wù)后,再刪除掉文件夾,代表釋放鎖,以便于其他線程可以再次獲取鎖。
上述的這種方式確實(shí)可以實(shí)現(xiàn)一把最基本的分布式鎖,但問題在于:這樣實(shí)現(xiàn)的話一方面性能會(huì)比較差,第二個(gè)也不能具備鎖重入的功能,第三方面也沒有具備合理的鎖失效機(jī)制以及阻塞機(jī)制。而一個(gè)優(yōu)秀的分布式鎖的實(shí)現(xiàn)方案應(yīng)該滿足如下幾個(gè)特性:
- ①在分布式環(huán)境中,可以保證不同進(jìn)程之間的線程互斥
- ②在同一時(shí)刻,同時(shí)只允許一條線程成功獲取到鎖資源
- ③保存互斥量的地方需要保證高可用性
- ④要保證可以高性能的獲取鎖與釋放鎖
- ⑤可以支持同一線程的鎖重入性
- ⑥具備合理的阻塞機(jī)制,競(jìng)爭(zhēng)鎖失敗的線程也有處理方案
- ⑦支持非阻塞式獲取鎖,獲取鎖失敗的線程可以直接返回
- ⑧具備合理的鎖失效機(jī)制,如超時(shí)失效等,可以確保避免死鎖情況出現(xiàn)
那么目前市面上對(duì)于分布式鎖的成熟方案有哪些呢?
- ①基于DB實(shí)現(xiàn)
- ②基于Redis實(shí)現(xiàn)
- ③基于Zookeeper實(shí)現(xiàn)
對(duì)于第一種方式的實(shí)現(xiàn)并不難,無非是在數(shù)據(jù)庫(kù)中創(chuàng)建一張lock
表,表中設(shè)置方法名、線程ID等字段。并為方法名字段建立唯一索引,當(dāng)線程執(zhí)行某個(gè)方法需要獲取鎖時(shí),就以這個(gè)方法名作為數(shù)據(jù)向表中插入,如果插入成功代表獲取鎖成功,如果插入失敗,代表有其他線程已經(jīng)在此之前持有鎖了,當(dāng)前線程可以阻塞等待或直接返回。同時(shí),也可以基于表中的線程ID字段為鎖重入提供支持。當(dāng)然,當(dāng)持有鎖的線程業(yè)務(wù)邏輯執(zhí)行完成后,應(yīng)當(dāng)刪除對(duì)應(yīng)的數(shù)據(jù)行,以此達(dá)到釋放鎖的目的。
這種方式依靠于數(shù)據(jù)庫(kù)的唯一索引,所以實(shí)現(xiàn)起來比較簡(jiǎn)單,但是問題在于:因?yàn)槭腔跀?shù)據(jù)庫(kù)實(shí)現(xiàn)的,所以獲取鎖、釋放鎖等操作都要涉及到數(shù)據(jù)落盤、刪盤等磁盤IO操作,性能方面值得考慮。同時(shí)也對(duì)于超時(shí)失效機(jī)制很難提供支持,在實(shí)現(xiàn)過程中也會(huì)出現(xiàn)很多其他問題,為了確保解決各類問題,實(shí)現(xiàn)的方式也會(huì)越發(fā)復(fù)雜。
OK~,那么接下來再看看其他兩種主流方案的實(shí)現(xiàn),redis以及ZK實(shí)現(xiàn)分布式鎖,也是目前應(yīng)用最廣泛的方式。
三、Redis實(shí)現(xiàn)分布式鎖及其細(xì)節(jié)問題分析
Redis實(shí)現(xiàn)分布式鎖是目前使用最廣泛的方式之一,因?yàn)镽edis屬于中間件,獨(dú)立部署在外,不附屬于任何一個(gè)Java程序,對(duì)于不同的Java進(jìn)程來說,都是可見的,同時(shí)它的性能也非常可觀,可以依賴于其本身提供的指令setnx key value
實(shí)現(xiàn)分布式鎖。
setnx key value
:往Redis中寫入一個(gè)K-V值。不過與普通的set
指令不同的是:setnx
只有當(dāng)key不存在時(shí)才會(huì)設(shè)置成功,當(dāng)key已存在時(shí),會(huì)返回設(shè)置失敗。同時(shí)因?yàn)閞edis對(duì)于客戶端的指令請(qǐng)求處理時(shí),是使用epoll多路復(fù)用模型的,所以當(dāng)同時(shí)多條線程一起向redis服務(wù)端發(fā)送setnx
指令時(shí),只會(huì)有一條線程設(shè)置成功。最終可以依賴于redis這些特性實(shí)現(xiàn)分布式鎖。
OK~,下面通過setnx key value
實(shí)現(xiàn)最基本的分布式鎖,如下:
// 庫(kù)存服務(wù)
@RestController
@RequestMapping("/inventory")
public class InventoryApi{
@Autowired
private InventoryService inventoryService;
@Value("${server.port}")
private String port;
// 注入Redis客戶端
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 扣庫(kù)存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
// 獲取鎖
String lockKey = "lock-" + inventory.getInventoryId();
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "竹子-熊貓");
if(!flag){
// 非阻塞式實(shí)現(xiàn)
return "服務(wù)器繁忙...請(qǐng)稍后重試?。?!";
// 自旋式實(shí)現(xiàn)(這種實(shí)現(xiàn)比較耗性能,
// 實(shí)際開發(fā)過程中需配合阻塞時(shí)間配合使用)
// return minusInventory(inventory);
}
// ----只有獲取鎖成功才能執(zhí)行下述的減庫(kù)存業(yè)務(wù)----
try{
// 查詢庫(kù)存信息
Inventory inventoryResult =
inventoryService.selectByPrimaryKey(inventory.getInventoryId());
if (inventoryResult.getShopCount() <= 0) {
return "庫(kù)存不足,請(qǐng)聯(lián)系賣家....";
}
// 扣減庫(kù)存
inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
int n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
} catch (Exception e) { // 確保業(yè)務(wù)出現(xiàn)異常也可以釋放鎖,避免死鎖
// 釋放鎖
stringRedisTemplate.delete(lockKey);
}
if (n > 0)
return "端口-" + port + ",庫(kù)存扣減成功?。。?;
return "端口-" + port + ",庫(kù)存扣減失?。。。?;
}
}
如上源碼,實(shí)現(xiàn)了一把最基本的分布式鎖,使用setnx
指令往redis中寫入一條數(shù)據(jù),以當(dāng)前商品ID作為key值,這樣可以確保鎖粒度得到控制。同時(shí)也使用try-catch
保證業(yè)務(wù)執(zhí)行出錯(cuò)時(shí)也能釋放鎖,可以有效避免死鎖問題出現(xiàn)。
一條線程(一個(gè)請(qǐng)求)想要執(zhí)行扣庫(kù)存業(yè)務(wù)時(shí),需要先往redis寫入數(shù)據(jù),當(dāng)寫入成功時(shí)代表獲取鎖成功,獲取鎖成功的線程可以執(zhí)行業(yè)務(wù)。反之,寫入失敗的線程代表已經(jīng)有線程在之前已經(jīng)獲取鎖了,可以自己處理獲取鎖失敗的邏輯,如上源碼實(shí)現(xiàn)了非阻塞式獲取鎖(可自行實(shí)現(xiàn)阻塞+重試+次數(shù)控制)。
3.1、宕機(jī)/重啟死鎖問題分析
前面已經(jīng)通過Redis實(shí)現(xiàn)了一把最基本的分布式鎖,但問題在于:假設(shè)8001
機(jī)器的線程T1
剛剛獲取鎖成功,但不巧的是:8001
所在的服務(wù)器宕機(jī)或斷電重啟了。那此時(shí)又會(huì)出現(xiàn)問題:獲取到鎖的T1
線程因?yàn)樗谶M(jìn)程/服務(wù)器掛了,所以T1
線程也會(huì)被迫死亡,那此時(shí)try-catch
也無法保證鎖的釋放,T1
線程不釋放鎖,其他線程嘗試setnx
獲取鎖時(shí)也不會(huì)成功,最終導(dǎo)致了死鎖現(xiàn)象的出現(xiàn)。那這個(gè)問題又該如何解決呢?加上Key過期時(shí)間即可。如下:
// 扣庫(kù)存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
// 獲取鎖
String lockKey = "lock-" + inventory.getInventoryId();
int timeOut = 100;
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "竹子-熊貓");
// 加上過期時(shí)間,可以保證死鎖也會(huì)在一定時(shí)間內(nèi)釋放鎖
stringRedisTemplate.expire(lockKey,timeOut,TimeUnit.SECONDS);
if(!flag){
// 非阻塞式實(shí)現(xiàn)
return "服務(wù)器繁忙...請(qǐng)稍后重試?。?!";
// 自旋式實(shí)現(xiàn)(這種實(shí)現(xiàn)比較耗性能,
// 實(shí)際開發(fā)過程中需配合阻塞時(shí)間配合使用)
// return minusInventory(inventory);
}
// ----只有獲取鎖成功才能執(zhí)行下述的減庫(kù)存業(yè)務(wù)----
try{
// 查詢庫(kù)存信息
Inventory inventoryResult =
inventoryService.selectByPrimaryKey(inventory.getInventoryId());
if (inventoryResult.getShopCount() <= 0) {
return "庫(kù)存不足,請(qǐng)聯(lián)系賣家....";
}
// 扣減庫(kù)存
inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
int n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
} catch (Exception e) { // 確保業(yè)務(wù)出現(xiàn)異常也可以釋放鎖,避免死鎖
// 釋放鎖
stringRedisTemplate.delete(lockKey);
}
if (n > 0)
return "端口-" + port + ",庫(kù)存扣減成功?。。?;
return "端口-" + port + ",庫(kù)存扣減失敗?。。?;
}
如上,在基礎(chǔ)版的分布式鎖中,再加上一個(gè)超時(shí)失效的機(jī)制,這樣可以有效避免死鎖的情況出現(xiàn)。就算獲取到分布式鎖的那條線程所在機(jī)器不小心宕機(jī)或重啟了,導(dǎo)致無法釋放鎖,那也不會(huì)產(chǎn)生死鎖情況,因?yàn)镵ey設(shè)置了過期時(shí)間,在設(shè)定時(shí)間內(nèi),如果沒有釋放鎖,那么時(shí)間一到,Redis會(huì)自動(dòng)釋放鎖,以確保其他程序的線程可以獲取到鎖。
OK,解決掉宕機(jī)死鎖的問題后,再來看看我們自己實(shí)現(xiàn)的這個(gè)分布式鎖是否還有缺陷呢?
3.2、加鎖與過期時(shí)間原子性問題分析
從上一步的敘述中,通過設(shè)定過期時(shí)間的方式解決了宕機(jī)死鎖問題,但問題在于:前面分析過,Redis處理客戶端指令時(shí)采用的是單線程多路復(fù)用的模型,這就代表著只會(huì)有一條線程在處理所有客戶端的請(qǐng)求,因?yàn)閷?shí)際開發(fā)過程中,往往會(huì)有多處同時(shí)操作redis,而前面的加鎖與設(shè)置過期時(shí)間兩條指令對(duì)于redis是分開的,這兩條指令在執(zhí)行時(shí)不一定可以確保同時(shí)執(zhí)行,如下:
從上圖可以看到,加鎖與設(shè)置過期時(shí)間這兩條指令不一定會(huì)隨時(shí)執(zhí)行,那會(huì)出現(xiàn)什么問題呢?因?yàn)橹噶钍欠珠_執(zhí)行的,所以原子性沒有保證,有可能導(dǎo)致時(shí)間和小幾率死鎖問題出現(xiàn)。所以加鎖和設(shè)置過期時(shí)間這兩條指令需保證原子性,怎么操作呢??jī)煞N方式:
- ①通過Lua語(yǔ)言編寫腳本執(zhí)行:
- redis是支持lua腳本執(zhí)行的,會(huì)把lua編譯成sha指令,支持同時(shí)執(zhí)行多條指令
- ②通過redis提供的原子性指令執(zhí)行:
- 在redis2.7版本后提供了一些原子性指令,其中就包括set指令,如下:
set key value ex 100 nx
- 可以通過這條指令代替之前的
setnx與expire
兩條指令
- 在redis2.7版本后提供了一些原子性指令,其中就包括set指令,如下:
那在Java程序中又該如何修改代碼呢?實(shí)則非常簡(jiǎn)單,在SpringBoot
整合Redis
的模板中,只需要把如上代碼稍微修改一下即可。如下:
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "竹子-熊貓",timeOut,TimeUnit.SECONDS);
3.3、過期時(shí)間的合理性分析
前面關(guān)于死鎖的問題已經(jīng)通過合理的鎖過期失效機(jī)制解決了,那再來思考一下這個(gè)過期時(shí)間的設(shè)定是否合理呢?前面案例中,設(shè)置的是100s
過期時(shí)間,乍一看感覺沒啥問題,但仔細(xì)往深處一想:如果一條線程獲取鎖之后死亡了,其他線程想要獲取鎖其不是需要等到100s
之后?因?yàn)樾枰鹊絩edis過期刪除后,釋放了鎖才可獲取。這明顯不合理,100s
的時(shí)間,如果分布式鎖設(shè)計(jì)成了阻塞可重試類型的,那么可能會(huì)讓當(dāng)前程序在100s
內(nèi)堆積大量請(qǐng)求,同時(shí)對(duì)于用戶體驗(yàn)感也并不好,在這一百秒內(nèi),用戶無論怎么重試,都不會(huì)下單成功,所以時(shí)間太長(zhǎng)顯然不合理。
那可能有小伙伴會(huì)說:“簡(jiǎn)單!時(shí)間設(shè)置的短一些不就好了嘛”,比如設(shè)置成5s。
確實(shí),一聽也沒啥毛病,但再仔細(xì)一想:分布式系統(tǒng)中,業(yè)務(wù)執(zhí)行往往會(huì)設(shè)計(jì)多個(gè)系統(tǒng)的RPC遠(yuǎn)程調(diào)用,因?yàn)槠渲猩婕熬W(wǎng)絡(luò)調(diào)用,網(wǎng)絡(luò)存在不穩(wěn)定因素,所以往往一個(gè)業(yè)務(wù)執(zhí)行的時(shí)間是很難具體的。這樣下來,如果設(shè)置的時(shí)間太短,最終可能會(huì)造成一個(gè)問題出現(xiàn):業(yè)務(wù)執(zhí)行時(shí)長(zhǎng)超過鎖過期時(shí)長(zhǎng),導(dǎo)致redis中key過期,最終redis釋放了鎖。而這個(gè)問題則會(huì)引發(fā)分布式鎖的ABC問題,如下:
線程A:獲取鎖成功,設(shè)置過期時(shí)間為5s,執(zhí)行業(yè)務(wù)邏輯
線程B:獲取鎖失敗,阻塞等待并在一定時(shí)間后重試獲取鎖
線程C:獲取鎖失敗,阻塞等待并在一定時(shí)間后重試獲取鎖
假設(shè)此時(shí)線程A執(zhí)行業(yè)務(wù),因?yàn)榫W(wǎng)絡(luò)波動(dòng)等原因,執(zhí)行總時(shí)長(zhǎng)花費(fèi)了7s,那么redis會(huì)在第五秒時(shí)將key刪除,因?yàn)檫^期了。
而恰巧線程B正好在第六秒時(shí),重試獲取鎖,喲嚯~,線程B一試,發(fā)現(xiàn)key不在了,自然就獲取鎖成功了。
到了第七秒時(shí),線程A執(zhí)行完了業(yè)務(wù),直接執(zhí)行了del lock_商品ID
的指令,刪除了key,但此時(shí)這個(gè)鎖已經(jīng)是線程B的了,線程A的鎖已經(jīng)被redis放掉了,所以線程A釋放掉了線程B的鎖。
最后,線程C醒了,去重試時(shí),又發(fā)現(xiàn)redis中沒有了Key,也獲取鎖成功,執(zhí)行......
通過如上案例分析,不難發(fā)現(xiàn)一個(gè)問題,過期時(shí)間設(shè)置的太短,會(huì)導(dǎo)致鎖資源錯(cuò)亂,出現(xiàn)ABC問題。如上問題主要是由于兩個(gè)原因?qū)е碌模孩冁i過期時(shí)間太短 ②非加鎖線程也可以釋放鎖。第二個(gè)問題待會(huì)兒再解決,目前先看看鎖時(shí)長(zhǎng)怎么才能設(shè)置合理這個(gè)問題。
經(jīng)過前面分析,我們發(fā)現(xiàn)這個(gè)時(shí)間設(shè)置長(zhǎng)了不合適,要是短了那更不行,此時(shí)進(jìn)退兩難,那怎么解決呢?實(shí)際上也比較容易,可以設(shè)置一條子線程,給當(dāng)前鎖資源續(xù)命。
開啟一條子線程,間隔2-3s去查詢一次Key是否過期,如果過期了則代表業(yè)務(wù)線程已經(jīng)釋放了鎖,如果未過期,代表業(yè)務(wù)線程還在執(zhí)行業(yè)務(wù),那么則對(duì)于key的過期時(shí)間再加上5S秒鐘。為了避免業(yè)務(wù)線程死亡,當(dāng)前子線程一直續(xù)命,造成“長(zhǎng)生鎖”導(dǎo)致死鎖的情況出現(xiàn),可以把子線程變?yōu)闃I(yè)務(wù)線程的守護(hù)線程,這樣可以有效避免這個(gè)問題的出現(xiàn),實(shí)現(xiàn)如下:
// 續(xù)命子線程
public class GuardThread extends Thread {
// 原本的key和過期時(shí)間
private String lockKey;
private int timeOut;
private StringRedisTemplate stringRedisTemplate;
private static boolean flag = true;
public GuardThread(String lockKey,
int timeOut, StringRedisTemplate stringRedisTemplate){
this.lockKey = lockKey;
this.timeOut = timeOut;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public void run() {
// 開啟循環(huán)續(xù)命
while (flag){
try {
// 先休眠一半的時(shí)間
Thread.sleep(timeOut / 2 * 1000);
}catch (Exception e){
e.printStackTrace();
}
// 時(shí)間過了一半之后再去續(xù)命
// 先查看key是否過期
Long expire = stringRedisTemplate.getExpire(
lockKey, TimeUnit.SECONDS);
// 如果過期了,代表主線程釋放了鎖
if (expire <= 0){
// 停止循環(huán)
flag = false;
}
// 如果還未過期
// 再為則續(xù)命一半的時(shí)間
stringRedisTemplate.expire(lockKey,expire
+ timeOut/2,TimeUnit.SECONDS);
}
}
}
// 扣庫(kù)存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
// 獲取鎖
String lockKey = "lock-" + inventory.getInventoryId();
int timeOut = 10;
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "竹子-熊貓",timeOut,TimeUnit.SECONDS);
if(!flag){
// 非阻塞式實(shí)現(xiàn)
return "服務(wù)器繁忙...請(qǐng)稍后重試!?。?;
// 自旋式實(shí)現(xiàn)(這種實(shí)現(xiàn)比較耗性能,
// 實(shí)際開發(fā)過程中需配合阻塞時(shí)間配合使用)
// return minusInventory(inventory);
}
// 創(chuàng)建子線程為鎖續(xù)命
GuardThread guardThread = new
GuardThread(lockKey,timeOut,stringRedisTemplate);
// 設(shè)置為當(dāng)前 業(yè)務(wù)線程 的守護(hù)線程
guardThread.setDaemon(true);
guardThread.start();
// ----只有獲取鎖成功才能執(zhí)行下述的減庫(kù)存業(yè)務(wù)----
try{
// 查詢庫(kù)存信息
Inventory inventoryResult =
inventoryService.selectByPrimaryKey(inventory.getInventoryId());
if (inventoryResult.getShopCount() <= 0) {
return "庫(kù)存不足,請(qǐng)聯(lián)系賣家....";
}
// 扣減庫(kù)存
inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
int n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
} catch (Exception e) { // 確保業(yè)務(wù)出現(xiàn)異常也可以釋放鎖,避免死鎖
// 釋放鎖
stringRedisTemplate.delete(lockKey);
}
if (n > 0)
return "端口-" + port + ",庫(kù)存扣減成功?。。?;
return "端口-" + port + ",庫(kù)存扣減失敗?。?!";
}
如上實(shí)現(xiàn),利用了一條子線程為分布式鎖續(xù)命,同時(shí)為了確保主線程意外死亡等問題造成一直續(xù)命,所以將子線程變?yōu)榱酥?業(yè)務(wù))線程的守護(hù)線程,主線程死亡那么作為守護(hù)線程的子線程也會(huì)跟著死亡,可以有效避免“長(zhǎng)生鎖”的現(xiàn)象出現(xiàn)。
3.4、獲取鎖與釋放鎖線程一致性分析
在上一個(gè)問題中,我們?cè)岬揭_保加鎖與釋放鎖的線程一致性,這個(gè)問題比較好解決,只需要把value的值換成一個(gè)唯一值即可,然后在釋放鎖時(shí)判斷一下是否相等即可,如下:
// 扣庫(kù)存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
// 獲取鎖
String lockKey = "lock-" + inventory.getInventoryId();
// value值變?yōu)殡S機(jī)的UUID值
String lockValue = UUID.randomUUID().toString();
int timeOut = 10;
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey,lockValue,timeOut,TimeUnit.SECONDS);
// 省略其他代碼.....
// ----只有獲取鎖成功才能執(zhí)行下述的減庫(kù)存業(yè)務(wù)----
try{
// 省略其他代碼.....
} catch (Exception e) {
// 先判斷是否是當(dāng)前線程加的鎖
if(lockVlue!=stringRedisTemplate.opsForValue().get(lockKey)){
// 不是則拋出異常
throw new RuntimeException("非法釋放鎖....");
}
// 確實(shí)是再釋放鎖
stringRedisTemplate.delete(lockKey);
}
// 省略其他代碼.....
}
在獲取鎖的時(shí)候,把寫入redis的value值換成一個(gè)隨機(jī)的UUID,然后在釋放鎖之前,先判斷一下是否為當(dāng)前線程加的鎖,確實(shí)為當(dāng)前線程加的鎖那么則釋放,反之拋出異常。
3.5、Redis主從架構(gòu)鎖失效問題分析
在一般開發(fā)過程中,為了保證Redis的高可用,都會(huì)采用主從復(fù)制架構(gòu)做讀寫分離,從而提升Redis整體的吞吐量以及可用性。但問題在于:Redis的主從架構(gòu)下,實(shí)現(xiàn)分布式鎖時(shí)有可能會(huì)導(dǎo)致鎖失效,為什么呢?
因?yàn)閞edis主從架構(gòu)中的數(shù)據(jù)不是實(shí)時(shí)復(fù)制的,而是定時(shí)/定量復(fù)制。也就代表著一條數(shù)據(jù)寫入了redis主機(jī)后,并不會(huì)同時(shí)同步給所有的從機(jī),寫入的指令只要在主機(jī)上寫入成功會(huì)立即返回寫入成功,數(shù)據(jù)同步則是在一定時(shí)間或一定量之后才同步給從機(jī)。
這樣聽著感覺也沒啥問題,但再仔細(xì)一思考,如果8001
的線程A剛剛往主機(jī)中寫入了Key,成功獲取到了分布式鎖,但redis主機(jī)還沒來得及把新數(shù)據(jù)同步給從機(jī),正巧因?yàn)橐馔忮礄C(jī)了,此時(shí)發(fā)生主從切換,而推選出來的新主也就是原本的舊從,因?yàn)榍懊驽礄C(jī)的主機(jī)還有部分?jǐn)?shù)據(jù)未復(fù)制過來,所以新主上不會(huì)有線程A的鎖記錄,此時(shí)8002
的線程T1
來嘗試獲取鎖,發(fā)生新主上沒有鎖記錄,那么則獲取鎖成功,此時(shí)又存在了兩條線程同時(shí)獲取到了鎖資源,同時(shí)執(zhí)行業(yè)務(wù)邏輯了。
OK~,如上描述的便是Redis主從架構(gòu)導(dǎo)致的分布式鎖失效問題,此時(shí)這個(gè)問題又該如何解決呢?方案如下:
- ①紅鎖算法:多臺(tái)獨(dú)立的Redis同時(shí)寫入數(shù)據(jù),鎖失效時(shí)間之內(nèi),一半以上的機(jī)器寫成功則返回獲取鎖成功,否則返回獲取鎖失敗,失敗時(shí)會(huì)釋放掉那些成功的機(jī)器上的鎖。
- 優(yōu)點(diǎn):可以完美解決掉主從架構(gòu)帶來的鎖失效問題
- 缺點(diǎn):成本高,需要線上部署多臺(tái)獨(dú)立的Redis節(jié)點(diǎn)
- 這種算法是Redis官方提出的解決方案:紅鎖算法
- ②額外記錄鎖狀態(tài):再通過額外的中間件等獨(dú)立部署的節(jié)點(diǎn)記錄鎖狀態(tài),比如在DB中記錄鎖狀態(tài),在嘗試獲取分布式鎖之前需先查詢DB中的鎖持有記錄,如果還在持有則繼續(xù)阻塞,只有當(dāng)狀態(tài)為未持有時(shí)再嘗試獲取分布式鎖。
- 優(yōu)點(diǎn):可以依賴于項(xiàng)目中現(xiàn)有的節(jié)點(diǎn)實(shí)現(xiàn),節(jié)約部署成本
- 缺點(diǎn):
- 實(shí)現(xiàn)需要配合定時(shí)器實(shí)現(xiàn)過期失效,保證鎖的合理失效機(jī)制
- 獲取鎖的性能方面堪憂,會(huì)大大增加獲取鎖的性能開銷
- 所有過程都需自己實(shí)現(xiàn),實(shí)現(xiàn)難度比較復(fù)雜
- 總結(jié):這種方式類似于兩把分布式鎖疊加實(shí)現(xiàn),先獲取一把后再獲取另一把
- ③Zookeeper實(shí)現(xiàn):使用Zookeeper代替Redis實(shí)現(xiàn),因?yàn)閆ookeeper追求的是高穩(wěn)定,所以Zookeeper實(shí)現(xiàn)分布式鎖時(shí),不會(huì)出現(xiàn)這個(gè)問題(稍后分析)
3.6、Redisson框架中的分布式鎖
在上述的內(nèi)容中,曾從分布式鎖的引出到自己實(shí)現(xiàn)的每個(gè)細(xì)節(jié)問題進(jìn)行了分析,但實(shí)際開發(fā)過程中并不需要我們自己去實(shí)現(xiàn),因?yàn)樽约簩?shí)現(xiàn)的分布式鎖多多少少會(huì)存在一些隱患問題。而這些工作實(shí)際已經(jīng)有框架封裝了,比如:Redisson框架,其內(nèi)部已經(jīng)基于redis為我們封裝好了分布式鎖,開發(fā)過程中屏蔽了底層處理,讓我們能夠像使用ReetrantLock
一樣使用分布式鎖,如下:
/* ---------pom.xml文件-------- */
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.8.2</version>
</dependency>
/* ---------application.yml文件-------- */
spring:
redis:
database: 0
host: 192.168.12.130
port: 6379
password: 123456
timeout: 2m
// 注入redisson的客戶端
@Autowired
private RedissonClient redisson;
// 寫入redis的key值
String lockKey = "lock-" + inventory.getInventoryId();
// 獲取一個(gè)Rlock鎖對(duì)象
RLock lock = redisson.getLock(lockKey);
// 獲取鎖,并為其設(shè)置過期時(shí)間為10s
lock.lock(10,TimeUnit.SECONDS);
try{
// 執(zhí)行業(yè)務(wù)邏輯....
} finally {
// 釋放鎖
lock.unlock();
}
/* ---------RedissonClient配置類-------- */
@Configuration
public class RedissonConfig {
// 讀取配置文件中的配置信息
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Value("${spring.redis.password}")
private String password;
// 注入RedissonClient的Bean交由Spring管理
@Bean
public RedissonClient redisson() {
//單機(jī)模式
Config config = new Config();
config.useSingleServer().
setAddress("redis://" + host + ":" + port).
setPassword(password).setDatabase(0);
return Redisson.create(config);
}
}
如上源碼,即可獲得一把最基本的分布式鎖,同時(shí)除開最基本的加鎖方法外,還支持其他形式的獲取鎖:
-
lock.tryLock(20,10,TimeUnit.SECONDS)
:非阻塞式獲取鎖,在獲取鎖失敗后的20s內(nèi)一直嘗試重新獲取鎖,超出20s則直接返回獲取鎖失敗 -
lock.lockAsync(10,TimeUnit.SECONDS)
:異步阻塞式獲取鎖,可以支持異步獲取加鎖的結(jié)果,該方法會(huì)返回一個(gè)Future
對(duì)象,可通過Future
對(duì)象異步獲取加鎖結(jié)果 -
lock.tryLockAsync(20,10,TimeUnit.SECONDS)
:異步非阻塞式獲取鎖,比上面那個(gè)多了一個(gè)超時(shí)時(shí)間
同時(shí)Redisson框架中的鎖實(shí)現(xiàn)還不僅僅只有一種,如下:
- FairLock公平鎖:與
ReetrantLock
一樣,支持創(chuàng)建公平鎖,即先到的線程一定優(yōu)化獲取鎖 - MultiLock連鎖:多個(gè)
RLock
對(duì)象組成一把鎖,也就是幾把鎖組成的一把鎖,可以用來實(shí)現(xiàn)紅鎖算法,因?yàn)?code>RLock對(duì)象可以不是一個(gè)Redisson
創(chuàng)建出來的,也就是可以使用多個(gè)Redis客戶端的連接對(duì)象獲取多把鎖組成連鎖,只有當(dāng)所有個(gè)鎖獲取成功后,才能返回獲取鎖成功,如果獲取一把個(gè)鎖失敗,則返回獲取鎖失敗 - RedLock紅鎖:和前面分析的Redis官方給出的紅鎖算法實(shí)現(xiàn)一致,繼承了連鎖,主要用于解決主從架構(gòu)鎖失效的問題
3.7、Redisson框架中的連鎖分析
連鎖向上繼承了RLock
,向下為RedLock
提供了實(shí)現(xiàn),所以它是Redisson框架中最為關(guān)鍵的一種鎖,先來看看它的使用方式:
// 獲取多個(gè)RLock鎖對(duì)象(redisson可以是不同的客戶端)
RLock lock1 = redisson.getLock("lock-1");
RLock lock2 = redisson.getLock("lock-2");
RLock lock3 = redisson.getLock("lock-3");
// 將多把鎖組合成一把連鎖,通過連鎖進(jìn)行獲取鎖與釋放鎖操作
RedissonMultiLock lock = new RedissonMultiLock(lock1,lock2,lock3);
// 獲取鎖:一半以上的鎖獲取成功才能成功,反之刪除寫入成功的節(jié)點(diǎn)數(shù)據(jù)
lock.lock();
// 釋放鎖
lock.unlock();
使用方式并不難理解,只需要?jiǎng)?chuàng)建多個(gè)RLock
鎖對(duì)象后,再通過多個(gè)鎖對(duì)象組和成一把連鎖,通過連鎖對(duì)象進(jìn)行獲取鎖與釋放鎖的操作即可。
3.8、Redisson框架中的連鎖源碼實(shí)現(xiàn)分析
OK~,上面簡(jiǎn)單的給出了MultiLock
連鎖的使用方式,接下來重點(diǎn)分析一下它的源碼實(shí)現(xiàn),源碼如下:
// RedissonMultiLock類 → lock()方法
public void lock() {
try {
// 調(diào)用了lockInterruptibly獲取鎖
this.lockInterruptibly();
} catch (InterruptedException var2) {
// 如果出現(xiàn)異常則中斷當(dāng)前線程
Thread.currentThread().interrupt();
}
}
// RedissonMultiLock類 → lockInterruptibly()方法
public void lockInterruptibly() throws InterruptedException {
// 這里傳入了-1
this.lockInterruptibly(-1L, (TimeUnit)null);
}
// RedissonMultiLock類 → lockInterruptibly()重載方法
public void lockInterruptibly(long leaseTime, TimeUnit unit)
throws InterruptedException {
// 計(jì)算基礎(chǔ)阻塞時(shí)間:使用鎖的個(gè)數(shù)*1500ms。
// 比如之前的案例:3*1500=4500ms
long baseWaitTime = (long)(this.locks.size() * 1500);
long waitTime = -1L;
// 前面?zhèn)魅肓?1,所以進(jìn)入的是if分支
if (leaseTime == -1L) {
// 掛起時(shí)間為4500,單位毫秒(MS)
waitTime = baseWaitTime;
unit = TimeUnit.MILLISECONDS;
}
// 這里是對(duì)于外部獲取鎖時(shí),指定了時(shí)間情況時(shí)的處理邏輯
else {
// 將外部傳入的時(shí)間轉(zhuǎn)換為毫秒值
waitTime = unit.toMillis(leaseTime);
// 如果外部給定的時(shí)間小于2000ms,那么賦值為2s
if (waitTime <= 2000L) {
waitTime = 2000L;
}
// 如果傳入的時(shí)間小于前面計(jì)算出的基礎(chǔ)時(shí)間
else if (waitTime <= baseWaitTime) {
// 獲取基礎(chǔ)時(shí)間的一半,如baseWaitTime=4500ms,waitTime=2250ms
waitTime = ThreadLocalRandom.current().
nextLong(waitTime / 2L, waitTime);
} else {
// 如果外部給定的時(shí)間大于前面計(jì)算出的基礎(chǔ)時(shí)間會(huì)進(jìn)這里
// 將基礎(chǔ)時(shí)間作為阻塞時(shí)長(zhǎng)
waitTime = ThreadLocalRandom.current().
nextLong(baseWaitTime, waitTime);
}
// 最終計(jì)算出掛起的時(shí)間
waitTime = unit.convert(waitTime, TimeUnit.MILLISECONDS);
}
// 自旋嘗試獲取鎖,直至獲取鎖成功
while(!this.tryLock(waitTime, leaseTime, unit)) {
;
}
}
上述源碼中,實(shí)際上不難理解,比之前文章中分析的JUC的源碼可讀性強(qiáng)很多,上述代碼中,簡(jiǎn)單的計(jì)算了一下時(shí)間后,最終自旋調(diào)用了tryLock
獲取鎖的方法一直嘗試獲取鎖。接著來看看tryLock
方法:
// RedissonMultiLock類 → tryLock()方法
public boolean tryLock(long waitTime, long leaseTime,
TimeUnit unit) throws InterruptedException {
long newLeaseTime = -1L;
// 如果外部獲取鎖時(shí),給定了過期時(shí)間
if (leaseTime != -1L) {
// 將newLeaseTime變?yōu)榻o定時(shí)間的兩倍
newLeaseTime = unit.toMillis(waitTime) * 2L;
}
// 獲取當(dāng)前時(shí)間
long time = System.currentTimeMillis();
long remainTime = -1L;
// 如果不是非阻塞式獲取鎖
if (waitTime != -1L) {
// 將過期時(shí)間改為用戶給定的時(shí)間
remainTime = unit.toMillis(waitTime);
}
// 該方法是空實(shí)現(xiàn),留下的拓展接口,直接返回了傳入的值
long lockWaitTime = this.calcLockWaitTime(remainTime);
// 返回0,也是拓展接口,留給子類拓展的,紅鎖中就拓展了這兩方法
// 這個(gè)變量是允許失敗的最大次數(shù),紅鎖中為個(gè)數(shù)的一半
int failedLocksLimit = this.failedLocksLimit();
// 獲取組成連鎖的所有RLock鎖集合
List<RLock> acquiredLocks = new ArrayList(this.locks.size());
// 獲取list的迭代器對(duì)象
ListIterator iterator = this.locks.listIterator();
// 通過List的迭代器遍歷整個(gè)連鎖集合
while(iterator.hasNext()) {
RLock lock = (RLock)iterator.next();
boolean lockAcquired;
// 嘗試獲取鎖
try {
// 如果是非阻塞式獲取鎖
if (waitTime == -1L && leaseTime == -1L) {
// 直接嘗試獲取鎖
lockAcquired = lock.tryLock();
} else {
// 比較阻塞時(shí)間和過期時(shí)間的大小
long awaitTime = Math.min(lockWaitTime, remainTime);
// 嘗試重新獲取鎖
lockAcquired = lock.tryLock(awaitTime,
newLeaseTime, TimeUnit.MILLISECONDS);
}
// 如果redis連接中斷/關(guān)閉了
} catch (RedisConnectionClosedException var21) {
// 回滾獲取成功的鎖(刪除寫入成功的key)
this.unlockInner(Arrays.asList(lock));
lockAcquired = false;
// 如果在給定時(shí)間內(nèi)未獲取到鎖
} catch (RedisResponseTimeoutException var22) {
// 也回滾所有獲取成功的個(gè)鎖
this.unlockInner(Arrays.asList(lock));
lockAcquired = false;
} catch (Exception var23) {
// 如果是其他原因?qū)е碌?,則直接返回獲取鎖失敗
lockAcquired = false;
}
// 如果獲取一把個(gè)鎖成功
if (lockAcquired) {
// 那么則記錄獲取成功的個(gè)鎖
acquiredLocks.add(lock);
} else {
// 如果獲取一把個(gè)鎖失敗,此次失敗的次數(shù)已經(jīng)達(dá)到了
// 最大的失敗次數(shù),那么直接退出循環(huán),放棄加鎖操作
if (this.locks.size() - acquiredLocks.size()
== this.failedLocksLimit()) {
break;
}
// 允許失敗的次數(shù)未0,獲取一個(gè)個(gè)鎖失敗則回滾
if (failedLocksLimit == 0) {
// 回滾所有成功的鎖
this.unlockInner(acquiredLocks);
// 如果是非阻塞式獲取鎖,則直接返回獲取鎖失敗
if (waitTime == -1L && leaseTime == -1L) {
return false;
}
// 獲取最新的失敗鎖的個(gè)數(shù)
failedLocksLimit = this.failedLocksLimit();
acquiredLocks.clear();
// 移動(dòng)迭代器的指針位置到上一個(gè)
while(iterator.hasPrevious()) {
iterator.previous();
}
// 如果允許失敗的次數(shù)不為0
} else {
// 每獲取個(gè)鎖失敗一次就減少一個(gè)數(shù)
--failedLocksLimit;
}
}
// 如果不是非阻塞式獲取鎖
if (remainTime != -1L) {
// 計(jì)算本次獲取鎖的所耗時(shí)長(zhǎng)
remainTime -= System.currentTimeMillis() - time;
time = System.currentTimeMillis();
// 如果已經(jīng)超出了給定時(shí)間,則回滾所有成功的鎖
if (remainTime <= 0L) {
this.unlockInner(acquiredLocks);
// 返回獲取鎖失敗
return false;
}
}
}
// 能執(zhí)行到這里肯定是已經(jīng)獲取鎖成功了
// 判斷是否設(shè)置了過期時(shí)間,如果設(shè)置了
if (leaseTime != -1L) {
// 獲取加鎖成功的個(gè)鎖集合
List<RFuture<Boolean>> futures = new ArrayList(acquiredLocks.size());
Iterator var25 = acquiredLocks.iterator();
// 迭代為每個(gè)獲取成功的個(gè)鎖創(chuàng)建異步任務(wù)對(duì)象
while(var25.hasNext()) {
RLock rLock = (RLock)var25.next();
RFuture<Boolean> future =
rLock.expireAsync(unit.toMillis(leaseTime),
TimeUnit.MILLISECONDS);
futures.add(future);
}
// 獲取Future的個(gè)鎖集合迭代器對(duì)象
var25 = futures.iterator();
// 迭代每個(gè)Futrue對(duì)象
while(var25.hasNext()) {
RFuture<Boolean> rFuture = (RFuture)var25.next();
// 異步為每個(gè)獲取個(gè)鎖成功的對(duì)象設(shè)置過期時(shí)間
rFuture.syncUninterruptibly();
}
}
// 返回獲取鎖成功
return true;
}
如上源碼,流程先不分析,先感慨一句:雖然看著長(zhǎng),但?。?!真心的比JUC中的源碼可讀性和易讀性高N倍,每句代碼都容易弄懂,閱讀起來并不算費(fèi)勁。
OK~,感慨完之后來總結(jié)一下tryLock
加鎖方法的總體邏輯:
- ①計(jì)算出阻塞時(shí)間、最大失敗數(shù)以及過期時(shí)間,然后獲取所有組成連鎖的個(gè)鎖集合
- ②迭代每把個(gè)鎖,嘗試對(duì)每把個(gè)鎖進(jìn)行加鎖,加鎖是也會(huì)判斷獲取鎖的方式是否為非阻塞式的:
- 是:直接獲取鎖
- 否:阻塞式獲取鎖,在給定時(shí)間內(nèi)會(huì)不斷嘗試獲取鎖
- ③判斷個(gè)鎖是否獲取成功:
- 成功:將獲取成功的個(gè)鎖添加到加鎖成功的集合
acquiredLocks
集合中 - 失?。号袛啻舜潍@取鎖失敗的次數(shù)是否已經(jīng)達(dá)到了允許的最大失敗次數(shù):
- 是:放棄獲取鎖,回滾所有獲取成功的鎖,返回獲取鎖失敗
- 否:允許失敗次數(shù)自減,繼續(xù)嘗試獲取下一把個(gè)鎖
- 注意:連鎖模式下最大失敗次數(shù)=0,紅鎖模式下為個(gè)鎖數(shù)量的一半
- 成功:將獲取成功的個(gè)鎖添加到加鎖成功的集合
- ④判斷目前獲取鎖過程的耗時(shí)是否超出了給定的阻塞時(shí)長(zhǎng):
- 是:回滾所有獲取成功的鎖,然后返回獲取鎖失敗
- 否:繼續(xù)獲取下把個(gè)鎖
- ⑤如果連鎖獲取成功(代表所有個(gè)都鎖獲取成功),判斷是否指定了過期時(shí)間:
- 是:異步為每個(gè)加鎖成功的個(gè)鎖設(shè)置過期時(shí)間并返回獲取鎖成功
- 否:直接返回獲取鎖成功
雖然獲取鎖的代碼看著長(zhǎng),但其邏輯并不算復(fù)雜,上述過程是連鎖的實(shí)現(xiàn),而紅鎖則是依賴于連鎖實(shí)現(xiàn)的,也比較簡(jiǎn)單,只是重寫failedLocksLimit()
獲取允許失敗次數(shù)的方法,允許獲取鎖失敗的次數(shù)變?yōu)榱藗€(gè)鎖數(shù)量的一半以及略微加了一些小拓展,感興趣的可以自己去分析其實(shí)現(xiàn)。
接著來看看釋放鎖的源碼實(shí)現(xiàn):
// RedissonMultiLock類 → unlock()方法
@Override
public void unlock() {
// 創(chuàng)建為沒把個(gè)鎖創(chuàng)建一個(gè)Future
List<RFuture<Void>> futures = new
ArrayList<RFuture<Void>>(locks.size());
// 遍歷所有個(gè)鎖
for (RLock lock : locks) {
// 釋放鎖
futures.add(lock.unlockAsync());
}
// 阻塞等待所有鎖釋放成功后再返回
for (RFuture<Void> future : futures) {
future.syncUninterruptibly();
}
}
// RedissonMultiLock類 → unlockInnerAsync()方法
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
// 獲取個(gè)鎖的Key名稱并通過Lua腳本釋放鎖(確保原子性)
return commandExecutor.evalWriteAsync(getName(),
LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()),
LockPubSub.unlockMessage, internalLockLeaseTime,
getLockName(threadId));
}
釋放鎖的邏輯更加簡(jiǎn)單,遍歷所有的個(gè)鎖,然后異步通過Lua腳本刪除所有的key
,在連鎖的釋放代碼中會(huì)同步阻塞等待所有鎖的Key
刪除后再返回。
四、Zookeeper實(shí)現(xiàn)分布式鎖剖析
Zookeeper分布式鎖是依賴于其內(nèi)部的順序臨時(shí)節(jié)點(diǎn)實(shí)現(xiàn)的,其原理就類似于最開始舉例的那個(gè)文件夾分布式鎖,因?yàn)閆ookeeper實(shí)際上就類似于一個(gè)文件系統(tǒng)的結(jié)構(gòu)。我們可以通過Curator框架封裝的API操作Zookeeper,完成分布式鎖的實(shí)現(xiàn)。如下:
// 創(chuàng)建分布式鎖對(duì)象
InterProcessMutex lock = InterProcessMutex(client,
"/locks/distributed_商品ID");
lock.acquire(); // 獲取鎖/加鎖
// 執(zhí)行業(yè)務(wù)邏輯...
lock.release(); // 釋放鎖
如上,通過Curator實(shí)現(xiàn)分布式鎖非常簡(jiǎn)單,因?yàn)橐呀?jīng)封裝好了API,所以應(yīng)用起來也非常方便,同時(shí)Zookeeper也可以實(shí)現(xiàn)公平鎖與非公平鎖兩種方案,如下:
- 公平鎖:先請(qǐng)求鎖的線程一定先獲取鎖
- 實(shí)現(xiàn)方式:通過臨時(shí)順序節(jié)點(diǎn)實(shí)現(xiàn),每條線程請(qǐng)求鎖時(shí)為其創(chuàng)建一個(gè)有序節(jié)點(diǎn),創(chuàng)建完成之后判斷自己創(chuàng)建的節(jié)點(diǎn)是不是最小的,如果是則直接獲取鎖成功,反之獲取鎖失敗,創(chuàng)建一個(gè)監(jiān)聽器,監(jiān)聽自己節(jié)點(diǎn)的前一個(gè)節(jié)點(diǎn)狀態(tài),當(dāng)前一個(gè)節(jié)點(diǎn)被刪除(代表前一個(gè)節(jié)點(diǎn)的創(chuàng)建線程釋放了鎖)自己嘗試獲取鎖
- 優(yōu)劣勢(shì):可以保證請(qǐng)求獲取鎖的有序性,但性能方面比非公平鎖低
- 非公平鎖:先請(qǐng)求鎖的線程不一定先獲取鎖
- 實(shí)現(xiàn)方式:多條線程在同一目錄下,同時(shí)創(chuàng)建一個(gè)名字相同的節(jié)點(diǎn),誰創(chuàng)建成功代表獲取鎖成功,反之則代表獲取鎖失敗
- 優(yōu)劣勢(shì):性能良好,但無法保證請(qǐng)求獲取鎖時(shí)的有序性
對(duì)于這兩種實(shí)現(xiàn)方式,非公平鎖的方案與前面的Redis
實(shí)現(xiàn)差不多,所以不再分析。下面重點(diǎn)來分析一下Zookeeper實(shí)現(xiàn)分布式的公平鎖的大致原理。但在分析之前先簡(jiǎn)單說明一些Zookeeper中會(huì)用到的概念。如下:
- 節(jié)點(diǎn)類型:
- ①持久節(jié)點(diǎn):被創(chuàng)建后會(huì)一直存在的節(jié)點(diǎn)信息,除非有刪除操作主動(dòng)清楚才會(huì)銷毀
- ②持久順序節(jié)點(diǎn):持久節(jié)點(diǎn)的有序版本,每個(gè)新創(chuàng)建的節(jié)點(diǎn)會(huì)在后面維護(hù)自增值保持先后順序,可以用于實(shí)現(xiàn)分布式全局唯一ID
- ③臨時(shí)節(jié)點(diǎn):被創(chuàng)建后與客戶端的會(huì)話生命周期保持一致,連接斷開則自動(dòng)銷毀
- ④臨時(shí)順序節(jié)點(diǎn):臨時(shí)節(jié)點(diǎn)的有序版本,與其多了一個(gè)有序性。分布式鎖則依賴這種類型實(shí)現(xiàn)
- 監(jiān)視器:當(dāng)zookeeper創(chuàng)建一個(gè)節(jié)點(diǎn)時(shí),會(huì)為該節(jié)點(diǎn)注冊(cè)一個(gè)監(jiān)視器,當(dāng)節(jié)點(diǎn)狀態(tài)發(fā)生改變時(shí),watch會(huì)被觸發(fā),zooKeeper將會(huì)向客戶端發(fā)送一條通知。不過值得注意的是watch只能被觸發(fā)一次
ok~,假設(shè)目前8001
服務(wù)中的線程T1
嘗試獲取鎖,那么會(huì)T1
會(huì)在Zookeeper
的/locks/distributed_商品ID
目錄下創(chuàng)建一個(gè)臨時(shí)節(jié)點(diǎn),Zookeeper
內(nèi)部會(huì)生成一個(gè)名字為xxx....-0000001
臨時(shí)順序節(jié)點(diǎn)。當(dāng)?shù)诙l線程來嘗試獲取鎖時(shí),也會(huì)在相同位置創(chuàng)建一個(gè)臨時(shí)順序節(jié)點(diǎn),名字為xxx....-0000002
。值得注意的是最后的數(shù)字是一個(gè)遞增的狀態(tài),從1開始自增,Zookeeper會(huì)維護(hù)這個(gè)先后順序。如下圖:
當(dāng)線程創(chuàng)建節(jié)點(diǎn)完成后,會(huì)查詢
/locks/distributed_商品ID
目錄下所有的子節(jié)點(diǎn),然后會(huì)判斷自己創(chuàng)建的節(jié)點(diǎn)是不是在所有節(jié)點(diǎn)的第一個(gè),也就是判斷自己的節(jié)點(diǎn)是否為最小的子節(jié)點(diǎn),如果是的話則獲取鎖成功,因?yàn)楫?dāng)前線程是第一個(gè)來獲取分布式鎖的線程,在它之前是沒有線程獲取鎖的,所以當(dāng)然可以加鎖成功,然后開始執(zhí)行業(yè)務(wù)邏輯。如下:而第二條線程創(chuàng)建節(jié)點(diǎn)成功后,也會(huì)去判斷自己是否是最小的節(jié)點(diǎn)。哦豁!第二條線程判斷的時(shí)候會(huì)發(fā)現(xiàn),在自己的節(jié)點(diǎn)之前還有一個(gè)
xxx...-0001
節(jié)點(diǎn),所以代表在自己前面已經(jīng)有線程持有了分布式鎖,所以會(huì)對(duì)上個(gè)節(jié)點(diǎn)加上一個(gè)監(jiān)視器,監(jiān)視上個(gè)節(jié)點(diǎn)的狀態(tài)變化。如下:此時(shí),第一條線程
T1
執(zhí)行完了業(yè)務(wù)代碼,準(zhǔn)備釋放鎖,也就是刪除自己創(chuàng)建的xxx...-0001
臨時(shí)順序節(jié)點(diǎn)。而第二條線程創(chuàng)建的監(jiān)視器會(huì)監(jiān)視著前面一個(gè)節(jié)點(diǎn)的狀態(tài),當(dāng)發(fā)現(xiàn)前面的節(jié)點(diǎn)已經(jīng)被刪除時(shí),就知道前面一條線程已經(jīng)執(zhí)行完了業(yè)務(wù),釋放了鎖資源,所以再次嘗試獲取鎖。如下:第二條線程重新嘗試獲取鎖時(shí),拿到當(dāng)前目錄下的所有節(jié)點(diǎn)判斷發(fā)現(xiàn),喲!自己是第一個(gè)(最小的那個(gè))節(jié)點(diǎn)啊?然后獲取鎖成功,開始執(zhí)行業(yè)務(wù)邏輯,后續(xù)再來新的線程則依次類推.....
至此,整個(gè)Zookeeper實(shí)現(xiàn)分布式鎖的過程分析完畢,關(guān)于自己動(dòng)手基于Zookeeper實(shí)現(xiàn)一遍我這邊就不再寫了,大家可以自習(xí)了解。實(shí)際開發(fā)過程中,Curator框架自帶的分布式鎖實(shí)現(xiàn)便已經(jīng)夠用了,同時(shí)使用也非常的方便。
五、分布式鎖性能優(yōu)化
經(jīng)過前述的分析,大家對(duì)分布式鎖應(yīng)該都有一個(gè)全面認(rèn)知了,但是請(qǐng)思考:如果對(duì)于類似于搶購(gòu)、秒殺業(yè)務(wù),又該如何處理呢?因?yàn)樵谶@種場(chǎng)景下,往往在一段時(shí)間內(nèi)會(huì)有大量用戶去請(qǐng)求同一個(gè)商品。從技術(shù)角度出發(fā),這樣會(huì)導(dǎo)致在同一時(shí)間內(nèi)會(huì)有大量的線程去請(qǐng)求同一把鎖。這會(huì)有何種隱患呢?會(huì)出現(xiàn)的問題是:雖然并發(fā)能抗住,但是對(duì)于用戶體驗(yàn)感不好,同時(shí)大量的用戶點(diǎn)擊搶購(gòu),但是只能有一個(gè)用戶搶購(gòu)成功,明顯不合理,這又該如何優(yōu)化?
參考并發(fā)容器中的分段容器,可以將共享資源(商品庫(kù)存)做提前預(yù)熱,分段分散到redis中。舉個(gè)例子:
1000個(gè)庫(kù)存商品,10W個(gè)用戶等待搶購(gòu),搶購(gòu)開始時(shí)間為下午15:00
提前預(yù)熱:兩點(diǎn)半左右開始將商品數(shù)量拆成10份或N份,如:[shopping_01;0-100]、[shopping_02;101-200]、[shopping_03;201-300]、[......]
也就是往redis中寫入十個(gè)key,值為100,在搶購(gòu)時(shí),過來的請(qǐng)求隨機(jī)分散到某個(gè)key上去,但是在扣減庫(kù)存之前,需要先獲取鎖,這樣就同時(shí)有了十把鎖,性能自然就上去了。
六、分布式鎖總結(jié)
本篇中從單機(jī)鎖的隱患 -> 分布式架構(gòu)下的安全問題引出 -> 分布式鎖的實(shí)現(xiàn)推導(dǎo) -> redis實(shí)現(xiàn)分布式鎖 -> redis實(shí)現(xiàn)分布式鎖的細(xì)節(jié)問題分析 -> redisson框架實(shí)現(xiàn)及其連鎖應(yīng)用與源碼分析 -> zookeeper實(shí)現(xiàn)分布式鎖 -> zookeeper實(shí)現(xiàn)原理
這條思路依次剖析了分布式鎖的前世今生,總的一句話概括分布式鎖的核心原理就是:在多個(gè)進(jìn)程中所有線程都可見的區(qū)域?qū)崿F(xiàn)了互斥量而已。
最后再來說說Redis與Zookeeper實(shí)現(xiàn)的區(qū)別與項(xiàng)目中如何抉擇?
Redis數(shù)據(jù)不是實(shí)時(shí)同步的,主機(jī)寫入成功后會(huì)立即返回,存在主從架構(gòu)鎖失效問題。
Zookeeper數(shù)據(jù)是實(shí)時(shí)同步的,主機(jī)寫入后需一半節(jié)點(diǎn)以上寫入成功才會(huì)返回。
所以如果你的項(xiàng)目追求高性能,可以放棄一定的穩(wěn)定性,那么推薦使用Redis實(shí)現(xiàn)。比如電商、線上教育等類型的項(xiàng)目。
但如果你的項(xiàng)目追求高穩(wěn)定,愿意犧牲一部分性能換取穩(wěn)定性,那么推薦使用Zookeeper實(shí)現(xiàn)。比如金融、銀行、政府等類型的項(xiàng)目。
當(dāng)然,如果你的項(xiàng)目是基于SpringCloud開發(fā)的,也可以考慮使用SpringCloud的全局鎖,但是不推薦,一般還是優(yōu)先考慮Redis和Zookeeper。