redis應用系列一:分散式鎖正確實現姿勢

笨小孩發表於2021-05-16

實現分散式鎖常見有三種實現方式:
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;
    }

那麼這段程式碼問題在哪裡?

  1. 由於是客戶端自己生成過期時間,所以需要強制要求分散式下每個客戶端的時間必須同步;
  2. 當鎖過期的時候,如果多個客戶端同時執行getset方法,那麼雖然最終只有一個客戶端可以加鎖,但是這個客戶端的鎖的過期時間可能被其他客戶端覆蓋;
  3. 鎖不具備擁有者標識,即任何客戶端都可以解鎖。
    因此此鎖安全性沒法保證,不滿足設計原則第一條

示例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 協議》,轉載必須註明作者和本文連結

相關文章