Redis作為單執行緒 為什麼我用它還是出現了超賣呢?

奕鵬發表於2021-10-25

專注於PHP、MySQL、Linux和前端開發,感興趣的感謝點個關注喲!!!文章整理在GitHub,Gitee主要包含的技術有PHP、Redis、MySQL、JavaScript、HTML&CSS、Linux、Java、Golang、Linux和工具資源等相關理論知識、面試題和實戰內容。

實戰說明

最近在一個專案營銷活動中,一位同事用到了Redis來實現商品的庫存管理。在壓測的過程中,發現存在超賣的情況。這裡總結一篇如何正確使用Redis來解決秒殺場景下,超賣的情況。

演示步驟

這裡不會直接給大家說明,該怎麼去實現安全、高效的分散式鎖。而是通過循序漸進的方式,通過不同的方式實現鎖,並發現每一種鎖的缺點以及針對該型別的鎖進行如何優化,最終達到實現一個高效、安全的分佈鎖。

第一種場景

該場景是利用Redis來儲存商品數量。先獲取庫存,針對庫存判斷,如果庫存大於0,則減少1,再更新Redis庫存資料。大致示意圖如下:
Snipaste_2021-10-25_21-19-53

  1. 當第一個請求來之後,去判斷Redis的庫存數量。接著給商品庫存減少一,然後去更新庫存數量。

  2. 當在第一個請求處理庫存邏輯之間,第二個請求來了,同樣的邏輯,去讀Redis庫存,判斷庫存數量接著執行減少庫存操作。此時他們操作的商品其實就是同一個商品。

  3. 然後這樣的邏輯,在秒殺這樣大量請求來,就很容易實際商品售賣的數量遠遠大於商品庫存數量。

public function demo1(ResponseInterface $response)
{
    $application = ApplicationContext::getContainer();
    $redisClient = $application->get(Redis::class);
    /** @var int $goodsStock 商品當前庫存*/
    $goodsStock = $redisClient->get($this->goodsKey);

    if ($goodsStock > 0) {
        $redisClient->decr($this->goodsKey);
        // TODO 執行額外業務邏輯
        return $response->json(['msg' => '秒殺成功'])->withStatus(200);
    }
    return $response->json(['msg' => '秒殺失敗,商品庫存不足。'])->withStatus(500);
}

問題分析:

  1. 該方式使用Redis來管理商品庫存,減少對MySQL的壓力。

  2. 假設此時庫存只有1,第一請求在判斷庫存為大於0,減少庫存的過程中。如果存在第二個請求來讀取到了資料,發現商品庫存是大於0的。兩者都會執行秒殺的邏輯,然而庫存只有一個,就遇到了超賣的情況。

  3. 此時,我們試想一下,如果我們只能讓一個請求處理庫存,其他的請求只有等待直到上一個請求結束才能去進行獲取商品庫存,是不是就能實現超賣呢?這就是下面幾種場景提到的鎖機制實現。

第二種場景

使用檔案鎖,第一請求來了之後,開啟檔案鎖。處理完畢業務之後,釋放當前的檔案鎖,接著處理下一個請求,依次迴圈。保證當前的所有請求,只有一個請求在處理庫存。請求處理完畢之後,則釋放鎖。
Snipaste_2021-10-25_21-28-40

  1. 使用檔案鎖,來一個請求給一個檔案加鎖。此時另外的請求就會被阻塞,直到上一個請求成功釋放鎖檔案,下一個請求才會執行。

  2. 所有的請求就猶如一個佇列一樣,前一個先入佇列後一個後入佇列,一次按照FIFO的順序進行。

    public function demo3(ResponseInterface $response)
    {
     $fp = fopen("/tmp/lock.txt", "r+");
    
     try {
         if (flock($fp, LOCK_EX)) {  // 進行排它型鎖定
             $application = ApplicationContext::getContainer();
             $redisClient = $application->get(Redis::class);
             /** @var int $goodsStock 商品當前庫存*/
             $goodsStock = $redisClient->get($this->goodsKey);
             if ($goodsStock > 0) {
                 $redisClient->decr($this->goodsKey);
                 // TODO 處理額外的業務邏輯
                 $result = true; // 業務邏輯處理最終結果
                 flock($fp, LOCK_UN);    // 釋放鎖定
                 fclose($fp);
                 if ($result) {
                     return $response->json(['msg' => '秒殺成功'])->withStatus(200);
                 }
                 return $response->json(['msg' => '秒殺失敗'])->withStatus(200);
             } else {
                 flock($fp, LOCK_UN);    // 釋放鎖定
                 fclose($fp);
                 return $response->json(['msg' => '庫存不足,秒殺失敗。'])->withStatus(500);
             }
         } else {
             fclose($fp);
             return $response->json(['msg' => '活動過於火爆,搶購的人過多,請稍後重試。'])->withStatus(500);
         }
     } catch (\Exception $exception) {
         fclose($fp);
         return $response->json(['msg' => '系統異常'])->withStatus(500);
     } finally {
         fclose($fp);
     }
    }

問題分析:

  1. 如果使用檔案鎖,開啟一個鎖和釋放一個鎖都是比較耗時的。在秒殺的業務場景下,大量請求過來,很容易出現大部分使用者一直處於請求等待的過程中。

  2. 當開啟一個檔案鎖時,都是針對當前伺服器。如果我們的專案屬於分散式部署,上述的加鎖也只能針對當前的伺服器進行加鎖,而不是針對的請求進行加鎖。如下圖:容易時刻存在多個多伺服器多個鎖。
    Snipaste_2021-10-25_21-31-57

第三種場景

該方案是通過先Redis儲存商品庫存,來一個請求就針對上面的庫存減少1,Redis如果返回的庫存小於0則表示當前的秒殺失敗。主要是利用到了Redis的單執行緒寫。保證每次對Redis的寫都只有一個執行緒在執行。
Snipaste_2021-10-25_21-36-05

public function demo2(ResponseInterface $response)
{
    $application = ApplicationContext::getContainer();
    $redisClient = $application->get(Redis::class);
    /** @var int $goodsStock Redis減少1後的庫存資料 */
    $goodsStock = $redisClient->decr($this->goodsKey);
    if ($goodsStock > 0) {
        // TODO 執行額外業務邏輯
        $result = true;// 業務處理的結果
        if ($result) {
            return $response->json(['msg' => '秒殺成功'])->withStatus(200);
        } else {
            $redisClient->incr($this->goodsKey);// 將減少的庫存進行增加1
            return $response->json(['msg' => '秒殺失敗'])->withStatus(500);
        }
    }
    return $response->json(['msg' => '秒殺失敗,商品庫存不足。'])->withStatus(500);
}

問題分析:

  1. 該方案雖然利用了Redis的單執行緒模型特點,可以避免超賣的清空。當庫存為0時,來一個秒殺請求,就會將庫存減少1,最終Redis的快取資料肯定會為小於0。

  2. 該方案存在使用者秒殺數量與實際秒殺商品數量不一致。如上述程式碼,在業務處理結果為FALSE的時候,給Redis增加1,如果增加1的過程中發生異常,沒有增加成功就會導致商品數量不一致的情況。

第四種場景

通過上面的三種情況分析,我們可以得出檔案鎖的情況是最好的一種方案。但是檔案鎖不能解決分散式部署的清空。這時候我們可以利用Redis的setnx,expire來實現分佈鎖。setnx命令先設定一個鎖,expire給鎖加一個超時時間。

public function demo4(ResponseInterface $response)
{
    $application = ApplicationContext::getContainer();
    $redisClient = $application->get(Redis::class);

    if ($redisClient->setnx($this->goodsKey, 1)) {
        // 假設該執行下面的操作時伺服器當機
        $redisClient->expire($this->goodsKey, 10);
        // TODO 處理業務邏輯
        $result = true;// 處理業務邏輯的結果
        // 刪除鎖
        $redisClient->del($this->goodsKey);
        if ($result) {
            return $response->json(['msg' => '秒殺成功。'])->withStatus(200);
        }
        return $response->json(['msg' => '秒殺失敗。'])->withStatus(500);
    }
    return $response->json(['msg' => '系統異常,請重試。'])->withStatus(500);
}

問題分析:

  1. 通過上面的例項程式碼,我們會感覺到該這種方法似乎沒有什麼問題。加一個鎖,在釋放鎖。但細想一下,setnx命令在新增鎖之後,給鎖設定過期時間(expire)時發生了異常導致沒有正常給鎖加上過期時間。是不是這一把鎖就一直在呢?

  2. 所以上述的情況來實現Redis分散式鎖,是不滿足原子性的。

第五種場景

在第四種場景中,利用到了Redis實現分散式鎖。但是該分散式鎖不是原子性的,好在Redis提供該兩個命令的結合版,可以實現原子性。就是set(key, value, [‘nx’, ‘ex’ => ‘過期時間’])。

public function demo5(ResponseInterface $response)
{
    $application = ApplicationContext::getContainer();
    $redisClient = $application->get(Redis::class);

    if ($redisClient->set($this->goodsKey, 1, ['nx', 'ex' => 10])) {
        try {
            // TODO 處理秒殺業務
            $result = true;// 處理業務邏輯的結果
            $redisClient->del($this->goodsKey);
            if ($result) {
                return $response->json(['msg' => '秒殺成功。'])->withStatus(200);
            } else {
                return $response->json(['msg' => '秒殺失敗。'])->withStatus(200);
            }
        } catch (\Exception $exception) {
            $redisClient->del($this->goodsKey);
        } finally {
            $redisClient->del($this->goodsKey);
        }
    }
    return $response->json(['msg' => '系統異常,請重試。'])->withStatus(500);
}

問題分析:

  1. 通過一步一步的推進,可能你會覺得第五種場景,Redis來實現分散式應該是天衣無縫了吧。我們仔細去觀察打TODO的地方,也是處理業務邏輯的地方。要是業務邏輯超過快取設定的10秒會怎麼樣?

  2. 如果邏輯處理超過10秒,此時第二個秒殺請求就能正常處理自身的業務請求。恰好,第一個請求的業務邏輯執行完畢,要刪除Redis鎖了,就會把第二個的請求的Redis鎖給刪除。第三個請求就會正常執行,按照此邏輯是不是Redis的鎖一樣是一個無效的鎖呢?

  3. 此情況就會導致當前的請求在刪除Redis鎖時,刪除的不是自身的鎖。如果我們在刪除鎖時,做一個驗證,只能刪除自身的鎖,看看此方案是否行的通?接下來,我們看看第六種情況。

第六種場景

該方案針對上面第五種情況,在刪除時新增了一個請求的唯一標識判斷。也就是說只有刪除自身新增鎖時的標識。

public function demo6(ResponseInterface $response)
{
    $application = ApplicationContext::getContainer();
    $redisClient = $application->get(Redis::class);
    /** @var string $client 當前請求的唯一標識*/
    $client = md5((string)mt_rand(100000, 100000000000000000).uniqid());
    if ($redisClient->set($this->goodsKey, $client, ['nx', 'ex' => 10])) {
        try {
            // TODO 處理秒殺業務邏輯
            $result = true;// 處理業務邏輯的結果
            $redisClient->del($this->goodsKey);
            if ($result) {
                return $response->json(['msg' => '秒殺成功'])->withStatus(200);
            }
            return $response->json(['msg' => '秒殺失敗'])->withStatus(500);
        } catch (\Exception $exception) {
            if ($redisClient->get($this->goodsKey) == $client) {
                // 此處存在時間差
                $redisClient->del($this->goodsKey);
            }
        } finally {
            if ($redisClient->get($this->goodsKey) == $client) {
                // 此處存在時間差
                $redisClient->del($this->goodsKey);
            }
        }
    }
    return $response->json(['msg' => '請稍後重試'])->withStatus(500);
}

問題分析

  1. 通過上面的情況分析下來,貌似一點問題都沒有了。然而,仔細的你可以看看我新增註釋的地方”此處存在時間差”。如果Redis在讀取到快取時,並且判斷請求的唯一標識是一致的,在執行del刪除鎖時,發生了一個阻塞、網路波動等情況。在該鎖過期之後,才去執行到del命令,此時刪除的鎖還是當前請求的鎖嗎?

  2. 此時去刪除鎖,肯定就不是當前請求的鎖。而是下一個請求的鎖。這種情況,是否也會存在鎖無效的情況呢?

問題總結

通過上面的幾種例項程式碼演示,發現很大問題是在給Redis釋放鎖的時候,因為不屬於一個原子性操作。結合第六種情況,如果我們能夠保證釋放鎖是一個原子性,新增鎖也是一個原子性,這樣是不是就能正確保證我們的分佈鎖沒有問題了?

  1. 新增鎖時,實現原子性操作,我們用Redis原生的命令就可以了。

  2. 釋放鎖時,只刪除自身新增的鎖,我們在第六種場景中已經得到解決。

  3. 接下來,就只需要考慮釋放鎖的時候,能夠實現原子性操作。由於Redis原生沒有這樣的命令,我們就需要藉助lua操作,來實現原子性。

具體實現

通過開啟官網,可以看到官網提供分散式鎖實現的幾種客戶端,直接使用即可。官網地址,這裡我使用的客戶端是rtckit/reactphp-redlock
。具體安裝方式,直接按照文件操作即可。這裡簡單的說明一下兩種方式的呼叫。

第一種方式

 public function demo7()
{
    /** @var Factory $factory 初始化一個Redis例項*/
    $factory = new \Clue\React\Redis\Factory();
    $client  = $factory->createLazyClient('127.0.0.1');

    /** @var Custodian $custodian 初始化一個鎖監聽器*/
    $custodian = new \RTCKit\React\Redlock\Custodian($client);
    $custodian->acquire('MyResource', 60, 'r4nd0m_token')
        ->then(function (?Lock $lock) use ($custodian) {
            if (is_null($lock)) {
                // 獲取鎖失敗
            } else {
                // 新增一個10s生命週期的鎖
                // TODO 處理業務邏輯
                // 釋放鎖
                $custodian->release($lock);
            }
        });
}

該方式的大致邏輯,和我們在第六種方案中是差不多的,都是使用Redis的set + nx 命令實現原子性加鎖,然後給當前加的鎖設定一個隨機的字串,用來處理釋放當前鎖時,不能去釋放他人的鎖。做大的差別就是在使用release釋放鎖時,該方法去呼叫了一個lua指令碼,來刪除鎖。保證鎖的釋放是一個原子性的。下面是釋放鎖的大致截圖。

// lua指令碼
public const RELEASE_SCRIPT = <<<EOD
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
EOD;

public function release(Lock $lock): PromiseInterface
{
    /** @psalm-suppress InvalidScalarArgument */
    return $this->client->eval(self::RELEASE_SCRIPT, 1, $lock->getResource(), $lock->getToken())
        ->then(function (?string $reply): bool {
            return $reply === '1';
        });
}

第二種方式

第二種方式和第一種差距不大,無非是增加了一個自旋鎖。一直去獲取鎖,如果沒有獲取到則放棄當前的請求。

public function demo8()
{
    /** @var Factory $factory 初始化一個Redis例項*/
    $factory = new \Clue\React\Redis\Factory();
    $client  = $factory->createLazyClient('127.0.0.1');

    /** @var Custodian $custodian 初始化一個鎖監聽器*/
    $custodian = new \RTCKit\React\Redlock\Custodian($client);
    $custodian->spin(100, 0.5, 'HotResource', 10, 'r4nd0m_token')
        ->then(function (?Lock $lock) use ($custodian) : void {
            if (is_null($lock)) {
                // 將進行100次的場次,每一次間隔0.5秒去獲取鎖,如果沒有獲取到鎖。則放棄加鎖請求。
            } else {
                // 新增一個10s生命週期的鎖
                // TODO 處理業務邏輯
                // 釋放鎖
                $custodian->release($lock);
            }
        });
}

自旋鎖

spinlock又稱自旋鎖,是實現保護共享資源而提出一種鎖機制。自旋鎖與互斥鎖比較類似,都是為了解決對某項資源的互斥使用。

無論是互斥鎖,還是自旋鎖,在任何時刻,最多隻能有一個保持者,只能有一個執行單元獲得鎖。但是兩者在排程機制上略有不同。對於互斥鎖,如果資源已經被佔用,資源申請者只能進入睡眠狀態。但是自旋鎖不會引起呼叫者睡眠,如果自旋鎖已經被別的執行單元保持,呼叫者就一直迴圈在那裡看是否該自旋鎖的保持者已經釋放了鎖,”自旋”一詞就是因此而得名。

總結

其實通過上面的幾種方案,細心的你,可能還會發現很多問題。

  1. 本身併發可以是多一個多執行緒的處理方式,我們這裡新增鎖之後,是不是並行處理變成序列處理了。降低了秒殺所謂的高效能。

  2. 在Redis主從複製、叢集等部署架構方案中,上面的方案還能行得通嗎?

  3. 很多人都在說zookeeper更適合拿來用分散式鎖場景,那zookeeper比Redis耗在哪些地方呢?

帶著種種疑問,我們在下一篇文章再見。喜歡的,感興趣的,歡迎你關注我的文章。文章中存在不足的地方,也歡迎指正。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
喜歡的,可以關注公眾號"卡二條的技術圈"。

相關文章