在使用 Redis 作為快取或儲存系統時,併發訪問可能會導致資料競爭和不一致性問題。爲了確保資料的一致性和操作的原子性,我們需要一種機制來管理對 Redis 的併發訪問。本文將介紹如何在 Node.js 中實現 Redis 分散式鎖,並分享我的解決方案和實現程式碼。
背景
在我們UI自動化測試的專案中,後端使用Nodejs,並且使用 Redis 作為快取層。然而,當多個程序同時訪問 Redis 時,會遇到併發問題,導致資料不一致。當時(2021年8月)網上關於如何在 Node.js 中處理 Redis 併發問題的資料並不多,因此我花了兩天時間研究並實現了一個基於 Redis 的分散式鎖機制。
實現方案
我們使用 Redis 的 SET
命令和 Lua 指令碼來實現分散式鎖。具體實現包括以下幾個部分:
初始化 RedisLock:設定鎖的預設過期時間和超時時間。
上鎖:嘗試獲取鎖,如果失敗則重試,直到超時。
釋放鎖:檢查鎖是否屬於當前持有者,如果是則釋放。
程式碼實現
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 時的資料一致性問題。儘管在實現過程中遇到了一些挑戰,但透過合理的設計和最佳化,我們成功構建了一個穩定、高效的鎖機制。