关于抢购秒杀的实现思路与事例代码

#事先说明,本次的文章所贴的事例代码并不是本人,具体出自什么地方?我也无从考究。不过今天要为你们讲的就是基于这些事例代码结合对应的我的理解进行分析。若是有什么以为说得不正确的请各位看官拍砖。也让我学而知不足。


#关于秒杀抢购的思路通常都基于三个部分进行设计

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演示生成用户名复制代码

#到此,关于抢购秒杀的应用优化思路暂时告一段落。若是上述理解有误请各位留言提供大家的思路,或者大家认为更好的方法让我学习下。谢谢

相关文章
相关标签/搜索