分佈式鎖

文章參考: http://www.importnew.com/27477.html

以下是自己在工作中遇到得問題,參考各種資料,最終實現,以做整理。

現在大環境都是用分佈式服務,原先單服務基於Java-API,多線程(Synchronized/ReentrantLock)已經不能滿足所需。

分佈式鎖一般有三種形式:1. 數據庫樂觀鎖;2. 基於Redis的分佈式鎖;3. 基於ZooKeeper的分佈式鎖。

首先,爲了確保分佈式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:

  1. 互斥性。在任意時刻,只有一個客戶端能持有鎖。
  2. 不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。
  3. 具有容錯性。只要大部分的Redis節點正常運行,客戶端就可以加鎖和解鎖。
  4. 解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

 

1.1 數據庫樂觀鎖

1)   電商平臺,多人搶購一個商品,除了要解決服務器性能壓力這塊,數據庫部分還可以巧用SQL樂觀鎖。
            比如:update pc_sku set stock = stock - 1 where id = 1 and stock > 0 

2)   要實現分佈式鎖,最簡單的方式可能就是直接創建一張鎖表,然後通過操作該表中的數據來實現了。

當我們要鎖住某個方法或資源時,我們就在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。

創建這樣一張數據庫表:

當我們想要鎖住某個方法時,執行以下SQL:

因爲我們對method_name做了唯一性約束,這裏如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,那麼我們就可以認爲操作成功的那個線程獲得了該方法的鎖,可以執行方法體內容。

當方法執行完畢之後,想要釋放鎖的話,需要執行以下Sql:

上面這種簡單的實現有以下幾個問題:

1、這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用。

2、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖。

3、這把鎖只能是非阻塞的,因爲數據的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。

4、這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因爲數據中數據已經存在了。

當然,我們也可以有其他方式解決上面的問題。

  • 數據庫是單點?搞兩個數據庫,數據之前雙向同步。一旦掛掉快速切換到備庫上。
  • 沒有失效時間?只要做一個定時任務,每隔一定時間把數據庫中的超時數據清理一遍。
  • 非阻塞的?搞一個while循環,直到insert成功再返回成功。
  • 非重入的?在數據庫表中加個字段,記錄當前獲得鎖的機器的主機信息和線程信息,那麼下次再獲取鎖的時候先查詢數據庫,如果當前機器的主機信息和線程信息在數據庫可以查到的話,直接把鎖分配給他就可以了。

1.2 基於Redis的分佈式鎖

命令 SET resource-name anystring NX EX max-lock-time 是一種在 Redis 中實現鎖的簡單方法。

客戶端執行以上的命令:

如果服務器返回 OK ,那麼這個客戶端獲得鎖。
如果服務器返回 NIL ,那麼客戶端獲取鎖失敗,可以在稍後再重試。
設置的過期時間到達之後,鎖將自動釋放。

可以通過以下修改,讓這個鎖實現更健壯:

不使用固定的字符串作爲鍵的值,而是設置一個不可猜測(non-guessable)的長隨機字符串,作爲口令串(token)。
不使用 DEL 命令來釋放鎖,而是發送一個 Lua 腳本,這個腳本只在客戶端傳入的值和鍵的口令串相匹配時,纔對鍵進行刪除。
這兩個改動可以防止持有過期鎖的客戶端誤刪現有鎖的情況出現。

以下是一個簡單的解鎖腳本示例:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

代碼實現:

引入Jedis包:首先我們要通過Maven引入Jedis開源組件,在pom.xml文件加入下面的代碼:

<dependency>

    <groupId>redis.clients</groupId>

    <artifactId>jedis</artifactId>

    <version>2.9.0</version>

</dependency>

請看以下代碼:

import redis.clients.jedis.Jedis;

import java.util.Collections;

public class RedisTool {
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 嘗試獲取分佈式鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖
     * @param requestId 請求標識
     * @param expireTime 超期時間
     * @return 是否獲取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

    /**
     * 釋放分佈式鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖
     * @param requestId 請求標識
     * @return 是否釋放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }
}

tryGetDistributedLock 可以看到,我們加鎖就一行代碼:jedis.set(String key, String value, String nxxx, String expx, int time),這個set()方法一共有五個形參:

  • 第一個爲key,我們使用key來當鎖,因爲key是唯一的。
  • 第二個爲value,我們傳的是requestId,很多童鞋可能不明白,有key作爲鎖不就夠了嗎,爲什麼還要用到value?原因就是我們在上面講到可靠性時,分佈式鎖要滿足第四個條件解鈴還須繫鈴人,通過給value賦值爲requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據。requestId可以使用UUID.randomUUID().toString()方法生成。
  • 第三個爲nxxx,這個參數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;
  • 第四個爲expx,這個參數我們傳的是PX,意思是我們要給這個key加一個過期的設置,具體時間由第五個參數決定。
  • 第五個爲time,與第四個參數相呼應,代表key的過期時間。

總的來說,執行上面的set()方法就只會導致兩種結果:1. 當前沒有鎖(key不存在),那麼就進行加鎖操作,並對鎖設置個有效期,同時value表示加鎖的客戶端。2. 已有鎖存在,不做任何操作。

心細的童鞋就會發現了,我們的加鎖代碼滿足我們可靠性裏描述的三個條件。首先,set()加入了NX參數,可以保證如果已有key存在,則函數不會調用成功,也就是隻有一個客戶端能持有鎖,滿足互斥性。其次,由於我們對鎖設置了過期時間,即使鎖的持有者後續發生崩潰而沒有解鎖,鎖也會因爲到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。最後,因爲我們將value賦值爲requestId,代表加鎖的客戶端請求標識,那麼在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。由於我們只考慮Redis單機部署的場景,所以容錯性我們暫不考慮。

tryGetDistributedLock 可以看到,我們解鎖只需要兩行代碼就搞定了!第一行代碼,我們寫了一個簡單的Lua腳本代碼,上一次見到這個編程語言還是在《黑客與畫家》裏,沒想到這次居然用上了。第二行代碼,我們將Lua代碼傳到jedis.eval()方法裏,並使參數KEYS[1]賦值爲lockKey,ARGV[1]賦值爲requestId。eval()方法是將Lua代碼交給Redis服務端執行。

那麼這段Lua代碼的功能是什麼呢?其實很簡單,首先獲取鎖對應的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)。那麼爲什麼要使用Lua語言來實現呢?因爲要確保上述操作是原子性的。

以下是模擬得main發放進行調用

import com.showjoy.data.page.JedisClient;
import redis.clients.jedis.Jedis;

import java.util.UUID;

public class My implements Runnable{

    int titckNum = 10;
    @Override
    public void run() {
        Jedis jedis = JedisClient.getJedis();
        String lockKey = "activityCommission";
        String requestId = UUID.randomUUID().toString();
        if(titckNum > 0){
            if(!RedisTool.tryGetDistributedLock(jedis,lockKey,requestId,1000)){
                return;
            }
            System.out.println(jedis.get(lockKey));
            try {
                Thread.sleep(1000);
                System.out.println("購買票:currentName: " + Thread.currentThread().getName()+ " 購買後餘票: " + --titckNum);
            } catch (InterruptedException e) {
                System.out.println("異常了異常了異常了");
                e.printStackTrace();
            }finally {
                System.out.println(Thread.currentThread().getName()+"準備釋放鎖了");
                RedisTool.releaseDistributedLock(jedis,lockKey,requestId);
            }
        }
    }

    public static void main(String[] args) {
        //創建共享資源
        My ticket = new My();
        //創建模擬買票人並開啓買票即線程
        Thread thread1 = new Thread(ticket, "老一");
        Thread thread2 = new Thread(ticket, "老二");
        Thread thread3 = new Thread(ticket, "老三");
        Thread thread4 = new Thread(ticket, "老四");
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章