在 Redis 裏,所謂 SETNX,是「SET if Not eXists」的縮寫,也就是隻有不存在的時候才設置,能夠利用它來實現鎖的效果,不過不少人沒有意識到 SETNX 有陷阱!php
好比說:某個查詢數據庫的接口,由於調用量比較大,因此加了緩存,並設定緩存過時後刷新,問題是當併發量比較大的時候,若是沒有鎖機制,那麼緩存過時的瞬間,大量併發請求會穿透緩存直接查詢數據庫,形成雪崩效應,若是有鎖機制,那麼就能夠控制只有一個請求去更新緩存,其它的請求視狀況要麼等待,要麼使用過時的緩存。html
下面以目前 PHP 社區裏最流行的 PHPRedis 擴展爲例,實現一段演示代碼:web
<?phpredis
$ok = $redis->setNX($key, $value);數據庫
if ($ok) {
$cache->update();
$redis->del($key);
}緩存
?>
緩存過時時,經過 SetNX 獲取鎖,若是成功了,那麼更新緩存,而後刪除鎖。看上去邏輯很是簡單,惋惜有問題:若是請求執行由於某些緣由意外退出了,致使建立了鎖可是沒有刪除鎖,那麼這個鎖將一直存在,以致於之後緩存再也得不到更新。因而乎咱們須要給鎖加一個過時時間以防不測:性能優化
<?php併發
$redis->multi();
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
$redis->exec();app
?>
由於 SetNX 不具有設置過時時間的功能,因此咱們須要藉助 Expire 來設置,同時咱們須要把二者用 Multi/Exec 包裹起來以確保請求的原子性,以避免 SetNX 成功了 Expire 卻失敗了。 惋惜還有問題:當多個請求到達時,雖然只有一個請求的 SetNX 能夠成功,可是任何一個請求的 Expire 卻均可以成功,如此就意味着即使獲取不到鎖,也能夠刷新過時時間,若是請求比較密集的話,那麼過時時間會一直被刷新,致使鎖一直有效。因而乎咱們須要在保證原子性的同時,有條件的執行 Expire,接着便有了以下 Lua 代碼:dom
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
沒想到實現一個看起來很簡單的功能還要用到 Lua 腳本,着實有些麻煩。其實 Redis 已經考慮到了你們的疾苦,從 2.6.12 起,SET 涵蓋了 SETEX 的功能,而且 SET 自己已經包含了設置過時時間的功能,也就是說,咱們前面須要的功能只用 SET 就能夠實現。
<?php
$ok = $redis->set($key, $value, array('nx', 'ex' => $ttl));
if ($ok) {
$cache->update();
$redis->del($key);
}
?>
如上代碼是完美的嗎?答案是還差一點!設想一下,若是一個請求更新緩存的時間比較長,甚至比鎖的有效期還要長,致使在緩存更新過程當中,鎖就失效了,此時另外一個請求會獲取鎖,但前一個請求在緩存更新完畢的時候,若是不加以判斷直接刪除鎖,就會出現誤刪除其它請求建立的鎖的狀況,因此咱們在建立鎖的時候須要引入一個隨機值:
<?php
$ok = $redis->set($key, $random, array('nx', 'ex' => $ttl));
if ($ok) {
$cache->update();
if ($redis->get($key) == $random) {
$redis->del($key);
}
}
?>如此基本實現了單機鎖,假如要實現分佈鎖,請參考:Distributed locks with Redis,這裏就不深刻討論了,總結:避免掉入 SETNX 陷阱的最好方法就是永遠不要使用它