Redis(3)——分佈式鎖深刻探究

1、分佈式鎖簡介

是一種用來解決多個執行線程 訪問共享資源 錯誤或數據不一致問題的工具。html

若是 git

把一臺服務器比做一個房子
,那麼
線程就比如裏面的住戶
,當他們想要共同訪問一個共享資源,例如廁所的時候,若是廁所門上沒有鎖...更甚者廁所沒裝門...這是會出原則性的問題的..

裝上了鎖,你們用起來就安心多了,本質也就是 同一時間只容許一個住戶使用程序員

而隨着互聯網世界的發展,單體應用已經愈來愈沒法知足複雜互聯網的高併發需求,轉而慢慢朝着分佈式方向發展,慢慢進化成了 更大一些的住戶。因此一樣,咱們須要引入分佈式鎖來解決分佈式應用之間訪問共享資源的併發問題。github

爲什麼須要分佈式鎖

通常狀況下,咱們使用分佈式鎖主要有兩個場景:redis

  1. 避免不一樣節點重複相同的工做:好比用戶執行了某個操做有可能不一樣節點會發送多封郵件;
  2. 避免破壞數據的正確性:若是兩個節點在同一條數據上同時進行操做,可能會形成數據錯誤或不一致的狀況出現;

Java 中實現的常見方式

上面咱們用簡單的比喻說明了鎖的本質:同一時間只容許一個用戶操做。因此理論上,可以知足這個需求的工具咱們都可以使用 算法

(就是其餘應用能幫咱們加鎖的)

  1. 基於 MySQL 中的鎖:MySQL 自己有自帶的悲觀鎖 for update 關鍵字,也能夠本身實現悲觀/樂觀鎖來達到目的;
  2. 基於 Zookeeper 有序節點:Zookeeper 容許臨時建立有序的子節點,這樣客戶端獲取節點列表時,就可以當前子節點列表中的序號判斷是否可以得到鎖;
  3. 基於 Redis 的單線程:因爲 Redis 是單線程,因此命令會以串行的方式執行,而且自己提供了像 SETNX(set if not exists) 這樣的指令,自己具備互斥性;

每一個方案都有各自的優缺點,例如 MySQL 雖然直觀理解容易,可是實現起來卻須要額外考慮 鎖超時加事務 等,而且性能侷限於數據庫,諸如此類咱們在此不做討論,重點關注 Redis。數據庫

Redis 分佈式鎖的問題

1)鎖超時

假設如今咱們有兩臺平行的服務 A B,其中 A 服務在 獲取鎖以後 因爲未知神祕力量忽然 掛了,那麼 B 服務就永遠沒法獲取到鎖了:緩存

因此咱們須要額外設置一個超時時間,來保證服務的可用性。安全

可是另外一個問題隨即而來:若是在加鎖和釋放鎖之間的邏輯執行得太長,以致於超出了鎖的超時限制,也會出現問題。由於這時候第一個線程持有鎖過時了,而臨界區的邏輯尚未執行完,與此同時第二個線程就提早擁有了這把鎖,致使臨界區的代碼不能獲得嚴格的串行執行。bash

爲了不這個問題,Redis 分佈式鎖不要用於較長時間的任務。若是真的偶爾出現了問題,形成的數據小錯亂可能就須要人工的干預。

有一個稍微安全一點的方案是 將鎖的 value 值設置爲一個隨機數,釋放鎖時先匹配隨機數是否一致,而後再刪除 key,這是爲了 確保當前線程佔有的鎖不會被其餘線程釋放,除非這個鎖是由於過時了而被服務器自動釋放的。

可是匹配 value 和刪除 key 在 Redis 中並非一個原子性的操做,也沒有相似保證原子性的指令,因此可能須要使用像 Lua 這樣的腳原本處理了,由於 Lua 腳本能夠 保證多個指令的原子性執行

延伸的討論:GC 可能引起的安全問題

Martin Kleppmann 曾與 Redis 之父 Antirez 就 Redis 實現分佈式鎖的安全性問題進行過深刻的討論,其中有一個問題就涉及到 GC

熟悉 Java 的同窗確定對 GC 不陌生,在 GC 的時候會發生 STW(Stop-The-World),這自己是爲了保障垃圾回收器的正常執行,但可能會引起以下的問題:

服務 A 獲取了鎖並設置了超時時間,可是服務 A 出現了 STW 且時間較長,致使了分佈式鎖進行了超時釋放,在這個期間服務 B 獲取到了鎖,待服務 A STW 結束以後又恢復了鎖,這就致使了 服務 A 和服務 B 同時獲取到了鎖,這個時候分佈式鎖就不安全了。

不只僅侷限於 Redis,Zookeeper 和 MySQL 有一樣的問題。

想吃更多瓜的童鞋,能夠訪問下列網站看看 Redis 之父 Antirez 怎麼說:antirez.com/news/101

2)單點/多點問題

若是 Redis 採用單機部署模式,那就意味着當 Redis 故障了,就會致使整個服務不可用。

而若是採用主從模式部署,咱們想象一個這樣的場景:

服務 A
申請到一把鎖以後,若是做爲主機的 Redis 宕機了,那麼
服務 B
在申請鎖的時候就會從從機那裏獲取到這把鎖,爲了解決這個問題,Redis 做者提出了一種 RedLock 紅鎖 的算法
(Redission 同 Jedis)

// 三個 Redis 集羣
RLock lock1 = redissionInstance1.getLock("lock1");
RLock lock2 = redissionInstance2.getLock("lock2");
RLock lock3 = redissionInstance3.getLock("lock3");

RedissionRedLock lock = new RedissionLock(lock1, lock2, lock2);
lock.lock();
// do something....
lock.unlock();
複製代碼

2、Redis 分佈式鎖的實現

分佈式鎖相似於 "佔坑",而 SETNX(SET if Not eXists) 指令就是這樣的一個操做,只容許被一個客戶端佔有,咱們來看看 源碼(t_string.c/setGenericCommand) 吧:

// SET/ SETEX/ SETTEX/ SETNX 最底層實現
void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
    long long milliseconds = 0; /* initialized to avoid any harmness warning */

    // 若是定義了 key 的過時時間則保存到上面定義的變量中
    // 若是過時時間設置錯誤則返回錯誤信息
    if (expire) {
        if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK)
            return;
        if (milliseconds <= 0) {
            addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name);
            return;
        }
        if (unit == UNIT_SECONDS) milliseconds *= 1000;
    }

    // lookupKeyWrite 函數是爲執行寫操做而取出 key 的值對象
    // 這裏的判斷條件是:
    // 1.若是設置了 NX(不存在),而且在數據庫中找到了 key 值
    // 2.或者設置了 XX(存在),而且在數據庫中沒有找到該 key
    // => 那麼回覆 abort_reply 給客戶端
    if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
        (flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL))
    {
        addReply(c, abort_reply ? abort_reply : shared.null[c->resp]);
        return;
    }
    
    // 在當前的數據庫中設置鍵爲 key 值爲 value 的數據
    genericSetKey(c->db,key,val,flags & OBJ_SET_KEEPTTL);
    // 服務器每修改一個 key 後都會修改 dirty 值
    server.dirty++;
    if (expire) setExpire(c,c->db,key,mstime()+milliseconds);
    notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
    if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC,
        "expire",key,c->db->id);
    addReply(c, ok_reply ? ok_reply : shared.ok);
}
複製代碼

就像上面介紹的那樣,其實在以前版本的 Redis 中,因爲 SETNXEXPIRE 並非 原子指令,因此在一塊兒執行會出現問題。

也許你會想到使用 Redis 事務來解決,但在這裏不行,由於 EXPIRE 命令依賴於 SETNX 的執行結果,而事務中沒有 if-else 的分支邏輯,若是 SETNX 沒有搶到鎖,EXPIRE 就不該該執行。

爲了解決這個疑難問題,Redis 開源社區涌現了許多分佈式鎖的 library,爲了治理這個亂象,後來在 Redis 2.8 的版本中,加入了 SET 指令的擴展參數,使得 SETNX 能夠和 EXPIRE 指令一塊兒執行了:

> SET lock:test true ex 5 nx
OK
... do something critical ...
> del lock:test
複製代碼

你只須要符合 SET key value [EX seconds | PX milliseconds] [NX | XX] [KEEPTTL] 這樣的格式就行了,你也在下方右拐參照官方的文檔:

另外,官方文檔也在 SETNX 文檔中提到了這樣一種思路:把 SETNX 對應 key 的 value 設置爲 <current Unix time + lock timeout + 1>,這樣在其餘客戶端訪問時就可以本身判斷是否可以獲取下一個 value 爲上述格式的鎖了。

代碼實現

下面用 Jedis 來模擬實現如下,關鍵代碼以下:

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

@Override
public String acquire() {
    try {
        // 獲取鎖的超時時間,超過這個時間則放棄獲取鎖
        long end = System.currentTimeMillis() + acquireTimeout;
        // 隨機生成一個 value
        String requireToken = UUID.randomUUID().toString();
        while (System.currentTimeMillis() < end) {
            String result = jedis
                .set(lockKey, requireToken, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
            if (LOCK_SUCCESS.equals(result)) {
                return requireToken;
            }
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    } catch (Exception e) {
        log.error("acquire lock due to error", e);
    }

    return null;
}

@Override
public boolean release(String identify) {
    if (identify == null) {
        return false;
    }

    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    Object result = new Object();
    try {
        result = jedis.eval(script, Collections.singletonList(lockKey),
            Collections.singletonList(identify));
        if (RELEASE_SUCCESS.equals(result)) {
            log.info("release lock success, requestToken:{}", identify);
            return true;
        }
    } catch (Exception e) {
        log.error("release lock due to error", e);
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }

    log.info("release lock failed, requestToken:{}, result:{}", identify, result);
    return false;
}
複製代碼
  • 引用自下方
    參考資料 3
    ,其中還有 RedLock 的實現和測試,有興趣的童鞋能夠戳一下

推薦閱讀

  1. 【官方文檔】Distributed locks with Redis - redis.io/topics/dist…
  2. Redis【入門】就這一篇! - www.wmyskxz.com/2018/05/31/…
  3. Redission - Redis Java Client 源碼 - github.com/redisson/re…
  4. 手寫一個 Jedis 以及 JedisPool - juejin.im/post/5e5101…

參考資料

  1. 再有人問你分佈式鎖,這篇文章扔給他 - juejin.im/post/5bbb0d…
  2. 【官方文檔】Distributed locks with Redis - redis.io/topics/dist…
  3. 【分佈式緩存系列】Redis實現分佈式鎖的正確姿式 - www.cnblogs.com/zhili/p/red…
  4. Redis源碼剖析和註釋(九)--- 字符串命令的實現(t_string) - blog.csdn.net/men_wen/art…
  5. 《Redis 深度歷險》 - 錢文品/ 著
  • 本文已收錄至個人 Github 程序員成長系列 【More Than Java】,學習,不止 Code,歡迎 star:github.com/wmyskxz/Mor…
  • 我的公衆號 :wmyskxz, 我的獨立域名博客:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!

很是感謝各位人才能 看到這裏,若是以爲本篇文章寫得不錯,以爲 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創做不易,各位的支持和承認,就是我創做的最大動力,咱們下篇文章見!

相關文章
相關標籤/搜索