Redis實現分散式鎖思路和任務佇列
1.Redis實現分散式鎖思路
思路很簡單,主要用到的redis函式是setnx(),這個應該是實現分散式鎖最主要的函式。首先是將某一任務標識名(這裡用Lock:order作為標識名的例子)作為鍵存到redis裡,併為其設個過期時間,如果是還有Lock:order請求過來,先是通過setnx()看看是否能將Lock:order插入到redis裡,可以的話就返回true,不可以就返回false。當然,在我的程式碼裡會比這個思路複雜一些,我會在分析程式碼時進一步說明。
2.Redis實現任務佇列
這裡的實現會用到上面的Redis分散式的鎖機制,主要是用到了Redis裡的有序集合這一資料結構。例如入隊時,通過zset的add()函式進行入隊,而出對時,可以用到zset的getScore()函式。另外還可以彈出頂部的幾個任務。
以上就是實現 分散式鎖 和 任務佇列 的簡單思路,如果你看完有點模稜兩可,那請看接下來的程式碼實現。
三、程式碼分析
(一)先來分析Redis分散式鎖的程式碼實現
(1)為避免特殊原因導致鎖無法釋放,在加鎖成功後,鎖會被賦予一個生存時間(通過lock方法的引數設定或者使用預設值),超出生存時間鎖會被自動釋放鎖的生存時間預設比較短(秒級),因此,若需要長時間加鎖,可以通過expire方法延長鎖的生存時間為適當時間,比如在迴圈內。
(2)系統級的鎖當程序無論何種原因時出現crash時,作業系統會自己回收鎖,所以不會出現資源丟失,但分散式鎖不用,若一次性設定很長時間,一旦由於各種原因出現程序crash 或者其他異常導致unlock未被呼叫時,則該鎖在剩下的時間就會變成垃圾鎖,導致其他程序或者程序重啟後無法進入加鎖區域。
先看加鎖的實現程式碼:這裡需要主要兩個引數,一個是$timeout,這個是迴圈獲取鎖的等待時間,在這個時間內會一直嘗試獲取鎖知道超時,如果為0,則表示獲取鎖失敗後直接返回而不再等待;另一個重要引數的$expire,這個引數指當前鎖的最大生存時間,以秒為單位的,它必須大於0,如果超過生存時間鎖仍未被釋放,則系統會自動強制釋放。這個引數的最要作用請看上面的(1)裡的解釋。
這裡先取得當前時間,然後再獲取到鎖失敗時的等待超時的時刻(是個時間戳),再獲取到鎖的最大生存時刻是多少。這裡redis的key用這種格式:"Lock:鎖的標識名",這裡就開始進入迴圈了,先是插入資料到redis裡,使用setnx()函式,這函式的意思是,如果該鍵不存在則插入資料,將最大生存時刻作為值儲存,假如插入成功,則對該鍵進行失效時間的設定,並將該鍵放在$lockedName數組裡,返回true,也就是上鎖成功;如果該鍵存在,則不會插入操作了,這裡有一步嚴謹的操作,那就是取得當前鍵的剩餘時間,假如這個時間小於0,表示key上沒有設定生存時間(key是不會不存在的,因為前面setnx會自動建立)如果出現這種狀況,那就是程序的某個例項setnx成功後 crash 導致緊跟著的expire沒有被呼叫,這時可以直接設定expire並把鎖納為己用。如果沒設定鎖失敗的等待時間 或者 已超過最大等待時間了,那就退出迴圈,反之則 隔 $waitIntervalUs 後繼續 請求。 這就是加鎖的整一個程式碼分析。
1 /** 2 * 加鎖 3 * @param [type] $name 鎖的標識名 4 * @param integer $timeout 迴圈獲取鎖的等待超時時間,在此時間內會一直嘗試獲取鎖直到超時,為0表示失敗後直接返回不等待 5 * @param integer $expire 當前鎖的最大生存時間(秒),必須大於0,如果超過生存時間鎖仍未被釋放,則系統會自動強制釋放 6 * @param integer $waitIntervalUs 獲取鎖失敗後掛起再試的時間間隔(微秒) 7 * @return [type] [description] 8 */ 9 public function lock($name, $timeout = 0, $expire = 15, $waitIntervalUs = 100000) { 10 if ($name == null) return false; 11 12 //取得當前時間 13 $now = time(); 14 //獲取鎖失敗時的等待超時時刻 15 $timeoutAt = $now + $timeout; 16 //鎖的最大生存時刻 17 $expireAt = $now + $expire; 18 19 $redisKey = "Lock:{$name}"; 20 while (true) { 21 //將rediskey的最大生存時刻存到redis裡,過了這個時刻該鎖會被自動釋放 22 $result = $this->redisString->setnx($redisKey, $expireAt); 23 24 if ($result != false) { 25 //設定key的失效時間 26 $this->redisString->expire($redisKey, $expireAt); 27 //將鎖標誌放到lockedNames數組裡 28 $this->lockedNames[$name] = $expireAt; 29 return true; 30 } 31 32 //以秒為單位,返回給定key的剩餘生存時間 33 $ttl = $this->redisString->ttl($redisKey); 34 35 //ttl小於0 表示key上沒有設定生存時間(key是不會不存在的,因為前面setnx會自動建立) 36 //如果出現這種狀況,那就是程序的某個例項setnx成功後 crash 導致緊跟著的expire沒有被呼叫 37 //這時可以直接設定expire並把鎖納為己用 38 if ($ttl < 0) { 39 $this->redisString->set($redisKey, $expireAt); 40 $this->lockedNames[$name] = $expireAt; 41 return true; 42 } 43 44 /*****迴圈請求鎖部分*****/ 45 //如果沒設定鎖失敗的等待時間 或者 已超過最大等待時間了,那就退出 46 if ($timeout <= 0 || $timeoutAt < microtime(true)) break; 47 48 //隔 $waitIntervalUs 後繼續 請求 49 usleep($waitIntervalUs); 50 51 } 52 53 return false; 54 }
接著看解鎖的程式碼分析:解鎖就簡單多了,傳入引數就是鎖標識,先是判斷是否存在該鎖,存在的話,就從redis裡面通過deleteKey()函式刪除掉鎖標識即可。
1 /** 2 * 解鎖 3 * @param [type] $name [description] 4 * @return [type] [description] 5 */ 6 public function unlock($name) { 7 //先判斷是否存在此鎖 8 if ($this->isLocking($name)) { 9 //刪除鎖 10 if ($this->redisString->deleteKey("Lock:$name")) { 11 //清掉lockedNames裡的鎖標誌 12 unset($this->lockedNames[$name]); 13 return true; 14 } 15 } 16 return false; 17 }
在貼上刪除掉所有鎖的方法,其實都一個樣,多了個迴圈遍歷而已。
1 /** 2 * 釋放當前所有獲得的鎖 3 * @return [type] [description] 4 */ 5 public function unlockAll() { 6 //此標誌是用來標誌是否釋放所有鎖成功 7 $allSuccess = true; 8 foreach ($this->lockedNames as $name => $expireAt) { 9 if (false === $this->unlock($name)) { 10 $allSuccess = false; 11 } 12 } 13 return $allSuccess; 14 }
以上就是用Redis實現分散式鎖的整一套思路和程式碼實現的總結和分享,這裡我附上正一個實現類的程式碼,程式碼裡我基本上對每一行進行了註釋,方便大家快速看懂並且能模擬應用。想要深入瞭解的請看整個類的程式碼:
Redis實現分散式鎖(二)用Redis實現任務佇列的程式碼分析
(1)任務佇列,用於將業務邏輯中可以非同步處理的操作放入佇列中,在其他執行緒中處理後出隊
(2)佇列中使用了分散式鎖和其他邏輯,保證入隊和出隊的一致性
(3)這個佇列和普通佇列不一樣,入隊時的id是用來區分重複入隊的,佇列裡面只會有一條記錄,同一個id後入的覆蓋前入的,而不是追加, 如果需求要求重複入隊當做不用的任務,請使用不同的id區分
先看入隊的程式碼分析:首先當然是對引數的合法性檢測,接著就用到上面加鎖機制的內容了,就是開始加鎖,入隊時我這裡選擇當前時間戳作為score,接著就是入隊了,使用的是zset資料結構的add()方法,入隊完成後,就對該任務解鎖,即完成了一個入隊的操作。
1 /** 2 * 入隊一個 Task 3 * @param [type] $name 佇列名稱 4 * @param [type] $id 任務id(或者其陣列) 5 * @param integer $timeout 入隊超時時間(秒) 6 * @param integer $afterInterval [description] 7 * @return [type] [description] 8 */ 9 public function enqueue($name, $id, $timeout = 10, $afterInterval = 0) { 10 //合法性檢測 11 if (empty($name) || empty($id) || $timeout <= 0) return false; 12 13 //加鎖 14 if (!$this->_redis->lock->lock("Queue:{$name}", $timeout)) { 15 Logger::get('queue')->error("enqueue faild becouse of lock failure: name = $name, id = $id"); 16 return false; 17 } 18 19 //入隊時以當前時間戳作為 score 20 $score = microtime(true) + $afterInterval; 21 //入隊 22 foreach ((array)$id as $item) { 23 //先判斷下是否已經存在該id了 24 if (false === $this->_redis->zset->getScore("Queue:$name", $item)) { 25 $this->_redis->zset->add("Queue:$name", $score, $item); 26 } 27 } 28 29 //解鎖 30 $this->_redis->lock->unlock("Queue:$name"); 31 32 return true; 33 34 }
接著來看一下出隊的程式碼分析:出隊一個Task,需要指定它的$id 和 $score,如果$score與佇列中的匹配則出隊,否則認為該Task已被重新入隊過,當前操作按失敗處理。首先和對引數進行合法性檢測,接著又用到加鎖的功能了,然後及時出隊了,先使用getScore()從Redis裡獲取到該id的score,然後將傳入的$score和Redis裡儲存的score進行對比,如果兩者相等就進行出隊操作,也就是使用zset裡的delete()方法刪掉該任務id,最後當前就是解鎖了。這就是出隊的程式碼分析。
1 /** 2 * 出隊一個Task,需要指定$id 和 $score 3 * 如果$score 與佇列中的匹配則出隊,否則認為該Task已被重新入隊過,當前操作按失敗處理 4 * 5 * @param [type] $name 佇列名稱 6 * @param [type] $id 任務標識 7 * @param [type] $score 任務對應score,從佇列中獲取任務時會返回一個score,只有$score和佇列中的值匹配時Task才會被出隊 8 * @param integer $timeout 超時時間(秒) 9 * @return [type] Task是否成功,返回false可能是redis操作失敗,也有可能是$score與佇列中的值不匹配(這表示該Task自從獲取到本地之後被其他執行緒入隊過) 10 */ 11 public function dequeue($name, $id, $score, $timeout = 10) { 12 //合法性檢測 13 if (empty($name) || empty($id) || empty($score)) return false; 14 15 //加鎖 16 if (!$this->_redis->lock->lock("Queue:$name", $timeout)) { 17 Logger:get('queue')->error("dequeue faild becouse of lock lailure:name=$name, id = $id"); 18 return false; 19 } 20 21 //出隊 22 //先取出redis的score 23 $serverScore = $this->_redis->zset->getScore("Queue:$name", $id); 24 $result = false; 25 //先判斷傳進來的score和redis的score是否是一樣 26 if ($serverScore == $score) { 27 //刪掉該$id 28 $result = (float)$this->_redis->zset->delete("Queue:$name", $id); 29 if ($result == false) { 30 Logger::get('queue')->error("dequeue faild because of redis delete failure: name =$name, id = $id"); 31 } 32 } 33 //解鎖 34 $this->_redis->lock->unlock("Queue:$name"); 35 36 return $result; 37 }
學過資料結構這門課的朋友都應該知道,佇列操作還有彈出頂部某個值的方法等等,這裡處理入隊出隊操作,我還實現了 獲取佇列頂部若干個Task 並將其出隊的方法,想了解的朋友可以看這段程式碼,假如看不太明白就留言,這裡我不再對其進行分析了。
1 /** 2 * 獲取佇列頂部若干個Task 並將其出隊 3 * @param [type] $name 佇列名稱 4 * @param integer $count 數量 5 * @param integer $timeout 超時時間 6 * @return [type] 返回陣列[0=>['id'=> , 'score'=> ], 1=>['id'=> , 'score'=> ], 2=>['id'=> , 'score'=> ]] 7 */ 8 public function pop($name, $count = 1, $timeout = 10) { 9 //合法性檢測 10 if (empty($name) || $count <= 0) return []; 11 12 //加鎖 13 if (!$this->_redis->lock->lock("Queue:$name")) { 14 Log::get('queue')->error("pop faild because of pop failure: name = $name, count = $count"); 15 return false; 16 } 17 18 //取出若干的Task 19 $result = []; 20 $array = $this->_redis->zset->getByScore("Queue:$name", false, microtime(true), true, false, [0, $count]); 21 22 //將其放在$result數組裡 並 刪除掉redis對應的id 23 foreach ($array as $id => $score) { 24 $result[] = ['id'=>$id, 'score'=>$score]; 25 $this->_redis->zset->delete("Queue:$name", $id); 26 } 27 28 //解鎖 29 $this->_redis->lock->unlock("Queue:$name"); 30 31 return $count == 1 ? (empty($result) ? false : $result[0]) : $result; 32 }