分佈式鎖和Redis實現

不少新手將 分佈式鎖分佈式事務 混淆,我的理解: 是用於解決多程序併發爭奪某一共享資源;事務 是用於保障一系列操做執行的一致性。我前面有幾篇文章講解了分佈式事務,關於2PC、TCC和異步確保方案的實現,此次打算把幾種分佈式鎖的方案說一說。java

1. 定義

在傳統單體架構中,咱們最多見的鎖是jdk的鎖。由於線程是操做系統可以運行調度的最小單位,在java多線程開發時,就不免涉及到不一樣線程競爭同一個進程下的資源。jdk庫給咱們提供了synchronized、Lock和併發包java.util.concurrent.* 等。可是它們都統一的限制,競爭資源的線程,都是運行在同一個Jvm進程下,在分佈式架構中,不一樣Jvm進程是沒法使用該鎖的。node

爲了防止分佈式系統中的多個進程之間相互干擾,咱們須要一種分佈式協調技術來對這些進程進行調度。而這個分佈式協調技術的核心就是來實現這個分佈式鎖python

舉個經典「超賣」的例子,某個電商項目中搶購100件庫存的商品,搶購接口的邏輯可簡單分爲:一、查詢庫存是否大於零;二、當庫存大於零時,購買商品。當只剩1件庫存時,A用戶和B用戶都同時執行了第一步,查詢庫存都爲1件,而後都執行購買操做。當他們購買完成,發現庫存是 -1 件了。咱們能夠在java代碼中將「查詢庫存」和「減庫存」的操做加鎖,保障A用戶和B用戶的請求沒法併發執行。但萬一咱們的接口服務是個集羣服務,A用戶和B用戶的請求分別被負載均衡轉發到不一樣的Jvm進程上,那仍是解決不了問題。redis

2. 分佈式鎖對比

經過前面的例子能夠知道,協調解決分佈式鎖的資源,確定不能是Jvm進程級別的資源,而應該是某個能夠共享的外部資源。算法

三種實現方式

常見分佈式鎖通常有三種實現方式:1. 數據庫鎖;2. 基於ZooKeeper的分佈式鎖;3. 基於Redis的分佈式鎖。sql

  1. 數據庫鎖:這種方式很容易被想到,把競爭的資源放到數據庫中,利用數據庫鎖來實現資源競爭,能夠參考以前的文章《數據庫事務和鎖》。例如:(1)悲觀鎖實現:查詢庫存商品的sql能夠加上 "FOR UPDATE" 以實現排他鎖,而且將「查詢庫存」和「減庫存」打包成一個事務 COMMIT,在A用戶查詢和購買完成以前,B用戶的請求都會被阻塞住。(2)樂觀鎖實現:在庫存表中加上版本號字段來控制。或者更簡單的實現是,當每次購買完成後發現庫存小於零了,回滾事務便可。
  2. zookeeper的分佈式鎖:實現分佈式鎖,ZooKeeper是專業的。它相似於一個文件系統,經過多系統競爭文件系統上的文件資源,起到分佈式鎖的做用。具體的實現方式,請參考以前的文章《zookeeper的開發應用》
  3. redis的分佈式鎖:以前的文章講過redis的開發應用和事務,一直沒有講過redis的分佈式鎖,這也是本文的核心內容。簡單來講是經過setnx競爭鍵的值。

「數據庫鎖」是競爭表級資源或行級資源,「zookeeper鎖」是競爭文件資源,「redis鎖」是爲了競爭鍵值資源。它們都是經過競爭程序外的共享資源,來實現分佈式鎖。數據庫

對比

不過在分佈式鎖的領域,仍是zookeeper更專業。redis本質上也是數據庫,全部其它兩種方案都是「兼職」實現分佈式鎖的,效果上沒有zookeeper好。segmentfault

  1. 性能消耗小:當真的出現併發鎖競爭時,數據庫或redis的實現基本都是經過阻塞,或不斷重試獲取鎖,有必定的性能消耗。而zookeeper鎖是經過註冊監聽器,當某個程序釋放鎖是,下一個程序監聽到消息再獲取鎖。
  2. 鎖釋放機制完善:若是是redis獲取鎖的那個客戶端bug了或者掛了,那麼只能等待超時時間以後才能釋放鎖;而zk的話,由於建立的是臨時znode,只要客戶端掛了,znode就沒了,此時就自動釋放鎖。
  3. 集羣的強一致性:衆所周知,zookeeper是典型實現了 CP 事務的案例,集羣中永遠由Leader節點來處理事務請求。而redis實際上是實現 AP 事務的,若是master節點故障了,發生主從切換,此時就會有可能出現鎖丟失的問題。
鎖的必要條件

另外爲了確保分佈式鎖可用,咱們至少要確保鎖的實現同時知足如下幾個條件:api

  1. 互斥性。在任意時刻,只有一個客戶端能持有鎖。
  2. 不會發生死鎖。即便有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其餘客戶端能加鎖。
  3. 解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端本身不能把別人加的鎖給解了。

3. Redis實現分佈式鎖

3.1. 加鎖

正確的加鎖
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";

    /**
     * 嘗試獲取分佈式鎖
     * @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;
    }
}

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

  1. key:咱們使用key來當鎖,由於key是惟一的。
  2. value:咱們傳的是requestId,不少童鞋可能不明白,有key做爲鎖不就夠了嗎,爲何還要用到value?緣由就是咱們在上面講到可靠性時,分佈式鎖要知足第四個條件解鈴還須繫鈴人,經過給value賦值爲requestId,咱們就知道這把鎖是哪一個請求加的了,在解鎖的時候就能夠有依據。requestId可使用UUID.randomUUID().toString()方法生成。
  3. Nxxx:這個參數咱們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,咱們進行set操做;若key已經存在,則不作任何操做;
  4. EXPX:這個參數咱們傳的是PX,意思是咱們要給這個key加一個過時的設置,具體時間由第五個參數決定。
  5. time:與第四個參數相呼應,表明key的過時時間。

總的來講,執行上面的set()方法就只會致使兩種結果:

  • 當前沒有鎖(key不存在),那麼就進行加鎖操做,並對鎖設置個有效期,同時value表示加鎖的客戶端。
  • 已有鎖存在,不作任何操做。
不推薦的加鎖方式(不推薦!!!)

我看過不少博客中,都用下面的方式來加鎖,即setnx和getset的配合,手動來維護鍵的過時時間。

public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {

    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);

    // 若是當前鎖不存在,返回加鎖成功
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }

    // 若是鎖存在,獲取鎖的過時時間
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // 鎖已過時,獲取上一個鎖的過時時間,並設置如今鎖的過時時間
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考慮多線程併發的狀況,只有一個線程的設置值和當前值相同,它纔有權利加鎖
            return true;
        }
    }
    // 其餘狀況,一概返回加鎖失敗
    return false;
}

表面上來看,這段代碼也是實現分佈式鎖的,並且代碼邏輯和上面的差很少,可是有下面幾個問題:

  1. 因爲是客戶端本身生成過時時間,因此須要強制要求分佈式下每一個客戶端的時間必須同步。
  2. 當鎖過時的時候,若是多個客戶端同時執行jedis.getSet()方法,那麼雖然最終只有一個客戶端能夠加鎖,可是這個客戶端的鎖的過時時間可能被其餘客戶端覆蓋。
  3. 鎖不具有擁有者標識,即任何客戶端均可以解鎖。

網上的這類代碼多是基於早期jedis的版本,當時有很大的侷限性。Redis 2.6.12以上版本爲set指令增長了可選參數,像前面說的jedis.set(String key, String value, String nxxx, String expx, int time)的api,能夠把 SETNXEXPIRE 打包在一塊兒執行,而且把過時鍵的解鎖交給redis服務器去管理。所以實際開發過程當中,你們不要再用這種比較原始的方式加鎖了。

3.2. 解鎖

正確的加鎖
public class RedisTool {

    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 釋放分佈式鎖
     * @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;
    }
}

首先獲取鎖對應的value值,檢查是否與requestId相等,若是相等則刪除鎖(解鎖)。那麼爲何要使用Lua語言來實現呢?由於要確保上述操做是原子性的。在以前《Redis的線程模型和事務》文章中,咱們經過事務的方式保證一系列操做指令的原子性,使用Lua腳本也一樣能夠實現相似的效果。

爲何要保證原子性呢?假如A請求在獲取鎖對應的value值驗證requestId相等後,下達刪除指令。可是因爲網絡等緣由,刪除的指令阻塞住了。而此時鎖由於超時自動解鎖了,而且B請求獲取到了鎖,從新加鎖。這時候A請求到刪除指令執行了,結果把B請求好不容易獲取到的鎖給刪了。

3.3. lua

Redis命令的計算能力並不算很強大,使用Lua語言則能夠在很大程度上彌補Redis的這個不足。在Redis中,執行Lua語言是原子性,也就是說Redis執行Lua的時候是不會被中斷的,具有原子性,這個特性有助於Redis對併發數據一致性的支持。

Redis支持兩種方法運行腳本,一種是直接輸入一些Lua語言的程序代碼,另外一種是將Lua語言編寫成文件。在實際應用中,一些簡單的腳本能夠採起第一種方式,對於有必定邏輯的通常採用第二種。而對於採用簡單腳本的,Redis支持緩存腳本,只是它會使用SHA-1算法對腳本進行簽名,而後把SHA-1標識返回,只要經過這個標識運行就能夠了。

redis中執行lua

這裏就簡單介紹,直接輸入一些Lua語言的程序代碼的方式,可在redis-cli中執行下列:

eval lua-script key-num [key1 key2 key3 ....] [value1 value2 value3 ....]

--示例1 
eval "return 'Hello World'" 0
--示例2
eval "redis.call('set',KEYS[1],ARGV[1])" 1 lua-key lua-value
  • eval 表明執行Lua語言的命令。
  • lua-script 表明Lua語言腳本。
  • key-num 表示參數中有多少個key,須要注意的是Redis中key是從1開始的,若是沒有key的參數,那麼寫0。
  • [key1 key2 key3…] 是key做爲參數傳遞給Lua語言,也能夠不填,可是須要和key-num的個數對應起來。
  • [value1 value2 value3 …] 這些參數傳遞給Lua語言,他們是可填可不填的。
lua中調用redis

在Lua語言中採用redis.call 執行操做:

redis.call(command,key[param1, param2…])

--示例1
eval "return redis.call('set','foo','bar')" 0
--示例2
eval "return redis.call('set',KEYS[1],'bar')" 1 foo
  • command 是命令,包括set、get、del等。
  • key 是被操做的鍵。
  • param1,param2… 表明給key的參數。

例如,實現一個getset的lua腳本
getset.lua

local key = KEYS[1]
local newValue = ARGV[1]
local oldValue = redis.call('get', key)
redis.call('set', key, newValue)
return oldValue

3.4. 侷限性和改進

前面咱們說過,在Redis集羣中,分佈式鎖的實現存在一些侷限性,當主從替換時難以保證一致性。

現象

在redis sentinel集羣中,咱們具備多臺redis,他們之間有着主從的關係,例如一主二從。咱們的set命令對應的數據寫到主庫,而後同步到從庫。當咱們申請一個鎖的時候,對應就是一條命令 setnx mykey myvalue ,在redis sentinel集羣中,這條命令先是落到了主庫。假設這時主庫down了,而這條數據還沒來得及同步到從庫,sentinel將從庫中的一臺選舉爲主庫了。這時,咱們的新主庫中並無mykey這條數據,若此時另一個client執行 setnx mykey hisvalue , 也會成功,即也能獲得鎖。這就意味着,此時有兩個client得到了鎖。這不是咱們但願看到的,雖然這個狀況發生的記錄很小,只會在主從failover的時候纔會發生,大多數狀況下、大多數系統均可以容忍,但不是全部的系統都能容忍這種瑕疵。

解決

爲了解決故障轉移狀況下的缺陷,Antirez 發明了 Redlock 算法。使用redlock算法,須要多個redis實例,加鎖的時候,它會向多半節點發送 setex mykey myvalue 命令,只要過半節點成功了,那麼就算加鎖成功了。這和zookeeper的實現方案很是相似,zookeeper集羣的leader廣播命令時,要求其中必須有過半的follower向leader反饋ACK才生效

在實際工做中使用的時候,咱們能夠選擇已有的開源實現,python有redlock-py,java 中有 Redisson redlock

redlock確實解決了上面所說的「不靠譜的狀況」。可是,它解決問題的同時,也帶來了代價。你須要多個redis實例,你須要引入新的庫 代碼也得調整,性能上也會有損。因此,果真是不存在「完美的解決方案」,咱們更須要的是可以根據實際的狀況和條件把問題解決了就好。

相關文章
相關標籤/搜索