Redis 分佈式鎖真的安全嗎?

今天我們來聊一聊 Redis 分佈式鎖。

首先大家可以先思考一個簡單的問題,爲什麼要使用分佈式鎖?普通的 jvm 鎖爲什麼不可以?

這個時候,大家肯定會吧啦吧啦想到一堆,例如 java 應用屬於進程級,不同的 ecs 中部署相同的應用,他們之間相互獨立。

所以,在分佈式系統中,當有多個客戶端需要獲取鎖時,我們需要分佈式鎖。此時,鎖是保存在一個共享存儲系統中的,可以被多個客戶端共享訪問和獲取。

分佈式鎖(SET NX)

知道了分佈式鎖的使用場景,我們來自己簡單的實現下分佈式鎖:

public class IndexController {
    public String deductStock() {
        String lockKey = "lock:product_101";
        //setNx 獲取分佈式鎖
        String clientId = UUID.randomUUID().toString();
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS); //jedis.setnx(k,v)
        if (!result) {
            return "error_code";
        }
        try {
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                System.out.println("扣減成功,剩餘庫存:" + realStock);
            } else {
                System.out.println("扣減失敗,庫存不足");
            }
        } finally {
            //解鎖
            if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
                stringRedisTemplate.delete(lockKey);
            }
    }
}

以上代碼簡單的實現了一個扣減庫存的業務邏輯,我們拆開來說下都做了什麼事情:

1、首先聲明瞭 lockkey,表示我們需要 set 的 keyName

2、其次 UUID.randomUUID().toString(); 生成該次請求的 requestId,爲什麼需要生成這個唯一的 UUID,後面在解鎖的時候會說到

3、獲取分佈式鎖,通過 stringRedisTemplate.opsForValue().setIfAbsent 來實現,該語句的意思是如果存在該 key 則返回 false,若不存在則進行 key 的設置,設置成功後返回 true,將當前線程獲取的 uuid 設置成 value,給定一個鎖的過期時間,防止該線程無限制持久鎖導致死鎖,也爲了防止該服務器突然宕機,導致其他機器的應用無法獲取該鎖,這個是必須要做的設置,至於過期的時間,可以根據內層業務邏輯的執行時間來決定

4、執行內層的業務邏輯,進行扣庫存的操作

5、業務邏輯執行完成後,走到 finally 的解鎖操作,進行解鎖操作時,首先我們來判斷當前鎖的值是否爲該線程持有的,防止當前線程執行較慢,導致鎖過期,從而刪除了其他線程持有的分佈式鎖,對於該操作,我來舉個例子:

看到這裏,爲什麼每個請求需要 requestId,並且在釋放鎖的情況下判斷是否是當前的 requestId 是有必要的。

以上,就是一個簡單的分佈式鎖的實現過程。但是你覺得上述實現還存在問題嗎?

答案是肯定的。若是在判斷完分佈式鎖的 value 與 requestId 之後,鎖過期了,依然會存在以上問題。

那麼有沒有什麼辦法可以規避以上問題,讓我們不需要去完成這些實現,只需要專注於業務邏輯呢?

我們可以使用 Redisson,並且 Redisson 有中文文檔,方便英文不好的同學查看(開發團隊中有中國的 jackygurui)。

接下來我們再把上述代碼簡單的改造下就可以規避這些問題:

public class IndexController {
    public String deductStock() {
        String lockKey = "lock:product_101";
        //setNx 獲取分佈式鎖
        //String clientId = UUID.randomUUID().toString();
        //Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS); //jedis.setnx(k,v)
        //獲取鎖對象
        RLock redissonLock = redisson.getLock(lockKey);
        //加分佈式鎖
        redissonLock.lock();
        try {
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                System.out.println("扣減成功,剩餘庫存:" + realStock);
            } else {
                System.out.println("扣減失敗,庫存不足");
            }
        } finally {
            //解鎖
            //if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
            //    stringRedisTemplate.delete(lockKey);
            //}
            //redisson分佈式鎖解鎖
            redissonLock.unlock();
    }
}

可以看到,使用 redisson 分佈式鎖會簡單很多,我們通過redissonLock.lock()redissonLock.unlock()解決了這個問題,看到這裏,是不是有同學會問,如果服務器宕機了,分佈式鎖會一直存在嗎,也沒有去指定過期時間?

redisson 分佈式鎖中有一個 watchdog 機制,即會給一個 leaseTime,默認爲 30s,到期後鎖自動釋放,如果一直沒有解鎖,watchdog 機制會一直重新設定鎖的過期時間,通過設置 TimeTask,延遲 10s 再次執行鎖續命,將鎖的過期時間重置爲 30s。下面就從 redisson.lock() 的源碼來看下:

lock 的最終加鎖方法:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        internalLockLeaseTime = unit.toMillis(leaseTime);
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                "if (redis.call('exists', KEYS[1]) == 0) then " +
                        "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "return redis.call('pttl', KEYS[1]);",
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }

可以看到 lua 腳本中redis.call('pexpire', KEYS[1], ARGV[1]);對 key 進行設置,並給定了一個internalLockLeaseTime,給定的internalLockLeaseTime就是默認的加鎖時間,爲 30s。

接下來我們在看下鎖續命的源碼:

private void scheduleExpirationRenewal(final long threadId) {
        if (!expirationRenewalMap.containsKey(this.getEntryName())) {
            Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
                public void run(Timeout timeout) throws Exception {
                    //重新設置鎖過期時間
                    RFuture<Boolean> future = RedissonLock.this.commandExecutor.evalWriteAsync(RedissonLock.this.getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(RedissonLock.this.getName()), new Object[]{RedissonLock.this.internalLockLeaseTime, RedissonLock.this.getLockName(threadId)});
                    future.addListener(new FutureListener<Boolean>() {
                        public void operationComplete(Future<Boolean> future) throws Exception {
                            RedissonLock.expirationRenewalMap.remove(RedissonLock.this.getEntryName());
                            if (!future.isSuccess()) {
                                RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", future.cause());
                            } else {
                                //獲取方法調用的結果
                                if ((Boolean)future.getNow()) {
                                    //進行遞歸調用
                                    RedissonLock.this.scheduleExpirationRenewal(threadId);
                                }
                            }
                        }
                    });
                }
            //延遲 this.internalLockLeaseTime / 3L 再執行run方法
            }, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
            if (expirationRenewalMap.putIfAbsent(this.getEntryName(), task) != null) {
                task.cancel();
            }
        }
    }

從源碼層可以看到,加鎖成功後,會延遲 10s 執行 task 中的 run 方法,然後在 run 方法裏面執行鎖過期時間的重置,如果時間重置成功,則繼續遞歸調用該方法,延遲 10s 後進行鎖續命,若重置鎖時間失敗,則可能表示鎖已釋放,退出該方法。

以上,就是關於一個 redis 分佈式鎖的說明,看到這裏,大家應該對分佈式鎖有一個大致的瞭解了。

但是儘管使用了 redisson 完成分佈式鎖的實現,對於分佈式鎖是否還存在問題,分佈式鎖真的安全嗎?

一般的,線上的環境肯定使用 redis cluster,如果數據量不大,也會使用的 redis sentinal。那麼就存在主從複製的問題,那麼是否會存在這種情況,在主庫設置了分佈式鎖,但是可能由於網絡或其他原因導致數據還沒有同步到從庫,此時主庫宕機,選擇從庫作爲主庫,新主庫中並沒有該鎖的信息,其他線程又可以進行鎖申請,造成了發生線程安全問題的可能。

爲了解決這個問題,redis 的作者實現了 redlock,基於 redlock 的實現有很大的爭論,並且現在已經棄用了,但是我們還是需要了解下原理,以及之後基於這些問題的解決方案。

分佈式鎖 Redlock

Redlock 是基於單 Redis 節點的分佈式鎖在 failover 的時候會產生解決不了的安全性問題而產生的,基於 N 個完全獨立的 Redis 節點。

下面我來看下 redlock 獲取鎖的過程:

運行 Redlock 算法的客戶端依次執行下面各個步驟,來完成獲取鎖的操作:

  1. 獲取當前時間(毫秒數)。

  2. 按順序依次向 N 個 Redis 節點執行獲取鎖的操作。這個獲取操作跟前面基於單 Redis 節點的獲取鎖的過程相同,包含隨機字符串my_random_value,也包含過期時間 (比如PX 30000,即鎖的有效時間)。爲了保證在某個 Redis 節點不可用的時候算法能夠繼續運行,這個獲取鎖的操作還有一個超時時間 (time out),它要遠小於鎖的有效時間(幾十毫秒量級)。客戶端在向某個 Redis 節點獲取鎖失敗以後,應該立即嘗試下一個 Redis 節點。這裏的失敗,應該包含任何類型的失敗,比如該 Redis 節點不可用,或者該 Redis 節點上的鎖已經被其它客戶端持有

  3. 計算整個獲取鎖的過程總共消耗了多長時間,計算方法是用當前時間減去第 1 步記錄的時間。如果客戶端從大多數 Redis 節點(>= N/2+1)成功獲取到了鎖,並且獲取鎖總共消耗的時間沒有超過鎖的有效時間 (lock validity time),那麼這時客戶端才認爲最終獲取鎖成功;否則,認爲最終獲取鎖失敗。

  4. 如果最終獲取鎖成功了,那麼這個鎖的有效時間應該重新計算,它等於最初的鎖的有效時間減去第 3 步計算出來的獲取鎖消耗的時間。

  5. 如果最終獲取鎖失敗了(可能由於獲取到鎖的 Redis 節點個數少於 N/2+1,或者整個獲取鎖的過程消耗的時間超過了鎖的最初有效時間),那麼客戶端應該立即向所有 Redis 節點發起釋放鎖的操作。

好了,瞭解了 redlock 獲取鎖的機制之後,我們再來討論下 redlock 會有哪些問題:

問題一:

假設一共有 5 個 Redis 節點:A, B, C, D, E。設想發生瞭如下的事件序列:

  1. 客戶端 1 成功鎖住了 A, B, C,獲取鎖成功(但 D 和 E 沒有鎖住)。

  2. 節點 C 崩潰重啓了,但客戶端 1 在 C 上加的鎖沒有持久化下來,丟失了。

  3. 節點 C 重啓後,客戶端 2 鎖住了 C, D, E,獲取鎖成功。

這樣,客戶端 1 和客戶端 2 同時獲得了鎖(針對同一資源)。

在默認情況下,Redis 的 AOF 持久化方式是每秒寫一次磁盤(即執行 fsync),因此最壞情況下可能丟失 1 秒的數據。爲了儘可能不丟數據,Redis 允許設置成每次修改數據都進行 fsync,但這會降低性能。當然,即使執行了 fsync 也仍然有可能丟失數據(這取決於系統而不是 Redis 的實現)。所以,上面分析的由於節點重啓引發的鎖失效問題,總是有可能出現的。爲了應對這一問題,Redis 作者 antirez 又提出了延遲重啓 (delayed restarts) 的概念。也就是說,一個節點崩潰後,先不立即重啓它,而是等待一段時間再重啓,這段時間應該大於鎖的有效時間 (lock validity time)。這樣的話,這個節點在重啓前所參與的鎖都會過期,它在重啓後就不會對現有的鎖造成影響。

關於 Redlock 還有一點細節值得拿出來分析一下:在最後釋放鎖的時候,antirez 在算法描述中特別強調,客戶端應該向所有 Redis 節點發起釋放鎖的操作。也就是說,即使當時向某個節點獲取鎖沒有成功,在釋放鎖的時候也不應該漏掉這個節點。這是爲什麼呢?設想這樣一種情況,客戶端發給某個 Redis 節點的獲取鎖的請求成功到達了該 Redis 節點,這個節點也成功執行了SET操作,但是它返回給客戶端的響應包卻丟失了。這在客戶端看來,獲取鎖的請求由於超時而失敗了,但在 Redis 這邊看來,加鎖已經成功了。因此,釋放鎖的時候,客戶端也應該對當時獲取鎖失敗的那些 Redis 節點同樣發起請求。實際上,這種情況在異步通信模型中是有可能發生的:客戶端向服務器通信是正常的,但反方向卻是有問題的。

所以,如果不進行延遲重啓,或者對於同一個主節點進行多個從節點的備份,並要求從節點的同步必須實時跟住主節點,也就是說需要配置 redis 從庫的同步策略,將延遲設置爲最小(主從同步是異步進行的),通過min-replicas-max-lag(舊版本的 redis 使用min-slaves-max-lag) 來設置主從庫間進行數據複製時,從庫給主庫發送 ACK 消息的最大延遲(以秒爲單位),也就是說,這個值需要設置爲 0,否則都有可能出現延遲,但是這個實際上在 redis 中是不存在的,min-replicas-max-lag設置爲 0,就代表着這個配置不生效。redis 本身是爲了高效而存在的,如果因爲需要保證業務的準確性而使用,大大降低了 redis 的性能,建議使用的別的方式。

問題二:

如果客戶端長期阻塞導致鎖過期,那麼它接下來訪問共享資源就不安全了(沒有了鎖的保護)。在 RedLock 中還是存在該問題的。

雖然在獲取鎖之後 Redlock 會去判斷鎖的有效性,如果鎖過期了,則會再去重新拿鎖。但是如果發生在獲取鎖之後,那麼該有效性都得不到保障了。

在上面的時序圖中,假設鎖服務本身是沒有問題的,它總是能保證任一時刻最多隻有一個客戶端獲得鎖。上圖中出現的 lease 這個詞可以暫且認爲就等同於一個帶有自動過期功能的鎖。客戶端 1 在獲得鎖之後發生了很長時間的 GC pause,在此期間,它獲得的鎖過期了,而客戶端 2 獲得了鎖。當客戶端 1 從 GC pause 中恢復過來的時候,它不知道自己持有的鎖已經過期了,它依然向共享資源(上圖中是一個存儲服務)發起了寫數據請求,而這時鎖實際上被客戶端 2 持有,因此兩個客戶端的寫請求就有可能衝突(鎖的互斥作用失效了)。

初看上去,有人可能會說,既然客戶端 1 從 GC pause 中恢復過來以後不知道自己持有的鎖已經過期了,那麼它可以在訪問共享資源之前先判斷一下鎖是否過期。但仔細想想,這絲毫也沒有幫助。因爲 GC pause 可能發生在任意時刻,也許恰好在判斷完之後。

也有人會說,如果客戶端使用沒有 GC 的語言來實現,是不是就沒有這個問題呢?質疑者 Martin 指出,系統環境太複雜,仍然有很多原因導致進程的 pause,比如虛存造成的缺頁故障 (page fault),再比如 CPU 資源的競爭。即使不考慮進程 pause 的情況,網絡延遲也仍然會造成類似的結果。

總結起來就是說,即使鎖服務本身是沒有問題的,而僅僅是客戶端有長時間的 pause 或網絡延遲,仍然會造成兩個客戶端同時訪問共享資源的衝突情況發生。

那怎麼解決這個問題呢?Martin 給出了一種方法,稱爲 fencing token。fencing token 是一個單調遞增的數字,當客戶端成功獲取鎖的時候它隨同鎖一起返回給客戶端。而客戶端訪問共享資源的時候帶着這個 fencing token,這樣提供共享資源的服務就能根據它進行檢查,拒絕掉延遲到來的訪問請求(避免了衝突)。如下圖:

在上圖中,客戶端 1 先獲取到的鎖,因此有一個較小的 fencing token,等於 33,而客戶端 2 後獲取到的鎖,有一個較大的 fencing token,等於 34。客戶端 1 從 GC pause 中恢復過來之後,依然是向存儲服務發送訪問請求,但是帶了 fencing token = 33。存儲服務發現它之前已經處理過 34 的請求,所以會拒絕掉這次 33 的請求。這樣就避免了衝突。

但是,對於客戶端和資源服務器之間的延遲(即發生在算法第 3 步之後的延遲),antirez 是承認所有的分佈式鎖的實現,包括 Redlock,是沒有什麼好辦法來應對的。包括在我們到生產環境中,無法避免分佈式鎖超時。

在討論中,有人提出客戶端 1 和客戶端 2 都發生了 GC pause,兩個 fencing token 都延遲了,它們幾乎同時到達了文件服務器,而且保持了順序。那麼,我們新加入的判斷邏輯,即判斷 fencing token 的合理性,應該對兩個請求都會放過,而放過之後它們幾乎同時在操作文件,還是衝突了。既然 Martin 宣稱 fencing token 能保證分佈式鎖的正確性,那麼上面這種可能的猜測也許是我們理解錯了。但是 Martin 並沒有在後面做出解釋。

問題三:

Redlock 對系統記時 (timing) 的過分依賴,下面給出一個例子(還是假設有 5 個 Redis 節點 A, B, C, D, E):

  1. 客戶端 1 從 Redis 節點 A, B, C 成功獲取了鎖(多數節點)。由於網絡問題,與 D 和 E 通信失敗。

  2. 節點 C 上的時鐘發生了向前跳躍,導致它上面維護的鎖快速過期。

  3. 客戶端 2 從 Redis 節點 C, D, E 成功獲取了同一個資源的鎖(多數節點)。

  4. 客戶端 1 和客戶端 2 現在都認爲自己持有了鎖。

上面這種情況之所以有可能發生,本質上是因爲 Redlock 的安全性 (safety property) 對系統的時鐘有比較強的依賴,一旦系統的時鐘變得不準確,算法的安全性也就保證不了了。

但是作者反駁到,通過恰當的運維,完全可以避免時鐘發生大的跳動,而 Redlock 對於時鐘的要求在現實系統中是完全可以滿足的。哪怕是手動修改時鐘這種人爲原因,不要那麼做就是了。否則的話,都會出現問題。

說了這麼多關於 Redlock 的問題,到底有沒有什麼分佈式鎖能保證安全性呢?我們接下來再來看看 ZooKeeper 分佈式鎖。

基於 ZooKeeper 的分佈式鎖更安全嗎?

很多人(也包括 Martin 在內)都認爲,如果你想構建一個更安全的分佈式鎖,那麼應該使用 ZooKeeper,而不是 Redis。那麼,爲了對比的目的,讓我們先暫時脫離開本文的題目,討論一下基於 ZooKeeper 的分佈式鎖能提供絕對的安全嗎?它需要 fencing token 機制的保護嗎?

Flavio Junqueira 是 ZooKeeper 的作者之一,他的這篇 blog 就寫在 Martin 和 antirez 發生爭論的那幾天。他在文中給出了一個基於 ZooKeeper 構建分佈式鎖的描述(當然這不是唯一的方式):

看起來這個鎖相當完美,沒有 Redlock 過期時間的問題,而且能在需要的時候讓鎖自動釋放。但仔細考察的話,並不盡然。

ZooKeeper 是怎麼檢測出某個客戶端已經崩潰了呢?實際上,每個客戶端都與 ZooKeeper 的某臺服務器維護着一個 Session,這個 Session 依賴定期的心跳 (heartbeat) 來維持。如果 ZooKeeper 長時間收不到客戶端的心跳(這個時間稱爲 Sesion 的過期時間),那麼它就認爲 Session 過期了,通過這個 Session 所創建的所有的 ephemeral 類型的 znode 節點都會被自動刪除。

設想如下的執行序列:

  1. 客戶端 1 創建了 znode 節點/lock,獲得了鎖。

  2. 客戶端 1 進入了長時間的 GC pause。

  3. 客戶端 1 連接到 ZooKeeper 的 Session 過期了。znode 節點/lock被自動刪除。

  4. 客戶端 2 創建了 znode 節點/lock,從而獲得了鎖。

  5. 客戶端 1 從 GC pause 中恢復過來,它仍然認爲自己持有鎖。

最後,客戶端 1 和客戶端 2 都認爲自己持有了鎖,衝突了。這與之前 Martin 在文章中描述的由於 GC pause 導致的分佈式鎖失效的情況類似。

看起來,用 ZooKeeper 實現的分佈式鎖也不一定就是安全的。該有的問題它還是有。但是,ZooKeeper 作爲一個專門爲分佈式應用提供方案的框架,它提供了一些非常好的特性,是 Redis 之類的方案所沒有的。像前面提到的 ephemeral 類型的 znode 自動刪除的功能就是一個例子。

還有一個很有用的特性是 ZooKeeper 的 watch 機制。這個機制可以這樣來使用,比如當客戶端試圖創建/lock的時候,發現它已經存在了,這時候創建失敗,但客戶端不一定就此對外宣告獲取鎖失敗。客戶端可以進入一種等待狀態,等待當/lock節點被刪除的時候,ZooKeeper 通過 watch 機制通知它,這樣它就可以繼續完成創建操作(獲取鎖)。這可以讓分佈式鎖在客戶端用起來就像一個本地的鎖一樣:加鎖失敗就阻塞住,直到獲取到鎖爲止。這樣的特性 Redlock 就無法實現。

小結一下,基於 ZooKeeper 的鎖和基於 Redis 的鎖相比在實現特性上有兩個不同:

總結

綜上所述,我們可以得出兩種結論:

最後,由於 redlock 的出現其實是爲了保證分佈式鎖的可靠性,但是由於實現的種種問題其可靠性並沒有 ZooKeeper 分佈式鎖來的高,對於可容錯的希望效率的場景下,redis 分佈式鎖又可以完全滿足,這也是導致了 redlock 被棄用的原因。

參考: http://zhangtielei.com/posts/blog-redlock-reasoning.html

作者:望靠德州 poker 發家的 cxy

來源:juejin.cn/post/7137224260862361637

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Wy4oroYWheXaexLRaHsdlw