得物技術淺談深入淺出的Redis分散式鎖

得物技術發表於2022-04-27

一、什麼是分散式鎖

1.1 分散式鎖介紹

分散式鎖是控制不同系統之間訪問共享資源的一種鎖實現,如果不同的系統或同一個系統的不同主機之間共享了某個資源時,往往需要互斥來防止彼此干擾來保證一致性。

1.2 為什麼需要分散式鎖

在單機部署的系統中,使用執行緒鎖來解決高併發的問題,多執行緒訪問共享變數的問題達到資料一致性,如使用synchornized、ReentrantLock等。但是在後端叢集部署的系統中,程式在不同的JVM虛擬機器中執行,且因為synchronized或ReentrantLock都只能保證同一個JVM程式中保證有效,所以這時就需要使用分散式鎖了。這裡就不再贅述synchornized鎖的原理。

1.3 分散式鎖需要具備的條件

分散式鎖需要具備互斥性、不會死鎖和容錯等。互斥性,在於不管任何時候,應該只能有一個執行緒持有一把鎖;不會死鎖在於即使是持有鎖的客戶端意外當機或發生程式被kill等情況時也能釋放鎖,不至於導致整個服務死鎖。容錯性指的是隻要大多數節點正常工作,客戶端應該都能獲取和釋放鎖。

二、分散式鎖的實現方式

目前主流的分散式鎖的實現方式,基於資料庫實現分散式鎖、基於Redis實現分散式鎖、基於ZooKeeper實現分散式鎖,本篇文章主要介紹了Redis實現的分散式鎖。

2.1 由單機部署到叢集部署鎖的演變

一開始在redis設定一個預設值key:ticket 對應的值為20,並搭建一個Spring Boot服務,用來模擬多視窗賣票現象,配置類的程式碼就不一一列出了。

2.1.1 單機模式解決併發問題

一開始的時候在redis預設定的門票值ticket=20,那麼當一個請求進來之後,會判斷是否餘票是否是大於0,若大於0那麼就將餘票減一,再重新寫入Redis中,倘若庫存小於0,那麼就會列印錯誤日誌。

@RestController
@Slf4j
public class RedisLockController {
    
    @Resource
    private Redisson redisson;
    
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    
    @RequestMapping("/lock")
    public String deductTicket() throws InterruptedException {
        String lockKey = "ticket";
        int ticketCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(lockKey));
        if (ticketCount > 0) {
            int realTicketCount = ticketCount - 1;
            log.info("扣減成功,剩餘票數:" + realTicketCount + "");
            stringRedisTemplate.opsForValue().set(lockKey, realTicketCount + "");
        } else {
            log.error("扣減失敗,餘票不足");
        }
        return "end";
    }
    
}

程式碼執行分析: 這裡明顯有一個問題,就是當前若有兩個執行緒同時請求進來,那麼兩個執行緒同時請求這段程式碼時,如圖thread 1 和thread 2同時,兩個執行緒從Redis拿到的資料都是20,那麼執行完成後thread 1 和thread 2又將減完後的庫存ticket=19重新寫入Redis,那麼資料就會產生問題,實際上兩個執行緒各減去了一張票數,然而實際寫進就減了一次票數,就出現了資料不一致的現象。

這種問題很好解決,上述問題的產生其實就是從Redis中拿資料和減餘票不是原子操作,那麼此時只需要將按下圖程式碼給這倆操作加上synchronized同步程式碼快就能解決這個問題。

@RestController
@Slf4j
public class RedisLockController {

    @Resource
    private Redisson redisson;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/lock")
    public String deductTicket() throws InterruptedException {
        String lockKey = "ticket";
        synchronized (this) {
            int ticketCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(lockKey));
            if (ticketCount > 0) {
                int realTicketCount = ticketCount - 1;
                log.info("扣減成功,剩餘票數:" + realTicketCount + "");
                stringRedisTemplate.opsForValue().set(lockKey, realTicketCount + "");
            } else {
                log.error("扣減失敗,餘票不足");
            }
        }
        return "end";
    }

}

程式碼執行分析: 此時當多個執行緒執行到第14行的位置時,只會有一個執行緒能夠獲取鎖,進入synchronized程式碼塊中執行,當該執行緒執行完成後才會釋放鎖,等下個執行緒進來之後就會重新給這段程式碼上鎖再執行。說簡單些就是讓每個執行緒排隊執行程式碼塊中的程式碼,從而保證了執行緒的安全。

上述的這種做法如果後端服務只有一臺機器,那毫無疑問是沒問題的,但是現在網際網路公司或者是一般軟體公司,後端服務都不可能只用一臺機器,最少都是2臺伺服器組成的後端服務叢集架構,那麼synchronized加鎖就顯然沒有任何作用了。

如下圖所示,若後端是兩個微服務構成的服務叢集,由nginx將多個的請求負載均衡轉發到不同的後端服務上,由於synchronize程式碼塊只能在同一個JVM程式中生效,兩個請求能夠同時進兩個服務,所以上面程式碼中的synchronized就一點作用沒有了。

用JMeter工具隨便測試一下,就很簡單能發現上述程式碼的bug。實際上synchronized和juc包下個那些鎖都是隻能用於JVM程式維度的鎖,並不能運用在叢集或分散式部署的環境中。

2.1.2 叢集模式解決併發問題

通過上面的實驗很容易就發現了synchronized等JVM程式級別的鎖並不能解決分散式場景中的併發問題,就是為了應對這種場景產生了分散式鎖。

本篇文章介紹了Redis實現的分散式鎖,可以通過Redis的setnx(只在鍵key不存在的情況下, 將鍵key的值設定為value。 若鍵key已經存在, 則SETNX命令不做任何動作。)的指令來解決的,這樣就可以解決上面叢集環境的鎖不唯一的情況。

@RestController
@Slf4j
public class RedisLockController {

    @Resource
    private Redisson redisson;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/lock")
    public String deductTicket() throws InterruptedException {

        String lockKey = "ticket";
        // redis setnx 操作
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "dewu");
        if (Boolean.FALSE.equals(result)) {
            return "error";
        }

        int ticketCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(lockKey));
        if (ticketCount > 0) {
            int realTicketCount = ticketCount - 1;
            log.info("扣減成功,剩餘票數:" + realTicketCount + "");
            stringRedisTemplate.opsForValue().set(lockKey, realTicketCount + "");
        } else {
            log.error("扣減失敗,餘票不足");
        }

        stringRedisTemplate.delete(lockKey);
        return "end";
    }

}

程式碼執行分析: 程式碼是有問題的,就是當執行扣減餘票操作時,若業務程式碼報了異常,那麼就會導致後面的刪除Redis的key程式碼沒有執行到,就會使Redis的key沒有刪掉的情況,那麼Redis的這個key就會一直存在Redis中,後面的執行緒再進來執行下面這行程式碼都是執行不成功的,就會導致執行緒死鎖,那麼問題就會很嚴重了。

為了解決上述問題其實很簡單,只要加上一個try...finally即可,這樣業務程式碼即使拋了異常也可以正常的釋放鎖。setnx + try ... finally解決,具體程式碼如下:

@RestController
@Slf4j
public class RedisLockController {

    @Resource
    private Redisson redisson;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/lock")
    public String deductTicket() throws InterruptedException {

        String lockKey = "ticket";
        // redis setnx 操作
        try {
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "dewu");
            if (Boolean.FALSE.equals(result)) {
                return "error";
            }
            
            int ticketCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(lockKey));
            if (ticketCount > 0) {
                int realTicketCount = ticketCount - 1;
                log.info("扣減成功,剩餘票數:" + realTicketCount + "");
                stringRedisTemplate.opsForValue().set(lockKey, realTicketCount + "");
            } else {
                log.error("扣減失敗,餘票不足");
            }
        } finally {
            stringRedisTemplate.delete(lockKey);
        }
        return "end";
    }

}

程式碼執行分析:上述業務程式碼執行報錯的問題解決了,但是又會有新的問題,當程式執行到try程式碼塊中某個位置服務當機或者服務重新發布,這樣就還是會有上述的Redis的key沒有刪掉導致死鎖的情況。這樣可以使用Redis的過期時間來進行設定key,setnx + 過期時間解決,如下程式碼所示:

@RestController
@Slf4j
public class RedisLockController {

    @Resource
    private Redisson redisson;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/lock")
    public String deductTicket() throws InterruptedException {

        String lockKey = "ticket";
        // redis setnx 操作
        try {
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "dewu");
            //程式執行到這
            stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
            if (Boolean.FALSE.equals(result)) {
                return "error";
            }

            int ticketCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(lockKey));
            if (ticketCount > 0) {
                int realTicketCount = ticketCount - 1;
                log.info("扣減成功,剩餘票數:" + realTicketCount + "");
                stringRedisTemplate.opsForValue().set(lockKey, realTicketCount + "");
            } else {
                log.error("扣減失敗,餘票不足");
            }
        } finally {
            stringRedisTemplate.delete(lockKey);
        }
        return "end";
    }

}

程式碼執行分析:上述程式碼解決了因為程式執行過程中當機導致的鎖沒有釋放導致的死鎖問題,但是如果程式碼像上述的這種寫法仍然還是會有問題,當程式執行到第18行時,程式當機了,此時Redis的過期時間並沒有設定,也會導致執行緒死鎖的現象。可以用了Redis設定的原子命設定過期時間的命令,原子性過期時間的setnx命令,如下程式碼所示:

@RestController
@Slf4j
public class RedisLockController {

    @Resource
    private Redisson redisson;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/lock")
    public String deductTicket() throws InterruptedException {

        String lockKey = "ticket";
        // redis setnx 操作
        try {
            Boolean result = stringRedisTemplate.opsForValue().setIfPresent(lockKey, "dewu", 10, TimeUnit.SECONDS);
            if (Boolean.FALSE.equals(result)) {
                return "error";
            }

            int ticketCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(lockKey));
            if (ticketCount > 0) {
                int realTicketCount = ticketCount - 1;
                log.info("扣減成功,剩餘票數:" + realTicketCount + "");
                stringRedisTemplate.opsForValue().set(lockKey, realTicketCount + "");
            } else {
                log.error("扣減失敗,餘票不足");
            }

        } finally {
            stringRedisTemplate.delete(lockKey);
        }
        return "end";
    }

}

程式碼執行分析:通過設定原子性過期時間命令可以很好的解決上述這種程式執行過程中突然當機的情況。這種Redis分散式鎖的實現看似已經沒有問題了,但在高併發場景下任會存在問題,一般軟體公司併發量不是很高的情況下,這種實現分散式鎖的方式已經夠用了,即使出了些小的資料不一致的問題,也是能夠接受的,但是如果是在高併發的場景下,上述的這種實現方式還是會存在很大問題。

如上面程式碼所示,該分散式鎖的過期時間是10s,假如thread 1執行完成時間需要15s,且當thread 1執行緒執行到10s時,Redis的key恰好就是過期就直接釋放鎖了,此時thread 2就可以獲得鎖執行程式碼了,假如thread 2執行緒執行完成時間需要8s,那麼當thread 2執行緒執行到第5s時,恰好thread 1執行緒執行了釋放鎖的程式碼————stringRedisTemplate.delete(lockKey); 此時,就會發現thread 1執行緒刪除的鎖並不是其自己的加鎖,而是thread 2加的鎖;那麼thread 3就又可以進來了,那麼假如一共執行5s,那麼當thread 3執行到第3s時,thread 2又會恰好執行到釋放鎖的程式碼,那麼thread 2又刪除了thread 3 加的鎖。

在高併發場景下,倘若遇到上述問題,那將是災難性的bug,只要高併發存在,那麼這個分散式鎖就會時而加鎖成功時而加鎖失敗。

解決上述問題其實也很簡單,讓每個執行緒加的鎖時給Redis設定一個唯一id的value,每次釋放鎖的時候先判斷一下執行緒的唯一id與Redis 存的值是否相同,若相同即可釋放鎖。設定執行緒id的原子性過期時間的setnx命令, 具體程式碼如下:

@RestController
@Slf4j
public class RedisLockController {

    @Resource
    private Redisson redisson;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/lock")
    public String deductTicket() throws InterruptedException {

        String lockKey = "ticket";
        String threadUniqueKey = UUID.randomUUID().toString();
        // redis setnx 操作
        try {
            Boolean result = stringRedisTemplate.opsForValue().setIfPresent(lockKey, threadUniqueKey, 10, TimeUnit.SECONDS);
            if (Boolean.FALSE.equals(result)) {
                return "error";
            }

            int ticketCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(lockKey));
            if (ticketCount > 0) {
                int realTicketCount = ticketCount - 1;
                log.info("扣減成功,剩餘票數:" + realTicketCount + "");
                stringRedisTemplate.opsForValue().set(lockKey, realTicketCount + "");
            } else {
                log.error("扣減失敗,餘票不足");
            }
        } finally {
            if (Objects.equals(stringRedisTemplate.opsForValue().get(lockKey), threadUniqueKey)) {
                stringRedisTemplate.delete(lockKey);
            }
        }
        return "end";
    }

}

程式碼執行分析:上述實現的Redis分散式鎖已經能夠滿足大部分應用場景了,但是還是略有不足,比如當執行緒進來需要的執行時間超過了Redis key的過期時間,那麼此時已經釋放了,你其他執行緒就可以立馬獲得鎖執行程式碼,就又會產生bug了。

分散式鎖Redis key的過期時間不管設定成多少都不合適,比如將過期時間設定為30s,那麼如果業務程式碼出現了類似慢SQL、查詢資料量很大那麼過期時間就不好設定了。那麼這裡有沒有什麼更好的方案呢?答案是有的——鎖續命。

那麼鎖續命方案的原來就在於當執行緒加鎖成功時,會開一個分執行緒,取鎖過期時間的1/3時間點定時執行任務,如上圖的鎖為例,每10s判斷一次鎖是否存在(即Redis的key),若鎖還存在那麼就直接重新設定鎖的過期時間,若鎖已經不存在了那麼就直接結束當前的分執行緒。

2.2 Redison框架實現Redis分散式鎖

上述“鎖續命”方案說起來簡單,但是實現起來還是挺複雜的,於是市面上有很多開源框架已經幫我們實現好了,所以就不需要自己再去重複造輪子再去寫一個分散式鎖了,所以本次就拿Redison框架來舉例,主要是可以學習這種設計分散式鎖的思想。

2.2.1 Redison分散式鎖的使用

Redison實現的分散式鎖,使用起來還是非常簡單的,具體程式碼如下:

@RestController
@Slf4j
public class RedisLockController {

    @Resource
    private Redisson redisson;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/lock")
    public String deductTicket() throws InterruptedException {

        //傳入Redis的key
        String lockKey = "ticket";
        // redis setnx 操作
        RLock lock = redisson.getLock(lockKey);
        try {
            //加鎖並且實現鎖續命
            lock.lock();
            int ticketCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(lockKey));
            if (ticketCount > 0) {
                int realTicketCount = ticketCount - 1;
                log.info("扣減成功,剩餘票數:" + realTicketCount + "");
                stringRedisTemplate.opsForValue().set(lockKey, realTicketCount + "");
            } else {
                log.error("扣減失敗,餘票不足");
            }

        } finally {
            //釋放鎖
            lock.unlock();
        }
        return "end";
    }

}

2.2.2 Redison分散式鎖的原理

Redison實現分散式鎖的原理流程如下圖所示,當執行緒1加鎖成功,並開始執行業務程式碼時,Redison框架會開啟一個後臺執行緒,每隔鎖過期時間的1/3時間定時判斷一次是否還持有鎖(Redis中的key是否還存在),若不持有那麼就直接結束當前的後臺執行緒,若還持有鎖,那麼就重新設定鎖的過期時間。當執行緒1加鎖成功後,那麼執行緒2就會加鎖失敗,此時執行緒2就會就會做類似於CAS的自旋操作,一直等待執行緒1釋放了之後執行緒2才能加鎖成功。

2.2.3 Redison分散式鎖的原始碼分析

Redison底層實現分散式鎖時使用了大量的lua指令碼保證了其加鎖操作的各種原子性。Redison實現分散式鎖使用lua指令碼的好處主要是能保證Redis的操作是原子性的,Redis會將整個指令碼作為一個整體執行,中間不會被其他命令插入。

Redisson核心使用lua指令碼加鎖原始碼分析:

方法名為tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command):

 //使用lua指令碼加鎖方法
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
     internalLockLeaseTime = unit.toMillis(leaseTime);

     return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
           //當第一個執行緒進來會直接執行這段邏輯                            
           //判斷傳入的Redis的key是否存在,即String lockKey = "ticket";
           "if (redis.call('exists', KEYS[1]) == 0) then " +    
           //如果不存在那麼就設定這個key為傳入值、當前執行緒id 即引數ARGV[2]值(即getLockName(threadId)),並且將執行緒id的value值設定為1
             "redis.call('hset', KEYS[1], ARGV[2], 1); " +    
          //再給這個key設定超時時間,超時時間即引數ARGV[1](即internalLockLeaseTime的值)的時間
             "redis.call('pexpire', KEYS[1], ARGV[1]); " +        
             "return nil; " +
             "end; " +
          //當第二個執行緒進來,Redis中的key已經存在(鎖已經存在),那麼直接進這段邏輯
          //判斷這個Redis key是否存在且當前的這個key是否是當前執行緒設定的
           "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
          //如果是的話,那麼就進入重入鎖的邏輯,利用hincrby指令將第一個執行緒進來將執行緒id的value值設定為1再加1 
          //然後每次釋放鎖的時候就會減1,直到這個值為0,這把鎖就釋放了,這點與juc的可重鎖類似           
          //“hincrby”指令為Redis hash結構的加法
             "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
             "redis.call('pexpire', KEYS[1], ARGV[1]); " +
             "return nil; " +
             "end; " +
          //倘若不是本執行緒加的鎖,而是其他執行緒加的鎖,由於上述lua指令碼都是有執行緒id的校驗,那麼上面的兩段lua指令碼都不會執行
          //那麼此時這裡就會將當前這個key的過期時間返回 
             "return redis.call('pttl', KEYS[1]);",
             Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));   // KEYS[1])  ARGV[1]   ARGV[2]
}
// getName()傳入KEYS[1],表示傳入解鎖的keyName,這裡是 String lockKey = "ticket";
// internalLockLeaseTime傳入ARGV[1],表示鎖的超時時間,預設是30秒
// getLockName(threadId)傳入ARGV[2],表示鎖的唯一標識執行緒id

設定監聽器方法:方法名tryAcquireOnceAsync(long leaseTime, TimeUnit unit, final long threadId)

    //設定監聽器方法:    
    private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, final long threadId) {
        if (leaseTime != -1) {
            return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
        }
   //加鎖成功這裡會返回一個null值,即ttlRemainingFuture為null
   //若執行緒沒有加鎖成功,那麼這裡返回的就是這個別的執行緒加過的鎖的剩餘的過期時間,即ttlRemainingFuture為過期時間
        RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
        //如果還持有這個鎖,則開啟定時任務不斷重新整理該鎖的過期時間
        //這裡給當前業務加了個監聽器
        ttlRemainingFuture.addListener(new FutureListener<Boolean>() {
            @Override
            public void operationComplete(Future<Boolean> future) throws Exception {
                if (!future.isSuccess()) {
                    return;
                }

                Boolean ttlRemaining = future.getNow();
                // lock acquired
                if (ttlRemaining) {
                    //定時任務執行方法
                    scheduleExpirationRenewal(threadId);
                }
            }
        });
        return ttlRemainingFuture;
    }

定時任務執行方法: 方法名scheduleExpirationRenewal(final long threadId):

    //定時任務執行方法
    private void scheduleExpirationRenewal(final long threadId) {
        if (expirationRenewalMap.containsKey(getEntryName())) {
            return;
        }

        //這裡new了一個TimerTask()定時任務器
        //這裡定時任務會推遲執行,推遲的時間是設定的鎖過期時間的1/3,
        //很容易就能發現是一開始鎖的過期時間預設值30s,具體可見private long lockWatchdogTimeout = 30 * 1000;
        //過期時間單位是秒
        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,
             //這裡又是一個lua指令碼
             //這裡lua指令碼先判斷了一下,Redis的key是否存在且設定key的執行緒id是否是引數ARGV[2]值
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + 
             //如果這個執行緒建立的Redis的key即鎖仍然存在,那麼久給鎖的過期時間重新設值為internalLockLeaseTime,也就是初始值30s
                            "redis.call('pexpire', KEYS[1], ARGV[1]); " +
             //Redis的key過期時間重新設定成功後,這裡的lua指令碼返回的就是1
                            "return 1; " +
                        "end; " +
             //如果主執行緒已經釋放了這個鎖,那麼這裡的lua指令碼就會返回0,直接結束“看門狗”的程式
                        "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();
        }
    }
//上面原始碼分析過了,當加鎖成功後tryAcquireAsync()返回的值為null, 那麼這個方法的返回值也為null
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
   return get(tryAcquireAsync(leaseTime, unit, threadId));
}
     public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
        //獲得當前執行緒id
         long threadId = Thread.currentThread().getId();
         //由上面的原始碼分析可以得出,當加鎖成功後,這個ttl就是null
         //若執行緒沒有加鎖成功,那麼這裡返回的就是這個別的執行緒加過的鎖的剩餘的過期時間
        Long ttl = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
         //如果加鎖成功後,這個ttl就是null,那麼這個方法後續就不需要做任何邏輯
         //若沒有加鎖成功這裡ttl的值不為null,為別的執行緒加過鎖的剩餘的過期時間,就會繼續往下執行
        if (ttl == null) {
            return;
        }

        RFuture<RedissonLockEntry> future = subscribe(threadId);
        commandExecutor.syncSubscription(future);

        try {
        //若沒有加鎖成功的執行緒,會在這裡做一個死迴圈,即自旋
            while (true) {
                //一直死迴圈嘗試加鎖,這裡又是上面的加鎖邏輯了
                ttl = tryAcquire(leaseTime, unit, threadId);
                // lock acquired
                if (ttl == null) {
                    break;
                }
        //這裡不會瘋狂自旋,這裡會判斷鎖失效之後才會繼續進行自旋,這樣可以節省一點CPU資源
                // waiting for message
                if (ttl >= 0) {
                    getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                    getEntry(threadId).getLatch().acquire();
                }
            }
        } finally {
            unsubscribe(future, threadId);
        }
    //        get(lockAsync(leaseTime, unit));
    }

Redison底層解鎖原始碼分析:

    @Override
    public void unlock() {
        // 呼叫非同步解鎖方法
        Boolean opStatus = get(unlockInnerAsync(Thread.currentThread().getId()));
        //當釋放鎖的執行緒和已存在鎖的執行緒不是同一個執行緒,返回null
        if (opStatus == null) {
            throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                    + id + " thread-id: " + Thread.currentThread().getId());
        }
        //根據執行lua指令碼返回值判斷是否取消續命訂閱
        if (opStatus) {
            // 取消續命訂閱
            cancelExpirationRenewal();
        }
    }
    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                //如果鎖已經不存在, 釋出鎖釋放的訊息,返回1
                "if (redis.call('exists', KEYS[1]) == 0) then " +
                    "redis.call('publish', KEYS[2], ARGV[1]); " +
                    "return 1; " +
                "end;" +
                //如果釋放鎖的執行緒和已存在鎖的執行緒不是同一個執行緒,返回null
                "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                    "return nil;" +
                "end; " +
                //當前執行緒持有鎖,用hincrby命令將鎖的可重入次數-1,即執行緒id的value值-1
                "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                //若執行緒id的value值即可重入鎖的次數大於0 ,就更新過期時間,返回0
                "if (counter > 0) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                    "return 0; " +
                //否則證明鎖已經釋放,刪除key併發布鎖釋放的訊息,返回1
                "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));

    }
    // getName()傳入KEYS[1],表示傳入解鎖的keyName
    // getChannelName()傳入KEYS[2],表示redis內部的訊息訂閱channel
    // LockPubSub.unlockMessage傳入ARGV[1],表示向其他redis客戶端執行緒傳送解鎖訊息
    // internalLockLeaseTime傳入ARGV[2],表示鎖的超時時間,預設是30秒
    // getLockName(threadId)傳入ARGV[3],表示鎖的唯一標識執行緒id
    void cancelExpirationRenewal() {
        // 將該執行緒從定時任務中刪除
        Timeout task = expirationRenewalMap.remove(getEntryName());
        if (task != null) {
            task.cancel();
        }
    }

上述情況如果是單臺Redis,那麼利用Redison開源框架實現Redis的分散式鎖已經很完美了,但是往往生產環境的的Redis一般都是哨兵主從架構,Redis的主從架構有別與Zookeeper的主從,客戶端只能請求Redis主從架構的Master節點,Slave節點只能做資料備份,Redis從Master同步資料到Slave並不需要同步完成後才能繼續接收新的請求,那麼就會存在一個主從同步的問題。

當Redis的鎖設定成功,正在執行業務程式碼,當Redis向從伺服器同步時,Redis的Maste節點當機了,Redis剛剛設定成功的鎖還沒來得及同步到Slave節點,那麼此時Redis的主從哨兵模式就會重新選舉出新的Master節點,那麼這個新的Master節點其實就是原來的Slave節點,此時後面請求進來的執行緒都會請求這個新的Master節點,然而選舉後產生的新Master節點實際上是沒有那把鎖的,那麼從而導致了鎖的失效。

上述問題用Redis主從哨兵架構實現的分散式鎖在這種極端情況下是無法避免的,但是一般情況下生產上這種故障的概率極低,即使偶爾有問題也是可以接受的。

如果想使分散式鎖變的百分百可靠,那可以選用Zookeeper作為分散式鎖,就能完美的解決這個問題。由於zk的主從資料同步有別與Redis主從同步,zk的強一致性使得當客戶端請求zk的Leader節點加鎖時,當Leader將這個鎖同步到了zk叢集的大部分節點時,Leader節點才會返回客戶端加鎖成功,此時當Leader節點當機之後,zk內部選舉產生新的Leader節點,那麼新的客戶款訪問新的Leader節點時,這個鎖也會存在,所以zk叢集能夠完美解決上述Redis叢集的問題。

由於Redis和Zookeeper的設計思路不一樣,任何分散式架構都需要滿足CAP理論,“魚和熊掌不可兼得”,要麼選擇AP要麼選擇CP,很顯然Redis是AP結構,而zk是屬於CP架構,也導致了兩者的資料同步本質上的區別。

其實設計Redis分散式鎖有種RedLock的思想就是借鑑zk實現分散式鎖的這個特點,這種Redis的加鎖方式在Redison框架中也有提供api,具體使用也很簡單,這裡就不一一贅述了。其主要思想如下圖所示:

這種實現方式,我認為生產上並不推薦使用。很簡單原本只需要對一個Redis加鎖,設定成功返回即可,但是現在需要對多個Redis進行加鎖,無形之中增加了好幾次網路IO,萬一第一個Redis加鎖成功後,後面幾個Redis在加鎖過程中出現了類似網路異常的這種情況,那第一個Redis的資料可能就需要做資料回滾操作了,那為了解決一個極低概率發生的問題又引入了多個可能產生的新問題,很顯然得不償失。並且這裡還有可能出現更多亂七八糟的問題,所以我認為這種Redis分散式鎖的實現方式極其不推薦生產使用。

退一萬說如果真的需要這種強一致性的分散式鎖的話,那為什麼不直接用zk實現的分散式鎖呢,效能肯定也比這個RedLock的效能要好。

三、分散式鎖使用場景

這裡著重講一下分散式鎖的兩種以下使用場景:

3.1 熱點快取key重建優化

一般情況下網際網路公司基本都是使用“快取”加過期時間的策略,這樣不僅加快資料讀寫, 而且還能保證資料的定期更新,這種策略能夠滿足大部分需求,但是也會有一種特殊情況會有問題:原本就存在一個冷門的key,因為某個熱點新聞的出現,突然這個冷門的key請求量暴增成了使其稱為了一個熱點key,此時快取失效,並且又無法在很短時間內重新設定快取,那麼快取失效的瞬間,就會有大量執行緒來訪問到後端,造成資料庫負載加大,從而可能會讓應用崩潰。

例如:“Air Force one”原本就是一個冷門的key存在於快取中,微博突然有個明星穿著“Air Force one”上了熱搜,那麼就會有很多明星的粉絲來得物app購買“Air Force one”,此時的“Air Force one”就直接成為了一個熱點key,那麼此時“Air Force one”這個key如果快取恰好失效了之後,就會有大量的請求同時訪問到db,會給後端造成很大的壓力,甚至會讓系統當機。

要解決這個問題只需要用一個簡單的分散式鎖即可解決這個問題,只允許一個執行緒去重建快取,其他執行緒等待重建快取的執行緒執行完, 重新從快取獲取資料即可。可見下面的例項虛擬碼:

    public String getCache(String key) {
        //從快取獲取資料
        String value = stringRedisTemplate.opsForValue().get(key);
        //傳入Redis的key
        try {
            if (Objects.isNull(value)) {
               //這裡只允許一個執行緒進入,重新設定快取
                String mutexKey = "lock_key" + key;

                //如果加鎖成功
                if (stringRedisTemplate.opsForValue().setIfPresent(mutexKey, "poizon", 30, TimeUnit.SECONDS)) {
                    //從db 獲取資料
                    value = mysql.getDataFromMySQL(key);
                    //寫回快取
                    stringRedisTemplate.opsForValue().setIfPresent(key, "poizon", 60, TimeUnit.SECONDS);
                    //釋放鎖
                    stringRedisTemplate.delete(mutexKey);
                }
                
            } else {
                Thread.sleep(100);
                getCache(key);
            }

        } catch (InterruptedException e) {
            log.error("getCache is error", e);
        }
        return value;
    }

3.2 解決快取與資料庫資料不一致問題

如果業務對資料的快取與資料庫需要強一致時,且併發量不是很高的情況下的情況下時,就可以直接加一個分散式讀寫鎖就可以直接解決這個問題了。可以直接利用可以加分散式讀寫鎖保證併發讀寫或寫寫的時候按順序排好隊,讀讀的時候相當於無鎖。

併發量不是很高且業務對快取與資料庫有著強一致對要求時,通過這種方式實現最簡單,且效果立竿見影。倘若在這種場景下,如果還監聽binlog通過訊息的方式延遲雙刪的方式去保證資料一致性的話,引入了新的中介軟體增加了系統的複雜度,得不償失。

3.3超高併發場景下的分散式鎖設計理論

與ConcurrentHashMap的設計思想有點類似,用分段鎖來實現,這個是之前在網上看到的實現思路,本人並沒有實際使用過,不知道水深不深,但是可以學習一下實現思路。

假如A商品的庫存是2000個,現在可以將該A商品的2000個庫存利用類似ConcurrentHashMap的原理將不同數量段位的庫存的利用取模或者是hash演算法讓其擴容到不同的節點上去,這樣這2000的庫存就水平擴容到了多個Redis節點上,然後請求Redis拿庫存的時候請求原本只能從一個Redis上取資料,現在可以從五個Redis上取資料,從而可以大大提高併發效率。

第四部分:總結與思考

綜上可知,Redis分散式鎖並不是絕對安全,Redis分散式鎖在某種極端情況下是無法避免的,但是一般情況下生產上這種故障的概率極低,即使偶爾有問題也是可以接受。

CAP 原則指的是在一個分散式系統中,一致性(Consistency)、可用性(Availability)、分割槽容錯性(Partition tolerance)這三個要素最多隻能同時實現兩點,不可能三者兼顧。魚和熊掌不可兼得”,要麼選擇AP要麼選擇CP,選擇Redis作為分散式鎖的元件在於其單執行緒記憶體操作效率很高,且在高併發場景下也可以保持很好的效能。

如果一定要要求分散式鎖百分百可靠,那可以選用Zookeeper或者MySQL作為分散式鎖,就能完美的解決鎖安全的問題,但是選擇了一致性那就要失去可用性,所以Zookeeper或者MySQL實現的分散式鎖的效能遠不如Redis實現的分散式鎖。

感謝閱讀此篇文章的你,若有不足的地方煩請指出,大家可以一起探討學習,共同進步。

文/harmony

關注得物技術,做最潮技術人!

相關文章