「分散式」實現分散式鎖的正確姿勢?!

架構隨筆發表於2018-09-21

「分散式」實現分散式鎖的正確姿勢?!

原理分析

最近看到好多博主都在推分散式鎖,實現方式很多,基於db、redis、zookeeper。zookeeper方式實現起來比較繁瑣,這裡我們就談談基於redis實現分散式鎖的正確實現方式。

背景

在很多網際網路產品應用中,有些場景需要加鎖處理,比如:秒殺,全域性遞增ID,樓層生成等等。大部分的解決方案是基於DB實現的,Redis為單程式單執行緒模式,採用佇列模式將併發訪問變成序列訪問,且多客戶端對Redis的連線並不存在競爭關係。 其次Redis提供一些命令SETNXGETSET,可以方便實現分散式鎖機制。

Redis命令介紹

使用Redis實現分散式鎖,有兩個重要函式需要介紹。

SETNX命令(SET if Not Exists)

  • 語法:
SETNX key value
複製程式碼
  • 功能:
    當且僅當 key 不存在,將 key 的值設為 value ,並返回1; 若給定的 key 已經存在,則 SETNX 不做任何動作,並返回0。

GETSET命令

  • 語法:
GETSET key value
複製程式碼
  • 功能:
    將給定 key 的值設為 value ,並返回 key 的舊值 (old value), 當 key 存在但不是字串型別時,返回一個錯誤,當key不存在時,返回nil。

GET命令

  • 語法:
GET key
複製程式碼
  • 功能:
    返回 key 所關聯的字串值,如果 key 不存在那麼返回特殊值 nil 。

DEL命令

  • 語法:
DEL key [KEY …]
複製程式碼
  • 功能:
    刪除給定的一個或多個 key ,不存在的 key 會被忽略。

兵貴精,不在多。分散式鎖,我們就依靠這四個命令。但在具體實現,還有很多細節,需要仔細斟酌,因為在分散式併發多程式中,任何一點出現差錯,都會導致死鎖,hold住所有程式。

加鎖實現

SETNX 可以直接加鎖操作,比如說對某個關鍵詞foo加鎖,客戶端可以嘗試 SETNX foo.lock <current unix time>

  • 如果返回1,表示客戶端已經獲取鎖,可以往下操作,操作完成後,通過 DEL foo.lock 命令來釋放鎖。
  • 如果返回0,說明foo已經被其他客戶端上鎖,如果鎖是非堵塞的,可以選擇返回撥用。如果是堵塞呼叫,就需要進入下一個重試迴圈,直至成功獲得鎖或者重試超時。

理想是美好的,現實是殘酷的。僅僅使用SETNX加鎖帶有競爭條件的,在某些特定的情況會造成死鎖錯誤。

處理死鎖

在上面的處理方式中,如果獲取鎖的客戶端執行時間過長,程式被kill掉,或者因為其他異常崩潰,導致無法釋放鎖,就會造成死鎖。所以,需要對加鎖要做時效性檢測。

因此,我們在加鎖時,把當前時間戳作為value存入此鎖中,通過當前時間戳和redis中的時間戳進行對比,如果超過一定差值,認為鎖已經時效,防止鎖無限期的鎖下去。

但是,在大併發情況,如果同時檢測鎖失效,並簡單粗暴的刪除死鎖,再通過SETNX上鎖,可能會導致競爭條件的產生,即多個客戶端同時獲取鎖。

情景描述如下:

  1. C1獲取鎖,並崩潰。C2和C3呼叫SETNX上鎖返回0後,獲得foo.lock的時間戳,通過比對時間戳,發現鎖超時。
  2. C2 向foo.lock傳送DEL命令。
  3. C2 向foo.lock傳送SETNX獲取鎖。
  4. C3 向foo.lock傳送DEL命令,此時C3傳送DEL時,其實DEL掉的是C2的鎖。
  5. C3 向foo.lock傳送SETNX獲取鎖。

此時C2和C3都獲取了鎖,產生競爭條件,如果在更高併發的情況,可能會有更多客戶端獲取鎖。

所以,DEL鎖的操作,不能直接使用在鎖超時的情況下,幸好我們有GETSET方法,假設我們現在有另外一個客戶端C4,看看如何使用GETSET方式,避免這種情況產生。

  1. C1獲取鎖,並崩潰。C2和C3呼叫SETNX上鎖返回0後,呼叫GET命令獲得foo.lock的時間戳T1,通過比對時間戳,發現鎖超時。
  2. C4(呼叫SETNX上鎖返回0後,呼叫GET命令獲得foo.lock的時間戳T1,通過比對時間戳,發現鎖超時)向foo.lock傳送GESET命令,GETSET foo.lock 並得到foo.lock中老的時間戳T2。
    • 如果T1=T2,說明C4獲得鎖。
    • 如果T1!=T2,說明C4之前有另外一個客戶端C5通過呼叫GETSET方式獲取並更改了時間戳,C4未獲得鎖。只能進入下次迴圈中。

時間戳問題

我們看到foo.lock的value值為時間戳,所以要在多客戶端情況下,保證鎖有效,一定要同步各伺服器的時間。如果各伺服器間,時間有差異,時間不一致的客戶端,在判斷鎖超時,就會出現偏差,從而產生競爭條件。鎖的超時與否,嚴格依賴時間戳。

鎖覆蓋問題

現在唯一的問題是,C4設定foo.lock的新時間戳,是否會對C5獲取得鎖產生影響?

其實我們可以看到C4和C5只有在呼叫GET命令獲得foo.lock的時間戳,通過比對時間戳,發現鎖超時後,幾乎同時呼叫GETSET方式獲取鎖,執行的時間差值極小,並且寫入foo.lock中的都是有效時間戳,所以對鎖並沒有影響

為了讓這個鎖更加強壯,獲取鎖的客戶端,應該在呼叫關鍵業務時,再次呼叫GET方法獲取T1,和寫入的T0時間戳進行對比,以免鎖因其他情況被執行DEL意外解開而不知。

但是如果遇到上面描述得問題,則T0則會與T1不一致,當然差別一般會很小。這就是鎖覆蓋問題

鎖覆蓋會導致什麼問題呢?

當客戶端的鎖過期時間被覆蓋,會造成鎖不具有標識性,會造成客戶端無法釋放鎖(客戶端只能釋放明確自己持有的鎖)。

nil 問題

GET返回nil時應該走哪種邏輯?

一、第一種走迴圈走setnx邏輯

  1. C1客戶端獲取鎖,並且處理完後,DEL掉鎖。
  2. 在DEL鎖之前,C2通過SETNX向foo.lock設定時間戳T0失敗,發現有客戶端獲取鎖,進入GET操作。C2 向foo.lock傳送GET命令,獲取返回值T1(nil)(因為此時C1執行DEL刪除鎖)。
  3. C2 迴圈,進入下一次SETNX邏輯。

二、第二種走超時邏輯

  1. C1客戶端獲取鎖,並且處理完後,DEL掉鎖。
  2. 在DEL鎖之前,C2通過SETNX向foo.lock設定時間戳T0發現有客戶端獲取鎖,進入GET操作。C2 向foo.lock傳送GET命令,獲取返回值T1(nil)(因為此時C1執行DEL刪除鎖)。
  3. C2 通過 `T0 > T1 + expire` 對比,進入GETSET流程
  4. C2呼叫GETSET向foo.lock傳送T0時間戳,返回foo.lock的原值T2,C2判斷如果T2=T1相等,獲得鎖,如果T2!=T1,未獲得鎖。

分析

兩種邏輯貌似都是OK,但是從邏輯處理上來說,當GET返回nil,表示鎖是被刪除的,而不是超時,應該走SETNX邏輯加鎖。

對於"第二種走超時邏輯"是否會造成死鎖,尚不清楚,不過推薦採用第一種方式。

GETSET返回nil時應該怎麼處理?

前提:假設C4客戶端獲取鎖後由於異常退出等原因未正常釋放鎖,導致鎖超時。此時,C1、C2和C3客戶端同時請求獲取鎖。C1、C2和C3客戶端呼叫GET介面,C1返回T1,此時C3網路情況更好,快速進入獲取鎖,並執行DEL刪除鎖,C2返回T2(nil)。C1進入超時處理邏輯。C2面臨上面提到「GET返回nil時應該走哪種邏輯?」的兩種選擇:1. 也進入超時處理邏輯;2. 繼續迴圈走setnx邏輯(推薦);

  1. C1向foo.lock傳送GETSET命令,獲取返回值T11(nil)。C1比對C1和C11發現兩者不同,處理邏輯認為未獲取鎖,然後繼續迴圈走setnx邏輯

  2. C2有兩種選擇:

    • 進入超時處理邏輯;
      C2 向foo.lock傳送GETSET命令,獲取返回值T22(C1寫入的時間戳)。C2比對T2和T22發現兩者不同,處理邏輯認為未獲取鎖,然後繼續迴圈走setnx邏輯。

    • 繼續迴圈走setnx邏輯;

  3. 很明顯,C1和C2最終都會繼續迴圈走setnx邏輯,然後通過SETNX向foo.lock設定時間戳T0會失敗,這其實是因為在步驟1中C1執行GETSET命令導致的。此時C1和C2都認為未獲取鎖,其實C1是已經獲取鎖了,但是他的處理邏輯沒有考慮GETSET返回nil的情況,只是單純的用GET和GETSET值進行對比

分析

至於為什麼會出現這種情況?就如上面設想的場景那樣,多客戶端時,每個客戶端連線redis後,發出的命令並不是連續的,導致從單客戶端看到的好像連續的命令,到redis server後,這兩條命令之間可能已經插入大量的其他客戶端發出的命令,比如DEL,SETNX等。

正確的處理方式就是GETSET返回nil時,獲取鎖成功。

總結

  1. 必要的超時機制:獲取鎖的客戶端一旦崩潰,一定要有過期機制,否則其他客戶端都降無法獲取鎖,造成死鎖問題。
  2. 分散式鎖,多客戶端的時間戳不能保證嚴格意義的一致性,所以在某些特定因素下,有可能存在問題。要適度的機制,可以承受小概率的事件產生。
  3. 只對關鍵處理節點加鎖,良好的習慣是,把相關的資源準備好,比如連線資料庫後,呼叫加鎖機制獲取鎖,直接進行操作,然後釋放,儘量減少持有鎖的時間。
  4. 在持有鎖期間要不要CHECK鎖,如果需要嚴格依賴鎖的狀態,最好在關鍵步驟中做鎖的CHECK檢查機制,但是根據我們的測試發現,在大併發時,每一次CHECK鎖操作,都要消耗掉幾個毫秒,而我們的整個持鎖處理邏輯才不到10毫秒,玩客沒有選擇做鎖的檢查。
  5. sleep學問,為了減少對redis的壓力,獲取鎖嘗試時,迴圈之間一定要做sleep操作。但是sleep時間是多少是門學問。需要根據自己的redis的QPS,加上持鎖處理時間等進行合理計算。如果redis的QPS足夠高,也可以考慮迴圈之間不sleep,迴圈一定次數/時間執行yeild,提高響應速度。
  6. 至於為什麼不使用Redis的muti,expire,watch等機制,可以查下參考資料,找下原因。

程式碼實現

程式碼庫

https://github.com/hutu92/distributed-lock

原始碼

package com.github.hutu92.concurrent.locks;
 
import com.alibaba.fastjson.JSON;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
 
/**
 * Created by liuchunlong on 2018/8/31.
 * <p>
 * 基於redis的分散式鎖 v1
 *
 * 需要客戶端時間同步
 */
public class DistributedLock {
 
    private static final long RETRY_BARRIER = 3 * 1000; // 請求鎖重試屏障,單位毫秒
 
    private final JedisPool jedisPool; // redis連線池
    private final String lockKey; // lock Key
    private final long lockExpiryInNanos; // 鎖的過期時長,單位納秒
 
    private static final ThreadLocal<Lock> lockThreadLocal = new ThreadLocal<Lock>();
 
    /**
     * 構造方法
     *
     * @param jedisPool          redis連線池
     * @param lockKey            鎖的Key
     * @param lockExpiryInMillis 鎖的過期時長,單位毫秒
     */
    public DistributedLock(JedisPool jedisPool, String lockKey, long lockExpiryInMillis) {
        this.jedisPool = jedisPool;
        this.lockKey = lockKey;
        this.lockExpiryInNanos = lockExpiryInMillis * 1000;
    }
 
    /**
     * 構造方法
     * <p>
     * 使用鎖預設的過期時長Integer.MAX_VALUE,即鎖永遠不會過期
     *
     * @param jedisPool redis連線池
     * @param lockKey   鎖的Key
     */
    public DistributedLock(JedisPool jedisPool, String lockKey) {
        this(jedisPool, lockKey, Integer.MAX_VALUE);
    }
 
    /**
     * 獲取鎖在redis中的Key標記
     *
     * @return locks key
     */
    public String getLockKey() {
        return this.lockKey;
    }
 
    /**
     * 鎖的過期時長
     *
     * @return
     */
    public long getLockExpiryInNanos() {
        return lockExpiryInNanos;
    }
 
    /**
     * 請求分散式鎖,不會阻塞,直接返回
     *
     * @param jedis redis 連線
     * @return 成功獲取鎖返回true, 否則返回false
     */
    private boolean tryAcquire(Jedis jedis) {
 
        final Lock newLock = new Lock(System.nanoTime() + this.lockExpiryInNanos);
 
        /**
         * 將新鎖(newLock)寫入redis中。如果成功寫入,redis中不存在鎖,獲取鎖成功;否則,redis中已存在鎖,獲取鎖失敗;
         */
        if (jedis.setnx(this.lockKey, newLock.toString()) == 1) {
            lockThreadLocal.set(newLock);
            return true;
        }
 
        /**
         * 至此,說明redis中已存在鎖,獲取鎖失敗,則需要進行如下操作:
         * 1. 判斷redis中已存在的鎖是否過期,如果過期則直接獲取鎖;
         * 2. 否則,獲取鎖失敗;
         */
 
        final String currentLockValue = jedis.get(lockKey);
        // 特別的,當jedis.get()獲取已存在的鎖currentLockValue為空時,應該重新SETNX
        if (currentLockValue == null || currentLockValue.length() == 0) {
            tryAcquire(jedis);
        }
        final Lock currentLock = Lock.fromJson(currentLockValue); // redis中已存在的鎖
 
        // 如果redis中已存在的鎖已超時,則重新獲取鎖
        if (isExpired(currentLock)) {
            String originLockValue = jedis.getSet(lockKey, newLock.toString());
 
            /**
             * 這裡還有個前置條件:
             *      會對已存在的鎖進行校驗,jedis.get()和jedis.getSet()獲取的鎖必須是同一鎖,重新獲取鎖才成功
             */
 
            // 特別的,當jedis.getSet()獲取已存在的鎖originLockValue為空時,則認定獲取鎖成功
            if (originLockValue == null || originLockValue.length() == 0) {
                lockThreadLocal.set(newLock);
                return true;
            }
 
            if (originLockValue.equals(currentLockValue)) {
                lockThreadLocal.set(newLock);
                return true;
            }
        }
 
        return false;
    }
 
    /**
     * 請求分散式鎖,不會阻塞,直接返回
     *
     * @return 成功獲取鎖返回true, 否則返回false
     */
    public boolean tryAcquire() {
 
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            return tryAcquire(jedis);
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
    }
 
    /**
     * 超時請求分散式鎖,會阻塞
     *
     * 採用"自旋獲取鎖"的方式,直至獲取鎖成功或者請求鎖超時
     *
     * @param acquireTimeoutInMillis 鎖的請求超時時長
     * @return
     */
    public boolean acquire(long acquireTimeoutInMillis) {
 
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
 
            long acquireTime = System.currentTimeMillis();
 
            // 鎖的請求到期時間
            long expiryTime = System.currentTimeMillis() + acquireTimeoutInMillis;
 
            while (expiryTime >= System.currentTimeMillis()) {
                boolean result = tryAcquire(jedis);
                if (result) { // 獲取鎖成功直接返回,否則迴圈重試
                    return true;
                }
 
                if ((System.currentTimeMillis() - acquireTime) > RETRY_BARRIER) {
                    Thread.yield();
                }
            }
 
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return false;
    }
 
    /**
     * 釋放鎖
     */
    public void release() {
 
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            release(jedis);
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
    }
 
    /**
     * 釋放鎖
     *
     * @param jedis
     */
    private void release(Jedis jedis) {
        Lock currlock = lockThreadLocal.get();
        if (currlock != null) {
            final String currentLockValue = jedis.get(lockKey);
            if (currentLockValue != null && currentLockValue.length() != 0) {
                final Lock currentLock = Lock.fromJson(currentLockValue); // redis中已存在的鎖
                if (currlock.equals(currentLock)) {
                    lockThreadLocal.remove();
                    jedis.del(lockKey);
                }
            }
        }
    }
 
    /**
     * 判斷當前執行緒是否持有鎖
     *
     * 未持有鎖或者鎖超時,返回false
     *
     * @return
     */
    public boolean isLocked() {
        Lock currlock = lockThreadLocal.get();
        // 如果當前執行緒儲存的lock不為null,並且未超時,則當前執行緒必然持有鎖,鎖未被意外釋放
        return currlock != null && !currlock.isExpired();
    }
 
    /**
     * 判斷指定的lock是否是當前執行緒持有的鎖
     *
     * @return
     */
    boolean isMine(final Lock lock) {
        Lock currlock = lockThreadLocal.get();
        return currlock != null && currlock.equals(lock);
    }
 
    /**
     * 判斷鎖是否超時
     *
     * @param lock
     * @return
     */
    boolean isExpired(final Lock lock) {
        return lock.isExpired();
    }
 
    /**
     * 鎖
     */
    protected static class Lock {
 
        private long expiryTime; // 鎖的過期時間,注意,不是過期時長,單位納秒
 
        Lock(long expiryTime) {
            this.expiryTime = expiryTime;
        }
 
        /**
         * 解析字串,根據解析出的過期時間構造Lock
         *
         * @param json
         * @return
         */
        static Lock fromJson(String json) {
            return JSON.parseObject(json, Lock.class);
        }
 
        @Override
        public String toString() {
            return JSON.toJSONString(this, false);
        }
 
        public long getExpiryTime() {
            return expiryTime;
        }
 
        /**
         * 判斷鎖是否超時,如果鎖的過期時間小於當前系統時間,則判定鎖超時
         *
         * @return
         */
        boolean isExpired() {
            return this.expiryTime < System.nanoTime();
        }
 
        @Override
        public boolean equals(Object obj) {
            return obj != null
                    && obj instanceof Lock
                    && this.expiryTime == ((Lock) obj).getExpiryTime();
        }
    }
}
複製程式碼

優化

上面存在的鎖覆蓋問題是不可避免的,還有就是要求客戶端時間同步。下面我們進一步優化這一問題。

Redis命令介紹

SET

  • 語法:
SET key value [EX seconds] [PX milliseconds] [NX|XX]
複製程式碼
  • 功能:
    將字串值 value 關聯到 key 。
    如果 key 已經持有其他值, SET 就覆寫舊值,無視型別。
    對於某個原本帶有生存時間(TTL)的鍵來說, 當 SET 命令成功在這個鍵上執行時,這個鍵原有的 TTL 將被清除。

  • 可選引數
    從 Redis 2.6.12 版本開始,SET 命令的行為可以通過一系列引數來修改:

    • EX second :設定鍵的過期時間為 second 秒。 SET key value EX second 效果等同於 SETEX key second value
    • PX millisecond :設定鍵的過期時間為 millisecond 毫秒。 SET key value PX millisecond 效果等同於 PSETEX key millisecond value
    • NX :只在鍵不存在時,才對鍵進行設定操作。 SET key value NX 效果等同於 SETNX key value
    • XX :只在鍵已經存在時,才對鍵進行設定操作。

因為 SET 命令可以通過引數來實現和 SETNX 、 SETEX 和 PSETEX 三個命令的效果,所以將來的 Redis 版本可能會廢棄並最終移除 SETNX 、 SETEX 和 PSETEX 這三個命令。

  • 返回值:
    在 Redis 2.6.12 版本以前, SET 命令總是返回 OK 。
    從 Redis 2.6.12 版本開始, SET 在設定操作成功完成時,才返回 OK 。
    如果設定了 NX 或者 XX ,但因為條件沒達到而造成設定操作未執行,那麼命令返回空批量回復(NULL Bulk Reply)。

使用模式

命令 SET resource-name anystring NX EX max-lock-time 是一種在 Redis 中實現鎖的簡單方法。

客戶端執行以上的命令:

  • 如果伺服器返回 OK ,那麼這個客戶端獲得鎖。
  • 如果伺服器返回 NIL ,那麼客戶端獲取鎖失敗,可以在稍後再重試。

設定的過期時間到達之後,鎖將自動釋放。

可以通過以下修改,讓這個鎖實現更健壯:

  • 不使用固定的字串作為鍵的值,而是設定一個不可猜測(non-guessable)的長隨機字串,作為口令串(token)。
  • 不使用 DEL 命令來釋放鎖,而是傳送一個 Lua 指令碼,這個指令碼只在客戶端傳入的值和鍵的口令串相匹配時,才對鍵進行刪除。

這兩個改動可以防止持有過期鎖的客戶端誤刪現有鎖的情況出現。

以下是一個簡單的解鎖指令碼示例:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end
複製程式碼

原始碼

package com.github.hutu92;
 
import com.alibaba.fastjson.JSON;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
 
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;
 
/**
 * Created by liuchunlong on 2018/9/4.
 * <p>
 * 基於redis的分散式鎖 v2
 * <p>
 * 不需要客戶端時間同步
 */
public class DistributedLock {
 
    private static final long RETRY_BARRIER = 600; // 重試屏障,單位毫秒
    private static final long INTERVAL_TIMES = 200; // 下一次重試等待,單位毫秒
 
    private final JedisPool jedisPool; // redis連線池
    private final String lockKey; // lock Key
    private final long lockExpiryInMillis; // 鎖的過期時長,單位納秒
 
    private final ThreadLocal<Lock> lockThreadLocal = new ThreadLocal<Lock>();
 
    /**
     * 構造方法
     *
     * @param jedisPool          redis連線池
     * @param lockKey            鎖的Key
     * @param lockExpiryInMillis 鎖的過期時長,單位毫秒
     */
    public DistributedLock(JedisPool jedisPool, String lockKey, long lockExpiryInMillis) {
        this.jedisPool = jedisPool;
        this.lockKey = lockKey;
        this.lockExpiryInMillis = lockExpiryInMillis;
    }
 
    /**
     * 構造方法
     * <p>
     * 使用鎖預設的過期時長Integer.MAX_VALUE,即鎖永遠不會過期
     *
     * @param jedisPool redis連線池
     * @param lockKey   鎖的Key
     */
    public DistributedLock(JedisPool jedisPool, String lockKey) {
        this(jedisPool, lockKey, Integer.MAX_VALUE);
    }
 
    /**
     * 獲取鎖在redis中的Key標記
     *
     * @return locks key
     */
    public String getLockKey() {
        return this.lockKey;
    }
 
    /**
     * 鎖的過期時長
     *
     * @return
     */
    public long getLockExpiryInMillis() {
        return lockExpiryInMillis;
    }
 
    /**
     * can override
     *
     * @param jedis
     * @return
     */
    private String nextUid(Jedis jedis) {
        // 可以考慮雪花演算法..
        return UUID.randomUUID().toString();
    }
 
    private synchronized Jedis getClient() {
        return jedisPool.getResource();
    }
 
    private synchronized void closeClient(Jedis jedis) {
        jedis.close();
    }
 
    /**
     * 請求分散式鎖,不會阻塞,直接返回
     *
     * @param jedis redis 連線
     * @return 成功獲取鎖返回true, 否則返回false
     */
    private boolean tryAcquire(Jedis jedis) {
 
        final Lock nLock = new Lock(nextUid(jedis));
        String result = jedis.set(this.lockKey, nLock.toString(), "NX", "PX", this.lockExpiryInMillis);
        if ("OK".equals(result)) {
            lockThreadLocal.set(nLock);
            return true;
        }
        return false;
    }
 
    /**
     * 請求分散式鎖,不會阻塞,直接返回
     *
     * @return 成功獲取鎖返回true, 否則返回false
     */
    public boolean tryAcquire() {
 
        Jedis jedis = null;
        try {
            jedis = getClient();
            return tryAcquire(jedis);
        } finally {
            if (jedis != null) {
                closeClient(jedis);
            }
        }
    }
 
    /**
     * 超時請求分散式鎖,會阻塞
     *
     * 採用"自旋獲取鎖"的方式,直至獲取鎖成功或者請求鎖超時
     *
     * @param acquireTimeoutInMillis 鎖的請求超時時長
     * @return
     */
    public boolean acquire(long acquireTimeoutInMillis) throws InterruptedException {
 
        Jedis jedis = null;
        try {
 
            jedis = getClient();
 
            long acquireTime = System.currentTimeMillis();
            long expiryTime = System.currentTimeMillis() + acquireTimeoutInMillis; // 鎖的請求到期時間
 
            while (expiryTime >= System.currentTimeMillis()) {
                boolean result = tryAcquire(jedis);
                if (result) { // 獲取鎖成功直接返回,否則迴圈重試
                    return true;
                }
 
                Thread.sleep(INTERVAL_TIMES);
            }
 
        } finally {
            if (jedis != null) {
                closeClient(jedis);
            }
        }
        return false;
    }
 
    /**
     * 釋放鎖
     *
     * @return
     */
    public boolean release() throws InterruptedException {
        return release(Integer.MAX_VALUE);
    }
 
    /**
     * 釋放鎖
     *
     * @return
     */
    public boolean release(long releaseTimeoutInMillis) throws InterruptedException {
 
        Jedis jedis = null;
        try {
            jedis = getClient();
            return release(jedis, releaseTimeoutInMillis);
        } finally {
            if (jedis != null) {
                closeClient(jedis);
            }
        }
    }
 
    /**
     * 釋放鎖
     *
     * @param jedis
     * @param releaseTimeoutInMillis
     * @return
     */
    private boolean release(Jedis jedis, long releaseTimeoutInMillis) throws InterruptedException {
        Lock cLock = lockThreadLocal.get();
        if (cLock == null) {
            System.out.println("lock is null!");
        }
        if (cLock != null) {
            String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
 
            long releaseTime = System.currentTimeMillis();
            long expiryTime = System.currentTimeMillis() + releaseTimeoutInMillis; // 鎖的釋放到期時間
 
            while (expiryTime >= System.currentTimeMillis()) {
                Object result = jedis.eval(luaScript, Collections.singletonList(this.lockKey),
                        Collections.singletonList(cLock.toString()));
                if (((Long) result) == 1L) {
                    lockThreadLocal.remove();
                    return true;
                }
 
                Thread.sleep(INTERVAL_TIMES);
            }
        }
        return false;
    }
 
 
    /**
     * 鎖
     */
    protected static class Lock {
 
        private String uid; // lock 唯一標識
 
        Lock(String uid) {
            this.uid = uid;
        }
 
        public String getUid() {
            return uid;
        }
 
        @Override
        public String toString() {
            return JSON.toJSONString(this, false);
        }
    }
 
}
複製程式碼

效能調優

這裡我們使用ab效能測試工具來模擬測試。

由於沒有使用佇列,對高併發請求進行削峰,所以所有的壓力都會被打到redis上。為了測試方便我這裡只是本地啟動了單機redis,沒有做其它的調優配置。

我們併發測試場景是1000個併發請求,總共2000個請求。

ab -n 2000 -c 1000 "localhost:8080/lock/v2/seckill"
複製程式碼

上述的地址是一個介面,介面程式碼如下:

@RestController
@RequestMapping("/lock")
public class LockController {
 
    private static LongAdder longAdder = new LongAdder();
    private static Long ACQUIRE_TIMEOUT_IN_MILLIS = (long) Integer.MAX_VALUE;
    private static Long stock = 100000L;
    private static DistributedLock lock;
 
    static {
        longAdder.add(stock);
    }
 
    private final JedisPool jedisPool;
 
    @Autowired
    public LockController(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
        lock = new DistributedLock(jedisPool, "seckillV2_" + UUID.randomUUID().toString());
    }
 
    @GetMapping("/v2/seckill")
    public String seckillV2() throws InterruptedException {
 
        boolean acquireResult = false;
        try {
            acquireResult = lock.acquire(ACQUIRE_TIMEOUT_IN_MILLIS);
 
            if (!acquireResult) {
                return "人太多了,換個姿勢操作一下!";
            }
 
            if (longAdder.longValue() == 0L) {
                return "已搶光!";
            }
 
            doSomeThing(jedisPool);
 
            longAdder.decrement();
 
            System.out.println("已搶: " + (stock - longAdder.longValue()) + ", 還剩下: " + longAdder.longValue());
 
        } finally {
            if (acquireResult) {
                boolean releaseResult = lock.release();
                if (!releaseResult) {
                    System.out.println("釋放鎖失敗!");
                }
            }
        }
 
        return "OK";
    }
 
    private void doSomeThing(JedisPool jedisPool) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
 
            jedis.incr("already_bought");
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
    }
}
複製程式碼

那麼我們這裡說的效能調優指的是什麼呢?

仔細分析上面的原始碼你會發現,獲取鎖的邏輯是迴圈獲取的,再每次迴圈之間,應該怎麼去處理?如果不做任何處理,直接繼續下一個迴圈,表面上看能夠及時的獲取鎖,但這會給redis更大的壓力,如果redis扛不住,到最後只會適得其反;而如果sleep等待,那麼等待多久呢?等待久了,鎖的獲取和釋放就會不及時;使用yield如何?等等

No1

if ((System.currentTimeMillis() - acquireTime) > RETRY_BARRIER) {
    Thread.yield();
}
複製程式碼

請求獲取鎖的前600毫秒內直接迴圈重試,如果超過600毫秒還未獲取到鎖則每次迴圈都將執行緒推遲到下一個時間片執行。

主要引數說明:

  • Failed requests:失敗的請求
  • Time per request:每個請求的平均耗時

No2

if ((System.currentTimeMillis() - acquireTime) > RETRY_BARRIER) {
    Thread.sleep(INTERVAL_TIMES);
} else {
    Thread.yield();
}
複製程式碼

請求獲取鎖的前600毫秒內每次迴圈重試都先將執行緒推遲到下一個時間片,如果超過600毫秒還未獲取到鎖則每次迴圈都將執行緒休眠200毫秒。

很明顯,出錯率降低了很多,每個請求的耗時也減少了一半,這是因為,No1中在600毫秒內的直接迴圈重試,會產生很多意義的請求,給redis造成了巨大的壓力,無法響應請求。

No3

Thread.sleep(INTERVAL_TIMES);
複製程式碼

請求獲取鎖的每次迴圈重試都將執行緒休眠200毫秒。

No4

Thread.sleep(INTERVAL_TIMES * 10);
複製程式碼

請求獲取鎖的每次迴圈重試都將執行緒休眠2秒。

很明顯,休眠時間過長,會使部分執行緒請求鎖的時間變長,不能夠及時獲取到鎖。

No5

Thread.yield();
複製程式碼

請求獲取鎖的每次迴圈重試都將執行緒推遲到下一個時間片執行。

總結

總的來說,No2與No3表現的都還可以。但是No2使用了Thread.yield();也會給redis造成壓力,我可以對比下兩者的 Percentage of the requests served within a certain time (ms) 資料。可以看到No3的90%以下請求的使用者平均時間要明顯低於No2的。所以最終我們選擇No3策略。

當然你也可以根據你的redis的QPS自行調整策略。

Apache壓力測試工具AB


相關文章