1. 程式人生 > >Redis(3)——分散式鎖深入探究

Redis(3)——分散式鎖深入探究

一、分散式鎖簡介

鎖 是一種用來解決多個執行執行緒 訪問共享資源 錯誤或資料不一致問題的工具。

如果 把一臺伺服器比作一個房子,那麼 執行緒就好比裡面的住戶,當他們想要共同訪問一個共享資源,例如廁所的時候,如果廁所門上沒有鎖...更甚者廁所沒裝門...這是會出原則性的問題的..

裝上了鎖,大家用起來就安心多了,本質也就是 同一時間只允許一個住戶使用。

而隨著網際網路世界的發展,單體應用已經越來越無法滿足複雜網際網路的高併發需求,轉而慢慢朝著分散式方向發展,慢慢進化成了 更大一些的住戶。所以同樣,我們需要引入分散式鎖來解決分散式應用之間訪問共享資源的併發問題。

為何需要分散式鎖

一般情況下,我們使用分散式鎖主要有兩個場景:

  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 服務就永遠無法獲取到鎖了:

所以我們需要額外設定一個超時時間,來保證服務的可用性。

但是另一個問題隨即而來:如果在加鎖和釋放鎖之間的邏輯執行得太長,以至於超出了鎖的超時限制,也會出現問題。因為這時候第一個執行緒持有鎖過期了,而臨界區的邏輯還沒有執行完,與此同時第二個執行緒就提前擁有了這把鎖,導致臨界區的程式碼不能得到嚴格的序列執行。

為了避免這個問題,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 怎麼說:http://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();

二、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] 這樣的格式就好了,你也在下方右拐參照官方的文件:

  • 官方文件:https://redis.io/commands/set

另外,官方文件也在 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 - https://redis.io/topics/distlock
  2. Redis【入門】就這一篇! - https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/
  3. Redission - Redis Java Client 原始碼 - https://github.com/redisson/redisson
  4. 手寫一個 Jedis 以及 JedisPool - https://juejin.im/post/5e5101c46fb9a07cab3a953a

參考資料

  1. 再有人問你分散式鎖,這篇文章扔給他 - https://juejin.im/post/5bbb0d8df265da0abd3533a5#heading-0
  2. 【官方文件】Distributed locks with Redis - https://redis.io/topics/distlock
  3. 【分散式快取系列】Redis實現分散式鎖的正確姿勢 - https://www.cnblogs.com/zhili/p/redisdistributelock.html
  4. Redis原始碼剖析和註釋(九)--- 字串命令的實現(t_string) - https://blog.csdn.net/men_wen/article/details/70325566
  5. 《Redis 深度歷險》 - 錢文品/ 著
  • 本文已收錄至我的 Github 程式設計師成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
  • 個人公眾號 :wmyskxz,個人獨立域名部落格:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!

非常感謝各位人才能 看到這裡,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文章見