天天看點

redis應用系列一:分布式鎖正确實作姿勢

實作分布式鎖常見有三種實作方式:

  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

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

$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

function releaseLock($lockKey)
    {
        $redis  = Redis::connection();
        $redis->del($lockKey);
    }      

這種不先判斷鎖的擁有者而直接解鎖的方式,會導緻任何用戶端都可以随時進行解鎖,即使這把鎖不是它的

示例 2

function releaseLock1($lockKey, $requestId)
    {
        $redis  = Redis::connection();
        $result = $redis->get($lockKey);
        // 判斷加鎖與解鎖是不是同一個用戶端
        if ($result == $requestId) {
            // 若在此時,這把鎖突然不是這個用戶端的,則會誤解鎖
            $redis->del($lockKey);
        }
    }      

這種解鎖方法沒有多大毛病,但是存在一個問題,有誤删鎖的可能性

比如 A 用戶端加鎖,執行一段事件後進行解鎖操作,在執行 del 鎖之前鎖過期,這時候用戶端 B 加鎖成功,接着用戶端 A 執行 del 鎖就會将用戶端 B 的鎖删除,沒有保證同一性

示例3

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);
    }
}      

繼續閱讀