使用redis的比較完美的加鎖解鎖
習慣性說一下寫這篇文章要說明什麼,我們經常用redis進行加鎖操作,目的是爲了解決併發可能帶來的問題。但是使用redis加鎖的方式有多種,本文對常見的幾種方式進行解析,並提供一種相對完美的方案。
read & write 問題
這是一個經典問題,請看代碼:
//redis中的某個鍵自增
$val = $this->redis->get($key);
$val ++;
$this->redis->set($val);
這段代碼邏輯沒有問題,就是先讀取數據,再修改數據,在寫回修改,這裏是希望每次訪問都遞增變量$val的值,但在併發情況下,存在情況是兩個進程都讀取到了一樣的初始值,然後都加1,最後寫回Redis,這種情況就會統計數據比實際的少。這個問題應該有許多人遇到過,思考過怎麼解決這類問題。這裏給出一個統一的解決方案,就是儘量保證操作的原子性,比如可以用redis的incr命令來實現自增(可以認爲redis的命令是原子的)。
加鎖
由上面的問題再進一步,來探討一個大家常用的,爲一個操作進行加鎖。
問題場景如下:有一個商品,每個用戶都可以去修改商品信息。假設用戶id分別爲6和8的用戶對id爲123的商品進行操作。
錯誤示例1
$key = '123';
$val = $this->redis->get($key);
if(!$val){
$this->redis->set($key,'123');
$this->redis->expire($key,'4');
/**此處修改商品信息操作
******
**/
$this->redis->del($key);
}else{
echo '錯誤提示';
}
上面這個錯誤示例,
錯誤點1:set和expire是分開寫的,如果說程序執行中再執行了set()後出現崩潰,則這個就變成了永久鎖(雖然這是個小概率事件)。
錯誤點2:這個商品中設置的key是商品id,val也是商品id,很多人認爲只有一個key就可以了,val是什麼無所謂。這就缺少了鎖的標識,無法判斷這個鎖的擁有者是誰,從而會帶來一系列影響如下。
- 用戶1進程獲取key對應的val,發現沒有鎖,所以調用了set,可能在set前,另一個用戶2的進程也發現沒有這個鎖,也進行set,就造成了兩個進程都認爲自己獲取到了鎖的情況,
- 然後繼續,如果1用戶的進程執行完了操作,刪除了key,用戶2進程未執行完畢,此時由於無法識別是否是自己加的鎖,就刪除了key,這時再有新的進程進入,檢查不到鎖,可以立即執行,則有可能和用戶2的修改衝突。
針對錯誤1和錯誤2的第1點,我們只需要去除read & write模式就可以解決,解決方案爲
//同時設置val和過期時間,並使用setnx
$status = $this->redis->setnx($key,$val,$expireTime);
if($status){
/**此處修改商品信息操作
******
**/
$this->redis->del($key);
}else{
echo '錯誤提示';
}
setnx,可以在設置時檢查是否存在鎖不存在則設置並返回1,如果存在不覆蓋並返回0。
針對錯誤2第2點,我們需要爲每個進程設置一個獨立的自己可以識別的val,如果一個用戶只能開一個進程,這個val可以爲用戶id,如果一個用戶可以設置多個進程,那麼必須按照實際車情況採用其他方式來區分,這裏我們以用戶id爲例,並且在刪除的時候只能刪除自己的鎖。那麼這裏問題又出現了,如果我們寫成這樣:
//同時設置val和過期時間,並使用setnx
$userId = 2;
$status = $this->redis->setnx($key,$userId,$expireTime);
if($status){
/**此處修改商品信息操作
******
**/
if($this->redis->get($key) == $userId){
$this->redis->del($key);
}
}else{
echo '錯誤提示';
}
這種情況看似沒有什麼問題,其實不然,大家注意我再設置所得時候,設置了一個過期時間,假如這個時間設置的是4秒,那麼如果進程A執行到刪除前一刻一不小心超過了4秒,那麼這個鎖就自動消失了。而另一個進程B查到沒有鎖,就加了一把自己的鎖,此時進程A執行刪除,就把B的鎖給刪除了(極小概率事件)。
這裏解決方案有兩種
- 設置比較長的expire時間,弊端:設置的太長,佔用內存時間長,設置的太短不能完全解決問題。(可能有人會想不設置過期時間就可以,那麼回到最初的錯誤點,如果程序設置了鎖後崩潰了就變成了永久的鎖。)
- 把對比和刪除弄成一個原子操作,這裏呢找到了一個方法,就是用redis的eval,把語句變成原子操作。注意redis用的是lua語法,我也是新學的
//同時設置val和過期時間,並使用setnx
$userId = 2;
$status = $this->redis->setnx($key,$userId,$expireTime);
if($status){
/**此處修改商品信息操作
******
**/
//因爲寫這個博客的機器沒有裝redis,所以沒有驗證這個語法對不對。請大家見諒
$script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
$result = $this->redis->eval(script,array($key,$val),1);
if ($result) {
return true;
}
}else{
echo '錯誤提示';
}
這裏就把兩個操作變成了一個原子操作。解決的加鎖和解鎖可能出現的問題。
我們來說一些題外話拓展:在進程有可能出現衝突的地方,一般我們叫做臨界區(操作系統中也有這個概念,是通過另一種叫做PV信號量的方式來解決的,其實可以理解爲組織等待進程隊列,P操作不能獲取到資源使用權的則進入等待隊列,等待V操作釋放資源後,檢查是否有等待隊列,進行進程釋放。當然PV操作也是原子性的。所以說解決相似問題的辦法也有一定的相似性)。
歡迎大家評論補充 --- vinter_he
對於一些get set 操作,保證其原子性是解決問題的本質,一般可以嘗試數據庫對應的原子命令,在例如:setnx 、getset 等。歸根結底也就是將異步問題在某個時間同步化!