什么是鎖
今天要談的是如何在分布式環境下實現一個全局鎖,在開始之前先說說非分布式下的鎖:
- 單機 – 單進程程序使用互斥鎖mutex,解決多個線程之間的同步問題
- 單機 – 多進程程序使用信號量sem,解決多個進程之間的同步問題
這里同步的意思很簡單:某個運行者,用某個工具,保障某段代碼,獨占的運行,直到釋放。
分布式鎖解決的是 多台機器 – 多個進程 之間的同步問題,因為不同的機器之間mutex/sem無法使用。不過要注意:即便如此,一個進程內多個線程之間仍舊建議使用mutex同步,盡量減少對分布式鎖服務造成不必要的負擔。
redis分布式鎖
首先呢,基於redis的分布式鎖並不是一個坊間方案,而是redis官網提供的解決思路並且有若干語言的實現版本直接使用。
今天要做的,首先是閱讀官方的文檔(中文點我,英文點我),有些地方講的不怎么清晰,所以我接下來會分析PHP版本的代碼,應該可以解答你的主要疑惑。
分析代碼
首先打開代碼:https://github.com/ronnylt/redlock-php/blob/master/src/RedLock.php,這是PHP的官方推薦實現版本,它基於composer安裝(不懂composer可以點我)。
構造函數
function __construct(array $servers, $retryDelay = 200, $retryCount = 3) { $this->servers = $servers; $this->retryDelay = $retryDelay; $this->retryCount = $retryCount; $this->quorum = min(count($servers), (count($servers) / 2 + 1)); }
- 需要傳入的是redis的若干master節點地址,並且這些master是純內存模式且無slave的。
- retryDelay是設置每一輪lock失敗或者異常,多久之后重新嘗試下一輪lock。
- retryCount是指最多幾輪lock失敗后徹底放棄。
- quorum體現了分布式里一個有名的”鴿巢原理”,也就是如果大於半數的節點操作成功則認為整個集群是操作成功的;在這里的意思是,如果超過1/2的(>=N/2+1)redis master調用鎖成功,則認為獲得了整個redis集群的鎖,假設A用戶獲得了集群的鎖,那么接下來的B用戶只能獲得<=1/2的redis master的鎖,相當於無法獲得集群的鎖。
初始化redis連接
private function initInstances() { if (empty($this->instances)) { foreach ($this->servers as $server) { list($host, $port, $timeout) = $server; $redis = new \Redis(); $redis->connect($host, $port, $timeout); $this->instances[] = $redis; } } }
- 遍歷每個redis master,建立到它們的連接並保存起來;
- 因為需要用到”鴿巢原理”,也就是redis數量足夠產生”大多數”這個目的:因此redis master數量最好>=3台,因為2台的話大多數是2台(2/2+1),這樣任何1台故障就無法產生”大多數”,那么整個分布式鎖就不可用了。
請求1個redis上鎖
private function lockInstance($instance, $resource, $token, $ttl) { return $instance->set($resource, $token, ['NX', 'PX' => $ttl]); }
- 請求某一台redis,如果key=resource不存在就設置value=token(算法生成,全局唯一),並且redis會在ttl時間后自動刪除這個key
請求1個redis放鎖
private function unlockInstance($instance, $resource, $token) { $script = ' if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end '; return $instance->eval($script, [$resource, $token], 1); }
- 請求某一台redis,給它發送一段lua腳本,如果resource的value不等於lock時設置的token則說明鎖已被它人占用無需釋放,否則說明是自己上的鎖可以DEL刪除。
- lua腳本在redis里原子執行,在這里即保障GET和DEL的原子性。
請求集群鎖
public function lock($resource, $ttl) { $this->initInstances(); $token = uniqid(); $retry = $this->retryCount; do { $n = 0; $startTime = microtime(true) * 1000; foreach ($this->instances as $instance) { if ($this->lockInstance($instance, $resource, $token, $ttl)) { $n++; } } # Add 2 milliseconds to the drift to account for Redis expires # precision, which is 1 millisecond, plus 1 millisecond min drift # for small TTLs. $drift = ($ttl * $this->clockDriftFactor) + 2; $validityTime = $ttl - (microtime(true) * 1000 - $startTime) - $drift; if ($n >= $this->quorum && $validityTime > 0) { return [ 'validity' => $validityTime, 'resource' => $resource, 'token' => $token, ]; } else { foreach ($this->instances as $instance) { $this->unlockInstance($instance, $resource, $token); } } // Wait a random delay before to retry $delay = mt_rand(floor($this->retryDelay / 2), $this->retryDelay); usleep($delay * 1000); $retry--; } while ($retry > 0); return false; }
- 首先整個lock過程最多會重試retry次,因此外層有do while。
- 為了獲取”大多數”的鎖,因此遍歷每個redis master去lock,統計成功的次數。
- 因為遍歷redis master進行逐個上鎖需要花費一定的時間,因此在第1個redis上鎖前記錄時間T1,結束最后一個redis上鎖動作的時間點T2,此時第1個redis的TTL已經消逝了T2-T1這么長的時間。
- 為了保障在鎖內計算期間鎖不會失效,我們剩余可以占用鎖的時間實際上是TTL – (T2 – T1),因為越靠前上鎖的redis其剩余時間越少,最少的就是第1個redis了。
- drift值用於補償不同機器時鍾的精度差異,怎么理解呢:
- 在我們的程序看來時間過去了(T2-T1),剩余的鎖時間認為是TTL-(T2-T1),在接下來的剩余時間內進行計算應該不會超過鎖的有效期。
- 但是第1台redis機器的機器時鍾也許跑的比較快(比如時鍾多前進了1毫秒),那么數據會提前1毫秒淘汰,然而我們認為TTL-(T2-T1)秒內鎖有效,而redis相當於TTL-(T2-T1)-1秒內鎖有效,這可能導致我們在鎖外計算。(drift+1)
- 另外,我們計算(T2-T1)之后到返回給lock的調用者之間還有一段代碼在運行,這段代碼的花費也將占用一些時間,所以drift應該也考慮這個。(drift+1)
- 最后,ttl * 0.01的意思是ttl越長,那么時鍾可能差異越大,所以這里做了一個動態計算的補償,比如ttl=100ms,那么就補償1ms的時鍾誤差,盡量避免遇到鎖已過期而我們仍舊在計算的情況發生。
- 如果鎖redis成功的次數>1/2,並且整個遍歷redis+鎖定的過程的耗時 沒有超過鎖的有效期,那么lock成功,將剩余的鎖時間(TTL減去上鎖花費的時間)+ 鎖的標識token 返回給用戶。
- 如果上鎖中途失敗(返回key已存在)或者異常(不知道操作結果),那么都認為上鎖失敗;如果上鎖失敗的數量超過1/2,那么本次上鎖失敗,需要遍歷所有redis進行回滾(回滾失敗也沒有辦法,其他人只能等待我們的key過期,並不會有什么錯誤)。
釋放集群鎖
public function unlock(array $lock) { $this->initInstances(); $resource = $lock['resource']; $token = $lock['token']; foreach ($this->instances as $instance) { $this->unlockInstance($instance, $resource, $token); } }
- 遍歷所有redis,利用lua腳本原子的安全的釋放自己建立的鎖。
故障處理
這里所有redis都是master,不開啟持久化,也不需要slave。
如果某台redis宕機,那么不要立即重啟它,因為宕機后redis沒有任何數據,如果你此時重啟它,那么其他進程就可以可以鎖住一個本應還沒有過期的key,這可能導致2個調用者同時在鎖內進行計算,舉個例子吧:
3個redis,兩個用戶A和B,有這么1個典型流程來說明上述情況:
- A發起lock,鎖住了2個redis(r1+r2),超過3/2+1(大多數),開始執行鎖內操作。
- r0() r1(A) r2(A)
- r1宕機,立即重啟,數據全部丟失;A仍舊在進行鎖內計算,並不知情。
- r0() r1() r2(A)
- B發起lock,鎖住了2個redis(r0+r1),超過3/2+1(大多數),開始執行鎖內操作。
- r0(B) r1(B) r2(A)
悲劇的事情發生了,因為r1宕機立即重啟導致B可以成功鎖住”大多數”redis,導致A和B並發操作。
紅色字體就是解決這個問題的:不要立即重啟,保持r1無法聯通,這樣的話B只能鎖住r0,沒有達到”大多數”從而上鎖失敗。那么何時重啟r1呢?根據業務最大的TTL判斷,當過了TTL秒后redis中所有key都會過期,遵守規則的A用戶的計算也應早已結束,此時B獲得鎖也可以保證獨占。
當然,無論宕機幾台原理都是一樣的,不要立即重啟,等待最大TTL過期后再啟動redis,你可以自己分析上述例子,假設r0和r1一起宕機看看又會發生什么。
分布式鎖用途
我也沒有經驗,不過猜想一個場景:
庫存服務通常需要高並發的update一行記錄以更新商品的剩余數量,而我們知道mysql的update是行鎖的,如果並發過高造成mysql的工作線程都在等待行鎖,將會影響mysql處理其他請求。
如果可以把行鎖用redis鎖取代,那么到達mysql層的並發將永遠都是1,問題將得到解決,不過要注意上述redis鎖的實現有一個問題就是高並發場景下,可能導致誰都無法獲取”大多數”的鎖,不過好在redis一般足夠穩定並且上述實現在lock失敗重試時有一個隨機的間隔值,從而讓某個Lock調用者有機會獲得”大多數”。