今天和大家分享的是redis學習中,如何在分布式環(huán)境下實現(xiàn)一個全局鎖,在開始之前先說說非分布式下的鎖:
· 單機 – 單進程程序使用互斥鎖mutex,解決多個線程之間的同步問題
· 單機 – 多進程程序使用信號量sem,解決多個進程之間的同步問題
這里同步的意思很簡單:某個運行者,用某個工具,保障某段代碼,獨占的運行,直到釋放。
分布式鎖解決的是 多臺機器 – 多個進程 之間的同步問題,因為不同的機器之間mutex/sem無法使用。不過要注意:即便如此,一個進程內(nèi)多個線程之間仍舊建議使用mutex同步,盡量減少對分布式鎖服務造成不必要的負擔。
redis分布式鎖
首先呢,基于redis的分布式鎖并不是一個坊間方案,而是redis官網(wǎng)提供的解決思路并且有若干語言的實現(xiàn)版本直接使用。
今天要做的,首先是閱讀官方的文檔,有些地方講的不怎么清晰,所以我接下來會分析PHP版本的代碼,應該可以解答你的主要疑惑。
分析代碼
構造函數(shù)
function __construct(array $servers, $retryDelay = 200, $retryCount = 3)
{
$this->servers = $servers;
$this->retryDelay = $retryDelay;
$this->retryCount = $retryCount;
$this->quorum = min(count($servers), (count($servers) / 2 + 1));
}
· 需要傳入的是redis的若干master節(jié)點地址,并且這些master是純內(nèi)存模式且無slave的。
· retryDelay是設置每一輪lock失敗或者異常,多久之后重新嘗試下一輪lock。
· retryCount是指最多幾輪lock失敗后徹底放棄。
· quorum體現(xiàn)了分布式里一個有名的”鴿巢原理”,也就是如果大于半數(shù)的節(jié)點操作成功則認為整個集群是操作成功的;在這里的意思是,如果超過1/2的(>=N/2+1)redis master調(diào)用鎖成功,則認為獲得了整個redis集群的鎖,假設A用戶獲得了集群的鎖,那么接下來的B用戶只能獲得<=1/2的redis master的鎖,相當于無法獲得集群的鎖。
初始化redis連接
private function initInstances()
{
if (empty($this->instances)) {
foreach ($this->serversas $server) {
list($host, $port, $timeout) = $server;
$redis = new \Redis();
$redis->connect($host, $port, $timeout);
$this->instances[] = $redis;
}
}
}
· 遍歷每個redis master,建立到它們的連接并保存起來;
· 因為需要用到”鴿巢原理”,也就是redis數(shù)量足夠產(chǎn)生”大多數(shù)”這個目的:因此redis master數(shù)量最好>=3臺,因為2臺的話大多數(shù)是2臺(2/2+1),這樣任何1臺故障就無法產(chǎn)生”大多數(shù)”,那么整個分布式鎖就不可用了。
請求1個redis上鎖
private function lockInstance($instance, $resource, $token, $ttl)
{
return $instance->set($resource, $token, ['NX', 'PX' => $ttl]);
}
· 請求某一臺redis,如果key=resource不存在就設置value=token(算法生成,全局唯一),并且redis會在ttl時間后自動刪除這個key
請求1個redis放鎖
private function unlockInstance($instance, $resource, $token)
{
$script = '
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
';
return $instance->eval($script, [$resource, $token], 1);
}
· 請求某一臺redis,給它發(fā)送一段lua腳本,如果resource的value不等于lock時設置的token則說明鎖已被它人占用無需釋放,否則說明是自己上的鎖可以DEL刪除。
· lua腳本在redis里原子執(zhí)行,在這里即保障GET和DEL的原子性。
請求集群鎖
public function lock($resource, $ttl)
{
$this->initInstances();
$token = uniqid();
$retry = $this->retryCount;
do {
$n = 0;
$startTime = microtime(true) * 1000;
foreach ($this->instancesas $instance) {
if ($this->lockInstance($instance, $resource, $token, $ttl)) {
$n++;
}
}
# Add 2 milliseconds to the drift to account for Redis expires
# precision, which is 1 millisecond, plus 1 millisecond min drift
# for small TTLs.
$drift = ($ttl * $this->clockDriftFactor) + 2;
$validityTime = $ttl - (microtime(true) * 1000 - $startTime) - $drift;
if ($n >= $this->quorum && $validityTime > 0) {
return [
'validity' => $validityTime,
'resource' => $resource,
'token' => $token,
];
} else {
foreach ($this->instancesas $instance) {
$this->unlockInstance($instance, $resource, $token);
}
}
// Wait a random delay before to retry
$delay = mt_rand(floor($this->retryDelay / 2), $this->retryDelay);
usleep($delay * 1000);
$retry--;
} while ($retry > 0);
return false;
}
· 首先整個lock過程最多會重試retry次,因此外層有do while。
· 為了獲取”大多數(shù)”的鎖,因此遍歷每個redis master去lock,統(tǒng)計成功的次數(shù)。
· 因為遍歷redis master進行逐個上鎖需要花費一定的時間,因此在第1個redis上鎖前記錄時間T1,結束最后一個redis上鎖動作的時間點T2,此時第1個redis的TTL已經(jīng)消逝了T2-T1這么長的時間。
· 為了保障在鎖內(nèi)計算期間鎖不會失效,我們剩余可以占用鎖的時間實際上是TTL – (T2 – T1),因為越靠前上鎖的redis其剩余時間越少,最少的就是第1個redis了。
· drift值用于補償不同機器時鐘的精度差異,怎么理解呢:
· 在我們的程序看來時間過去了(T2-T1),剩余的鎖時間認為是TTL-(T2-T1),在接下來的剩余時間內(nèi)進行計算應該不會超過鎖的有效期。
· 但是第1臺redis機器的機器時鐘也許跑的比較快(比如時鐘多前進了1毫秒),那么數(shù)據(jù)會提前1毫秒淘汰,然而我們認為TTL-(T2-T1)秒內(nèi)鎖有效,而redis相當于TTL-(T2-T1)-1秒內(nèi)鎖有效,這可能導致我們在鎖外計算。(drift+1)
· 另外,我們計算(T2-T1)之后到返回給lock的調(diào)用者之間還有一段代碼在運行,這段代碼的花費也將占用一些時間,所以drift應該也考慮這個。(drift+1)
· 最后,ttl * 0.01的意思是ttl越長,那么時鐘可能差異越大,所以這里做了一個動態(tài)計算的補償,比如ttl=100ms,那么就補償1ms的時鐘誤差,盡量避免遇到鎖已過期而我們?nèi)耘f在計算的情況發(fā)生。
· 如果鎖redis成功的次數(shù)>1/2,并且整個遍歷redis+鎖定的過程的耗時 沒有超過鎖的有效期,那么lock成功,將剩余的鎖時間(TTL減去上鎖花費的時間)+ 鎖的標識token 返回給用戶。
· 如果上鎖中途失?。ǚ祷?/span>key已存在)或者異常(不知道操作結果),那么都認為上鎖失??;如果上鎖失敗的數(shù)量超過1/2,那么本次上鎖失敗,需要遍歷所有redis進行回滾(回滾失敗也沒有辦法,其他人只能等待我們的key過期,并不會有什么錯誤)。
釋放集群鎖
public function unlock(array $lock)
{
$this->initInstances();
$resource = $lock['resource'];
$token = $lock['token'];
foreach ($this->instancesas $instance) {
$this->unlockInstance($instance, $resource, $token);
}
}
· 遍歷所有redis,利用lua腳本原子的安全的釋放自己建立的鎖。
故障處理
這里所有redis都是master,不開啟持久化,也不需要slave。
如果某臺redis宕機, 那么不要立即重啟它 ,因為宕機后redis沒有任何數(shù)據(jù),如果你此時重啟它,那么其他進程就可以可以鎖住一個本應還沒有過期的key,這可能導致2個調(diào)用者同時在鎖內(nèi)進行計算,舉個例子吧:
3個redis,兩個用戶A和B,有這么1個典型流程來說明上述情況:
· A發(fā)起lock,鎖住了2個redis(r1+r2),超過3/2+1(大多數(shù)),開始執(zhí)行鎖內(nèi)操作。
· r0() r1(A) r2(A)
· r1宕機,立即重啟,數(shù)據(jù)全部丟失;A仍舊在進行鎖內(nèi)計算,并不知情。
· r0() r1() r2(A)
· B發(fā)起lock,鎖住了2個redis(r0+r1),超過3/2+1(大多數(shù)),開始執(zhí)行鎖內(nèi)操作。
· r0(B) r1(B) r2(A)
悲劇的事情發(fā)生了,因為r1宕機立即重啟導致B可以成功鎖住”大多數(shù)”redis,導致A和B并發(fā)操作。
紅色字體就是解決這個問題的:不要立即重啟,保持r1無法聯(lián)通,這樣的話B只能鎖住r0,沒有達到”大多數(shù)”從而上鎖失敗。那么何時重啟r1呢?根據(jù)業(yè)務最大的TTL判斷,當過了TTL秒后redis中所有key都會過期,遵守規(guī)則的A用戶的計算也應早已結束,此時B獲得鎖也可以保證獨占。
當然,無論宕機幾臺原理都是一樣的,不要立即重啟,等待最大TTL過期后再啟動redis,你可以自己分析上述例子,假設r0和r1一起宕機看看又會發(fā)生什么。
分布式鎖用途
我也沒有經(jīng)驗,不過猜想一個場景:
庫存服務通常需要高并發(fā)的update一行記錄以更新商品的剩余數(shù)量,而我們知道mysql的update是行鎖的,如果并發(fā)過高造成mysql的工作線程都在等待行鎖,將會影響mysql處理其他請求。
如果可以把行鎖用redis鎖取代,那么到達mysql層的并發(fā)將永遠都是1,問題將得到解決,不過要注意上述redis鎖的實現(xiàn)有一個問題就是高并發(fā)場景下,可能導致誰都無法獲取”大多數(shù)”的鎖,不過好在redis一般足夠穩(wěn)定并且上述實現(xiàn)在lock失敗重試時有一個隨機的間隔值,從而讓某個Lock調(diào)用者有機會獲得”大多數(shù)”。
來源:魚兒的博客