再說分散式鎖

雨季不再來發表於2019-02-28

分散式鎖

1. 什麼是分散式鎖?

1.1 使用場景

鎖,就是為了防止多個執行緒併發操作共享變數而導致不可預期的結果鎖採取的一種序列化的方式,那分散式鎖
,顧名思義,也就是在控制分散式條件下,執行緒能夠序列化的操作共享變數,從而達到不同機器的程式的執行緒之間的同步或者互斥。

1.2 常見的實現方式

分散式鎖常見的實現方式有redis的實現,zk的實現,tair的實現,本篇我們主要討論下通過redis的實現。

2. 分散式鎖redis的實現

2.1 鎖的實現

話不多說,我們先上程式碼

class RedisLock{
    public boolean lock(String key, V v, int expireTime){
            int retry = 0;
            //獲取鎖失敗最多嘗試10次
            while (retry < failRetryTimes){
                //獲取鎖
                Boolean result = redis.setNx(key, v, expireTime);
                if (result){
                    return true;
                }
    
                try {
                    //獲取鎖失敗間隔一段時間重試
                    TimeUnit.MILLISECONDS.sleep(sleepInterval);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return false;
                }
    
            }
    
            return false;
        }
        public boolean unlock(String key){
            return redis.delete(key);
        }
}
複製程式碼

這是分散式鎖簡單的實現,先嚐試往redis裡面設值,如果成功則返回true,否則睡眠指定的時間重試,直到獲取成功。
如果超過重試的次數獲取鎖還是失敗的話,就返回false。

2.2 鎖的不足

我們看下上面的實現,會發現以下存在的幾個問題:

  1. 在我們這一步Boolean result = redis.setNx(key, v, expireTime);去設定k,v的時候,如果此時返回的false是由於超時導致,而實際redis是執行成功了,
    那我們重新再設定就會一直失敗,我們就會在這裡空等待一個expireTime的時間週期。
  2. 鎖的釋放沒有檢測當前的鎖是否是當前執行緒所加,所以是有可能誤釋放掉別的執行緒加的鎖
  3. 可重入性,這個其實可以和第2條放在一起

2.3 鎖的改進

根據上面的兩點的不足,我們改進下鎖的實現程式碼:

public class RedisLock {
    public boolean lock(String key, V v, int expireTime){
        int retry = 0;
        //獲取鎖失敗最多嘗試10次
        while (retry < failRetryTimes){
            //1.先獲取鎖,如果是當前執行緒已經持有,則直接返回
            //2.防止後面設定鎖超時,其實是設定成功,而網路超時導致客戶端返回失敗,所以獲取鎖之前需要查詢一下
            V value = redis.get(key);
            //如果當前鎖存在,並且屬於當前執行緒持有,直接返回
            if (null != value && value.equals(v)){
                return true;
            }

            //獲取鎖
            Boolean result = redis.setNx(key, v, expireTime);
            if (result){
                return true;
            }

            try {
                //獲取鎖失敗間隔一段時間重試
                TimeUnit.MILLISECONDS.sleep(sleepInterval);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }
        }

        return false;
    }
    public boolean unlock(String key, String requestId){
        String value = redis.get(key);
        //鎖應該是已經超時了,其實這裡可以加一些監控去看下
        if (Strings.isNullOrEmpty(value)){
            return true;
        }
        //判斷當前鎖的持有者是否是當前執行緒,如果是的話釋放鎖,不是的話返回false
        if (value.equals(requestId)){
            redis.delete(key);
            return true;
        }

        return false;
    }
}
複製程式碼

可以看到,我們針對上面不足的兩點已經做了些改進,這部分基本上已經滿足了我們業務的需求。
其實,我們還是可以做一些優化的,並非必須,不同的業務可以有不同的實現方式:

  1. 在最後釋放鎖的時候,我們先去判斷該鎖是否存在並且屬於當前執行緒所有,如果是的話再去釋放鎖,而這兩步操作其實並非是原子性的,所以也是會存在
    競汏條件,出現問題,所以我們可以把這兩部通過lua指令碼實現做成原子性的,具體可以不同的業務去考量
  2. 鎖的睡眠時間和重試次數可以暴露出來,不同的業務可以根據業務特點進行控制

2.4 分散式鎖實踐遇到的問題

在我們分散式鎖的實際使用中,有遇到過一些考慮不足的點,這裡簡單列一下:

  1. 大多數業務的分散式鎖,k和v是一致的,這種其實在併發量比較小的時候,問題不大,但是在併發量比較大的時候,是會出現當前執行緒釋放掉別的執行緒的鎖的,這點見到的比較多,
    所以也單獨列出來說明下。
  2. 過期時間的設定,這個比較重要。我們如果分散式鎖之後,進行本地事務的操作,如果我們設定的過期時間比事務的超時時間短,那麼可能就存在的問題是分散式鎖已經過期了,但是事務還在等待提交,等到下一個
    執行緒獲取到鎖之後,那就會存在併發提交,這就和我們想通過分散式鎖達到的預期結果相違背了,所以在設定過期時間的時候,如果有事務的操作需要格外注意下,包括失敗之後的重試,如果有的話
    也需要多考量下。
  3. 鎖的監控,對於鎖獲取時間比較長,以及釋放的時候鎖已經過期了,對於這部分請求可以監控下來,業務上去排查下,是否存在一些問題,在系統上儘量我們還是做到
    由我們主動的去找系統的問題,而不是通過系統被動的曝出問題我們去排查。

2.5 不合理的實現

網上經常還可以看到這種實現方式,就是獲取到鎖之後要檢查下鎖的過期時間,如果鎖過期了要重新設定下時間,大致程式碼如下:

public boolean tryLock2(String key, int expireTime){
        long expires = System.currentTimeMillis() + expireTime;

        //獲取鎖
        Boolean result = redis.setNx(key, expires, expireTime);
        if (result){
            return true;
        }

        V value = redis.get(key);
        if (value != null && (Long)value < System.currentTimeMillis()){
            //鎖已經過期
            String oldValue = redis.getSet(key, expireTime);
            if (oldValue != null && oldValue.equals(value)){
                return true;
            }
        }

        return false;

    }
複製程式碼

這種實現存在的問題,過度依賴當前伺服器的時間了,如果在大量的併發請求下,都判斷出了鎖過期,而這個時候再去設定鎖的時候,最終是會只有一個執行緒,但是可能會導致不同伺服器根據自身不同的時間覆蓋掉最終獲取鎖的那個執行緒設定的時間。

3. 其他實現的方式

3.1 zk的實現

網上有關zk實現的程式碼比較多,這裡就不展示程式碼了,可以大致說下思路:

3.1.1獲取鎖
  1. 先有一個鎖跟節點,lockRootNode,這可以是一個永久的節點
  2. 客戶端獲取鎖,先在lockRootNode下建立一個順序的瞬時節點,節點裡面可以儲存當前執行緒的一些資訊,比如requestId等可以唯一識別當前執行緒的資訊。瞬時節點可以保證客戶端斷開連線,節點也自動刪除
  3. 呼叫lockRootNode父節點的getChildren()方法,獲取所有的節點,並從小到大排序,獲取最小節點,並且判斷最小節點的節點資訊是否是當前執行緒,若是,則返回true,獲取鎖成功,否則,關注比自己序號小的節點的釋放動作(exist watch),這樣可以保證每一個客戶端只需要關注一個節點,不需要關注所有的節點,避免羊群效應。
  4. 如果有節點釋放操作,重複步驟3
3.1.2釋放鎖

只需要刪除步驟2中建立的節點即可

3.2 tair的實現

通過tair來實現分散式鎖和redis的實現核心差不多,不過tair有個很方便的api,感覺是實現分散式鎖的最佳配置,就是put api呼叫的時候需要傳入一個version,就和資料庫的樂觀鎖一樣,修改資料之後,版本會自動累加,如果傳入的版本和當前資料版本不一致,就不允許修改,具體可以看下這篇文章的實現:Tair分散式鎖這裡就不再多說了

小結

分散式鎖的常見實現方式,更多的通過redis和tair比較多一些。當然其使用的過程中存在的問題還有好多我們沒有提及到,比如redis叢集模式下,master down之後,鎖如果還沒來得及同步到從,那這個時候也會導致業務出現問題。
這裡也是想說明下,具體使用方式還是需要根據不同的業務的需求進行考量,畢竟我們使用這個是基於業務,需要保證業務的穩定執行。

相關文章