高併發業務場景下的秒殺解決方案 (初探)

奕鵬發表於2019-10-15

文章簡介

本文內容是對併發業務場景出現超賣情況而寫的一片解決方案。主要是利用到了 Redis 中的佇列技術。

超賣介紹

所謂的超賣,就是我們的售賣量大於了物品的庫存量。該情況一般出現在電商系統中促銷類的業務場景中。輕則只是部分商品超賣,較小的經濟損失,但是當大量的超賣情況,例如淘寶雙十一這樣的業務場景下導致超賣,則損失是非常大的,同時給使用者體驗帶來的也是負面影響,很有可能損失使用者量。記得之前遇到一個公司,做電商專案,就是因為超賣導致公司倒閉。

常規的秒殺模式

首先,我們見下圖.
常規的秒殺示例圖

1.第一步是我們使用者進入商品秒殺頁面,點選秒殺按鈕,向服務端傳送秒殺請求。

2.服務端在接受到使用者秒殺請求,根據請求的商品id引數,去查詢資料庫中該商品id的庫存量。

3.當查詢到該商品庫存量後,進行判斷。如果庫存量不足,則返回給使用者,商品庫存不足的資訊。

4.當查詢到該商品的庫存足夠時,則生成訂單資料並減少商品庫存。接著將成功資訊返回給使用者。

5.使用者接受到搶購成功訊息後,才可進入下單頁面。此時按照正常邏輯,進行下單支付。

這種模式為什麼會出現超賣呢?

按照我們上面所講的,按理來說是一種正常的邏輯流程。但是當並打量大的時候,就會出現超賣情況。在上圖第 2 步驟中,是做商品庫存的查詢。假如此時我們查詢到的商品庫存為 1,這時候就會走 4 中上面的部分(插入搶購資訊並減少庫存),由於併發量大的情況下,下一個請求在上一個還未執行減庫操作就去查詢了商品庫存,這時候查詢出來的庫存量依然是 1。同樣的,會走到 4 上面的步驟中去。然後上一個請求執行了減庫操作,此時庫存為 0,第二個請求再去減庫時,就會把庫存量設定為-1,這樣就出現了超賣情況。由於併發,同時會發生很多請求,因此減少的數量不僅僅是 1 了,或許是成百上千甚至上萬等等。

解決超賣思路

網上有很多這樣的思路,幾乎是通過佇列技術來解決的。先將商品庫存資訊快取到我們的快取中去,例如 Redis。(文章中示例也是通過該方案實現)。

秒殺實現

這裡單獨講一講示例程式碼中秒殺的解決思路。

  1. 在秒殺前將商品的庫存資訊加入到 Redis 快取中。如下格式:
$redis->lpush('商品id',1);

當每一個商品有多少個庫存則迴圈多少次,這樣就可以保證每個商品佇列中的長度就是商品庫存長度。其實這裡個人是有一個疑問的,如果商品少,我們加入到快取的耗時是很小的,但是商品數量大,這樣就很耗時,並且 redis 是放在記憶體中的,也暫用大量的記憶體。

  1. 當秒殺開始時,使用者傳送請求,每次去檢測一下商品的佇列是否為空,當非空時,則使用 lpop 減少一個長度,也就是減少一個庫存量。這時候將秒殺的資訊寫入到快取中去,給快取資訊配一個唯一的鍵,將該鍵返回給使用者。(由於 lpop 是原子性的,即是大量併發來了,也是要在 Redis 內部進行排隊執行的,假如在判斷是否為空時,檢測到是非空,進行 lpop 操作,由於佇列是空,這時候去執行出佇列也是返回錯誤的)。

  2. 返回給使用者秒殺成功的資訊,使用者根據返回的鍵進行下單操作。利用該鍵,將秒殺中的快取資訊寫入資料庫並生成對應的訂單。

接下來,我們可以結合上圖,得出下面的流程圖:
redis秒殺

程式碼具體實現

建立公共的 Redis 連線

<?php
/**
 * Redis連線
 */
$redis = new Redis();
$result = $redis->connect('127.0.0.1',6379,2);
if(!$result){
    die('redis connect fail');
}

秒殺前將商品庫存寫入快取中

/**
 * 模擬商品庫存如佇列
 */
require_once __DIR__.'/redis_connect.php';
// 模擬資料庫查詢的商品資料
$goodsList = [
    ['id'=>1,'name'=>'夏季外套','price'=>12.32,'count'=>12],
    ['id'=>2,'name'=>'冬季外套','price'=>12.32,'count'=>1],
    ['id'=>3,'name'=>'秋季外套','price'=>12.32,'count'=>2],
    ['id'=>4,'name'=>'春季外套','price'=>12.32,'count'=>23],
    ['id'=>5,'name'=>'男士內衣','price'=>12.32,'count'=>8],
    ['id'=>6,'name'=>'男士馬甲','price'=>12.32,'count'=>180],
    ['id'=>7,'name'=>'男士長褲','price'=>12.32,'count'=>120],
];

// 將商品庫存新增到redis佇列中
$goodqueue = 'goods:queue:';
foreach($goodsList as $key => $val){
    $count = $val['count'];
    for($i=0;$i<$count;$i++){
       $result = $redis->lpush($goodqueue.$val['id'],1);
       echo $result.'<br/>';
    }
}

模擬客戶傳送請求,這裡可以開多個視窗,增加請求量。

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <title>Document</title>
    </head>
    <body>
        模擬秒殺場景,使用者請求
        <div class="content"></div>
        <script src="https://cdn.bootcss.com/jquery/2.2.0/jquery.min.js"></script>
        <script>
            // 簡單模擬1000個使用者傳送請求
            for (let index = 0; index < 1000; index++) {
                $.ajax({
                    type: "POST",
                    url: "http://localhost/Test/redis_miaosha.php",
                    data: {
                        userId: index,
                        goodsId: Math.floor(Math.random() * 10)
                    },
                    dataType: "json",
                    success: function(res) {
                        console.log(res.result);
                        if (res.result === "OK") {
                            $(".content").append(
                                "<a href='http://localhost/Test/redis_server.php?key=" +
                                    res.key +
                                    "' target='_blank'>使用者id為" +
                                    index +
                                    "的搶購成功!</a><br/>"
                            );
                        } else if (res.result === "FAIL") {
                            $(".content").append(
                                "<a href=''>使用者id為" +
                                    index +
                                    "的搶購失敗!</a><br/>"
                            );
                        }
                    }
                });
            }
        </script>
    </body>
</html>

服務端接收秒殺請求並寫入快取

<?php
/**
 * 模擬使用者秒殺場景
 */
require_once __DIR__.'/redis_connect.php';
/**
 *
 * 1.接受使用者請求
 * 2.驗證使用者是否已經參與秒殺,商品是否存在
 * 3.根據商品id減少商品佇列中的庫存數量
 * 4.將使用者的秒殺資料寫入server層中,並返回秒殺資料對應的唯一key值
 * 5.使用者點選下單,根據serve層中的快取資料,生成訂單資料並減少資料庫商品的庫存資料
 */
 $getParams = $_POST;
$userId = $getParams['userId'];
$goodsId = $getParams['goodsId'];

$key = 'goods:miaosha:';
$userResult = $redis->get($key.$userId);
if($userResult){
    $userResult = json_decode($userResult,true);
    echo json_encode(['result'=>$userResult['result'],'key'=>$key.$userId]);// 已經參與過秒殺了
    die();
}else{
    $goodqueue = 'goods:queue:'.$goodsId;
    $result = $redis->lpop($goodqueue);// 刪除商品redis佇列快取
    if($result){
        $data = json_encode(['result'=>'OK','userId'=>$userId,'goodsId'=>$goodsId]);
        $redis->set($key.$userId,$data);// 將秒殺資訊寫入快取中
        echo json_encode(['result'=>'OK','userId'=>$userId,'goodsId'=>$goodsId,'key'=>$key.$userId]);
        die();
    }else{
        echo json_encode(['result'=>'FAIL','message'=>'商品不存在','goodsId'=>$goodsId]);// 商品庫存不存在
        die();
    }
}

客戶端在接收到秒殺請求結果後,進行支付

<?php
/**
 * 使用者下單介面
 */
require_once __DIR__.'/redis_connect.php';
$key = $_GET['key'];
$data = $redis->get($key);
/**
 * 生成訂單,訂單入庫
 *
 */

相關文章