yulu

处理高并发,秒杀

最近面试遇到很多涉及高并发,抢购的问题,随总结以下方案:

我们通常衡量一个Web系统的吞吐率的指标是QPS(Query Per Second,每秒处理请求数),解决每秒数万次的高并发场景,这个指标非常关键。举个例子,我们假设处理一个业务请求平均响应时间为100ms,同时,系统内有20台Web服务器,配置MaxClients为500个(表示服务器的最大连接数目)。

那么,我们的Web系统的理论峰值QPS为(理想化的计算方式):

20*500/0.1 = 100000 (10万QPS)

在高并发的实际场景下,机器都处于高负载的状态,在这个时候平均响应时间会被大大增加。

就Web服务器而言,他打开了越多的连接进程,CPU需要处理的上下文切换也越多,额外增加了CPU的消耗,然后就直接导致平均响应时间增加。因此上述的MaxClient数目,要根据CPU、内存等硬件因素综合考虑,绝对不是越多越好。可以通过Apache自带的ab来测试一下,取一个合适的值。然后,我们选择内存操作级别的存储的Redis,在高并发的状态下,存储的响应时间至关重要。网络带宽虽然也是一个因素,不过,这种请求数据包一般比较小,一般很少成为请求的瓶颈。负载均衡成为系统瓶颈的情况比较少,在这里不做讨论哈。

那么问题来了,假设我们的系统,在5w/s的高并发状态下,平均响应时间从100ms变为250ms(实际情况,甚至更多):

20*500/0.25 = 40000 (4万QPS)

于是,我们的系统剩下了4w的QPS,面对5w每秒的请求,中间相差了1w。

举个例子,高速路口,1秒钟来5部车,每秒通过5部车,高速路口运作正常。突然,这个路口1秒钟只能通过4部车,车流量仍然依旧,结果必定出现大塞车。(5条车道忽然变成4条车道的感觉)

同理,某一个秒内,20*500个可用连接进程都在满负荷工作中,却仍然有1万个新来请求,没有连接进程可用,系统陷入到异常状态也是预期之内。

其实在正常的非高并发的业务场景中,也有类似的情况出现,某个业务请求接口出现问题,响应时间极慢,将整个Web请求响应时间拉得很长,逐渐将Web服务器的可用连接数占满,其他正常的业务请求,无连接进程可用。

更可怕的问题是,是用户的行为特点,系统越是不可用,用户的点击越频繁,恶性循环最终导致“雪崩”(其中一台Web机器挂了,导致流量分散到其他正常工作的机器上,再导致正常的机器也挂,然后恶性循环),将整个Web系统拖垮。

如果系统发生“雪崩”,贸然重启服务,是无法解决问题的。最常见的现象是,启动起来后,立刻挂掉。这个时候,最好在入口层将流量拒绝,然后再将重启。如果是redis/memcache这种服务也挂了,重启的时候需要注意“预热”,并且很可能需要比较长的时间。

秒杀和抢购的场景,流量往往是超乎我们系统的准备和想象的。这个时候,过载保护是必要的。如果检测到系统满负载状态,拒绝请求也是一种保护措施。在前端设置过滤是最简单的方式,但是,这种做法是被用户“千夫所指”的行为。更合适一点的是,将过载保护设置在CGI入口层,快速将客户的直接请求返回

示例:

假设某个抢购场景中,我们一共只有100个商品,在最后一刻,我们已经消耗了99个商品,仅剩最后一个。这个时候,系统发来多个并发请求,这批请求读取到的商品余量都是99个,然后都通过了这一个余量判断,最终导致超发。

解决方案
  1. 将库存字段number字段设为unsigned,当库存为0时,因为字段不能为负数,将会返回false

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    <?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);
    }
  2. 悲观锁思路
    悲观锁,也就是在修改数据的时候,采用锁定状态,排斥外部请求的修改。遇到加锁的状态,就必须等待。

    在高并发的情况下,每个请求都要等待锁,以至于某些线程永远不会抢到锁,这种请求就会’死’在那。这种’死’请求会很多,瞬间增大系统的平均响应时间,结果是可用连接数被耗尽,系统陷入异常。

  3. 使用mysql事务,锁住操作行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    <?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");
    }
  4. FIFO队列思路
    那好,那么我们稍微修改一下上面的场景,我们直接将请求放入队列中的,采用FIFO(First Input First Output,先进先出),这样的话,我们就不会导致某些请求永远获取不到锁。

    这种方案类似强行将多线程改为单线程,此种方法虽然解决了锁的问题。但是在高并发下又会带来另一个问题,即在高并发的场景下,因为请求过多,很可能一瞬间将队列内存撑满,然后导致系统有陷入异常。简单处理的话,我们可能会采用硬件扩大内存来处理。但是这种方案还是治标不治本,扩大内存导致系统处理完一个队列里的请求的速度可能无法与高并发涌入队列里的请求的速度相比,到最后还是会导致系统异常。

  5. 文件锁的思路,使用非阻塞的文件排他锁
    对于日IP不高或者说并发数不是很大的应用,一般不用考虑这些!用一般的文件操作方法完全没有问题。但如果并发高,在我们对文件进行读写操作时,很有可能多个进程对同一文件进行操作,如果这时不对文件的访问进行相应的独占,就容易造成数据丢失。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    <?php
    //优化方案5:使用非阻塞的文件排他锁
    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);
  6. 乐观锁思路
    乐观锁,是相对于“悲观锁”采用更为宽松的加锁机制,大都是采用带版本号(Version)更新。实现就是,这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回抢购失败。这样的话,我们就不需要考虑队列的问题,不过,它会增大CPU的计算开销。但是,综合来说,这是一个比较好的解决方案。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    <?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;
    }
    }
  7. 最直接,最简单的方式
    高并发无非就是短时间涌入服务器大量的请求,因此,我们完全可以对请求进行过滤。比如我们过滤掉99%的请求,只允许1%的请求到服务器。当然这种方式有点不厚道……本来就是秒杀,看脸的世界,也就无所谓。