在 Redis 里癞松,所謂SETNX是
「SET if Not exists」的縮寫瓜客,也就是只有不存在的時(shí)候才設(shè)置,可以利用它來實(shí)現(xiàn)鎖的效果掘而,不過很多人沒有意識到 SETNX 有陷阱迫悠!
比如說:某個(gè)查詢數(shù)據(jù)庫的接口鹏漆,因?yàn)檎{(diào)用量比較大,所以加了緩存创泄,并設(shè)定緩存過期后刷新艺玲,問題是當(dāng)并發(fā)量比較大的時(shí)候,如果沒有鎖機(jī)制鞠抑,那么緩存過期的瞬間饭聚,大量并發(fā)請求會(huì)穿透緩存直接查詢數(shù)據(jù)庫,造成雪崩效應(yīng)搁拙,如果有鎖機(jī)制秒梳,那么就可以控制只有一個(gè)請求去更新緩存,其它的請求視情況要么等待箕速,要么使用過期的緩存酪碘。
下面以目前 PHP 社區(qū)里最流行的擴(kuò)展為例,實(shí)現(xiàn)一段演示代碼:
<?php
$ok = $redis->setNX($key, $value);
if ($ok) {
$cache->update();
$redis->del($key);
}
?>
緩存過期時(shí)盐茎,通過 SetNX 獲取鎖兴垦,如果成功了,那么更新緩存字柠,然后刪除鎖探越。看上去邏輯非常簡單窑业,可惜有問題:如果請求執(zhí)行因?yàn)槟承┰蛞馔馔顺隽饲蔗#瑢?dǎo)致創(chuàng)建了鎖但是沒有刪除鎖,那么這個(gè)鎖將一直存在常柄,以至于以后緩存再也得不到更新鲤氢。于是乎我們需要給鎖加一個(gè)過期時(shí)間以防不測:
<?php
$redis->multi();
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
$redis->exec();
?>
因?yàn)?SetNX 不具備設(shè)置過期時(shí)間的功能,所以我們需要借助Expire來設(shè)置西潘,同時(shí)我們需要把兩者用Multi/Exec包裹起來以確保請求的原子性铜异,以免 SetNX 成功了 Expire 卻失敗了。 可惜還有問題:當(dāng)多個(gè)請求到達(dá)時(shí)秸架,雖然只有一個(gè)請求的 SetNX 可以成功,但是任何一個(gè)請求的 Expire 卻都可以成功东抹,如此就意味著即便獲取不到鎖,也可以刷新過期時(shí)間食茎,如果請求比較密集的話馏谨,那么過期時(shí)間會(huì)一直被刷新惧互,導(dǎo)致鎖一直有效喊儡。于是乎我們需要在保證原子性的同時(shí),有條件的執(zhí)行 Expire买喧,接著便有了如下 Lua 代碼:
local key = KEYS[1]
local value = KEYS[2]
local ttl = KEYS[3]
local ok = redis.call('setnx', key, value)
if ok == 1 then
redis.call('expire', key, ttl)
end
return ok
沒想到實(shí)現(xiàn)一個(gè)看起來很簡單的功能還要用到 Lua 腳本淤毛,著實(shí)有些麻煩算柳。其實(shí) Redis 已經(jīng)考慮到了大家的疾苦埠居,從 2.6.12 起,SET涵蓋了 SETEX 的功能纸颜,并且 SET 本身已經(jīng)包含了設(shè)置過期時(shí)間的功能绎橘,也就是說称鳞,我們前面需要的功能只用 SET 就可以實(shí)現(xiàn)。
<?php
$ttl = 60;
$ok = $redis->set($key, $value, array('nx', 'ex' => $ttl));
if ($ok) {
$cache->update();
$redis->del($key);
}
//這里$ttl設(shè)置的時(shí)間要恰到好處,要大于請求執(zhí)行的時(shí)間熙暴。
?>
如上代碼是完美的嗎?答案是還差一點(diǎn)亚皂!設(shè)想一下国瓮,如果一個(gè)請求更新緩存的時(shí)間比較長乃摹,甚至比鎖的有效期還要長峡懈,導(dǎo)致在緩存更新過程中肪康,鎖就失效了,此時(shí)另一個(gè)請求會(huì)獲取鎖谒撼,但前一個(gè)請求在緩存更新完畢的時(shí)候廓潜,如果不加以判斷直接刪除鎖善榛,就會(huì)出現(xiàn)誤刪除其它請求創(chuàng)建的鎖的情況移盆,所以我們在創(chuàng)建鎖的時(shí)候需要引入一個(gè)隨機(jī)值:
//######最終判斷加鎖代碼
<?php
$random=mt_rand().uniqid(true);
$ok = $redis->set($key, $random, array('nx', 'ex' => $ttl));//對查詢進(jìn)行加鎖(一般是唯一數(shù)據(jù)項(xiàng) 例如訂單號)
if($ok){
//相當(dāng)于業(yè)務(wù)這里一般來處理邏輯
$cache->update();//更新緩存
if ($redis->get($key) == $random) { //保證刪自己的咒循,別因?yàn)檎埱髸r(shí)間長而刪掉別人的內(nèi)容
$redis->del($key);//處理完之后 釋放鎖
}
}
?>
補(bǔ)充:本文在刪除鎖的時(shí)候叙甸,實(shí)際上是有問題的,沒有考慮到 GC pause 之類的問題造成的影響熔萧,比如 A 請求在 DEL 之前卡住了哪痰,然后鎖過期了,這時(shí)候 B 請求又成功獲取到了鎖,此時(shí) A 請求緩過來了肋演,就會(huì) DEL 掉 B 請求創(chuàng)建的鎖爹殊,此問題遠(yuǎn)比想象的要復(fù)雜.