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

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


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

1.用戶頁面層,這個部分能夠設置頁面緩存,cdn加速,適當的請求攔截。固然前二者相信各位很容易理解,那什麼是請求攔截了?其實說白了就是當用戶點擊了提交按鈕後,記得經過ajax把按鈕設置爲禁用狀態。須知道用戶在煩躁的時候但是會瘋狂地點擊提交按鈕,這部分的請求若是你不過濾到那豈不是在白白浪費服務器的資源?php

2.數據接入層,在數據接入層的這個層面來講咱們通常咱們就要對用戶的請求進行判斷,儘可能把惡意的請求都拒絕在外,常見的作法就是同一個IP在限定的時間段內限制訪問次數,或者經過記錄用戶的UID來限制用一個用戶的UID在每分鐘的請求次數,用來過濾一些高端用戶經過腳原本參與請求的。mysql

3.數據處理層,最後咱們本次文章就是要基於數據處理層的代碼展現來爲你們說一下關於搶購的處理思路。其實對於搶購和秒殺的核心處理思路就是防止超賣,還有防止服務器迅時流量的爆增致使服務的崩潰。ajax

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

14834077822.jpg

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

優化方案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的事務,鎖住操做的行bash

<?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演示生成用戶名複製代碼

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

相關文章
相關標籤/搜索