在單進程的系統中,當遇到併發情況下,會出現一些數據異常的問題,但是如果這些數據是需要保證唯一性的話,那我們就希望在同一時刻,只能有一個線程在執行這塊代碼,通常我們一般都是通過簡單的加鎖或同步來實現並解決這個問題。
但是以上都是單進程多線程的情況,如果出現多進程多線程,顯然會出現問題。因爲多線程之間是可以共享內存的,但是多進程之間是不行的,所以這個時候需要用到分佈式鎖。
分佈式鎖常用實現方案
分佈式鎖通常是藉助於一個第三方組件並利用它自身的排他性來達到多進程的互斥。如下:
基於數據庫實現分佈式鎖
基於緩存,實現分佈式鎖,如redis
基於Zookeeper實現分佈式鎖
基於數據庫:鎖實現也有兩種方式,一是基於數據庫表(創建一張鎖表),另一種是基於數據庫排他鎖。
基於zookeeper:鎖的實現是依靠臨時有序節點,每個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個唯一的瞬時有序節點。
基於緩存:下面我們要重點講的就是redis。基於 Redis 的 NX EX 參數。
基於redis的分佈式鎖實現
Redis有一系列的命令,其特點是以NX結尾,NX的意思可以理解爲 NOT EXISTS(不存在),SETNX命令 (SET IF NOT EXISTS) 可以理解爲如果不存在則插入,Redis分佈式鎖的實現主要就是使用SETNX命令
1、使用setnx() 設置鎖
$expire = 10;//有效期10秒
$key = 'lock';//key
$value = time() + $expire;//鎖的值 = Unix時間戳 + 鎖的有效期
$lock = $redis->setnx($key, $value);
//判斷是否上鎖成功,成功則執行下步操作
if(!empty($lock))
{
//下步操作...
}
如果返回 1 ,則表示當前進程獲得鎖,並獲得了當前插入/更新緩存的操作權限。
如果返回 0,表示鎖已被其他進程獲取,這是進程可以返回結果或者等待當前鎖失效再請求。
2、存在死鎖的問題
如果單單隻用SETNX命令設置鎖的話,如果當持有鎖的進程崩潰或刪除鎖失敗時,其他進程將無法獲取到鎖,問題就大了。
解決方法是在獲取鎖失敗的同時獲取鎖的值,並將值與當前時間進行對比,如果值小於當前時間說明鎖以過期失效,進程可運用Redis的DEL命令刪除該鎖
$expire = 10;//有效期10秒
$key = 'lock';//key
$value = time() + $expire;//鎖的值 = Unix時間戳 + 鎖的有效期
$status = true;
while($status)
{
$lock = $redis->setnx($key, $value);
if(empty($lock))
{
$value = $redis->get($key);
if($value < time())
{
$redis->del($key);
}
}else{
$status = false;
//下步操作....
}
}
但是,簡單粗暴的用DEL命令刪除鎖再SETNX命令上鎖也會出現問題。比如,進程1獲得鎖後崩潰或刪除鎖失敗,這時進程2檢測到鎖存在當已過期,用DEL命令刪除鎖並用SETNX命令設置鎖,進程3也檢測到鎖過期,也用DEL命令刪除鎖也用SETNX命令設置了鎖,這時進程2和進程3同時獲得了鎖。問題大了
爲了解決這個問題,這裏用到了Redis的GETSET命令,GETSET命令在給鎖設置新值的同時返回鎖的舊值,這裏利用了GETSET命令同時獲取和賦值的特性,在此期間其他進程無法修改鎖的值。
例如:
進程1獲得鎖後操作超時/崩潰/刪除鎖失敗,
進程2檢測到鎖已存在,但獲取鎖的值對比當前時間發現鎖已過期,
進程2通過GETSET命令重新給鎖賦予新的值,並獲取到的鎖的舊值,再次對比鎖的舊值與當前時間,如果鎖的舊值依然小於當前時間的話,這時進程2就可以忽略進程1餘留下的廢鎖進行下步操作了。
進程2完成下步操作後返回前應該刪除鎖,但在刪除鎖時可以先檢測鎖是否還未過期,未過期才做刪除操作,已過期的就沒必要在去刪除鎖了,因爲很有可能其他進程檢測到鎖過期時已經去獲取鎖了。
這裏要說明的是,如果有其他進程在進程2之前獲取到鎖,那麼進程2將獲取鎖失敗,但是進程2在用GETSET獲取鎖的舊值時也賦予了鎖新的值,改寫了其他進程賦予鎖的超時值。看到這大家可能會有疑問了,進程2沒獲取到鎖怎麼能改變鎖的值呢?是的,進程2改變了鎖的原有值,但這一點小小的時間誤差帶來的影響是可以忽略。
3、以下是Redis實現分佈式鎖的完整PHP代碼:
<?php
/**
* 實現Redis分佈鎖
*/
$key = 'test'; //要更新信息的緩存KEY
$lockKey = 'lock:'.$key; //設置鎖KEY
$lockExpire = 10; //設置鎖的有效期爲10秒
//獲取緩存信息
$result = $redis->get($key);
//判斷緩存中是否有數據
if(empty($result))
{
$status = TRUE;
while ($status)
{
//設置鎖值爲當前時間戳 + 有效期
$lockValue = time() + $lockExpire;
/**
* 創建鎖
* 試圖以$lockKey爲key創建一個緩存,value值爲當前時間戳
* 由於setnx()函數只有在不存在當前key的緩存時纔會創建成功
* 所以,用此函數就可以判斷當前執行的操作是否已經有其他進程在執行了
* @var [type]
*/
$lock = $redis->setnx($lockKey, $lockValue);
/**
* 滿足兩個條件中的一個即可進行操作
* 1、上面一步創建鎖成功;
* 2、 1)判斷鎖的值(時間戳)是否小於當前時間 $redis->get()
* 2)同時給鎖設置新值成功 $redis->getset()
*/
if(!empty($lock) || ($redis->get($lockKey) < time() && $redis->getSet($lockKey, $lockValue) < time() ))
{
//給鎖設置生存時間
$redis->expire($lockKey, $lockExpire);
//******************************
//此處執行插入、更新緩存操作...
//******************************
//以上程序走完刪除鎖
//檢測鎖是否過期,過期鎖沒必要刪除
if($redis->ttl($lockKey))
$redis->del($lockKey);
$status = FALSE;
}else{
/**
* 如果存在有效鎖這裏做相應處理
* 等待當前操作完成再執行此次請求
* 直接返回
*/
sleep(2);//等待2秒後再嘗試執行操作
}
}
}