1. 程式人生 > >談談Redis的SETNX分散式鎖

談談Redis的SETNX分散式鎖

在 Redis 裡,所謂 SETNX,是「SET if Not eXists」的縮寫,也就是隻有不存在的時候才設定,可以利用它來實現鎖的效果,不過很多人沒有意識到 SETNX 有陷阱!

比如說:某個查詢資料庫的介面,因為呼叫量比較大,所以加了快取,並設定快取過期後重新整理,問題是當併發量比較大的時候,如果沒有鎖機制,那麼快取過期的瞬間,大量併發請求會穿透快取直接查詢資料庫,造成雪崩效應,如果有鎖機制,那麼就可以控制只有一個請求去更新快取,其它的請求視情況要麼等待,要麼使用過期的快取。

下面以目前 PHP 社群裡最流行的 PHPRedis 擴充套件為例,實現一段演示程式碼:

<?php

$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();

?>

因為 SetNX 不具備設定過期時間的功能,所以我們需要藉助 Expire 來設定,同時我們需要把兩者用

Multi/Exec 包裹起來以確保請求的原子性,以免 SetNX 成功了 Expire 卻失敗了。 可惜還有問題:當多個請求到達時,雖然只有一個請求的 SetNX 可以成功,但是任何一個請求的 Expire 卻都可以成功,如此就意味著即便獲取不到鎖,也可以重新整理過期時間,如果請求比較密集的話,那麼過期時間會一直被重新整理,導致鎖一直有效。於是乎我們需要在保證原子性的同時,有條件的執行 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

沒想到實現一個看起來很簡單的功能還要用到 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);
    }
}

?>


虛擬碼: $lock = 0 while($lock != 1){ $timestamp = time() + $timeout + 1; $lock = SETNX(‘lock.foo’,$timestamp); if($lock == 1 or (time() > (GET(‘lock.foo’) and time() > (GETSET(‘lock.foo’,timestamp))){ break; } else{ sleep(10ms); } } do_job()

# release if(now() < GET('lock.foo')){ DEL('lock.foo'); }

如此基本實現了單機鎖,假如要實現分佈鎖,請參考:Distributed locks with Redis,這裡就不深入討論了,總結:避免掉入 SETNX 陷阱的最好方法就是永遠不要使用它!