天天看點

Redis分布式鎖真的安全嗎?

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

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

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

是以,在分布式系統中,當有多個用戶端需要擷取鎖時,我們需要分布式鎖。此時,鎖是儲存在一個共享存儲系統中的,可以被多個用戶端共享通路和擷取。

Redis分布式鎖真的安全嗎?

分布式鎖(SET NX)

Redis分布式鎖真的安全嗎?

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

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的解鎖操作,進行解鎖操作時,首先我們來判斷目前鎖的值是否為該線程持有的,防止目前線程執行較慢,導緻鎖過期,進而删除了其他線程持有的分布式鎖,對于該操作,我來舉個例子:

  • 時刻1:線程A擷取分布式鎖,開始執行業務邏輯
  • 時刻2:線程B等待分布式鎖釋放
  • 時刻3:線程A所在機器IO處理緩慢、GC pause等問題導緻處理緩慢
  • 時刻4:線程A依舊處于block狀态,鎖過期
  • 時刻5:線程B擷取分布式鎖,開始執行業務邏輯,此時線程A結束block,開始釋放鎖
  • 時刻6:線程B處理業務邏輯緩慢,線程A釋放分布式鎖,但是此時釋放的是線程B的鎖,導緻其他線程可以開始擷取鎖

看到這裡,為什麼每個請求需要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的實作有很大的争論,并且現在已經棄用了,但是我們還是需要了解下原理,以及之後基于這些問題的解決方案。

Redis分布式鎖真的安全嗎?

分布式鎖Redlock

Redis分布式鎖真的安全嗎?

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會去判斷鎖的有效性,如果鎖過期了,則會再去重新拿鎖。但是如果發生在擷取鎖之後,那麼該有效性都得不到保障了。

Redis分布式鎖真的安全嗎?

在上面的時序圖中,假設鎖服務本身是沒有問題的,它總是能保證任一時刻最多隻有一個用戶端獲得鎖。上圖中出現的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,這樣提供共享資源的服務就能根據它進行檢查,拒絕掉延遲到來的通路請求(避免了沖突)。如下圖:

Redis分布式鎖真的安全嗎?

在上圖中,用戶端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分布式鎖。

Redis分布式鎖真的安全嗎?

基于ZooKeeper的分布式鎖更安全嗎?

Redis分布式鎖真的安全嗎?

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

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

  • 用戶端嘗試建立一個znode節點,比如/lock。那麼第一個用戶端就建立成功了,相當于拿到了鎖;而其它的用戶端會建立失敗(znode已存在),擷取鎖失敗。
  • 持有鎖的用戶端通路共享資源完成後,将znode删掉,這樣其它用戶端接下來就能來擷取鎖了。
  • znode應該被建立成ephemeral的。這是znode的一個特性,它保證如果建立znode的那個用戶端崩潰了,那麼相應的znode會被自動删除。這保證了鎖一定會被釋放。

看起來這個鎖相當完美,沒有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的鎖相比在實作特性上有兩個不同:

  • 在正常情況下,用戶端可以持有鎖任意長的時間,這可以確定它做完所有需要的資源通路操作之後再釋放鎖。這避免了基于Redis的鎖對于有效時間(lock validity time)到底設定多長的兩難問題。實際上,基于ZooKeeper的鎖是依靠Session(心跳)來維持鎖的持有狀态的,而Redis不支援Session。
  • 基于ZooKeeper的鎖支援在擷取鎖失敗之後等待鎖重新釋放的事件。這讓用戶端對鎖的使用更加靈活。
Redis分布式鎖真的安全嗎?

總結

Redis分布式鎖真的安全嗎?

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

  • 如果僅是為了效率(efficiency),那麼你可以自己選擇你喜歡的一種分布式鎖的實作。當然,你需要清楚地知道它在安全性上有哪些不足,以及它會帶來什麼後果,這也是為什麼我們需要了解實作原理的原因,大多數情況下不會出問題,但是就萬一的情況,處理起來可能需要大量的時間定位問題。
  • 如果你是為了正确性(correctness),那麼請慎之又慎。就目前來說ZooKeeper的分布鎖相對于redlock更加合理。

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

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

原文連結:https://mp.weixin.qq.com/s/Wy4oroYWheXaexLRaHsdlw

繼續閱讀