再聊分散式鎖

翎逸發表於2018-02-11

[toc]

今天我們來聊聊分散式鎖。

使用場景

首先,我們看這樣一個場景:客戶下單的時候,我們呼叫庫存中心進行減庫存,那我們一般的操作都是

update store set num = $num where id=$id

這種通過設定庫存的修改方式,我們知道在併發量高的時候會存在資料庫的丟失更新,比如a,b當前兩個事務,查詢出來的庫存都是5,a買了3個單子要把庫存設定為2,而b買了1個單子要把庫存設定為4,那這個時候就會出現a會覆蓋b的更新,所以我們更多的都是會加個條件

update store set num = $num where id=$id and num=$query_num

即樂觀鎖的方式來處理,當然也可以通過版本號來處理樂觀鎖,都是一樣的,但是這是更新一個表,如果我們牽扯到多個表呢,我們希望和這個單子關聯的所有的表同一時間只能被一個執行緒來處理更新,多個執行緒按照不同的順序去更新同一個單子關聯的不同資料,出現死鎖的概率比較大。對於非敏感的資料,我們也沒有必要去都加樂觀鎖處理,我們的服務都是多機器部署的,要保證多程式多執行緒同時只能有一個程式的一個執行緒去處理,這個時候我們就需要用到分散式鎖。
分散式鎖的實現方式有很多,我們今天分別通過資料庫,zk,redis以及tair的實現邏輯

資料庫實現

加x鎖

更新一個單子關聯的所有的資料,先查詢出這個單子,並加上排他鎖,在進行一系列的更新操作

begin transaction;
select ...for update;
doSomething();
commit();

這種處理需要主要依靠排他鎖來阻塞其他執行緒,不過這個需要注意幾點:

  1. 查詢的資料一定要在資料庫裡存在,如果不存在的話,資料庫會加gap鎖,而gap鎖之間是相容的,這種如果兩個執行緒都加了gap鎖,另一個再更新的話會出現死鎖。不過一般能更新的資料都是存在的
  2. 後續的處理流程需要儘可能的時間短,即在更新的時候提前準備好資料,保證事務處理的時間足夠的短,流程足夠的短,因為開啟事務是一直佔著連線的,如果流程比較長會消耗過多的資料庫連線的。

唯一鍵

通過在一張表裡建立唯一鍵來獲取鎖,比如執行saveStore這個方法

insert table lock_store (`method_name`) values($method_name)

其中method_name是個唯一鍵,通過這種方式也可以做到,解鎖的時候直接刪除改行記錄就行。不過這種方式,鎖就不會是阻塞式的,因為插入資料是立馬可以得到返回結果的。

那針對以上資料庫實現的兩種分散式鎖,存在什麼樣的優缺點呢

優點

  1. 簡單,方便,快速實現

缺點

  1. 基於資料庫,開銷比較大,效能可能會存在影響
  2. 基於資料庫的當前讀來實現,資料庫會在底層做優化,可能用到索引,可能不用到索引,這個依賴於查詢計劃的分析

zk的實現

使用zk來實現,程式碼網上比較多,我這裡大致說下步驟,我們重點看redis的實現。

獲取鎖

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

釋放鎖

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

使用zk的分散式鎖存在什麼樣的優缺點呢?

優點

  1. 客戶端如果出現當機故障的話,鎖可以馬上釋放
  2. 可以實現阻塞式鎖,通過watcher監聽,實現起來也比較簡單
  3. 叢集模式,穩定性比較高

缺點

  1. 一旦網路有任何的抖動,zk就會認為客戶端已經當機,就會斷掉連線,其他客戶端就可以獲取到鎖。當然zk有重試機制,這個就比較依賴於其重試機制的策略了
  2. 自己沒有做過測試,網上看到的說是效能上不如快取

redis實現

分散式鎖介紹這塊,我們重點看下redis的分散式鎖的實現。
我們先舉個例子,比如現在我要更新產品的資訊,產品的唯一鍵就是productId

簡單實現1

 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);
    }
    public static void main(String[] args) {
        Integer productId = 324324;
        RedisLock<Integer> redisLock = new RedisLock<Integer>();
        redisLock.lock(productId+"", productId, 1000);
    }

這是一個簡單的實現,存在的問題

  1. 可能會導致當前執行緒的鎖誤被其他執行緒釋放,比如a執行緒獲取到了鎖正在執行,但是由於內部流程處理超時或者gc導致鎖過期,這個時候b執行緒獲取到了鎖,a和b執行緒處理的是同一個productId,b還在處理的過程中,這個時候a處理完了,a去釋放鎖,可能就會導致a把b獲取的鎖釋放了。
  2. 不能實現可重入
  3. 客戶端如果第一次已經設定成功,但是由於超時返回失敗,此後客戶端嘗試會一直失敗

針對以上問題我們改進下

  1. v傳requestId,然後我們在釋放鎖的時候判斷一下,如果是當前requestId,那就可以釋放,否則不允許釋放
  2. 加入count的鎖計數,在獲取鎖的時候查詢一次,如果是當前執行緒已經持有的鎖,那鎖技術加1,直接返回true

簡單實現2

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

            //如果鎖已經被持有了,那需要等待鎖的釋放
            if (value == null || count <= 0){
                //獲取鎖
                Boolean result = redis.setNx(key, v, expireTime);
                if (result){
                    count = 1;
                    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)){
            count = 0;
            return true;
        }
        //判斷當前鎖的持有者是否是當前執行緒,如果是的話釋放鎖,不是的話返回false
        if (value.equals(requestId)){
            if (count > 1){
                count -- ;
                return true;
            }
            
            boolean delete = redis.delete(key);
            if (delete){
                count = 0;
            }
            return delete;
        }

        return false;
    }
    public static void main(String[] args) {
        Integer productId = 324324;
        RedisLock<String> redisLock = new RedisLock<String>();
        String requestId = UUID.randomUUID().toString();
        redisLock.lock(productId+"", requestId, 1000);
    }

這種實現基本解決了誤釋放和可重入的問題。
這裡說明幾點:

  1. 引入count實現重入的話,看業務需要,並且在釋放鎖的時候,其實也可以直接就把鎖刪除了,一次釋放搞定,不需要在通過count數量釋放多次,看業務需要吧
  2. 關於要考慮設定鎖超時,所以需要在設定鎖的時候查詢一次,可能會有效能的考量,看具體業務吧
  3. 目前獲取鎖失敗的等待時間是在程式碼裡面設定的,可以提出來,修改下等待的邏輯即可

錯誤實現

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

 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;

    }

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

tair的實現

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

參考
http://www.cnblogs.com/luxiaoxun/p/4889764.html
http://blog.csdn.net/abccheng/article/details/72420996


相關文章