高并发业务场景下的秒杀解决方案 (初探)

文章简介

本文内容是对并发业务场景出现超卖情况而写的一片解决方案。主要是利用到了 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);
/**
 * 生成订单,订单入库
 *
 */
本帖由系统于 4周前 自动加精
讨论数量: 4

redis list 可以批量插入数据, 不一定每次都只插入一个值.

$numberArr = range(1,100);  
//var_dump($numberArr);
$redis->lPush('goods:queue:5',...$numberArr); // 可变参数

file
这样再使用先进先出, 可以提示每个秒杀成功的用户自己的秒杀名次

经测试, 100个数据速度差距是22倍, 一万个数据差距是387倍,
测试代码:

<?php
function microtime_float()
{
    list($usec, $sec) = explode(" ", microtime());
    return ((float)$usec + (float)$sec);
}
$redis = new \Redis();
$goodsNum = 10000;
$redis->connect('127.0.0.1');
echo '批量插入方式',PHP_EOL;

$startTime = microtime_float();
$numberArr = range(1,$goodsNum);
//var_dump($numberArr);
$redis->lPush('goods:queue:7',...$numberArr); //使用可变参数

$stopTime = microtime_float();
echo '运行时间: ',$stopTime-$startTime, PHP_EOL;

echo '<hr>';

echo '循环方式',PHP_EOL;
$startTime = microtime_float();
for ($i=0;$i<$goodsNum;$i++)
{
    $redis->lPush('goods:queue:8',$i+1);
}
$stopTime = microtime_float();
echo '运行时间: ',$stopTime-$startTime, PHP_EOL;
1个月前 评论
lovecn 1个月前
奕鹏 (楼主) 1个月前

在php7.2的版本测试了,确实提升了很多。 :+1:

1个月前 评论

为什么要用队列?不能直接存个整数然后decr吗?

1个月前 评论
奕鹏 (楼主) 1个月前
靈紋 (作者) 1个月前
Timgle

图片看不了了 :joy:

2周前 评论
奕鹏 (楼主) 2周前

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!