(十三)全面理解并發(fā)編程之分布式架構(gòu)下Redis、ZK分布式鎖的前世今生

引言

在前面的大部分文章中,我們反復(fù)圍繞著線程安全相關(guān)問題在對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對象進(jìn)行加鎖的隱式鎖,另一種則是JUC提供的顯式鎖方案。在開發(fā)過程中使用它們都能夠保證多線程的安全問題。但把它們放在多機(jī)器/多應(yīng)用部署的環(huán)境下,也能保證相同的效果嗎?先來看一個案例:

兩個服務(wù):[訂單服務(wù):8000端口]、[商品服務(wù):8001、8002端口]
數(shù)據(jù)庫:訂單庫[db_order]、商品庫[db_shopping]
訂單服務(wù)中提供了一個下單接口,用戶下單后會調(diào)用它,調(diào)用下單接口后,會通過restTemplate進(jìn)行RPC調(diào)用商品服務(wù)的扣庫存接口,實(shí)現(xiàn)庫存扣減下單操作。
源碼如下:

// 訂單服務(wù)
@RestController
@RequestMapping("/order")
public class OrderApi{
    // 庫存服務(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";
        // 生成一個訂單ID(分布式架構(gòu)中要使用分布式ID生成策略,此處是模擬)
        String orderId = UUID.randomUUID().toString();
        // 模擬生成訂單記錄
        Order order = new
            Order(orderId,"黃金竹子","88888.88",inventoryId);
        
        // RPC調(diào)用庫存接口
        String responseResult = restTemplate.getForObject(
            REST_URL_PREFIX + "/minusInventory?inventoryId="
                + inventoryId, String.class);
        System.out.println("調(diào)用后庫存接口結(jié)果:" + responseResult);
        
        Integer n = orderService.insertSelective(order);
        
        if (n > 0) 
            return "下單成功....";
        return "下單失敗....";
    }
}

// 庫存服務(wù)
@RestController
@RequestMapping("/inventory")
public class InventoryApi{
    @Autowired
    private InventoryService inventoryService;
    @Value("${server.port}")
    private String port;
    
    // 扣庫存接口
    @RequestMapping("/minusInventory")
    public String minusInventory(Inventory inventory) {
        // 查詢庫存信息
        Inventory inventoryResult =
            inventoryService.selectByPrimaryKey(inventory.getInventoryId());
        
        if (inventoryResult.getShopCount() <= 0) {
            return "庫存不足,請聯(lián)系賣家....";
        }
        
        // 扣減庫存
        inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
        int n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
        
        if (n > 0)
            return "端口-" + port + ",庫存扣減成功!!!";
        return "端口-" + port + ",庫存扣減失?。。?!";
    }
}

觀察上述源碼,存在什么問題?線程安全問題。按照之前的做法我們會對扣庫存的接口使用Synchronized或ReetrantLock加鎖,如下:

// 庫存服務(wù) → 扣庫存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
    int n;
    synchronized(InventoryApi.class){
        // 查詢庫存信息
        Inventory inventoryResult =
            inventoryService.selectByPrimaryKey(inventory.getInventoryId());
        
        if (inventoryResult.getShopCount() <= 0) {
            return "庫存不足,請聯(lián)系賣家....";
        }
        
        // 扣減庫存
        inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
        n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
        System.out.println("庫存信息-剩余庫存數(shù)量:" +
                inventoryResult.getShopCount());
    }
    if (n > 0)
        return "端口:" + port + ",庫存扣減成功?。。?;
    return "端口:" + port + ",庫存扣減失敗?。。?;
}

是不是感覺沒問題了?看測試:

通過JMeter壓測工具,1秒內(nèi)對下單接口進(jìn)行八百次調(diào)用,此時出現(xiàn)了一個比較有意思的現(xiàn)象,測試結(jié)果如下:

訂單服務(wù)控制臺日志:[端口:8000]
    ......
    調(diào)用后庫存接口結(jié)果:端口-8001,庫存扣減成功?。?!
    調(diào)用后庫存接口結(jié)果:端口-8002,庫存扣減成功?。。?    ......
    調(diào)用后庫存接口結(jié)果:端口-8001,庫存扣減成功?。?!
    調(diào)用后庫存接口結(jié)果:端口-8001,庫存扣減成功?。?!
    調(diào)用后庫存接口結(jié)果:端口-8002,庫存扣減成功!??!
    ......

商品服務(wù)控制臺日志:[端口:8001]
    ......
    庫存信息-剩余庫存數(shù)量:999
    ......
    庫存信息-剩余庫存數(shù)量:788
    庫存信息-剩余庫存數(shù)量:787
    .....

商品服務(wù)控制臺日志:[端口:8002]
    ......
    庫存信息-剩余庫存數(shù)量:998
    庫存信息-剩余庫存數(shù)量:996
    庫存信息-剩余庫存數(shù)量:993
    ......
    庫存信息-剩余庫存數(shù)量:788
    .....

注意觀察如上日志,在兩個商品服務(wù)[8001/8002]中都出現(xiàn)了庫存信息-剩余庫存數(shù)量:788這么一條日志記錄,這代表第799個商品被賣了兩次,還是出現(xiàn)了線程安全問題,導(dǎo)致了庫存超賣??晌覀儾皇且呀?jīng)通過Class對象加鎖了嗎?為什么還會有這樣的問題出現(xiàn)呢?下面我們來依次分析。

問題分析

在關(guān)于Synchronized關(guān)鍵字原理剖析的文章中已經(jīng)得知:Synchronized關(guān)鍵字是依賴于對象做為鎖資源進(jìn)行加鎖操作的,每個對象都會存在一個伴生的Monitor監(jiān)視器,Synchronized則是通過它進(jìn)行上鎖,加鎖過程如下:

Synchronized執(zhí)行流程

OK~,有了上述基礎(chǔ)知識之后,再接著分析前面的問題。為什么會出現(xiàn)線程安全問題?

實(shí)際上這個問題也不難理解,前面的案例中,我們是通過InventoryApi.class類對象進(jìn)行上鎖的,如果在單體程序中確實(shí)沒有任何問題,因?yàn)?code>Class對象是唯一的,當(dāng)多條線程搶占一把Class鎖時,同一時刻只會有一條線程獲取鎖成功,這樣自然而然也不存在線程安全問題了。但目前的問題就出在這里,InventoryApi.class對象在單體程序中確實(shí)只存在一個,但目前商品服務(wù)是屬于多應(yīng)用/多機(jī)器部署的,目前商品服務(wù)有兩個進(jìn)程,那這就代表著存在兩個不同的Java堆,在兩個不同的堆空間中,都存在各自的InventoryApi.class對象,也就是代表著“此時Class對象并不是唯一的,此刻出現(xiàn)了兩把鎖”。而正是因?yàn)檫@個問題才最終導(dǎo)致了線程安全問題的出現(xiàn)。如下圖:

單體與分布式

還是回到最開始敘述線程安全的起點(diǎn),同一時刻滿足三要素:“多線程”、“共享資源”以及“非原子性操作”便會產(chǎn)生線程安全問題,而SynchronizedReetrantLock都是從破壞了第一個要素的角度出發(fā),以此解決了線程安全問題。但目前在分布式架構(gòu)中,SynchronizedReetrantLock因?yàn)槎噙M(jìn)程導(dǎo)致的多個Java空間堆出現(xiàn),所以不管是Synchronized還是ReetrantLock都已經(jīng)無法保證同一時刻只有一條線程對共享資源進(jìn)行非原子性操作,最終線程安全問題的出現(xiàn)已經(jīng)無法避免。

二、分布式鎖思想及其實(shí)現(xiàn)過程推導(dǎo)

經(jīng)過前面的分析,已經(jīng)得知了分布式架構(gòu)下的線程安全問題產(chǎn)生的根本原因,那目前又該如何解決這個問題呢?可能學(xué)習(xí)過分布式鎖的小伙伴會直接回答:分布式鎖。但此刻請你拋下之前的記憶,我們完全從零開始對分布式鎖進(jìn)行推導(dǎo)。

大家可以先代入一個角色:假設(shè)市面上還沒有成熟的解決方案,你是第一位遇到這個問題的人,那么你又該如何去解決這類問題呢?OK~,接下來我們一起逐步推導(dǎo)。

先思考思考前面的Synchronized、ReetrantLock是如何解決單體程序的線程安全問題的呢?

  • Synchronized:
    • 依賴堆中對象的Monitor監(jiān)視器
    • 通過操作監(jiān)視器中的_count字段實(shí)現(xiàn)互斥
  • ReetrantLock:
    • 依賴于AQS同步器
    • 通過操作AQS中volatile修飾的state成員實(shí)現(xiàn)互斥

它們有什么共同點(diǎn)呢?都存在一個互斥量,并且互斥量都是所有線程可見的。

OK~,明白了這兩點(diǎn)之后,再反過來思考一下,分布式環(huán)境中又是因?yàn)槭裁丛驅(qū)е碌陌踩珕栴}復(fù)發(fā)了呢?答案是:互斥量所在的區(qū)域?qū)τ谄渌M(jìn)程中的線程來說是不可見的。比如Synchronized關(guān)鍵字通過某個Class對象作為鎖對象,一個堆空間中的Class對象對于當(dāng)前進(jìn)程中的所有線程來說是可見的,但是對于其他進(jìn)程的線程是不可見的。ReetrantLock也是同理,volatile修飾的state變量,對于當(dāng)前進(jìn)程中的所有線程可見,但對于另外進(jìn)程中的線程是不可見的。

那么此時想要解決分布式情況下的線程安全問題的思路是不是明了啦?

我們只需要找一個多個進(jìn)程之間所有線程可見的區(qū)域?qū)崿F(xiàn)這個互斥量即可。
比如:在一臺服務(wù)器的同一路徑下創(chuàng)建一個文件夾。獲取鎖操作則是創(chuàng)建文件夾,反之,釋放鎖的邏輯則是刪除文件夾,這樣可以很好的實(shí)現(xiàn)一把分布式鎖,因?yàn)镺S特性規(guī)定,在同一路徑下,相同名字的文件夾只能創(chuàng)建一個。所以當(dāng)兩條線程同時執(zhí)行獲取鎖邏輯時,永遠(yuǎn)只會有一條線程創(chuàng)建成功,成功創(chuàng)建文件夾的那條線程則代表獲取鎖成功,那么可以去執(zhí)行業(yè)務(wù)邏輯。當(dāng)這條線程執(zhí)行完業(yè)務(wù)后,再刪除掉文件夾,代表釋放鎖,以便于其他線程可以再次獲取鎖。

上述的這種方式確實(shí)可以實(shí)現(xiàn)一把最基本的分布式鎖,但問題在于:這樣實(shí)現(xiàn)的話一方面性能會比較差,第二個也不能具備鎖重入的功能,第三方面也沒有具備合理的鎖失效機(jī)制以及阻塞機(jī)制。而一個優(yōu)秀的分布式鎖的實(shí)現(xiàn)方案應(yīng)該滿足如下幾個特性:

  • ①在分布式環(huán)境中,可以保證不同進(jìn)程之間的線程互斥
  • ②在同一時刻,同時只允許一條線程成功獲取到鎖資源
  • ③保存互斥量的地方需要保證高可用性
  • ④要保證可以高性能的獲取鎖與釋放鎖
  • ⑤可以支持同一線程的鎖重入性
  • ⑥具備合理的阻塞機(jī)制,競爭鎖失敗的線程也有處理方案
  • ⑦支持非阻塞式獲取鎖,獲取鎖失敗的線程可以直接返回
  • ⑧具備合理的鎖失效機(jī)制,如超時失效等,可以確保避免死鎖情況出現(xiàn)

那么目前市面上對于分布式鎖的成熟方案有哪些呢?

  • ①基于DB實(shí)現(xiàn)
  • ②基于Redis實(shí)現(xiàn)
  • ③基于Zookeeper實(shí)現(xiàn)

對于第一種方式的實(shí)現(xiàn)并不難,無非是在數(shù)據(jù)庫中創(chuàng)建一張lock表,表中設(shè)置方法名、線程ID等字段。并為方法名字段建立唯一索引,當(dāng)線程執(zhí)行某個方法需要獲取鎖時,就以這個方法名作為數(shù)據(jù)向表中插入,如果插入成功代表獲取鎖成功,如果插入失敗,代表有其他線程已經(jīng)在此之前持有鎖了,當(dāng)前線程可以阻塞等待或直接返回。同時,也可以基于表中的線程ID字段為鎖重入提供支持。當(dāng)然,當(dāng)持有鎖的線程業(yè)務(wù)邏輯執(zhí)行完成后,應(yīng)當(dāng)刪除對應(yīng)的數(shù)據(jù)行,以此達(dá)到釋放鎖的目的。

這種方式依靠于數(shù)據(jù)庫的唯一索引,所以實(shí)現(xiàn)起來比較簡單,但是問題在于:因?yàn)槭腔跀?shù)據(jù)庫實(shí)現(xiàn)的,所以獲取鎖、釋放鎖等操作都要涉及到數(shù)據(jù)落盤、刪盤等磁盤IO操作,性能方面值得考慮。同時也對于超時失效機(jī)制很難提供支持,在實(shí)現(xiàn)過程中也會出現(xiàn)很多其他問題,為了確保解決各類問題,實(shí)現(xiàn)的方式也會越發(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ú)立部署在外,不附屬于任何一個Java程序,對于不同的Java進(jìn)程來說,都是可見的,同時它的性能也非常可觀,可以依賴于其本身提供的指令setnx key value實(shí)現(xiàn)分布式鎖。

setnx key value:往Redis中寫入一個K-V值。不過與普通的set指令不同的是:setnx只有當(dāng)key不存在時才會設(shè)置成功,當(dāng)key已存在時,會返回設(shè)置失敗。同時因?yàn)閞edis對于客戶端的指令請求處理時,是使用epoll多路復(fù)用模型的,所以當(dāng)同時多條線程一起向redis服務(wù)端發(fā)送setnx指令時,只會有一條線程設(shè)置成功。最終可以依賴于redis這些特性實(shí)現(xiàn)分布式鎖。

OK~,下面通過setnx key value實(shí)現(xiàn)最基本的分布式鎖,如下:

// 庫存服務(wù)
@RestController
@RequestMapping("/inventory")
public class InventoryApi{
    @Autowired
    private InventoryService inventoryService;
    @Value("${server.port}")
    private String port;
    // 注入Redis客戶端
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    // 扣庫存接口
    @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ù)器繁忙...請稍后重試!!!";
            // 自旋式實(shí)現(xiàn)(這種實(shí)現(xiàn)比較耗性能,
            // 實(shí)際開發(fā)過程中需配合阻塞時間配合使用)
            // return minusInventory(inventory);
        }
        
        // ----只有獲取鎖成功才能執(zhí)行下述的減庫存業(yè)務(wù)----        
        try{
            // 查詢庫存信息
            Inventory inventoryResult =
                inventoryService.selectByPrimaryKey(inventory.getInventoryId());
            
            if (inventoryResult.getShopCount() <= 0) {
                return "庫存不足,請聯(lián)系賣家....";
            }
            
            // 扣減庫存
            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 + ",庫存扣減成功?。?!";
        return "端口-" + port + ",庫存扣減失?。。。?;
    }
}

如上源碼,實(shí)現(xiàn)了一把最基本的分布式鎖,使用setnx指令往redis中寫入一條數(shù)據(jù),以當(dāng)前商品ID作為key值,這樣可以確保鎖粒度得到控制。同時也使用try-catch保證業(yè)務(wù)執(zhí)行出錯時也能釋放鎖,可以有效避免死鎖問題出現(xiàn)。

一條線程(一個請求)想要執(zhí)行扣庫存業(yè)務(wù)時,需要先往redis寫入數(shù)據(jù),當(dāng)寫入成功時代表獲取鎖成功,獲取鎖成功的線程可以執(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ī)或斷電重啟了。那此時又會出現(xiàn)問題:獲取到鎖的T1線程因?yàn)樗谶M(jìn)程/服務(wù)器掛了,所以T1線程也會被迫死亡,那此時try-catch也無法保證鎖的釋放,T1線程不釋放鎖,其他線程嘗試setnx獲取鎖時也不會成功,最終導(dǎo)致了死鎖現(xiàn)象的出現(xiàn)。那這個問題又該如何解決呢?加上Key過期時間即可。如下:

// 扣庫存接口
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
    // 獲取鎖
    String lockKey = "lock-" + inventory.getInventoryId();
    int timeOut = 100;
    Boolean flag = stringRedisTemplate.opsForValue()
            .setIfAbsent(lockKey, "竹子-熊貓");
    // 加上過期時間,可以保證死鎖也會在一定時間內(nèi)釋放鎖
    stringRedisTemplate.expire(lockKey,timeOut,TimeUnit.SECONDS);
    
    if(!flag){
        // 非阻塞式實(shí)現(xiàn)
        return "服務(wù)器繁忙...請稍后重試?。。?;
        // 自旋式實(shí)現(xiàn)(這種實(shí)現(xiàn)比較耗性能,
        // 實(shí)際開發(fā)過程中需配合阻塞時間配合使用)
        // return minusInventory(inventory);
    }
    
    // ----只有獲取鎖成功才能執(zhí)行下述的減庫存業(yè)務(wù)----        
    try{
        // 查詢庫存信息
        Inventory inventoryResult =
            inventoryService.selectByPrimaryKey(inventory.getInventoryId());
        
        if (inventoryResult.getShopCount() <= 0) {
            return "庫存不足,請聯(lián)系賣家....";
        }
        
        // 扣減庫存
        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 + ",庫存扣減成功?。?!";
    return "端口-" + port + ",庫存扣減失敗?。?!";
}

如上,在基礎(chǔ)版的分布式鎖中,再加上一個超時失效的機(jī)制,這樣可以有效避免死鎖的情況出現(xiàn)。就算獲取到分布式鎖的那條線程所在機(jī)器不小心宕機(jī)或重啟了,導(dǎo)致無法釋放鎖,那也不會產(chǎn)生死鎖情況,因?yàn)镵ey設(shè)置了過期時間,在設(shè)定時間內(nèi),如果沒有釋放鎖,那么時間一到,Redis會自動釋放鎖,以確保其他程序的線程可以獲取到鎖。

OK,解決掉宕機(jī)死鎖的問題后,再來看看我們自己實(shí)現(xiàn)的這個分布式鎖是否還有缺陷呢?

3.2、加鎖與過期時間原子性問題分析

從上一步的敘述中,通過設(shè)定過期時間的方式解決了宕機(jī)死鎖問題,但問題在于:前面分析過,Redis處理客戶端指令時采用的是單線程多路復(fù)用的模型,這就代表著只會有一條線程在處理所有客戶端的請求,因?yàn)閷?shí)際開發(fā)過程中,往往會有多處同時操作redis,而前面的加鎖與設(shè)置過期時間兩條指令對于redis是分開的,這兩條指令在執(zhí)行時不一定可以確保同時執(zhí)行,如下:

Redis處理客戶端指令模型

從上圖可以看到,加鎖與設(shè)置過期時間這兩條指令不一定會隨時執(zhí)行,那會出現(xiàn)什么問題呢?因?yàn)橹噶钍欠珠_執(zhí)行的,所以原子性沒有保證,有可能導(dǎo)致時間和小幾率死鎖問題出現(xiàn)。所以加鎖和設(shè)置過期時間這兩條指令需保證原子性,怎么操作呢?兩種方式:

  • ①通過Lua語言編寫腳本執(zhí)行:
    • redis是支持lua腳本執(zhí)行的,會把lua編譯成sha指令,支持同時執(zhí)行多條指令
  • ②通過redis提供的原子性指令執(zhí)行:
    • 在redis2.7版本后提供了一些原子性指令,其中就包括set指令,如下:
      • set key value ex 100 nx
    • 可以通過這條指令代替之前的setnx與expire兩條指令

那在Java程序中又該如何修改代碼呢?實(shí)則非常簡單,在SpringBoot整合Redis的模板中,只需要把如上代碼稍微修改一下即可。如下:

Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "竹子-熊貓",timeOut,TimeUnit.SECONDS);

3.3、過期時間的合理性分析

前面關(guān)于死鎖的問題已經(jīng)通過合理的鎖過期失效機(jī)制解決了,那再來思考一下這個過期時間的設(shè)定是否合理呢?前面案例中,設(shè)置的是100s過期時間,乍一看感覺沒啥問題,但仔細(xì)往深處一想:如果一條線程獲取鎖之后死亡了,其他線程想要獲取鎖其不是需要等到100s之后?因?yàn)樾枰鹊絩edis過期刪除后,釋放了鎖才可獲取。這明顯不合理,100s的時間,如果分布式鎖設(shè)計成了阻塞可重試類型的,那么可能會讓當(dāng)前程序在100s內(nèi)堆積大量請求,同時對于用戶體驗(yàn)感也并不好,在這一百秒內(nèi),用戶無論怎么重試,都不會下單成功,所以時間太長顯然不合理。

那可能有小伙伴會說:“簡單!時間設(shè)置的短一些不就好了嘛”,比如設(shè)置成5s。

確實(shí),一聽也沒啥毛病,但再仔細(xì)一想:分布式系統(tǒng)中,業(yè)務(wù)執(zhí)行往往會設(shè)計多個系統(tǒng)的RPC遠(yuǎn)程調(diào)用,因?yàn)槠渲猩婕熬W(wǎng)絡(luò)調(diào)用,網(wǎng)絡(luò)存在不穩(wěn)定因素,所以往往一個業(yè)務(wù)執(zhí)行的時間是很難具體的。這樣下來,如果設(shè)置的時間太短,最終可能會造成一個問題出現(xiàn):業(yè)務(wù)執(zhí)行時長超過鎖過期時長,導(dǎo)致redis中key過期,最終redis釋放了鎖。而這個問題則會引發(fā)分布式鎖的ABC問題,如下:

線程A:獲取鎖成功,設(shè)置過期時間為5s,執(zhí)行業(yè)務(wù)邏輯
線程B:獲取鎖失敗,阻塞等待并在一定時間后重試獲取鎖
線程C:獲取鎖失敗,阻塞等待并在一定時間后重試獲取鎖
假設(shè)此時線程A執(zhí)行業(yè)務(wù),因?yàn)榫W(wǎng)絡(luò)波動等原因,執(zhí)行總時長花費(fèi)了7s,那么redis會在第五秒時將key刪除,因?yàn)檫^期了。
而恰巧線程B正好在第六秒時,重試獲取鎖,喲嚯~,線程B一試,發(fā)現(xiàn)key不在了,自然就獲取鎖成功了。
到了第七秒時,線程A執(zhí)行完了業(yè)務(wù),直接執(zhí)行了del lock_商品ID的指令,刪除了key,但此時這個鎖已經(jīng)是線程B的了,線程A的鎖已經(jīng)被redis放掉了,所以線程A釋放掉了線程B的鎖。
最后,線程C醒了,去重試時,又發(fā)現(xiàn)redis中沒有了Key,也獲取鎖成功,執(zhí)行......

通過如上案例分析,不難發(fā)現(xiàn)一個問題,過期時間設(shè)置的太短,會導(dǎo)致鎖資源錯亂,出現(xiàn)ABC問題。如上問題主要是由于兩個原因?qū)е碌模孩冁i過期時間太短 ②非加鎖線程也可以釋放鎖。第二個問題待會兒再解決,目前先看看鎖時長怎么才能設(shè)置合理這個問題。

經(jīng)過前面分析,我們發(fā)現(xiàn)這個時間設(shè)置長了不合適,要是短了那更不行,此時進(jìn)退兩難,那怎么解決呢?實(shí)際上也比較容易,可以設(shè)置一條子線程,給當(dāng)前鎖資源續(xù)命。

開啟一條子線程,間隔2-3s去查詢一次Key是否過期,如果過期了則代表業(yè)務(wù)線程已經(jīng)釋放了鎖,如果未過期,代表業(yè)務(wù)線程還在執(zhí)行業(yè)務(wù),那么則對于key的過期時間再加上5S秒鐘。為了避免業(yè)務(wù)線程死亡,當(dāng)前子線程一直續(xù)命,造成“長生鎖”導(dǎo)致死鎖的情況出現(xiàn),可以把子線程變?yōu)闃I(yè)務(wù)線程的守護(hù)線程,這樣可以有效避免這個問題的出現(xiàn),實(shí)現(xiàn)如下:

// 續(xù)命子線程
public class GuardThread extends Thread {
    // 原本的key和過期時間
    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 {
                // 先休眠一半的時間
                Thread.sleep(timeOut / 2 * 1000);
            }catch (Exception e){
                e.printStackTrace();
            }
            // 時間過了一半之后再去續(xù)命
            // 先查看key是否過期
            Long expire = stringRedisTemplate.getExpire(
                lockKey, TimeUnit.SECONDS);
            // 如果過期了,代表主線程釋放了鎖
            if (expire <= 0){
                // 停止循環(huán)
                flag = false;
            }
            // 如果還未過期
            // 再為則續(xù)命一半的時間
            stringRedisTemplate.expire(lockKey,expire
                + timeOut/2,TimeUnit.SECONDS);
        }
    }
}

// 扣庫存接口
@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ù)器繁忙...請稍后重試?。?!";
        // 自旋式實(shí)現(xiàn)(這種實(shí)現(xiàn)比較耗性能,
        // 實(shí)際開發(fā)過程中需配合阻塞時間配合使用)
        // 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í)行下述的減庫存業(yè)務(wù)----        
    try{
        // 查詢庫存信息
        Inventory inventoryResult =
            inventoryService.selectByPrimaryKey(inventory.getInventoryId());
        
        if (inventoryResult.getShopCount() <= 0) {
            return "庫存不足,請聯(lián)系賣家....";
        }
        
        // 扣減庫存
        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 + ",庫存扣減成功?。?!";
    return "端口-" + port + ",庫存扣減失敗!??!";
}

如上實(shí)現(xiàn),利用了一條子線程為分布式鎖續(xù)命,同時為了確保主線程意外死亡等問題造成一直續(xù)命,所以將子線程變?yōu)榱酥?業(yè)務(wù))線程的守護(hù)線程,主線程死亡那么作為守護(hù)線程的子線程也會跟著死亡,可以有效避免“長生鎖”的現(xiàn)象出現(xiàn)。

3.4、獲取鎖與釋放鎖線程一致性分析

在上一個問題中,我們曾提到要確保加鎖與釋放鎖的線程一致性,這個問題比較好解決,只需要把value的值換成一個唯一值即可,然后在釋放鎖時判斷一下是否相等即可,如下:

// 扣庫存接口
@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í)行下述的減庫存業(yè)務(wù)----        
    try{
        // 省略其他代碼.....
    } catch (Exception e) {
        // 先判斷是否是當(dāng)前線程加的鎖
        if(lockVlue!=stringRedisTemplate.opsForValue().get(lockKey)){
            // 不是則拋出異常
            throw new RuntimeException("非法釋放鎖....");
        }
        // 確實(shí)是再釋放鎖
        stringRedisTemplate.delete(lockKey);
    }
    // 省略其他代碼.....
}

在獲取鎖的時候,把寫入redis的value值換成一個隨機(jī)的UUID,然后在釋放鎖之前,先判斷一下是否為當(dāng)前線程加的鎖,確實(shí)為當(dāng)前線程加的鎖那么則釋放,反之拋出異常。

3.5、Redis主從架構(gòu)鎖失效問題分析

在一般開發(fā)過程中,為了保證Redis的高可用,都會采用主從復(fù)制架構(gòu)做讀寫分離,從而提升Redis整體的吞吐量以及可用性。但問題在于:Redis的主從架構(gòu)下,實(shí)現(xiàn)分布式鎖時有可能會導(dǎo)致鎖失效,為什么呢?

因?yàn)閞edis主從架構(gòu)中的數(shù)據(jù)不是實(shí)時復(fù)制的,而是定時/定量復(fù)制。也就代表著一條數(shù)據(jù)寫入了redis主機(jī)后,并不會同時同步給所有的從機(jī),寫入的指令只要在主機(jī)上寫入成功會立即返回寫入成功,數(shù)據(jù)同步則是在一定時間或一定量之后才同步給從機(jī)。

這樣聽著感覺也沒啥問題,但再仔細(xì)一思考,如果8001的線程A剛剛往主機(jī)中寫入了Key,成功獲取到了分布式鎖,但redis主機(jī)還沒來得及把新數(shù)據(jù)同步給從機(jī),正巧因?yàn)橐馔忮礄C(jī)了,此時發(fā)生主從切換,而推選出來的新主也就是原本的舊從,因?yàn)榍懊驽礄C(jī)的主機(jī)還有部分?jǐn)?shù)據(jù)未復(fù)制過來,所以新主上不會有線程A的鎖記錄,此時8002的線程T1來嘗試獲取鎖,發(fā)生新主上沒有鎖記錄,那么則獲取鎖成功,此時又存在了兩條線程同時獲取到了鎖資源,同時執(zhí)行業(yè)務(wù)邏輯了。

OK~,如上描述的便是Redis主從架構(gòu)導(dǎo)致的分布式鎖失效問題,此時這個問題又該如何解決呢?方案如下:

  • ①紅鎖算法:多臺獨(dú)立的Redis同時寫入數(shù)據(jù),鎖失效時間之內(nèi),一半以上的機(jī)器寫成功則返回獲取鎖成功,否則返回獲取鎖失敗,失敗時會釋放掉那些成功的機(jī)器上的鎖。
    • 優(yōu)點(diǎn):可以完美解決掉主從架構(gòu)帶來的鎖失效問題
    • 缺點(diǎn):成本高,需要線上部署多臺獨(dú)立的Redis節(jié)點(diǎn)
    • 這種算法是Redis官方提出的解決方案:紅鎖算法
  • ②額外記錄鎖狀態(tài):再通過額外的中間件等獨(dú)立部署的節(jié)點(diǎn)記錄鎖狀態(tài),比如在DB中記錄鎖狀態(tài),在嘗試獲取分布式鎖之前需先查詢DB中的鎖持有記錄,如果還在持有則繼續(xù)阻塞,只有當(dāng)狀態(tài)為未持有時再嘗試獲取分布式鎖。
    • 優(yōu)點(diǎn):可以依賴于項(xiàng)目中現(xiàn)有的節(jié)點(diǎn)實(shí)現(xiàn),節(jié)約部署成本
    • 缺點(diǎn):
      • 實(shí)現(xiàn)需要配合定時器實(shí)現(xiàn)過期失效,保證鎖的合理失效機(jī)制
      • 獲取鎖的性能方面堪憂,會大大增加獲取鎖的性能開銷
      • 所有過程都需自己實(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)分布式鎖時,不會出現(xiàn)這個問題(稍后分析)

3.6、Redisson框架中的分布式鎖

在上述的內(nèi)容中,曾從分布式鎖的引出到自己實(shí)現(xiàn)的每個細(xì)節(jié)問題進(jìn)行了分析,但實(shí)際開發(fā)過程中并不需要我們自己去實(shí)現(xiàn),因?yàn)樽约簩?shí)現(xiàn)的分布式鎖多多少少會存在一些隱患問題。而這些工作實(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();
// 獲取一個Rlock鎖對象
RLock lock = redisson.getLock(lockKey);
// 獲取鎖,并為其設(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);
    }
}

如上源碼,即可獲得一把最基本的分布式鎖,同時除開最基本的加鎖方法外,還支持其他形式的獲取鎖:

  • lock.tryLock(20,10,TimeUnit.SECONDS):非阻塞式獲取鎖,在獲取鎖失敗后的20s內(nèi)一直嘗試重新獲取鎖,超出20s則直接返回獲取鎖失敗
  • lock.lockAsync(10,TimeUnit.SECONDS):異步阻塞式獲取鎖,可以支持異步獲取加鎖的結(jié)果,該方法會返回一個Future對象,可通過Future對象異步獲取加鎖結(jié)果
  • lock.tryLockAsync(20,10,TimeUnit.SECONDS):異步非阻塞式獲取鎖,比上面那個多了一個超時時間

同時Redisson框架中的鎖實(shí)現(xiàn)還不僅僅只有一種,如下:

  • FairLock公平鎖:與ReetrantLock一樣,支持創(chuàng)建公平鎖,即先到的線程一定優(yōu)化獲取鎖
  • MultiLock連鎖:多個RLock對象組成一把鎖,也就是幾把鎖組成的一把鎖,可以用來實(shí)現(xiàn)紅鎖算法,因?yàn)?code>RLock對象可以不是一個Redisson創(chuàng)建出來的,也就是可以使用多個Redis客戶端的連接對象獲取多把鎖組成連鎖,只有當(dāng)所有個鎖獲取成功后,才能返回獲取鎖成功,如果獲取一把個鎖失敗,則返回獲取鎖失敗
  • RedLock紅鎖:和前面分析的Redis官方給出的紅鎖算法實(shí)現(xiàn)一致,繼承了連鎖,主要用于解決主從架構(gòu)鎖失效的問題

3.7、Redisson框架中的連鎖分析

連鎖向上繼承了RLock,向下為RedLock提供了實(shí)現(xiàn),所以它是Redisson框架中最為關(guān)鍵的一種鎖,先來看看它的使用方式:

// 獲取多個RLock鎖對象(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();

使用方式并不難理解,只需要創(chuàng)建多個RLock鎖對象后,再通過多個鎖對象組和成一把連鎖,通過連鎖對象進(jìn)行獲取鎖與釋放鎖的操作即可。

3.8、Redisson框架中的連鎖源碼實(shí)現(xiàn)分析

OK~,上面簡單的給出了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 {
    // 計算基礎(chǔ)阻塞時間:使用鎖的個數(shù)*1500ms。
    // 比如之前的案例:3*1500=4500ms
    long baseWaitTime = (long)(this.locks.size() * 1500);
    long waitTime = -1L;
    // 前面?zhèn)魅肓?1,所以進(jìn)入的是if分支
    if (leaseTime == -1L) {
        // 掛起時間為4500,單位毫秒(MS)
        waitTime = baseWaitTime;
        unit = TimeUnit.MILLISECONDS;
    } 
    // 這里是對于外部獲取鎖時,指定了時間情況時的處理邏輯
    else {
        // 將外部傳入的時間轉(zhuǎn)換為毫秒值
        waitTime = unit.toMillis(leaseTime);
        // 如果外部給定的時間小于2000ms,那么賦值為2s
        if (waitTime <= 2000L) {
            waitTime = 2000L;
        } 
        // 如果傳入的時間小于前面計算出的基礎(chǔ)時間
        else if (waitTime <= baseWaitTime) {
            // 獲取基礎(chǔ)時間的一半,如baseWaitTime=4500ms,waitTime=2250ms
            waitTime = ThreadLocalRandom.current().
                nextLong(waitTime / 2L, waitTime);
        } else {
            // 如果外部給定的時間大于前面計算出的基礎(chǔ)時間會進(jìn)這里
            // 將基礎(chǔ)時間作為阻塞時長
            waitTime = ThreadLocalRandom.current().
                nextLong(baseWaitTime, waitTime);
        }
        // 最終計算出掛起的時間
        waitTime = unit.convert(waitTime, TimeUnit.MILLISECONDS);
    }
    // 自旋嘗試獲取鎖,直至獲取鎖成功
    while(!this.tryLock(waitTime, leaseTime, unit)) {
        ;
    }
}

上述源碼中,實(shí)際上不難理解,比之前文章中分析的JUC的源碼可讀性強(qiáng)很多,上述代碼中,簡單的計算了一下時間后,最終自旋調(diào)用了tryLock獲取鎖的方法一直嘗試獲取鎖。接著來看看tryLock方法:

// RedissonMultiLock類 → tryLock()方法
public boolean tryLock(long waitTime, long leaseTime,
        TimeUnit unit) throws InterruptedException {
    long newLeaseTime = -1L;
    // 如果外部獲取鎖時,給定了過期時間
    if (leaseTime != -1L) {
        // 將newLeaseTime變?yōu)榻o定時間的兩倍
        newLeaseTime = unit.toMillis(waitTime) * 2L;
    }
    
    // 獲取當(dāng)前時間
    long time = System.currentTimeMillis();
    long remainTime = -1L;
    // 如果不是非阻塞式獲取鎖
    if (waitTime != -1L) {
        // 將過期時間改為用戶給定的時間
        remainTime = unit.toMillis(waitTime);
    }
    // 該方法是空實(shí)現(xiàn),留下的拓展接口,直接返回了傳入的值
    long lockWaitTime = this.calcLockWaitTime(remainTime);
    // 返回0,也是拓展接口,留給子類拓展的,紅鎖中就拓展了這兩方法
    // 這個變量是允許失敗的最大次數(shù),紅鎖中為個數(shù)的一半
    int failedLocksLimit = this.failedLocksLimit();
    // 獲取組成連鎖的所有RLock鎖集合
    List<RLock> acquiredLocks = new ArrayList(this.locks.size());
    // 獲取list的迭代器對象
    ListIterator iterator = this.locks.listIterator();
    
    // 通過List的迭代器遍歷整個連鎖集合
    while(iterator.hasNext()) {
        RLock lock = (RLock)iterator.next();
        
        boolean lockAcquired;
        // 嘗試獲取鎖
        try {
            // 如果是非阻塞式獲取鎖
            if (waitTime == -1L && leaseTime == -1L) {
                // 直接嘗試獲取鎖
                lockAcquired = lock.tryLock();
            } else {
                // 比較阻塞時間和過期時間的大小
                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;
        // 如果在給定時間內(nèi)未獲取到鎖
        } catch (RedisResponseTimeoutException var22) {
            // 也回滾所有獲取成功的個鎖
            this.unlockInner(Arrays.asList(lock));
            lockAcquired = false;
        } catch (Exception var23) {
            // 如果是其他原因?qū)е碌?,則直接返回獲取鎖失敗
            lockAcquired = false;
        }
        
        // 如果獲取一把個鎖成功
        if (lockAcquired) {
            // 那么則記錄獲取成功的個鎖
            acquiredLocks.add(lock);
        } else {
            // 如果獲取一把個鎖失敗,此次失敗的次數(shù)已經(jīng)達(dá)到了
            // 最大的失敗次數(shù),那么直接退出循環(huán),放棄加鎖操作
            if (this.locks.size() - acquiredLocks.size() 
                == this.failedLocksLimit()) {
                break;
            }
            // 允許失敗的次數(shù)未0,獲取一個個鎖失敗則回滾
            if (failedLocksLimit == 0) {
                // 回滾所有成功的鎖 
                this.unlockInner(acquiredLocks);
                // 如果是非阻塞式獲取鎖,則直接返回獲取鎖失敗
                if (waitTime == -1L && leaseTime == -1L) {
                    return false;
                }
                // 獲取最新的失敗鎖的個數(shù)
                failedLocksLimit = this.failedLocksLimit();
                acquiredLocks.clear();
                // 移動迭代器的指針位置到上一個
                while(iterator.hasPrevious()) {
                    iterator.previous();
                }
            
            // 如果允許失敗的次數(shù)不為0
            } else {
                // 每獲取個鎖失敗一次就減少一個數(shù)
                --failedLocksLimit;
            }
        }
        // 如果不是非阻塞式獲取鎖
        if (remainTime != -1L) {
            // 計算本次獲取鎖的所耗時長
            remainTime -= System.currentTimeMillis() - time;
            time = System.currentTimeMillis();
            // 如果已經(jīng)超出了給定時間,則回滾所有成功的鎖
            if (remainTime <= 0L) {
                this.unlockInner(acquiredLocks);
                // 返回獲取鎖失敗
                return false;
            }
        }
    }
    
    // 能執(zhí)行到這里肯定是已經(jīng)獲取鎖成功了
    // 判斷是否設(shè)置了過期時間,如果設(shè)置了
    if (leaseTime != -1L) {
        // 獲取加鎖成功的個鎖集合
        List<RFuture<Boolean>> futures = new ArrayList(acquiredLocks.size());
        Iterator var25 = acquiredLocks.iterator();

        // 迭代為每個獲取成功的個鎖創(chuàng)建異步任務(wù)對象
        while(var25.hasNext()) {
            RLock rLock = (RLock)var25.next();
            RFuture<Boolean> future =
                rLock.expireAsync(unit.toMillis(leaseTime),
                TimeUnit.MILLISECONDS);
            futures.add(future);
        }
        // 獲取Future的個鎖集合迭代器對象
        var25 = futures.iterator();
        
        // 迭代每個Futrue對象
        while(var25.hasNext()) {
            RFuture<Boolean> rFuture = (RFuture)var25.next();
            // 異步為每個獲取個鎖成功的對象設(shè)置過期時間
            rFuture.syncUninterruptibly();
        }
    }
    // 返回獲取鎖成功
    return true;
}

如上源碼,流程先不分析,先感慨一句:雖然看著長,但?。。≌嫘牡谋菾UC中的源碼可讀性和易讀性高N倍,每句代碼都容易弄懂,閱讀起來并不算費(fèi)勁。
OK~,感慨完之后來總結(jié)一下tryLock加鎖方法的總體邏輯:

  • ①計算出阻塞時間、最大失敗數(shù)以及過期時間,然后獲取所有組成連鎖的個鎖集合
  • ②迭代每把個鎖,嘗試對每把個鎖進(jìn)行加鎖,加鎖是也會判斷獲取鎖的方式是否為非阻塞式的:
    • 是:直接獲取鎖
    • 否:阻塞式獲取鎖,在給定時間內(nèi)會不斷嘗試獲取鎖
  • ③判斷個鎖是否獲取成功:
    • 成功:將獲取成功的個鎖添加到加鎖成功的集合acquiredLocks集合中
    • 失?。号袛啻舜潍@取鎖失敗的次數(shù)是否已經(jīng)達(dá)到了允許的最大失敗次數(shù):
      • 是:放棄獲取鎖,回滾所有獲取成功的鎖,返回獲取鎖失敗
      • 否:允許失敗次數(shù)自減,繼續(xù)嘗試獲取下一把個鎖
      • 注意:連鎖模式下最大失敗次數(shù)=0,紅鎖模式下為個鎖數(shù)量的一半
  • ④判斷目前獲取鎖過程的耗時是否超出了給定的阻塞時長:
    • 是:回滾所有獲取成功的鎖,然后返回獲取鎖失敗
    • 否:繼續(xù)獲取下把個鎖
  • ⑤如果連鎖獲取成功(代表所有個都鎖獲取成功),判斷是否指定了過期時間:
    • 是:異步為每個加鎖成功的個鎖設(shè)置過期時間并返回獲取鎖成功
    • 否:直接返回獲取鎖成功

雖然獲取鎖的代碼看著長,但其邏輯并不算復(fù)雜,上述過程是連鎖的實(shí)現(xiàn),而紅鎖則是依賴于連鎖實(shí)現(xiàn)的,也比較簡單,只是重寫failedLocksLimit()獲取允許失敗次數(shù)的方法,允許獲取鎖失敗的次數(shù)變?yōu)榱藗€鎖數(shù)量的一半以及略微加了一些小拓展,感興趣的可以自己去分析其實(shí)現(xiàn)。

接著來看看釋放鎖的源碼實(shí)現(xiàn):

// RedissonMultiLock類 → unlock()方法
@Override
public void unlock() {
    // 創(chuàng)建為沒把個鎖創(chuàng)建一個Future
    List<RFuture<Void>> futures = new
        ArrayList<RFuture<Void>>(locks.size());
    // 遍歷所有個鎖
    for (RLock lock : locks) {
        // 釋放鎖
        futures.add(lock.unlockAsync());
    }
    // 阻塞等待所有鎖釋放成功后再返回
    for (RFuture<Void> future : futures) {
        future.syncUninterruptibly();
    }
}

// RedissonMultiLock類 → unlockInnerAsync()方法
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    // 獲取個鎖的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));
}

釋放鎖的邏輯更加簡單,遍歷所有的個鎖,然后異步通過Lua腳本刪除所有的key,在連鎖的釋放代碼中會同步阻塞等待所有鎖的Key刪除后再返回。

四、Zookeeper實(shí)現(xiàn)分布式鎖剖析

Zookeeper分布式鎖是依賴于其內(nèi)部的順序臨時節(jié)點(diǎn)實(shí)現(xiàn)的,其原理就類似于最開始舉例的那個文件夾分布式鎖,因?yàn)閆ookeeper實(shí)際上就類似于一個文件系統(tǒng)的結(jié)構(gòu)。我們可以通過Curator框架封裝的API操作Zookeeper,完成分布式鎖的實(shí)現(xiàn)。如下:

// 創(chuàng)建分布式鎖對象
InterProcessMutex lock = InterProcessMutex(client,
    "/locks/distributed_商品ID");
lock.acquire(); // 獲取鎖/加鎖

// 執(zhí)行業(yè)務(wù)邏輯...

lock.release(); // 釋放鎖

如上,通過Curator實(shí)現(xiàn)分布式鎖非常簡單,因?yàn)橐呀?jīng)封裝好了API,所以應(yīng)用起來也非常方便,同時Zookeeper也可以實(shí)現(xiàn)公平鎖與非公平鎖兩種方案,如下:

  • 公平鎖:先請求鎖的線程一定先獲取鎖
    • 實(shí)現(xiàn)方式:通過臨時順序節(jié)點(diǎn)實(shí)現(xiàn),每條線程請求鎖時為其創(chuàng)建一個有序節(jié)點(diǎn),創(chuàng)建完成之后判斷自己創(chuàng)建的節(jié)點(diǎn)是不是最小的,如果是則直接獲取鎖成功,反之獲取鎖失敗,創(chuàng)建一個監(jiān)聽器,監(jiān)聽自己節(jié)點(diǎn)的前一個節(jié)點(diǎn)狀態(tài),當(dāng)前一個節(jié)點(diǎn)被刪除(代表前一個節(jié)點(diǎn)的創(chuàng)建線程釋放了鎖)自己嘗試獲取鎖
    • 優(yōu)劣勢:可以保證請求獲取鎖的有序性,但性能方面比非公平鎖低
  • 非公平鎖:先請求鎖的線程不一定先獲取鎖
    • 實(shí)現(xiàn)方式:多條線程在同一目錄下,同時創(chuàng)建一個名字相同的節(jié)點(diǎn),誰創(chuàng)建成功代表獲取鎖成功,反之則代表獲取鎖失敗
    • 優(yōu)劣勢:性能良好,但無法保證請求獲取鎖時的有序性

對于這兩種實(shí)現(xiàn)方式,非公平鎖的方案與前面的Redis實(shí)現(xiàn)差不多,所以不再分析。下面重點(diǎn)來分析一下Zookeeper實(shí)現(xiàn)分布式的公平鎖的大致原理。但在分析之前先簡單說明一些Zookeeper中會用到的概念。如下:

  • 節(jié)點(diǎn)類型:
    • ①持久節(jié)點(diǎn):被創(chuàng)建后會一直存在的節(jié)點(diǎn)信息,除非有刪除操作主動清楚才會銷毀
    • ②持久順序節(jié)點(diǎn):持久節(jié)點(diǎn)的有序版本,每個新創(chuàng)建的節(jié)點(diǎn)會在后面維護(hù)自增值保持先后順序,可以用于實(shí)現(xiàn)分布式全局唯一ID
    • ③臨時節(jié)點(diǎn):被創(chuàng)建后與客戶端的會話生命周期保持一致,連接斷開則自動銷毀
    • ④臨時順序節(jié)點(diǎn):臨時節(jié)點(diǎn)的有序版本,與其多了一個有序性。分布式鎖則依賴這種類型實(shí)現(xiàn)
  • 監(jiān)視器:當(dāng)zookeeper創(chuàng)建一個節(jié)點(diǎn)時,會為該節(jié)點(diǎn)注冊一個監(jiān)視器,當(dāng)節(jié)點(diǎn)狀態(tài)發(fā)生改變時,watch會被觸發(fā),zooKeeper將會向客戶端發(fā)送一條通知。不過值得注意的是watch只能被觸發(fā)一次

ok~,假設(shè)目前8001服務(wù)中的線程T1嘗試獲取鎖,那么會T1會在Zookeeper/locks/distributed_商品ID目錄下創(chuàng)建一個臨時節(jié)點(diǎn),Zookeeper內(nèi)部會生成一個名字為xxx....-0000001臨時順序節(jié)點(diǎn)。當(dāng)?shù)诙l線程來嘗試獲取鎖時,也會在相同位置創(chuàng)建一個臨時順序節(jié)點(diǎn),名字為xxx....-0000002。值得注意的是最后的數(shù)字是一個遞增的狀態(tài),從1開始自增,Zookeeper會維護(hù)這個先后順序。如下圖:

創(chuàng)建臨時節(jié)點(diǎn)

當(dāng)線程創(chuàng)建節(jié)點(diǎn)完成后,會查詢/locks/distributed_商品ID目錄下所有的子節(jié)點(diǎn),然后會判斷自己創(chuàng)建的節(jié)點(diǎn)是不是在所有節(jié)點(diǎn)的第一個,也就是判斷自己的節(jié)點(diǎn)是否為最小的子節(jié)點(diǎn),如果是的話則獲取鎖成功,因?yàn)楫?dāng)前線程是第一個來獲取分布式鎖的線程,在它之前是沒有線程獲取鎖的,所以當(dāng)然可以加鎖成功,然后開始執(zhí)行業(yè)務(wù)邏輯。如下:
8001線程T1獲取分布式鎖

而第二條線程創(chuàng)建節(jié)點(diǎn)成功后,也會去判斷自己是否是最小的節(jié)點(diǎn)。哦豁!第二條線程判斷的時候會發(fā)現(xiàn),在自己的節(jié)點(diǎn)之前還有一個xxx...-0001節(jié)點(diǎn),所以代表在自己前面已經(jīng)有線程持有了分布式鎖,所以會對上個節(jié)點(diǎn)加上一個監(jiān)視器,監(jiān)視上個節(jié)點(diǎn)的狀態(tài)變化。如下:
Zookeeper實(shí)現(xiàn)分布式公平鎖

此時,第一條線程T1執(zhí)行完了業(yè)務(wù)代碼,準(zhǔn)備釋放鎖,也就是刪除自己創(chuàng)建的xxx...-0001臨時順序節(jié)點(diǎn)。而第二條線程創(chuàng)建的監(jiān)視器會監(jiān)視著前面一個節(jié)點(diǎn)的狀態(tài),當(dāng)發(fā)現(xiàn)前面的節(jié)點(diǎn)已經(jīng)被刪除時,就知道前面一條線程已經(jīng)執(zhí)行完了業(yè)務(wù),釋放了鎖資源,所以再次嘗試獲取鎖。如下:
Zookeeper實(shí)現(xiàn)分布式鎖完整流程

第二條線程重新嘗試獲取鎖時,拿到當(dāng)前目錄下的所有節(jié)點(diǎn)判斷發(fā)現(xiàn),喲!自己是第一個(最小的那個)節(jié)點(diǎn)???然后獲取鎖成功,開始執(zhí)行業(yè)務(wù)邏輯,后續(xù)再來新的線程則依次類推.....

至此,整個Zookeeper實(shí)現(xiàn)分布式鎖的過程分析完畢,關(guān)于自己動手基于Zookeeper實(shí)現(xiàn)一遍我這邊就不再寫了,大家可以自習(xí)了解。實(shí)際開發(fā)過程中,Curator框架自帶的分布式鎖實(shí)現(xiàn)便已經(jīng)夠用了,同時使用也非常的方便。

五、分布式鎖性能優(yōu)化

經(jīng)過前述的分析,大家對分布式鎖應(yīng)該都有一個全面認(rèn)知了,但是請思考:如果對于類似于搶購、秒殺業(yè)務(wù),又該如何處理呢?因?yàn)樵谶@種場景下,往往在一段時間內(nèi)會有大量用戶去請求同一個商品。從技術(shù)角度出發(fā),這樣會導(dǎo)致在同一時間內(nèi)會有大量的線程去請求同一把鎖。這會有何種隱患呢?會出現(xiàn)的問題是:雖然并發(fā)能抗住,但是對于用戶體驗(yàn)感不好,同時大量的用戶點(diǎn)擊搶購,但是只能有一個用戶搶購成功,明顯不合理,這又該如何優(yōu)化?

參考并發(fā)容器中的分段容器,可以將共享資源(商品庫存)做提前預(yù)熱,分段分散到redis中。舉個例子:

1000個庫存商品,10W個用戶等待搶購,搶購開始時間為下午15:00
提前預(yù)熱:兩點(diǎn)半左右開始將商品數(shù)量拆成10份或N份,如:[shopping_01;0-100]、[shopping_02;101-200]、[shopping_03;201-300]、[......]
也就是往redis中寫入十個key,值為100,在搶購時,過來的請求隨機(jī)分散到某個key上去,但是在扣減庫存之前,需要先獲取鎖,這樣就同時有了十把鎖,性能自然就上去了。

六、分布式鎖總結(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)原理這條思路依次剖析了分布式鎖的前世今生,總的一句話概括分布式鎖的核心原理就是:在多個進(jìn)程中所有線程都可見的區(qū)域?qū)崿F(xiàn)了互斥量而已。

最后再來說說Redis與Zookeeper實(shí)現(xiàn)的區(qū)別與項(xiàng)目中如何抉擇?

Redis數(shù)據(jù)不是實(shí)時同步的,主機(jī)寫入成功后會立即返回,存在主從架構(gòu)鎖失效問題。
Zookeeper數(shù)據(jù)是實(shí)時同步的,主機(jī)寫入后需一半節(jié)點(diǎn)以上寫入成功才會返回。
所以如果你的項(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。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容