分散式鎖中-基於 Redis 的實現需避坑 - Jedis 篇

ITPUB社群發表於2022-11-21



一、redis 介紹

Redis 應該是目前最受歡迎的高效能的快取資料庫了,不由得感慨 Reids 發展之迅速。蒐集了一下 3.0 及之後各版本的知名特性,整理出來方便讀者朋友們有個簡單瞭解(感興趣的朋友還需自行深入研究),情況大致如下:

  • 3.0 開始支援 cluster 叢集模式
  • 4.0 開發的 lazyfree 和 PSYNC2 解決了 Redis 長久的大 key 刪除阻塞問題及同步中斷無法續傳的問題
  • 5.0 新增了 stream 資料結構使 Redis 具備功能完整的輕量級訊息佇列能力
  • 6.0 更是釋出了諸多企業級特性如 threaded-io、TLS 和 ACL 等,大幅提升了 Redis 的效能和安全性
  • 7.0 Function 徹底解決了過去 Lua 指令碼同步丟失的問題;Multi Part AOF 增強了 Redis 的資料持久化的可靠性
1.1 特性介紹

為滿足本篇目標所需,這裡著重介紹以下幾個關鍵特性:

  • 資料組織:Redis 中支援多種資料結構,將他們靈活組合搭配即可滿足分散式鎖在不同場景下的功能需求:
    • Jedis 和 Lettuce 這類框架中常使用 String 來做簡易的鎖資訊儲存
    • Redisson 中使用 Hash 結構來儲存更多維度的鎖資訊,如:業務名稱作為 key,uuid + 執行緒 id 作為 field,加鎖次數作為 value
    • Redisson 中在公平鎖的場景下引入 List 和 ZSet, List 型別用於執行緒排隊,Zset 型別存放等待執行緒的順序,分數 score 是等待執行緒的超時時間戳。

分散式鎖中-基於 Redis 的實現需避坑 - Jedis 篇Redis 的資料結構(來自網路)

  • 叢集模式:Redis 採用叢集模式分片儲存資料,整個叢集擁有固定的 2 的 32 次方個槽位,資料被分配到這些槽位中,每個例項只分管一部分槽位,而非如 etcd、ZK 這種每個例項中的資料都一致;叢集模式提供的是資料規模擴大後的橫向 AP 能力,應對單節點的風險需再加上主從模式,但當某個 master 節點掛之後,slave 節點可能還未同步到全部資料,會導致資料丟失;一致性保障能力偏弱

分散式鎖中-基於 Redis 的實現需避坑 - Jedis 篇Redis 的叢集模式(來自網路)

  • 順序變更:一種簡單的搶鎖邏輯是判斷 key 是否已存在,Redis 中沒有給變更操作附加順序資訊(如 etcd 中的 Revision),但服務端以序列方式處理資料的變更,那就可以結合其他資料結構來記錄請求順序資訊,如公平鎖的實現也會依賴其他資料結構儲存資訊,用於判斷鎖狀態;但當用到的資料型別和指令變多後,由於是非原子性操作,自然就會遇到結果與預期不一致這類問題,Redis 提供的 lua 指令碼機制可用於解決此類問題 ,使用者在客戶端編排自定義指令碼邏輯:可用多個指令操控多個資料,然後將指令碼傳送給服務端,服務端執行 lua 指令碼,並保障一個 lua 指令碼內的所有操作是原子性的

分散式鎖中-基於 Redis 的實現需避坑 - Jedis 篇Redis lua 指令碼的工作機制(來自網路)

  • TTL 機制:TTL(Time To Live)機制是給單個 key 設定存活時間,超過時間後 Redis 自動刪除這個 key

Redis 的分散式鎖正是基於以上特性來實現的,簡單來說是:

TTL 機制:用於支撐異常情況下的鎖自動釋放的能力

順序變更:用於支撐獲取鎖和排隊等待的能力

叢集+主從模式:用於支撐鎖服務的高可用

Redis 沒有提供對分散式鎖親和的監聽機制,需要客戶端主動輪詢感知資料變更。

二. 加鎖解鎖的流程描述

使用 Jedis 指令實現分散式鎖的核心流程如下圖所示

分散式鎖中-基於 Redis 的實現需避坑 - Jedis 篇

  1. 準備客戶端、key 和 value

  2. 若 key 不存在,指定過期時間成功寫入 Key-Value 則搶鎖成功,並定時推後 key 的過期時間

  3. 若 key 已存在,則採用重試策略間歇性搶鎖。

  4. 解鎖時,刪除 key 並撤銷推後 key 過期時間的邏輯

其中第 2 和第 4 是核心環節,有幾個版本的演進很有趣味:

  1. 插入 key 和設定過期時間並非原子操作:setnx + expire 加鎖和設定過期是兩個分開的獨立操作;若發生異常,導致設定過期操作未執行,則此鎖就成了永恆鎖,其他客戶端就再也搶不到了

  2. 以原子性操作完成插入 key 和設定過期時間:使用 set 的擴充套件指令,如下:

SET key value [EX seconds] [PX milliseconds] [NX|XX]
  • NX :當 key 不存在時,才插入 Key
  • XX :當插入 key 時,指定值為固定的 lockValue
  • EX second :設定 key 的過期時間單位秒(PX\EX 二選一)
  • PX millisecond :設定鍵的過期時間單位毫秒(PX\EX 二選一)
if(jedis.set(key, lockValue, "NX""EX", 100) == 1){ //加鎖成功
  try {
      do work //執行業務
      //這裡缺點什麼?
  }catch(Exception e){
      //...
  }finally {
     jedis.del(key); //釋放鎖,這裡可能誤刪其他client的鎖key
  }
}
  1. 引入 lockValue 的隨機值校驗,避免誤釋放其它客戶端的鎖,場景如下:
  • client1 加鎖成功,key 10s 後過期,完成邏輯後,刪除 key 之前,因 GC 導致持鎖超過 10s,Redis 自動刪除了 key,之後其他客戶端可以搶鎖
  • 假如是 client2 接下來成功搶鎖,開始處理持鎖後的邏輯。而此時 client1 GC 結束了會繼續執行刪除 key 的操作,但此時釋放的其實是 client2 的 key

解決辦法是:加鎖時指定的 lockValue 為隨機值,每次加鎖時的值都是唯一的,釋放鎖時若 lockValue 與加鎖時的值一致才可釋放,否則什麼都不做,邏輯如下:

if(jedis.set(key, randomLockValue, "NX""EX", 100) == 1){ //加鎖
   try {
       do something  //業務處理
   }catch(){
 }
 finally {
      //判斷是不是當前執行緒加的鎖,是才釋放
      //但判斷和釋放鎖兩個操作不是原子性的
      if (randomLockValue.equals(jedis.get(key))) {
         jedis.del(key); //釋放鎖
      }
   }
}

以上程式碼遺留的問題是判斷 randomlockValue 和釋放鎖兩個操作不是原子性的。

  1. 引入 lua 指令碼,保障判斷 randomlockValue 和刪除 key 這兩個操作的原子性,邏輯如下:
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(key),
Collections.singletonList(randomLockValue));
if("1".equals(result.toString())){
    return true;
}

至此依然存在的一個問題是:若持鎖後,業務邏輯執行耗時 超過了 key 的過期時間,則鎖 Key 會被 Reids 主動刪除。

  1. 引入 watchDog 定時推後 key 的過期時間,避免業務未執行完時,key 過期被 Redis 刪除。
if(jedis.set(key, randomLockValue, "NX""EX", 100) == 1){ //加鎖成功
  try {
      do work //執行業務
      //watchDog定時延後Key的過期時間
  }catch(Exception e){
      //...
  }finally {
     String script =
              "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                      "   return redis.call('del',KEYS[1]) " +
                      "else" +
                      "   return 0 " +
                      "end";
      try {
          Object result = jedis.eval(script, Collections.singletonList(key),
                                  Collections.singletonList(randomLockValue));
          if("1".equals(result.toString())){
              return true;
          }
          return false;
      }catch(Exception e){
      //...
    }
  }
}

三. Jedis 分散式鎖的能力

一個分散式鎖應具備這樣一些功能特點:

  • 互斥性:在同一時刻,只有一個客戶端能持有鎖

  • 安全性:避免死鎖,如果某個客戶端獲得鎖之後處理時間超過最大約定時間,或者持鎖期間發生了故障導致無法主動釋放鎖,其持有的鎖也能夠被其他機制正確釋放,並保證後續其它客戶端也能加鎖,整個處理流程繼續正常執行

  • 可用性:也被稱作容錯性,分散式鎖需要有高可用能力,避免單點故障,當提供鎖的服務節點故障(當機)時不影響服務執行,這裡有兩種模式:一種是分散式鎖服務自身具備叢集模式,遇到故障能自動切換恢復工作;另一種是客戶端向多個獨立的鎖服務發起請求,當某個鎖服務故障時仍然可以從其他鎖服務讀取到鎖資訊(Redlock)

  • 可重入性:對同一個鎖,加鎖和解鎖必須是同一個執行緒程,即不能把其他執行緒持有的鎖給釋放了

  • 高效靈活:加鎖、解鎖的速度要快;支援阻塞和非阻塞;支援公平鎖和非公平鎖

表格中標題使用 Redis-簡單鎖,主要是跟 RedLock 做區分,這種簡單鎖使用 Jedis 、Lettuce、Redisson 都能實現,任何一把鎖的資訊只儲存在一個 Redis master 例項中,而 RedLock 是 Redisson 提供的高階分散式鎖,它需要客戶端同時跟多個 Redis master 例項協作才能完成,即一把鎖的資訊同時存在於多個 master 例項中。

能力ZKetcdRedis-簡單鎖RedlockMySql
互斥

安全連結異常時,session 丟失自動釋放鎖基於租約,超時自動釋放鎖基於 TTL,超時自動釋放鎖

可用性相對可用性還好

可重入服務端非可重入,本地執行緒可重入服務端非可重入,本地執行緒可重入需自研服務端非可重入,本地執行緒可重入需自研

加解鎖速度速度不算快速度快,GRPC 協議優勢以及服務端能力的優勢速度快

阻塞非阻塞客戶端兩種能力都提供jetcd-core 中,阻塞非阻塞由 Future#get 支撐Jedis非阻塞,

Redission提供阻塞能力



公平非公平公平鎖公平鎖非公平鎖,

Redission

提供公平鎖


可續期天然支援天然支援Jedis需自研 watchDog,Redission自帶

其他因素
技術棧偏,效能不佳
多數公司不熟悉
容易受業務快取操作干擾


四、Jedis 庫實現分散式鎖

Jedis 是 Redis 官方推出的用於透過 Java 連線 Redis 客戶端的一個工具包,提供了 Redis 的各種命令支援。

1.pom 依賴
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>4.3.0</version>
</dependency>
2. 相關的 API 介紹
  • 使用 SET 的擴充套件指令加鎖(SET key value [EX seconds][px milliseconds] [NX|XX])
 SetParams params = SetParams.setParams().nx().ex(lockState.getLeaseTTL());
 String result = client.set(lockState.getLockKey(), lockState.getLockValue(), params);
  • 使用 lua 解鎖
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = client.eval(script, 1, lockState.getLockKey(), lockState.getLockValue());

3. 分散式鎖示例
  • 鎖的封裝
package com.rock.dlock.jedis;

import com.rock.dlock.common.DtLockException;
import com.rock.dlock.common.KeepAliveAction;
import com.rock.dlock.common.KeepAliveTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.JedisPooled;
import redis.clients.jedis.params.SetParams;

import java.net.SocketTimeoutException;
import java.util.concurrent.TimeUnit;

/**
 * @author zs
 * @date 2022/11/13 4:44 PM
 */
public class DemoJedisLock {
    private final static Logger log = LoggerFactory.getLogger(DemoJedisLock.class);
    private JedisPooled client;

    private LockState lockState;
    private KeepAliveTask keepAliveTask;

    private int sleepMillisecond;

    private final static String RESULT_OK = "OK";
    private static final Long UNLOCK_SUCCESS = 1L;

    class LockState {
        private String lockKey;
        private String lockValue;
        private String errorMsg;
        private int leaseTTL;
        private long leaseId;
        private boolean lockSuccess;

        public LockState(String lockKey, int leaseTTL) {
            this.lockKey = lockKey;
            this.leaseTTL = leaseTTL;
        }

        public LockState(String lockKey, String value, int leaseTTL) {
            this.lockKey = lockKey;
            this.lockValue = value;
            this.leaseTTL = leaseTTL;
        }

        public String getLockKey() {
            return lockKey;
        }

        public void setLockKey(String lockKey) {
            this.lockKey = lockKey;
        }

        public String getLockValue() {
            return lockValue;
        }

        public void setLockValue(String lockValue) {
            this.lockValue = lockValue;
        }

        public String getErrorMsg() {
            return errorMsg;
        }

        public void setErrorMsg(String errorMsg) {
            this.errorMsg = errorMsg;
        }

        public long getLeaseId() {
            return leaseId;
        }

        public void setLeaseId(long leaseId) {
            this.leaseId = leaseId;
        }

        public boolean isLockSuccess() {
            return lockSuccess;
        }

        public void setLockSuccess(boolean lockSuccess) {
            this.lockSuccess = lockSuccess;
        }

        public int getLeaseTTL() {
            return leaseTTL;
        }

        public void setLeaseTTL(int leaseTTL) {
            this.leaseTTL = leaseTTL;
        }
    }


    public DemoJedisLock(JedisPooled client, String key, String value, int ttlSeconds) {
        //1.準備客戶端
        this.client = client;
        this.lockState = new LockState(key, value, ttlSeconds);
        this.sleepMillisecond = (ttlSeconds * 1000) / 3; //搶鎖的重試間隔可由使用者指定
    }


    public boolean tryLock(long waitTime, TimeUnit waitUnit) throws DtLockException {
        long totalMillisSeconds = waitUnit.toMillis(waitTime);
        long start = System.currentTimeMillis();
        //重試,直到成功或超過指定時間
        while (true) {
            // 搶鎖
            try {
                SetParams params = SetParams.setParams().nx().ex(lockState.getLeaseTTL());
                String result = client.set(lockState.getLockKey(), lockState.getLockValue(), params);
                if (RESULT_OK.equals(result)) {
                    manualKeepAlive();
                    log.info("[jedis-lock] lock success 執行緒:{} 加鎖成功,key:{} , value:{}", Thread.currentThread().getName(), lockState.getLockKey(), lockState.getLockValue());
                    lockState.setLockSuccess(true);
                    return true;
                } else {
                    if (System.currentTimeMillis() - start >= totalMillisSeconds) {
                        return false;
                    }
                    Thread.sleep(sleepMillisecond);
                }
            } catch (Exception e) {
                Throwable cause = e.getCause();
                if (cause instanceof SocketTimeoutException) {//忽略網路抖動等異常
                }
                log.error("[jedis-lock] lock failed:" + e);
                throw new DtLockException("[jedis-lock] lock failed:" + e.getMessage(), e);
            }

        }
    }

    //此實現中忽略,網路通訊異常部分的處理,可參考tryLock
    public void unlock() throws DtLockException {
        try {
            // 首先停止續約
            if (keepAliveTask != null) {
                keepAliveTask.close();
            }
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            Object result = client.eval(script, 1, lockState.getLockKey(), lockState.getLockValue());

            if (UNLOCK_SUCCESS.equals(result)) {
                log.info("[jedis-lock] unlock success 執行緒 : {} 解鎖成功,鎖key : {} ,路徑:{}", Thread.currentThread().getName(), lockState.getLockKey(), lockState.getLockValue());
            } else {
                log.info("[jedis-lock] unlock del key failed ,執行緒 : {} 解鎖成功,鎖key : {} ,路徑:{}", Thread.currentThread().getName(), lockState.getLockKey(), lockState.getLockValue());
            }
        } catch (Exception e) {
            log.error("[jedis-lock] unlock failed:" + e.getMessage(), e);
            throw new DtLockException("[jedis-lock] unlock failed:" + e.getMessage(), e);
        }
    }

    // 定時將Key的過期推遲
    private void manualKeepAlive() {
        final String t_key = lockState.getLockKey();
        final int t_ttl = lockState.getLeaseTTL();

        keepAliveTask = new KeepAliveTask(new KeepAliveAction() {
            @Override
            public void run() throws DtLockException {
                // 重新整理值
                try {
                    client.expire(t_key, t_ttl);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, t_ttl);
        keepAliveTask.start();
    }
}
  • 異常類的簡單實現
package com.rock.dlock.common;

public class DtLockException extends RuntimeException{
    public DtLockException(String message) {
        super(message);
    }

    public DtLockException(String message, Throwable cause) {
        super(message, cause);
    }

    public static DtLockException clientException(){
        return new DtLockException("client is empty");
    }
}
  • watchDog 的任務抽象

package com.rock.dlock.common;

public interface KeepAliveAction {
    void run() throws DtLockException;
}
  • watchDog 的簡單實現
package com.rock.dlock.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.TimeUnit;

/**
 * @author zs
 * @date 2022/11/7 4:20 PM
 */
public class KeepAliveTask extends Thread {
    private static final Logger LOGGER = LoggerFactory.getLogger(KeepAliveTask.class);
    public volatile boolean isRunning = true;
    /**
     * 過期時間,單位s
     */
    private long ttlSeconds;
    private KeepAliveAction action;
    public KeepAliveTask(KeepAliveAction action, long ttlSeconds) {
        this.ttlSeconds = ttlSeconds;
        this.action = action;
        this.setDaemon(true);
    }

    @Override
    public void run() {
        final long sleep = this.ttlSeconds * 1000 / 3; // 每隔三分之一過期時間,續租一次
        while (isRunning) {
            try {
                // 1、續租,重新整理值
                action.run();
                LOGGER.debug("續租成功!");
                TimeUnit.MILLISECONDS.sleep(sleep);
            } catch (InterruptedException e) {
                close();
            } catch (DtLockException e) {
                close();
            }
        }
    }

    public void close() {
        isRunning = false;
        this.interrupt();
    }
}

4. 測試鎖
import com.rock.dlock.jedis.DemoJedisLock;
import redis.clients.jedis.JedisPooled;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * @author zs
 * @date 2022/11/13 4:51 PM
 */
public class TestJedisLock {
    public static void main(String[] args) {

        JedisPooled jedis = new JedisPooled("127.0.0.1", 6379);
        DemoJedisLock demoEtcdLock1 = new DemoJedisLock(jedis, "rock", UUID.randomUUID().toString(), 10);
        DemoJedisLock demoEtcdLock2 = new DemoJedisLock(jedis, "rock", UUID.randomUUID().toString(), 10);

        boolean lock1 = demoEtcdLock1.tryLock(20, TimeUnit.SECONDS);
        if (lock1) {
            try {
                System.out.printf("do something");
            } finally {
                demoEtcdLock1.unlock();
            }
        }
        demoEtcdLock1.tryLock(20, TimeUnit.SECONDS);
        demoEtcdLock2.tryLock(20, TimeUnit.SECONDS);//等待鎖,超時後放棄
    }
}

五、使用 Jedis 的一些注意事項

通常分散式鎖服務會和業務邏輯使用同一個Redis 叢集,自然也使用同一個 Jedis 客戶端;當業務邏輯側對 Redis 的讀寫併發提高時,會給 Redis 叢集和 Jedis 客戶度帶來壓力;為應對一些異常情況,我們除了解功能層面的 API,還需要了解一下客戶端的一些配置調優,主要是池化管理和網路通訊兩個方面

5.1 池化管理

在使用 Jedis 時可以配置 JedisPool 連線池,池化處理有許多好處,如:提高響應的速度、降低資源的消耗、方便管理和維護;JedisPool 配置引數大部分是由 JedisPoolConfig 的對應項來賦值的,在生產中我們需要關注它的配置併合理的賦值,如此能夠提升 Redis 的服務效能,降低資源開銷。下邊是對一些重要引數的說明、預設及設定建議:

引數說明預設值建議
maxTotal資源池中的最大連線數8
maxIdle資源池允許的最大空閒連線數8
minIdle資源池確保的最少空閒連線數0
blockWhenExhausted當資源池用盡後,呼叫者是否要等待。只有當值為 true 時,下面的maxWaitMillis才會生效。true建議使用預設值。
maxWaitMillis當資源池連線用盡後,呼叫者的最大等待時間(單位為毫秒)。-1(表示永不超時)不建議使用預設值。
testOnBorrow向資源池借用連線時是否做連線有效性檢測(ping)。檢測到的無效連線將會被移除。false業務量很大時候建議設定為 false,減少一次 ping 的開銷。
testOnReturn向資源池歸還連線時是否做連線有效性檢測(ping)。檢測到無效連線將會被移除。false業務量很大時候建議設定為 false,減少一次 ping 的開銷。
jmxEnabled是否開啟 JMX 監控true建議開啟,請注意應用本身也需要開啟。

空閒 Jedis 物件的回收檢測由以下四個引數組合完成,testWhileIdle是該功能的開關。

名稱說明預設值建議
testWhileIdle是否開啟空閒資源檢測。falsetrue
timeBetweenEvictionRunsMillis空閒資源的檢測週期(單位為毫秒)-1(不檢測)建議設定,週期自行選擇,也可以預設也可以使用下方JedisPoolConfig 中的配置。
minEvictableIdleTimeMillis資源池中資源的最小空閒時間(單位為毫秒),達到此值後空閒資源將被移除。180000(即 30 分鐘)可根據自身業務決定,一般預設值即可,也可以考慮使用下方JeidsPoolConfig中的配置。
numTestsPerEvictionRun做空閒資源檢測時,每次檢測資源的個數。3可根據自身應用連線數進行微調,如果設定為 -1,就是對所有連線做空閒監測。

透過原始碼可以發現這些配置是 GenericObjectPoolConfig 物件的屬性,這個類實際上是 rg.apache.commons.pool2.impl apache 提供的,也就是說 jedis 的連線池是依託於 apache 提供的物件池來,這個物件池的宣告週期如下圖,感興趣的可以看下:

分散式鎖中-基於 Redis 的實現需避坑 - Jedis 篇
5.2 網路調優
  • max-redirects:這個是叢集模式下,重定向的最大數量;舉例說明,比如第一臺掛了,連第二臺,第二臺掛了連第三臺,重新連線的次數不能超過這個值

  • timeout:客戶端超時時間,單位是毫秒

Rsdis 節點故障或者網路抖動時,這兩個值如果不合理可能會導致很嚴重的問題,比如 timeout 設定為 1000,maxRedirect 為 2,一旦出現 redis 連線問題,將會導致請求阻塞 3s 左右。而這個 3 秒的阻塞在可能導致常規業務流量下的執行緒池耗盡,需根據業務場景調整。

五、總結

本篇介紹瞭如何基於 Redis 的特性來實現一個分散式鎖,並基於 Jedis 庫提供了一個分散式鎖的示例,呈現了其關鍵 API 的用法;此示例尚未達到生產級可用,如異常、可重入、可重試、超時控制等功能都未補全,計劃在下一篇介紹完 redlock 之後,再介紹一個健壯的分散式鎖客戶端要如何抽象設計,如何適配 ZK 、Redis 、etcd 。

參考和感謝

https://view.inews.qq.com/a/20220211A01JGQ00 https://cloud.tencent.com/developer/article/2052387




來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024420/viewspace-2924363/,如需轉載,請註明出處,否則將追究法律責任。

相關文章