分布式鎖(Redisson)-從零開始,深入理解與不斷優化

2021-12-29 架構師必備

作者:大程子的技術成長路
連結:https://www.jianshu.com/p/bc4ff4694cf3

分布式鎖場景案例1

如下代碼模擬了下單減庫存的場景,我們分析下在高並發場景下會存在什麼問題

package com.wangcp.redisson;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class IndexController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 模擬下單減庫存的場景
     * @return
     */
    @RequestMapping(value = "/duduct_stock")
    public String deductStock(){
        // 從redis 中拿當前庫存的值
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        if(stock > 0){
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock",realStock + "");
            System.out.println("扣減成功,剩餘庫存:" + realStock);
        }else{
            System.out.println("扣減失敗,庫存不足");
        }
        return "end";
    }
}

假設在redis中庫存(stock)初始值是100。

現在有5個客戶端同時請求該接口,可能就會存在同時執行

int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));

這行代碼,獲取到的值都為100,緊跟著判斷大於0後都進行-1操作,最後設置到redis 中的值都為99。但正常執行完成後redis中的值應為 95。

案例2-使用synchronized 實現單機鎖

在遇到案例1的問題後,大部分人的第一反應都會想到加鎖來控制事務的原子性,如下代碼所示:

@RequestMapping(value = "/duduct_stock")
public String deductStock(){
    synchronized (this){
        // 從redis 中拿當前庫存的值
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        if(stock > 0){
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock",realStock + "");
            System.out.println("扣減成功,剩餘庫存:" + realStock);
        }else{
            System.out.println("扣減失敗,庫存不足");
        }
    }
    return "end";
}

現在當有多個請求訪問該接口時,同一時刻只有一個請求可進入方法體中進行庫存的扣減,其餘請求等候。

但我們都知道,synchronized 鎖是屬於JVM級別的,也就是我們俗稱的「單機鎖」。但現在基本大部分公司使用的都是集群部署,現在我們思考下以上代碼在集群部署的情況下還能保證庫存數據的一致性嗎?

答案是不能,如上圖所示,請求經Nginx分發後,可能存在多個服務同時從Redis中獲取庫存數據,此時只加synchronized (單機鎖)是無效的,並發越高,出現問題的機率就越大。

案例3-使用SETNX實現分布式鎖

setnx:將 key 的值設為 value,若且唯若 key 不存在。

          若給定 key 已經存在,則 setnx 不做任何動作。

使用setnx實現簡單的分布式鎖:

/**
 * 模擬下單減庫存的場景
 * @return
 */
@RequestMapping(value = "/duduct_stock")
public String deductStock(){
    String lockKey = "product_001";
    // 使用 setnx 添加分布式鎖
    // 返回 true 代表之前redis中沒有key為 lockKey 的值,並已進行成功設置
    // 返回 false 代表之前redis中已經存在 lockKey 這個key了
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "wangcp");
    if(!result){
        // 代表已經加鎖了
        return "error_code";
    }

    // 從redis 中拿當前庫存的值
    int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
    if(stock > 0){
        int realStock = stock - 1;
        stringRedisTemplate.opsForValue().set("stock",realStock + "");
        System.out.println("扣減成功,剩餘庫存:" + realStock);
    }else{
        System.out.println("扣減失敗,庫存不足");
    }

    // 釋放鎖
    stringRedisTemplate.delete(lockKey);
    return "end";
}

我們知道 Redis 是單線程執行,現在再看案例2中的流程圖時,哪怕高並發場景下多個請求都執行到了setnx的代碼,redis會根據請求的先後順序進行排列,只有排列在隊頭的請求才能設置成功。其它請求只能返回「error_code」。

當setnx設置成功後,可執行業務代碼對庫存扣減,執行完成後對鎖進行釋放。

我們再來思考下以上代碼已經完美實現分布式鎖了嗎?能夠支撐高並發場景嗎?答案並不是,上面的代碼還是存在很多問題的,離真正的分布式鎖還差的很遠。我們分析下以上代碼存在的問題:

死鎖:假如第一個請求在setnx加鎖完成後,執行業務代碼時出現了異常,那釋放鎖的代碼就無法執行,後面所有的請求也都無法進行操作了。

針對死鎖的問題,我們對代碼再次進行優化,添加try-finally,在finally中添加釋放鎖代碼,這樣無論如何都會執行釋放鎖代碼,如下所示:

/**
     * 模擬下單減庫存的場景
     * @return
     */
@RequestMapping(value = "/duduct_stock")
public String deductStock(){
    String lockKey = "product_001";

    try{
        // 使用 setnx 添加分布式鎖
        // 返回 true 代表之前redis中沒有key為 lockKey 的值,並已進行成功設置
        // 返回 false 代表之前redis中已經存在 lockKey 這個key了
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "wangcp");
        if(!result){
            // 代表已經加鎖了
            return "error_code";
        }
        // 從redis 中拿當前庫存的值
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        if(stock > 0){
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock",realStock + "");
            System.out.println("扣減成功,剩餘庫存:" + realStock);
        }else{
            System.out.println("扣減失敗,庫存不足");
        }
    }finally {
        // 釋放鎖
        stringRedisTemplate.delete(lockKey);
    }

    return "end";
}

經過改進後的代碼是否還存在問題呢?我們思考正常執行的情況下應該是沒有問題,但我們假設請求在執行到業務代碼時服務突然宕機了,或者正巧你的運維同事重新發版,粗暴的 kill -9 掉了呢,那代碼還能執行 finally 嗎?

案例4-加入過期時間

針對想到的問題,對代碼再次進行優化,加入過期時間,這樣即便出現了上述的問題,在時間到期後鎖也會自動釋放掉,不會出現「死鎖」的情況。

@RequestMapping(value = "/duduct_stock")
public String deductStock(){
    String lockKey = "product_001";

    try{
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"wangcp",10,TimeUnit.SECONDS);
        if(!result){
            // 代表已經加鎖了
            return "error_code";
        }
        // 從redis 中拿當前庫存的值
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        if(stock > 0){
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock",realStock + "");
            System.out.println("扣減成功,剩餘庫存:" + realStock);
        }else{
            System.out.println("扣減失敗,庫存不足");
        }
    }finally {
        // 釋放鎖
        stringRedisTemplate.delete(lockKey);
    }

    return "end";
}

現在我們再思考一下,給鎖加入過期時間後就可以了嗎?就可以完美運行不出問題了嗎?

超時時間設置的10s真的合適嗎?如果不合適設置多少秒合適呢?如下圖所示

假設同一時間有三個請求。

請求1首先加鎖後需執行15秒,但在執行到10秒時鎖失效釋放。

請求2進入後加鎖執行,在請求2執行到5秒時,請求1執行完成進行鎖釋放,但此時釋放掉的是請求2的鎖。

請求3在請求2執行5秒時開始執行,但在執行到3秒時請求2執行完成將請求3的鎖進行釋放。

我們現在只是模擬3個請求便可看出問題,如果在真正高並發的場景下,可能鎖就會面臨「一直失效」或「永久失效」。

那麼具體問題出在哪裡呢?總結為以下幾點:

1.存在請求釋放鎖時釋放掉的並不是自己的鎖

2.超時時間過短,存在代碼未執行完便自動釋放

針對問題我們思考對應的解決方法:

案例5-Redisson分布式鎖

SpringBoot集成Redisson步驟

引入依賴

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.6.5</version>
</dependency>

初始化客戶端

@Bean
public RedissonClient redisson(){
    // 單機模式
    Config config = new Config();
    config.useSingleServer().setAddress("redis://192.168.3.170:6379").setDatabase(0);
    return Redisson.create(config);
}

Redisson實現分布式鎖

package com.wangcp.redisson;

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class IndexController {

    @Autowired
    private RedissonClient redisson;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 模擬下單減庫存的場景
     * @return
     */
    @RequestMapping(value = "/duduct_stock")
    public String deductStock(){
        String lockKey = "product_001";
        // 1.獲取鎖對象
        RLock redissonLock = redisson.getLock(lockKey);
        try{
            // 2.加鎖
            redissonLock.lock();  // 等價於 setIfAbsent(lockKey,"wangcp",10,TimeUnit.SECONDS);
            // 從redis 中拿當前庫存的值
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if(stock > 0){
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock",realStock + "");
                System.out.println("扣減成功,剩餘庫存:" + realStock);
            }else{
                System.out.println("扣減失敗,庫存不足");
            }
        }finally {
            // 3.釋放鎖
            redissonLock.unlock();
        }
        return "end";
    }
}

Redisson 分布式鎖實現原理圖Redisson 底層源碼分析

我們點擊 lock() 方法,查看源碼,最終看到以下代碼

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  "if (redis.call('exists', KEYS[1]) == 0) then " +
                      "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  "return redis.call('pttl', KEYS[1]);",
                    Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }

沒錯,加鎖最終執行的就是這段 lua 腳本語言。

if (redis.call('exists', KEYS[1]) == 0) then 
    redis.call('hset', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; 
end;

腳本的主要邏輯為:

exists 判斷 key 是否存在

當判斷不存在則設置 key

然後給設置的key追加過期時間

這樣來看其實和我們前面案例中的實現方法好像沒什麼區別,但實際上並不是。

這段lua腳本命令在Redis中執行時,會被當成一條命令來執行,能夠保證原子性,故要不都成功,要不都失敗。

我們在源碼中看到Redssion的許多方法實現中很多都用到了lua腳本,這樣能夠極大的保證命令執行的原子性。

Redisson鎖自動「續命」源碼

private void scheduleExpirationRenewal(final long threadId) {
    if (expirationRenewalMap.containsKey(getEntryName())) {
        return;
    }

    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {

            RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                                                                     "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                                                                     "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                                                                     "return 1; " +
                                                                     "end; " +
                                                                     "return 0;",
                                                                     Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));

            future.addListener(new FutureListener<Boolean>() {
                @Override
                public void operationComplete(Future<Boolean> future) throws Exception {
                    expirationRenewalMap.remove(getEntryName());
                    if (!future.isSuccess()) {
                        log.error("Can't update lock " + getName() + " expiration", future.cause());
                        return;
                    }

                    if (future.getNow()) {
                        // reschedule itself
                        scheduleExpirationRenewal(threadId);
                    }
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

    if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {
        task.cancel();
    }
}

這段代碼是在加鎖後開啟一個守護線程進行監聽。Redisson超時時間默認設置30s,線程每10s調用一次判斷鎖還是否存在,如果存在則延長鎖的超時時間。

現在,我們再回過頭來看看案例5中的加鎖代碼與原理圖,其實完善到這種程度已經可以滿足很多公司的使用了,並且很多公司也確實是這樣用的。但我們再思考下是否還存在問題呢?例如以下場景:

眾所周知 Redis 在實際部署使用時都是集群部署的,那在高並發場景下我們加鎖,當把key寫入到master節點後,master還未同步到slave節點時master宕機了,原有的slave節點經過選舉變為了新的master節點,此時可能就會出現鎖失效問題。

通過分布式鎖的實現機制我們知道,高並發場景下只有加鎖成功的請求可以繼續處理業務邏輯。那就出現了大夥都來加鎖,但有且僅有一個加鎖成功了,剩餘的都在等待。其實分布式鎖與高並發在語義上就是相違背的,我們的請求雖然都是並發,但Redis幫我們把請求進行了排隊執行,也就是把我們的並行轉為了串行。串行執行的代碼肯定不存在並發問題了,但是程序的性能肯定也會因此受到影響。

針對這些問題,我們再次思考解決方案

在思考解決方案時我們首先想到CAP原則(一致性、可用性、分區容錯性),那麼現在的Redis就是滿足AP(可用性、分區容錯性),如果想要解決該問題我們就需要尋找滿足CP(一致性、分區容錯性)的分布式系統。首先想到的就是zookeeper,zookeeper的集群間數據同步機制是當主節點接收數據後不會立即返回給客戶端成功的反饋,它會先與子節點進行數據同步,半數以上的節點都完成同步後才會通知客戶端接收成功。並且如果主節點宕機後,根據zookeeper的Zab協議(Zookeeper原子廣播)重新選舉的主節點一定是已經同步成功的。

那麼問題來了,Redisson與zookeeper分布式鎖我們如何選擇呢?答案是如果並發量沒有那麼高,可以用zookeeper來做分布式鎖,但是它的並發能力遠遠不如Redis。如果你對並發要求比較高的話,那就用Redis,偶爾出現的主從架構鎖失效的問題其實是可以容忍的。

關於第二個提升性能的問題,我們可以參考ConcurrentHashMap的鎖分段技術的思想,例如我們代碼的庫存量當前為1000,那我們可以分為10段,每段100,然後對每段分別加鎖,這樣就可以同時執行10個請求的加鎖與處理,當然有要求的同學還可以繼續細分。但其實Redis的Qps已經達到10W+了,沒有特別高並發量的場景下也是完全夠用的。

相關焦點

  • 分布式鎖中的王者方案 - Redisson
    上篇講解了如何用 Redis 實現分布式鎖的五種方案,但我們還是有更優的王者方案,就是用 Redisson。第一步:線程 A 在 0 秒時,搶佔到鎖,0.1 秒後,開始執行等待 10 s。為了避免這種情況的發生,Redisson內部提供了一個監控鎖的看門狗,它的作用是在Redisson實例被關閉前,不斷的延長鎖的有效期。默認情況下,看門狗的檢查鎖的超時時間是30秒鐘,也可以通過修改Config.lockWatchdogTimeout來另行指定。
  • Redisson實現Redis分布式鎖的N種姿勢
    前幾天發的一篇文章《Redlock:Redis分布式鎖最牛逼的實現》,引起了一些同學的討論,也有一些同學提出了一些疑問,這是好事兒。本文在講解如何使用Redisson實現Redis普通分布式鎖,以及Redlock算法分布式鎖的幾種方式的同時,也附帶解答這些同學的一些疑問。
  • 一文掌握 Redisson 分布式鎖原理(值得收藏)
    Thread.currentThread().getId();    // 🚩 嘗試獲取鎖, 下面重點分析    Long ttl = tryAcquire(-1, leaseTime, unit, threadId);    // 成功獲取鎖, 過期時間為空    if (ttl == null) {        return;    }    // 訂
  • Redisson 分布式鎖實戰與watch dog機制解讀
    實現的方案有很多,這裡,就以我們平時在網上常看到的redis分布式鎖方案為例,來對比看看 Redisson 提供的分布式鎖有什麼高級的地方。普通的 Redis 分布式鎖的缺陷我們在網上看到的redis分布式鎖的工具方法,大都滿足互斥、防止死鎖的特性,有些工具方法會滿足可重入特性。如果只滿足上述3種特性會有哪些隱患呢?
  • 基於Redisson分布式鎖解決秒殺系統的「超賣」問題
    Redisson開源地址為https://github.com/redisson/redisson/wiki/目錄在正文開始之前,先來解決一個問題:為什麼要用Redisso,而不是Redis?而Redisson的分布式鎖則可以很好地解決這種問題,其底層的實現機制在於:Redisson內部提供了一個監控鎖的看門狗WatchDog,其作用在於Redis實例被關閉之前,不斷延長鎖的有效期。除此之外,Redisson還通過加鎖的方法提供了leaseTime等參數來指定加鎖的有效時間,即超過這個時間後「鎖」便自動解開了。
  • Redlock:Redis分布式鎖最牛逼的實現
    正因為如此,Redis作者antirez基於分布式環境下提出了一種更高級的分布式鎖的實現方式:Redlock。筆者認為,Redlock也是Redis所有分布式鎖實現方式中唯一能讓面試官高潮的方式。客戶端使用當前時間減去開始獲取鎖時間(步驟1記錄的時間)就得到獲取鎖使用的時間。若且唯若從大多數(N/2+1,這裡是3個節點)的Redis節點都取到鎖,並且使用的時間小於鎖失效時間時,鎖才算獲取成功。如果取到了鎖,key的真正有效時間等於有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
  • redisson分布式讀寫鎖-讀鎖watchDog原理
    redisson分布式鎖文章回顧redisson分布式可重入鎖加鎖原理
  • Redisson 如何實現分布式鎖
    針對項目中使用的分布式鎖進行簡單的示例配置以及源碼解析,並列舉源碼中使用到的一些基礎知識點,但是沒有對redisson中使用到的netty知識進行解析
  • Redisson分布式鎖學習總結:讀鎖 RedissonReadLock#unLock 釋放鎖源碼分析
    ('publish', KEYS[2], ARGV[1]); " + "return 1; ", Arrays.KEYS:["myLock","redisson_rwlock:{myLock}","{myLock}:UUID-1:threadId-1:rwlock_timeout","{myLock}"]1.2、ARGVSLockPubSub.UNLOCK_MESSAGE, getLockName(thread
  • 帶你研究Redis分布式鎖,源碼走起
    前言前陣子我們講了分布式鎖的實現方式之一:zookeeper,那麼這次我們來講講同樣流行,甚至更勝一籌的Redis。除了這兩種其實還有資料庫實現分布式鎖啊,但是這種方式是非主流,所以咱這裡就不講了,要講咱就講主流的。
  • Redis 分布式鎖進化史解讀 + 缺陷分析
    在鎖競爭較高的情況下,會出現Value不斷被覆蓋,但是沒有一個Client獲取到鎖  2. 在獲取鎖的過程中不斷的修改原有鎖的數據,設想一種場景C1,C2競爭鎖,C1獲取到了鎖,C2鎖執行了GETSET操作修改了C1鎖的過期時間,如果C1沒有正確釋放鎖,鎖的過期時間被延長,其它Client需要等待更久的時間V2.0 基於SETNXtryLock(){    SETNX Key 1 Seconds}
  • 面試官:你能說清楚分布式鎖,進程鎖,線程鎖的區別嗎?
    比如分布式集群要操作某一行數據時,這個數據的流水號是唯一的,那麼我們就把這個流水號作為一把鎖的id,當某進程要操作該數據時,先去第三方存儲介質中看該鎖id是否存在,如果不存在,則將該鎖id寫入,然後執對該數據的操作;當其他進程要訪問這個數據時,會先到第三方存儲介質中查看有沒有這個數據的鎖id,有的話就認為這行數據目前已經有其他進程在使用了,就會不斷地輪詢第三方存儲介質看其他進程是否釋放掉該鎖;當進程操作完該數據後
  • 如何從零開始學習凸優化?
    Boyd的書側重凸分析的基礎,花了非常長的篇幅介紹函數的凸性、對偶等,但在機器學習中,至少在剛入門不久的階段這些東西用的不算多,或者說在大多數情況下只需要對這些有基本概念就行;2. 這本書雖然很厚,但介紹的算法非常有限,學了很長時間你會覺得對你的研究基本毫無幫助,你要的東西書中沒有,學的東西大多也用不上,讓你很容易半途而廢;3.
  • Redisson分布式鎖學習總結:寫鎖 RedissonWriteLock#unLock 釋放鎖源碼分析
    <Object>asList(getName(), getChannelName()):KEYS:["myLock","redisson_rwlock:{myLock}"]1.2、ARGVSLockPubSub.UNLOCK_MESSAGE, getLockName(threadId
  • 【110期】面試官:Redis分布式鎖如何解決鎖超時問題?
    來自:www.jianshu.com/p/39b3570d3b56一、前言關於redis分布式鎖, 查了很多資料, 發現很多只是實現了最基礎的功能
  • 零拷貝ZeroCopy,如何提高數據傳輸效率?4個場景深入理解
    在追求低延遲的傳輸場景中,經常使用到零拷貝(zero-copy),它是一種高效的數據傳輸機制,非常實用。零拷貝在很多框架中得到了廣泛應用,比如Netty、Kafka、Spark,下面通過4個場景、10張圖詳細深入介紹概念原理。
  • 深入理解Java虛擬機(程序編譯與代碼優化)
    我們這裡拿 HotSpot 來說明,不過後面的內容涉及具體實現細節的內容很少,主流虛擬機中 JIT 的實現又有頗多相似之處,因此對理解其它虛擬機的實現也有很高的參考價值。解釋器與編譯器儘管並不是所有的 Java 虛擬機都採用解釋器與編譯器並存的架構,但許多主流的商用虛擬機,如 HotSpot、J9 等,都同時包含解釋器與編譯器。