關於搶購秒殺的實現思路與事例程式碼

逍遙俠發表於2018-10-30

#事先說明,本次的文章所貼的事例程式碼並非本人,具體出自什麼地方?我也無從考究。不過今天要為大家講的就是基於這些事例程式碼結合對應的個人理解進行分析。如果有什麼覺得說得不正確的請各位看官拍磚。也讓我學而知不足。


#關於秒殺搶購的思路一般都基於三個部分進行設計

1.使用者頁面層,這個部分可以設定頁面快取,cdn加速,適當的請求攔截。當然前兩者相信各位很容易理解,那什麼是請求攔截了?其實說白了就是當使用者點選了提交按鈕後,記得通過ajax把按鈕設定為禁用狀態。須知道使用者在煩躁的時候可是會瘋狂地點選提交按鈕,這部分的請求如果你不過濾到那豈不是在白白浪費伺服器的資源?

2.資料接入層,在資料接入層的這個層面來說我們一般我們就要對使用者的請求進行判斷,儘量把惡意的請求都拒絕在外,常見的做法就是同一個IP在限定的時間段內限制訪問次數,或者通過記錄使用者的UID來限制用一個使用者的UID在每分鐘的請求次數,用來過濾一些高階使用者通過指令碼來參與請求的。

3.資料處理層,最後我們本次文章就是要基於資料處理層的程式碼展示來為大家說一下關於搶購的處理思路。其實對於搶購和秒殺的核心處理思路就是防止超賣,還有防止伺服器迅時流量的爆增導致服務的崩潰。

那麼我們先看一個傳統的搶購流程

14834077822.jpg

上面這個例子,假設某個搶購場景中,我們一共只有100個商品,在最後一刻,我們已經消耗了99個商品,僅剩最後一個。這個時候,系統發來多個併發請求,這批請求讀取到的商品餘量都是99個,然後都通過了這一個餘量判斷,最終導致超發。在上面的這個圖中,就導致了併發使用者B也“搶購成功”,多讓一個人獲得了商品。這種場景,在高併發的情況下非常容易出現。

優化方案1:將庫存欄位number欄位設為unsigned,當庫存為0時,因為欄位不能為負數,將會返回false

<?php
//優化方案1:將庫存欄位number欄位設為unsigned,當庫存為0時,因為欄位不能為負數,將會返回false
include('./mysql.php');
$username = 'wang'.rand(0,1000);
//生成唯一訂單
function build_order_no(){
  return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
}
//記錄日誌
function insertLog($event,$type=0,$username){
    global $conn;
    $sql="insert into ih_log(event,type,usernma)
    values('$event','$type','$username')";
    return mysqli_query($conn,$sql);
}
function insertOrder($order_sn,$user_id,$goods_id,$sku_id,$price,$username,$number)
{
      global $conn;
      $sql="insert into ih_order(order_sn,user_id,goods_id,sku_id,price,username,number)
      values('$order_sn','$user_id','$goods_id','$sku_id','$price','$username','$number')";
     return  mysqli_query($conn,$sql);
}
//模擬下單操作
//庫存是否大於0
$sql="select number from ih_store where goods_id='$goods_id' and sku_id='$sku_id' ";
$rs=mysqli_query($conn,$sql);
$row = $rs->fetch_assoc();
  if($row['number']>0){//高併發下會導致超賣
      if($row['number']<$number){
        return insertLog('庫存不夠',3,$username);
      }
      $order_sn=build_order_no();
      //庫存減少
      $sql="update ih_store set number=number-{$number} where sku_id='$sku_id' and number>0";
      $store_rs=mysqli_query($conn,$sql);
      if($store_rs){
          //生成訂單
          insertOrder($order_sn,$user_id,$goods_id,$sku_id,$price,$username,$number);
          insertLog('庫存減少成功',1,$username);
      }else{
          insertLog('庫存減少失敗',2,$username);
      }
  }else{
      insertLog('庫存不夠',3,$username);
  }
?>
複製程式碼

當然上述的優化還是不夠的,接下來我們要進行的另一個優化方式就是往悲觀鎖去考慮,什麼是悲觀鎖呢?其實就是在修改資料的時候,採用鎖定狀態,排斥外部請求的修改。遇到加鎖的狀態,就必須等待。

14834077833.jpg

優化方案2:使用MySQL的事務,鎖住操作的行

<?php
//優化方案2:使用MySQL的事務,鎖住操作的行
include('./mysql.php');
//生成唯一訂單號
function build_order_no(){
  return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
}
//記錄日誌
function insertLog($event,$type=0){
    global $conn;
    $sql="insert into ih_log(event,type)
    values('$event','$type')";
    mysqli_query($conn,$sql);
}
//模擬下單操作
//庫存是否大於0
mysqli_query($conn,"BEGIN");  //開始事務
$sql="select number from ih_store where goods_id='$goods_id' and sku_id='$sku_id' FOR UPDATE";//此時這條記錄被鎖住,其它事務必須等待此次事務提交後才能執行
$rs=mysqli_query($conn,$sql);
$row=$rs->fetch_assoc();
if($row['number']>0){
    //生成訂單
    $order_sn=build_order_no();
    $sql="insert into ih_order(order_sn,user_id,goods_id,sku_id,price)
    values('$order_sn','$user_id','$goods_id','$sku_id','$price')";
    $order_rs=mysqli_query($conn,$sql);
    //庫存減少
    $sql="update ih_store set number=number-{$number} where sku_id='$sku_id'";
    $store_rs=mysqli_query($conn,$sql);
    if($store_rs){
      echo '庫存減少成功';
        insertLog('庫存減少成功');
        mysqli_query($conn,"COMMIT");//事務提交即解鎖
    }else{
      echo '庫存減少失敗';
        insertLog('庫存減少失敗');
    }
}else{
  echo '庫存不夠';
    insertLog('庫存不夠');
    mysqli_query($conn,"ROLLBACK");
}
?>
複製程式碼

雖然上述的方案的確解決了執行緒安全的問題,但是,別忘記,我們的場景是“高併發”。也就是說,會很多這樣的修改請求,每個請求都需要等待“鎖”,某些執行緒可能永遠都沒有機會搶到這個“鎖”,這種請求就會死在那裡。同時,這種請求會很多,瞬間增大系統的平均響應時間,結果是可用連線數被耗盡,系統陷入異常。

因此我們就可以採用一種非阻塞模式檔案鎖的方式來解決這個問題。首先在貼程式碼之前你可能會問什麼是非阻塞呢?簡單來說說,檔案鎖可以分為兩種模式,一種是阻塞檔案鎖,另一種是非阻塞檔案鎖。阻塞檔案鎖,會當檔案被佔用的時候,其他使用者無法開啟檔案且一直在等待過程。而非阻塞檔案鎖呢,檔案在被佔用時,可以直接返回false給使用者,從而節省使用者的等待時間。

優化方案3:非阻塞檔案排他鎖方式

<?php

##注意進入佇列的操作這裡沒有

//優化方案3:使用非阻塞的檔案排他鎖
include ('./mysql.php');
//生成唯一訂單號
function build_order_no(){
  return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
}
//記錄日誌
function insertLog($event,$type=0){
    global $conn;
    $sql="insert into ih_log(event,type)
    values('$event','$type')";
    mysqli_query($conn,$sql);
}
$fp = fopen("lock.txt", "w+");
if(!flock($fp,LOCK_EX | LOCK_NB)){
    echo "系統繁忙,請稍後再試";
    return;
}
//下單
$sql="select number from ih_store where goods_id='$goods_id' and sku_id='$sku_id'";
$rs =  mysqli_query($conn,$sql);
$row = $rs->fetch_assoc();
if($row['number']>0){//庫存是否大於0
    //模擬下單操作
    $order_sn=build_order_no();
    $sql="insert into ih_order(order_sn,user_id,goods_id,sku_id,price)
    values('$order_sn','$user_id','$goods_id','$sku_id','$price')";
    $order_rs =  mysqli_query($conn,$sql);
    //庫存減少
    $sql="update ih_store set number=number-{$number} where sku_id='$sku_id'";
    $store_rs =  mysqli_query($conn,$sql);
    if($store_rs){
      echo '庫存減少成功';
        insertLog('庫存減少成功');
        flock($fp,LOCK_UN);//釋放鎖
    }else{
      echo '庫存減少失敗';
        insertLog('庫存減少失敗');
    }
}else{
  echo '庫存不夠';
    insertLog('庫存不夠');
}
fclose($fp);
 ?>
複製程式碼

對於日IP不高或者說併發數不是很大的應用,用一般的檔案操作方法完全沒有問題。但如果併發高,在我們對通過使用檔案鎖操作其實是非常消耗效能的。因此我們可以引入新的思路。

4. FIFO佇列思路

那好,那麼我們稍微修改一下上面的場景,我們直接將請求放入佇列中的,採用FIFO(First Input First Output,先進先出),當然這裡的佇列我們要使用我們耳熟能詳的redis佇列。

優化思路4:通過引入佇列的方式

#先將商品庫存如佇列

<?php
$store=1000;
$redis=new Redis();
$result=$redis->connect('127.0.0.1',6379);
$res=$redis->llen('goods_store');
echo $res;
$count=$store-$res;
for($i=0;$i<$count;$i++){
	$redis->lpush('goods_store',1);
}
echo $redis->llen('goods_store');
複製程式碼

#資料處理
<?php
$conn=mysql_connect("localhost","big","123456");  
if(!$conn){  
	echo "connect failed";  
	exit;  
} 
mysql_select_db("big",$conn); 
mysql_query("set names utf8");
 
$price=10;
$user_id=1;
$goods_id=1;
$sku_id=11;
$number=1;
 
//生成唯一訂單號
function build_order_no(){
    return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
}
//記錄日誌
function insertLog($event,$type=0){
	global $conn;
	$sql="insert into ih_log(event,type) 
	values('$event','$type')";  
	mysql_query($sql,$conn);  
}
 
//模擬下單操作
//下單前判斷redis佇列庫存量
$redis=new Redis();
$result=$redis->connect('127.0.0.1',6379);
$count=$redis->lpop('goods_store');
if(!$count){
	insertLog('error:no store redis');
	return;
}
 
//生成訂單  
$order_sn=build_order_no();
$sql="insert into ih_order(order_sn,user_id,goods_id,sku_id,price) 
values('$order_sn','$user_id','$goods_id','$sku_id','$price')";  
$order_rs=mysql_query($sql,$conn); 
 
//庫存減少
$sql="update ih_store set number=number-{$number} where sku_id='$sku_id'";
$store_rs=mysql_query($sql,$conn);  
if(mysql_affected_rows()){  
	insertLog('庫存減少成功');
}else{  
	insertLog('庫存減少失敗');
} 

複製程式碼

那麼新的問題來了,高併發的場景下,因為請求很多,很可能一瞬間將佇列記憶體“撐爆”,然後系統又陷入到了異常狀態。或者設計一個極大的記憶體佇列,也是一種方案,但是,系統處理完一個佇列內請求的速度根本無法和瘋狂湧入佇列中的數目相比。也就是說,佇列內的請求會越積累越多,最終Web系統平均響應時候還是會大幅下降,系統還是陷入異常。

這個時候,我們就可以討論一下“樂觀鎖”的思路了。樂觀鎖,是相對於“悲觀鎖”採用更為寬鬆的加鎖機制,大都是採用帶版本號(Version)更新。實現就是,這個資料所有請求都有資格去修改,但會獲得一個該資料的版本號,只有版本號符合的才能更新成功,其他的返回搶購失敗。這樣的話,我們就不需要考慮佇列的問題,不過,它會增大CPU的計算開銷。但是,綜合來說,這是一個比較好的解決方案。

14834077835.jpg

有很多軟體和服務都“樂觀鎖”功能的支援,例如Redis中的watch就是其中之一。通過這個實現,我們保證了資料的安全。

<?php
$redis = new redis();
 $result = $redis->connect('127.0.0.1', 6379);
 echo $mywatchkey = $redis->get("mywatchkey");

$rob_total = 100;   //搶購數量
if($mywatchkey<=$rob_total){
    $redis->watch("mywatchkey");
    $redis->multi(); //在當前連線上啟動一個新的事務。
    //插入搶購資料
    $redis->set("mywatchkey",$mywatchkey+1);
    $rob_result = $redis->exec();
    if($rob_result){
         $redis->hSet("watchkeylist","user_".mt_rand(1, 9999),$mywatchkey);
        $mywatchlist = $redis->hGetAll("watchkeylist");
        echo "搶購成功!<br/>";
      
        echo "剩餘數量:".($rob_total-$mywatchkey-1)."<br/>";
        echo "使用者列表:<pre>";
        var_dump($mywatchlist);
    }else{
          $redis->hSet("watchkeylist","user_".mt_rand(1, 9999),'meiqiangdao');
        echo "手氣不好,再搶購!";exit;
    }
}
?>

#注意請購成功的使用者,需要另外寫定時任務去處理成功的使用者,這裡的mt_rand演示生成使用者名稱複製程式碼

#到此,關於搶購秒殺的應用優化思路暫時告一段落。如果上述理解有誤請各位留言提供你們的思路,或者你們認為更好的方法讓我學習下。謝謝


相關文章