分佈式鎖tair redis zookeeper,安全性

tair分佈式鎖實現:https://yq.aliyun.com/articles/58928html

redis分佈式鎖:https://www.cnblogs.com/jianwei-dai/p/6137896.htmllinux

                          https://blog.csdn.net/abbc7758521/article/details/77990048git

                  分佈式鎖之Redis實現(最終版)程序員

redis、zookeeper分佈式鎖安全性討論:https://blog.csdn.net/jackcaptain1015/article/details/71157004github

http://mp.weixin.qq.com/s/JTsJCDuasgIJ0j95K8Ay8w      http://mp.weixin.qq.com/s/4CUe7OpM6y1kQRK8TOC_qQredis

基於Redis實現分佈式鎖以前,這些坑你必定得知道(最終版的問題及解決方案)算法

使用setnx get getset實現分佈式鎖

多個進程執行如下Redis命令:c#

SETNX lock.foo <current Unix time + lock timeout + 1>api

若是 SETNX 返回1,說明該進程得到鎖,SETNX將鍵 lock.foo 的值設置爲鎖的超時時間(當前時間 + 鎖的有效時間)。 
若是 SETNX 返回0,說明其餘進程已經得到了鎖,進程不能進入臨界區。進程能夠在一個循環中不斷地嘗試 SETNX 操做,以得到鎖。安全

可是這個命令沒有超時設置,得用expire命令設置超時時間。可是2個命令啊,不是原子操做了。

解決死鎖

考慮一種狀況,若是進程得到鎖後,斷開了與 Redis 的鏈接(多是進程掛掉,或者網絡中斷),若是沒有有效的釋放鎖的機制,那麼其餘進程都會處於一直等待的狀態,即出現「死鎖」。

上面在使用 SETNX 得到鎖時,咱們將鍵 lock.foo 的值設置爲鎖的有效時間,進程得到鎖後,其餘進程還會不斷的檢測鎖是否已超時,若是超時,那麼等待的進程也將有機會得到鎖。

然而,鎖超時時,咱們不能簡單地使用 DEL 命令刪除鍵 lock.foo 以釋放鎖。考慮如下狀況,進程P1已經首先得到了鎖 lock.foo,而後進程P1掛掉了。進程P2,P3正在不斷地檢測鎖是否已釋放或者已超時,執行流程以下:

  • P2和P3進程讀取鍵 lock.foo 的值,檢測鎖是否已超時(經過比較當前時間和鍵 lock.foo 的值來判斷是否超時)
  • P2和P3進程發現鎖 lock.foo 已超時
  • P2執行 DEL lock.foo命令
  • P2執行 SETNX lock.foo命令,並返回1,即P2得到鎖
  • P3執行 DEL lock.foo命令將P2剛剛設置的鍵 lock.foo 刪除(這步是因爲P3剛纔已檢測到鎖已超時)
  • P3執行 SETNX lock.foo命令,並返回1,即P3得到鎖
  • P2和P3同時得到了鎖

從上面的狀況能夠得知,在檢測到鎖超時後,進程不能直接簡單地執行 DEL 刪除鍵的操做以得到鎖。

爲了解決上述算法可能出現的多個進程同時得到鎖的問題,咱們再來看如下的算法。 
咱們一樣假設進程P1已經首先得到了鎖 lock.foo,而後進程P1掛掉了。接下來的狀況:

  • 進程P4執行 SETNX lock.foo 以嘗試獲取鎖
  • 因爲進程P1已得到了鎖,因此P4執行 SETNX lock.foo 返回0,即獲取鎖失敗
  • P4執行 GET lock.foo 來檢測鎖是否已超時,若是沒超時,則等待一段時間,再次檢測
  • 若是P4檢測到鎖已超時,即當前的時間大於鍵 lock.foo 的值,P4會執行如下操做 
    GETSET lock.foo <current Unix timestamp + lock timeout + 1>
  • 因爲 GETSET 操做在設置鍵的值的同時,還會返回鍵的舊值,經過比較鍵 lock.foo 的舊值是否小於當前時間,能夠判斷進程是否已得到鎖
  • 假如另外一個進程P5也檢測到鎖已超時,並在P4以前執行了 GETSET 操做,那麼P4的 GETSET 操做返回的是一個大於當前時間的時間戳,這樣P4就不會得到鎖而繼續等待。注意到,即便P4接下來將鍵 lock.foo 的值設置了比P5設置的更大的值也沒影響。

另外,值得注意的是,在進程釋放鎖,即執行 DEL lock.foo 操做前,須要先判斷鎖是否已超時。若是鎖已超時,那麼鎖可能已由其餘進程得到,這時直接執行 DEL lock.foo 操做會致使把其餘進程已得到的鎖釋放掉,因此不須要再對鎖進行處理。

public static boolean acquireLock(String lock) { // 1. 經過SETNX試圖獲取一個lock
    boolean success = false; Jedis jedis = pool.getResource(); long value = System.currentTimeMillis() + expired + 1; System.out.println(value); long acquired = jedis.setnx(lock, String.valueOf(value)); //SETNX成功,則成功獲取一個鎖
    if (acquired == 1) success = true; //SETNX失敗,說明鎖仍然被其餘對象保持,檢查其是否已經超時
    else { long oldValue = Long.valueOf(jedis.get(lock)); //超時
    if (oldValue < System.currentTimeMillis()) { String getValue = jedis.getSet(lock, String.valueOf(value)); // 獲取鎖成功
        if (Long.valueOf(getValue) == oldValue)   success = true; // 已被其餘進程捷足先登了
      else   success = false; } //未超時,則直接返回失敗
    else success = false; } pool.returnResource(jedis); return success; } 

最終版redis鎖

加鎖

public class Redis { 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()方法一共有五個形參:

  • 第一個爲key,咱們使用key來當鎖,由於key是惟一的。
  • 第二個爲value,咱們傳的是requestId,requestId是客戶端的惟一標誌。
  • 第三個爲nxxx,這個參數咱們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,咱們進行set操做;若key已經存在,則不作任何操做;
  • 第四個爲expx,這個參數咱們傳的是PX,意思是咱們要給這個key加一個過時的設置,具體時間由第五個參數決定。
  • 第五個爲time,與第四個參數相呼應,表明key的過時時間。

能夠看到上面的set()方法,經過requestId解決了分佈式下不一樣客戶端時間不統一問題,經過超期時間解決了屢次getset覆蓋問題,經過解鎖時判斷requestId解決了任何客戶端均可以解鎖問題。

解鎖

使用LUA,將get、del操做原子化。由於有這樣的場景:

好比客戶端A加鎖,一段時間以後客戶端A解鎖,在執行jedis.del()以前,鎖忽然過時了,此時客戶端B嘗試加鎖成功,而後客戶端A再執行del()方法,則將客戶端B的鎖給解除了。

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; } }

問題

其實還有問題

一、單點問題:加鎖成功,可是尚未把數據同步到slave。忽然master掛了,某個slave變爲master,其餘節點加鎖依舊成功。

可參考RedLock,加鎖能夠加鎖多個節點,不只僅時master節點。可是這樣性能又很差。沒有銀彈啊。

二、執行時間超過了鎖的過時時間:上面寫到爲了避免出現一直上鎖的狀況,加了一個兜底的過時時間,時間到了鎖自動釋放,可是,若是在這期間任務並無作完怎麼辦?因爲GC或者網絡延遲致使的任務時間變長,很難保證任務必定能在鎖的過時時間內完成。

深刻思考點

Redlock算法

對於第一個單點問題,順着redis的思路,接下來想到的確定是Redlock了。Redlock爲了解決單機的問題,須要多個(大於2)redis的master節點,多個master節點互相獨立,沒有數據同步。

Redlock的實現以下:

  1. 獲取當前時間。
  2. 依次獲取N個節點的鎖。 每一個節點加鎖的實現方式同上。這裏有個細節,就是每次獲取鎖的時候的過時時間都不一樣,須要減去以前獲取鎖的操做的耗時,
  • 好比傳入的鎖的過時時間爲500ms,
  • 獲取第一個節點的鎖花了1ms,那麼第一個節點的鎖的過時時間就是499ms,
  • 獲取第二個節點的鎖花了2ms,那麼第二個節點的鎖的過時時間就是497ms
  • 若是鎖的過時時間小於等於0了,說明整個獲取鎖的操做超時了,整個操做失敗
  1. 判斷是否獲取鎖成功。 若是client在上述步驟中獲取到了(N/2 + 1)個節點鎖,而且每一個鎖的過時時間都是大於0的,則獲取鎖成功,不然失敗。失敗時釋放鎖。
  2. 釋放鎖。 對全部節點發送釋放鎖的指令,每一個節點的實現邏輯和上面的簡單實現同樣。爲何要對全部節點操做?由於分佈式場景下從一個節點獲取鎖失敗不表明在那個節點上加速失敗,可能實際上加鎖已經成功了,可是返回時由於網絡抖動超時了。

以上就是你們常見的redlock實現的描述了,一眼看上去就是簡單版本的多master版本,若是真是這樣就太簡單了,接下來分析下這個算法在各個場景下是怎樣被玩壞的。

高併發場景下的問題

如下問題不是說在併發不高的場景下不容易出現,只是在高併發場景下出現的機率更高些而已。 性能問題。 性能問題來自於兩個方面。

  1. 獲取鎖的時間上。若是redlock運用在高併發的場景下,存在N個master節點,一個一個去請求,耗時會比較長,從而影響性能。這個好解決。經過上面描述不難發現,從多個節點獲取鎖的操做並非一個同步操做,能夠是異步操做,這樣能夠多個節點同時獲取。即便是並行處理的,仍是得預估好獲取鎖的時間,保證鎖的TTL > 獲取鎖的時間+任務處理時間。
  2. 被加鎖的資源太大。加鎖的方案自己就是會爲了正確性而犧牲併發的,犧牲和資源大小成正比。這個時候能夠考慮對資源作拆分,拆分的方式有兩種:
  3. 從業務上將鎖住的資源拆分紅多段,每段分開加鎖。好比,我要對一個商戶作若干個操做,操做前要鎖住這個商戶,這時我能夠將若干個操做拆成多個獨立的步驟分開加鎖,提升併發。
  4. 用分桶的思想,將一個資源拆分紅多個桶,一個加鎖失敗當即嘗試下一個。好比批量任務處理的場景,要處理200w個商戶的任務,爲了提升處理速度,用多個線程,每一個線程取100個商戶處理,就得給這100個商戶加鎖,若是不加處理,很難保證同一時刻兩個線程加鎖的商戶沒有重疊,這時能夠按一個維度,好比某個標籤,對商戶進行分桶,而後一個任務處理一個分桶,處理完這個分桶再處理下一個分桶,減小競爭。

重試的問題。 不管是簡單實現仍是redlock實現,都會有重試的邏輯。若是直接按上面的算法實現,是會存在多個client幾乎在同一時刻獲取同一個鎖,而後每一個client都鎖住了部分節點,可是沒有一個client獲取大多數節點的狀況。解決的方案也很常見,在重試的時候讓多個節點錯開,錯開的方式就是在重試時間中加一個隨機時間。這樣並不能根治這個問題,可是能夠有效緩解問題,親試有效。

節點宕機

對於單master節點且沒有作持久化的場景,宕機就掛了,這個就必須在實現上支持重複操做,本身作好冪等。

對於多master的場景,好比redlock,咱們來看這樣一個場景:

  1. 假設有5個redis的節點:A、B、C、D、E,沒有作持久化。
  2. client1從A、B、C 3個節點獲取鎖成功,那麼client1獲取鎖成功。
  3. 節點C掛了。
  4. client2從C、D、E獲取鎖成功,client2也獲取鎖成功,那麼在同一時刻client1和client2同時獲取鎖,redlock被玩壞了。

怎麼解決呢?最容易想到的方案是打開持久化。持久化能夠作到持久化每一條redis命令,但這對性能影響會很大,通常不會採用,若是不採用這種方式,在節點掛的時候確定會損失小部分的數據,可能咱們的鎖就在其中。 另外一個方案是延遲啓動。就是一個節點掛了修復後,不當即加入,而是等待一段時間再加入,等待時間要大於宕機那一刻全部鎖的最大TTL。 但這個方案依然不能解決問題,若是在上述步驟3中B和C都掛了呢,那麼只剩A、D、E三個節點,從D和E獲取鎖成功就能夠了,仍是會出問題。那麼只能增長master節點的總量,緩解這個問題了。增長master節點會提升穩定性,可是也增長了成本,須要在二者之間權衡。

任務執行時間超過鎖的TTL

以前產線上出現過由於網絡延遲致使任務的執行時間遠超預期,鎖過時,被多個線程執行的狀況。 這個問題是全部分佈式鎖都要面臨的問題,包括基於zookeeper和DB實現的分佈式鎖,這是鎖過時了和client不知道鎖過時了之間的矛盾。 在加鎖的時候,咱們通常都會給一個鎖的TTL,這是爲了防止加鎖後client宕機,鎖沒法被釋放的問題。可是全部這種姿式的用法都會面臨同一個問題,就是沒發保證client的執行時間必定小於鎖的TTL。雖然大多數程序員都會樂觀的認爲這種狀況不可能發生,我也曾經這麼認爲,直到被現實一次又一次的打臉。
示例流程:
  1. Client1獲取到鎖
  2. Client1開始任務,而後發生了STW的GC,時間超過了鎖的過時時間
  3. Client2 獲取到鎖,開始了任務
  4. Client1的GC結束,繼續任務,這個時候Client1和Client2都認爲本身獲取了鎖,都會處理任務,從而發生錯誤。

Martin Kleppmann舉的是GC的例子,我碰到的是網絡延遲的狀況。無論是哪一種狀況,不能否認的是這種狀況沒法避免,一旦出現很容易懵逼。

如何解決呢?一種解決方案是不設置TTL,而是在獲取鎖成功後,給鎖加一個watchdog,watchdog會起一個定時任務,在鎖沒有被釋放且快要過時的時候會續期。這樣說有些抽象,下面結合redisson源碼說下:

public class RedissonLock extends RedissonExpirable implements RLock { ... @Override public void lock() { try { lockInterruptibly(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } @Override public void lock(long leaseTime, TimeUnit unit) { try { lockInterruptibly(leaseTime, unit); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } ... }
redisson
redisson經常使用的加鎖api是上面兩個,一個是不傳入TTL,這時是redisson本身維護,會主動續期;另一種是本身傳入TTL,這種redisson就不會幫咱們自動續期了,或者本身將leaseTime的值傳成-1,可是不建議這種方式,既然已經有現成的API了,何須還要用這種奇怪的寫法呢。 接下來分析下不傳參的方法的加鎖邏輯:
public class RedissonLock extends RedissonExpirable implements RLock { ... public static final long LOCK_EXPIRATION_INTERVAL_SECONDS = 30; protected long internalLockLeaseTime = TimeUnit.SECONDS.toMillis(LOCK_EXPIRATION_INTERVAL_SECONDS); @Override public void lock() { try { lockInterruptibly(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } @Override public void lockInterruptibly() throws InterruptedException { lockInterruptibly(-1, null); } @Override public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException { long threadId = Thread.currentThread().getId(); Long ttl = tryAcquire(leaseTime, unit, threadId); // lock acquired
        if (ttl == null) { return; } RFuture<RedissonLockEntry> future = subscribe(threadId); commandExecutor.syncSubscription(future); try { while (true) { ttl = tryAcquire(leaseTime, unit, threadId); // lock acquired
                if (ttl == null) { break; } // waiting for message
                if (ttl >= 0) { getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } else { getEntry(threadId).getLatch().acquire(); } } } finally { unsubscribe(future, threadId); } // get(lockAsync(leaseTime, unit));
 } private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) { return get(tryAcquireAsync(leaseTime, unit, threadId)); } private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) { if (leaseTime != -1) { return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS, TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG); ttlRemainingFuture.addListener(new FutureListener<Long>() { @Override public void operationComplete(Future<Long> future) throws Exception { if (!future.isSuccess()) { return; } Long ttlRemaining = future.getNow(); // lock acquired
                if (ttlRemaining == null) { scheduleExpirationRenewal(threadId); } } }); return ttlRemainingFuture; } private void scheduleExpirationRenewal(final long threadId) { if (expirationRenewalMap.containsKey(getEntryName())) { return; } Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { RFuture<Boolean> future = commandExecutor.evalWriteAsync(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.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); future.addListener(new FutureListener<Boolean>() { @Override public void operationComplete(Future<Boolean> future) throws Exception { expirationRenewalMap.remove(getEntryName()); if (!future.isSuccess()) { log.error("Can't update lock " + getName() + " expiration", future.cause()); return; } if (future.getNow()) { // reschedule itself
 scheduleExpirationRenewal(threadId); } } }); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) { task.cancel(); } } ... }
具體加鎖邏輯

能夠看到,最後加鎖的邏輯會進入到org.redisson.RedissonLock#tryAcquireAsync中,在獲取鎖成功後,會進入scheduleExpirationRenewal,這裏面初始化了一個定時器,dely的時間是internalLockLeaseTime / 3。在redisson中,internalLockLeaseTime是30s,也就是每隔10s續期一次,每次30s。 若是是基於zookeeper實現的分佈式鎖,能夠利用zookeeper檢查節點是否存活,從而實現續期,zookeeper分佈式鎖沒用過,不詳細說。

不過這種作法也沒法百分百作到同一時刻只有一個client獲取到鎖,若是續期失敗,好比發生了Martin Kleppmann所說的STW的GC,或者client和redis集羣失聯了,只要續期失敗,就會形成同一時刻有多個client得到鎖了。在個人場景下,我將鎖的粒度拆小了,redisson的續期機制已經夠用了。 若是要作得更嚴格,得加一個續期失敗終止任務的邏輯。這種作法在之前Python的代碼中實現過,Java尚未碰到這麼嚴格的狀況。

這裏也提下Martin Kleppmann的解決方案,我本身以爲這個方案並不靠譜,緣由後面會提到。 他的方案是讓加鎖的資源本身維護一套保證不會因加鎖失敗而致使多個client在同一時刻訪問同一個資源的狀況。

在客戶端獲取鎖的同時,也獲取到一個資源的token,這個token是單調遞增的,每次在寫資源時,都檢查當前的token是不是較老的token,若是是就不讓寫。對於上面的場景,Client1獲取鎖的同時分配一個33的token,Client2獲取鎖的時候分配一個34的token,在client1 GC期間,Client2已經寫了資源,這時最大的token就是34了,client1 從GC中回來,再帶着33的token寫資源時,會由於token過時被拒絕。這種作法須要資源那一邊提供一個token生成器。 對於這種fencing的方案,我有幾點問題:
  1. 沒法保證事務。示意圖中畫的只有34訪問了storage,可是在實際場景中,可能出如今一個任務內屢次訪問storage的狀況,並且必須是原子的。若是client1帶着33token在GC前訪問過一次storage,而後發生了GC。client2獲取到鎖,帶着34的token也訪問了storage,這時兩個client寫入的數據是否還能保證數據正確?若是不能,那麼這種方案就有缺陷,除非storage本身有其餘機制能夠保證,好比事務機制;若是能,那麼這裏的token就是多餘的,fencing的方案就是畫蛇添足。
  2. 高併發場景不實用。由於每次只有最大的token能寫,這樣storage的訪問就是線性的,在高併發場景下,這種方式會極大的限制吞吐量,而分佈式鎖也大可能是在這種場景下用的,很矛盾的設計。
  3. 這是全部分佈式鎖的問題。這個方案是一個通用的方案,能夠和Redlock用,也能夠和其餘的lock用。因此我理解僅僅是一個和Redlock無關的解決方案。

系統時鐘漂移

這個問題只是考慮過,但在實際項目中並無碰到過,由於理論上是可能出現的,這裏也說下。 redis的過時時間是依賴系統時鐘的,若是時鐘漂移過大時會影響到過時時間的計算。

爲何系統時鐘會存在漂移呢?先簡單說下系統時間,linux提供了兩個系統時間:clock realtime和clock monotonic。clock realtime也就是xtime/wall time,這個時間時能夠被用戶改變的,被NTP改變,gettimeofday拿的就是這個時間,redis的過時計算用的也是這個時間。 clock monotonic ,直譯過來時單調時間,不會被用戶改變,可是會被NTP改變。

最理想的狀況時,全部系統的時鐘都時時刻刻和NTP服務器保持同步,但這顯然時不可能的。致使系統時鐘漂移的緣由有兩個:

  1. 系統的時鐘和NTP服務器不一樣步。這個目前沒有特別好的解決方案,只能相信運維同窗了。
  2. clock realtime被人爲修改。在實現分佈式鎖時,不要使用clock realtime。不過很惋惜,redis使用的就是這個時間,我看了下Redis 5.0源碼,使用的仍是clock realtime。Antirez說過改爲clock monotonic的,不過大佬尚未改。也就是說,人爲修改redis服務器的時間,就能讓redis出問題了。
相關文章
相關標籤/搜索