切換語言為:簡體

解決 Node.js 中 Redis 併發問題:使用 RedisLock 實現分散式鎖

  • 爱糖宝
  • 2024-07-26
  • 2105
  • 0
  • 0

在使用 Redis 作為快取或儲存系統時,併發訪問可能會導致資料競爭和不一致性問題。爲了確保資料的一致性和操作的原子性,我們需要一種機制來管理對 Redis 的併發訪問。本文將介紹如何在 Node.js 中實現 Redis 分散式鎖,並分享我的解決方案和實現程式碼。

背景

在我們UI自動化測試的專案中,後端使用Nodejs,並且使用 Redis 作為快取層。然而,當多個程序同時訪問 Redis 時,會遇到併發問題,導致資料不一致。當時(2021年8月)網上關於如何在 Node.js 中處理 Redis 併發問題的資料並不多,因此我花了兩天時間研究並實現了一個基於 Redis 的分散式鎖機制。

實現方案

我們使用 Redis 的 SET 命令和 Lua 指令碼來實現分散式鎖。具體實現包括以下幾個部分:

  1. 初始化 RedisLock:設定鎖的預設過期時間和超時時間。

  2. 上鎖:嘗試獲取鎖,如果失敗則重試,直到超時。

  3. 釋放鎖:檢查鎖是否屬於當前持有者,如果是則釋放。

程式碼實現

class RedisLock {
  /**
   * 初始化 RedisLock
   * @param {*} client Redis 客戶端例項
   * @param {*} options 配置選項
   */
  constructor(client, options = {}) {
    if (!client) {
      throw new Error('client 不存在');
    }

    if (client.status !== 'connecting') {
      throw new Error('client 未正常連結');
    }

    this.lockLeaseTime = options.lockLeaseTime || 2; // 預設鎖過期時間 2 秒
    this.lockTimeout = options.lockTimeout || 5; // 預設鎖超時時間 5 秒
    this.expiryMode = options.expiryMode || 'EX';
    this.setMode = options.setMode || 'NX';
    this.client = client;
  }

  /**
   * 上鎖
   * @param {*} key 鎖的鍵
   * @param {*} val 鎖的值
   * @param {*} expire 鎖的過期時間
   */
  async lock(key, val, expire) {
    const start = Date.now();
    const self = this;

    return (async function intranetLock() {
      try {
        const result = await self.client.set(key, val, self.expiryMode, expire || self.lockLeaseTime, self.setMode);

        // 上鎖成功
        if (result === 'OK') {
          return true;
        }

        // 鎖超時
        if (Math.floor((Date.now() - start) / 1000) > self.lockTimeout) {
          global.nts && global.nts({
            title: `上鎖重試超時結束`,
            source: 'server/n-slave/redis.js',
            flag: Math.random(),
            detail: { key, val }
          });
          return false;
        }

        // 迴圈等待重試
        await sleep();
        return intranetLock();
      } catch (err) {
        global.nts && global.nts({
          title: `上鎖重試超時結束進入catch函式`,
          source: 'server/n-slave/redis.js',
          flag: Math.random(),
          detail: { err }
        });

        // 重試機制
        if (Math.floor((Date.now() - start) / 1000) <= self.lockTimeout) {
          await sleep();
          return intranetLock();
        } else {
          throw new Error(err);
        }
      }
    })();
  }

  /**
   * 釋放鎖
   * @param {*} key 鎖的鍵
   * @param {*} val 鎖的值
   */
  async unLock(key, val) {
    const self = this;
    const script = `
      if redis.call('get',KEYS[1]) == ARGV[1] then
        return redis.call('del',KEYS[1])
      else
        return 0
      end
    `;

    try {
      const result = await self.client.eval(script, 1, key, val);

      if (result === 1) {
        return true;
      }

      return false;
    } catch (err) {
      global.nts && global.nts({
        title: `釋放鎖進入catch函式`,
        source: 'server/n-slave/redis.js',
        flag: Math.random(),
        detail: { err }
      });

      // 備用方案:再次嘗試解鎖
      try {
        const result = await self.client.eval(script, 1, key, val);
        return result === 1;
      } catch (retryErr) {
        throw new Error(retryErr);
      }
    }
  }
}

// 初始化 Redis 客戶端
const ioRedis = new redis(config.redisConfig);
const redisLock = new RedisLock(ioRedis);

/**
 * 設定原生任務函式
 * @param {*} key 鎖的鍵
 * @param {*} cb 回撥函式
 * @param {*} i 回撥函式引數
 */
async function setNativeTasksFn(key, cb, i) {
  try {
    const id = uuid.v1();
    await redisLock.lock(key, id, 200);
    await cb(i);
    const unLock = await redisLock.unLock(key, id);
  } catch (err) {
    global.nts && global.nts({
      title: `上鎖失敗`,
      source: 'server/n-slave/redis.js',
      flag: Math.random(),
      detail: { key, err }
    });
  }
}

// sleep 函式實現,用於等待重試
function sleep(ms = 100) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

程式碼解析

  • 初始化 RedisLock

    • 建構函式中初始化 Redis 客戶端,並設定鎖的預設過期時間和超時時間。

    • 確保客戶端正常連線,否則丟擲錯誤。

  • 上鎖

    • lock 方法嘗試獲取鎖,使用 Redis 的 SET 命令,設定過期時間和互斥模式。

    • 如果鎖獲取失敗,則迴圈等待重試,直到超時。

    • 使用自呼叫函式 intranetLock 實現鎖重試機制。

    • catch 語句中增加重試機制,確保在鎖超時之前多次嘗試獲取鎖。

  • 釋放鎖

    • unLock 方法使用 Lua 指令碼檢查並刪除鎖,確保只有持有鎖的客戶端才能釋放鎖。

    • 使用 Redis 的 EVAL 命令執行 Lua 指令碼,確保原子操作。

    • catch 語句中增加備用方案,再次嘗試解鎖,確保系統的穩定性和可靠性。

  • 設定任務函式

    • setNativeTasksFn 方法封裝了鎖的獲取和釋放邏輯,在執行任務前後進行鎖操作,確保任務的原子性。

小知識點講解

獲取分散式鎖 Redis SET 命令選項解析:expiryMode 和 setMode

await self.client.set(key, val, self.expiryMode, expire || self.lockLeaseTime, self.setMode);

  • expiryMode (過期模式)

    • EX: 單位:秒。適用於會話資料、一次性驗證碼、短期授權令牌等。

    • PX:單位:毫秒。適用於實時性要求高的應用場景。

  • setMode (設定模式)

    • NX:只有當鍵不存在時纔會設定鍵。適用於分散式鎖、唯一性約束。

    • XX:只有當鍵已經存在時纔會設定鍵(更新該鍵的值)。適用於只更新已存在的記錄。

  • 組合應用場景

    • EX + NX:實現帶有超時機制的分散式鎖,確保鎖在一定時間後自動釋放。

    • PX + NX:實現需要精確控制過期時間的分散式鎖或臨時快取。

    • EX + XX:延長已存在鍵的過期時間,確保資料在需要時得到更新。

    • PX + XX:精確控制已存在鍵的過期時間,用於需要精確更新過期時間的場景。

注意事項

  • 併發訪問高峰期,頻繁的鎖操作可能導致 Redis 效能下降。

    最佳化建議:最佳化鎖的重試機制,減少不必要的重試操作,結合業務場景調整鎖的超時時間和重試策略。

  • 鎖的持有時間設定不當可能導致鎖過期過快或持有時間過長。

    最佳化建議:根據具體業務場景合理設定鎖的持有時間,並在必要時動態調整。

  • 當前錯誤處理較為簡單,只是記錄日誌和丟擲錯誤。

    最佳化建議:在錯誤處理過程中,增加重試機制或備用方案,確保系統的穩定性和可靠性。

結論

透過上述方案,我們在 Node.js 中實現了 Redis 分散式鎖,有效解決了併發訪問 Redis 時的資料一致性問題。儘管在實現過程中遇到了一些挑戰,但透過合理的設計和最佳化,我們成功構建了一個穩定、高效的鎖機制。


0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.