談談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 來設定,同時我們需要把兩者用
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 陷阱的最好方法就是永遠不要使用它!