實現分散式鎖常見有三種實現方式:
1.基於資料庫
2.基於快取(redis)分散式鎖,
3.基於Zookeeper實現分散式鎖
以下是他們在可靠性、效能、複雜性三個維度的對比
評判維度 | 比較 |
---|---|
可靠性 | Zookeeper > 快取 > 資料庫 |
效能 | 快取 > Zookeeper >= 資料庫 |
複雜性 | Zookeeper >= 快取 > 資料庫 |
由於redis高效能,在許多密集型的業務場景中是運用最多,因此以下介紹基於redis分散式鎖的實現
分析
Why
- 安全性(互斥性):在任意時刻,當且僅當只有一個客戶端能持有鎖
- 活性A(無死鎖):即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖
- 同一性:加鎖和解鎖必須保證為同一個客戶端
- 活性B(容錯性):只要大部分的Redis節點正常執行,客戶端就可以加鎖和解鎖
what
- 時間維度保證資料強一致性
When
- 存在競爭(庫存競爭,工單/任務競爭)
Where
- 搶購
- 秒殺
- 搶單
- 派單
- 庫存
Who
- 庫存競爭:給標識庫存的唯一屬性加鎖作為key
- 工單 / 任務競爭:給工單 / 任務 加鎖作為key
How
- 沒鎖可以加鎖
- 有鎖加鎖失敗
- 給鎖設定過期時間
- 解鎖和加鎖是同一個使用者
How much
- 一條指令
How feel
- 樂觀鎖
- 悲觀鎖
常見加鎖方式
示例1
public function lock($lockKey, $requestId, $expireTime)
{
$redis = Redis::connection();
$result = $redis->setnx($lockKey, $requestId);
if ($result) {
// 若在這裡程式突然崩潰,則無法設定過期時間,將發生死鎖
$redis->expire($lockKey, $expireTime);
}
}
此處乍一看這種方式並沒有什麼問題,
But由於是兩條redis命令,So不具有原子性;
試想如果程式在執行完第一句setnx命令之後突然掛掉,那麼會發生死鎖,和設計原則相違背。
因此不是最優解
示例2
public function lock2($lockKey, $requestId, $expireTime)
{
$expires = microtime(true) + $expireTime;
$redis = Redis::connection();
// 如果當前鎖不存在,返回加鎖成功
$result = $redis->setnx($lockKey, microtime(true));
if ($result) {
return true;
}
// 如果鎖存在,獲取鎖的過期時間
$currenExpires = $redis->get($lockKey);
if ($currenExpires && $currenExpires < microtime(true)) {
// 鎖已過期,獲取上一個鎖的過期時間,並設定現在鎖的過期時間
$oldExpires = $redis->getset($lockKey, $expires);
if ($oldExpires && $oldExpires == $currenExpires) {
// 考慮多執行緒併發的情況,只有一個執行緒的設定值和當前值相同,它才有權利加鎖
return true;
}
}
// 其他情況,一律返回加鎖失敗
return false;
}
那麼這段程式碼問題在哪裡?
- 由於是客戶端自己生成過期時間,所以需要強制要求分散式下每個客戶端的時間必須同步;
- 當鎖過期的時候,如果多個客戶端同時執行
getset
方法,那麼雖然最終只有一個客戶端可以加鎖,但是這個客戶端的鎖的過期時間可能被其他客戶端覆蓋;- 鎖不具備擁有者標識,即任何客戶端都可以解鎖。
因此此鎖安全性沒法保證,不滿足設計原則第一條
示例3
/**
* @param $lockKey 鎖
* @param $requestId 請求標識
* @param $expireTime 超期時間
* @return bool
*/
public function lock3($lockKey, $requestId, $expireTime)
{
$ret = Redis::set($lockKey, $requestId, 'PX', $expireTime, 'NX');
if ($ret) {
return true;
}
return false;
}
此鎖既滿足了安全性,又有活性,並且滿足同一性(解鎖中體現),同時實現簡單,是一種最優解
常見解鎖方式
示例1
public function releaseLock($lockKey)
{
$redis = Redis::connection();
$redis->del($lockKey);
}
這種不先判斷鎖的擁有者而直接解鎖的方式,會導致任何客戶端都可以隨時進行解鎖,即使這把鎖不是它的
示例2
public function releaseLock1($lockKey, $requestId)
{
$redis = Redis::connection();
$result = $redis->get($lockKey);
// 判斷加鎖與解鎖是不是同一個客戶端
if ($result == $requestId) {
// 若在此時,這把鎖突然不是這個客戶端的,則會誤解鎖
$redis->del($lockKey);
}
}
這種解鎖方法沒有多大毛病,但是存在一個問題,有誤刪鎖的可能性
比如A客戶端加鎖,執行一段事件後進行解鎖操作,在執行del
鎖之前鎖過期,這時候客戶端B加鎖成功,接著客戶端A執行del
鎖就會將客戶端B的鎖刪除,沒有保證同一性
示例3
public function releaseLock13($lockKey, $requestId)
{
$luaScript = <<<EOF
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
EOF;
// 利用lua指令碼,保證原子性
$res = Redis::eval($luaScript, 1, $lockKey, $requestId);
if ($res) {
return true;
}
return false;
}
此種方法利用lua指令碼,保證原子性,是一種最優解
完整實現
trait RedisMutexLock{
/**
* 獲取分散式鎖(加鎖)
* @param lockKey 鎖key
* @param requestId 客戶端請求標識
* @param expireTime 超期時間,毫秒,預設15s
* @param isNegtive 是否是悲觀鎖,預設否
* @return 是否獲取成功
*/
public function tryGetDistributedLock($lockKey, $requestId, $expireTime = 15000, $isNegtive = false)
{
if ($isNegtive) {//悲觀鎖
/**
* 悲觀鎖 迴圈阻塞式鎖取,阻塞時間為2s
*/
$endtime = microtime(true) * 1000 + $this->acquireTimeout * 1000;
while (microtime(true) * 1000 < $endtime) { //每隔一段時間嘗試獲取一次鎖
$acquired = Redis::set($lockKey, $requestId, 'PX', $expireTime, 'NX');
if ($acquired) { //獲取鎖成功,返回true
return true;
}
usleep(100);
}
//獲取鎖超時,返回false
return false;
} else {//樂觀鎖
/**
* 樂觀鎖只嘗試一次,成功返回true,失敗返回false
*/
$ret = Redis::set($lockKey, $requestId, 'PX', $expireTime, 'NX');
if ($ret) {
return true;
}
return false;
}
}
/**
* 解鎖
* @param $lockKey 鎖key
* @param $requestId 客戶端請求唯一標識
*/
public function releaseDistributedLock($lockKey, $requestId)
{
$luaScript = <<<EOF
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
EOF;
$res = Redis::eval($luaScript, 1, $lockKey, $requestId);
if ($res) {
return true;
}
return false;
}
}
使用
use RedisMutexLock;
public function __construct()
{
define("REQUEST_ID", md5(uniqid(env('APP_NAME'), true)) . rand(10000, 99999));
$this->requestId = $_SERVER['x_request_id'] ?? REQUEST_ID;
}
// 搶單
public function addOrder()
{
// 訂單加鎖
$lock = $this->tryGetDistributedLock($this->redisOrderKey, $this->requestId);
if (!$lock) {
return ['error' => 1900001];
}
try {
// TODO 處理業務
} catch (\Exception $e) {
// 異常處理
} finally {
// 處理完釋放鎖
$this->releaseDistributedLock($this->redisOrderKey, $this->requestId);
}
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結