秒杀

一、秒杀有哪些特点

1、突然多了很多访问,可能导致原有商城瘫痪

秒杀活动只是网站营销的一个附加活动,这个活动具有时间短,并发访问量大的特点,如果和网站原有应用部署在一起,必然会对现有业务造成冲击,稍有不慎可能导致整个网站瘫痪。

解决方案:将秒杀系统独立部署,独立域名

2、 带宽需求大

假设商品页面大小1M(主要是商品图片大小),那么10000用户并发,需要的网络带宽是:10G(1M×10000),这些网络带宽是因为秒杀活动新增的,超过网站平时使用的带宽。

解决方案:

(1)将秒杀商品页面缓存在CDN

(2)静态资源(图片、视频、js、css)存储到oss

3、有大部分请求不会生成订单
4、要防止并发带来的超卖问题

而秒杀业务中,其他问题逗号解决,唯独超卖问题算是一个难点,而且其带来的后果也是非常严重的,因为超卖就意味着商家或系统要承担超卖的这部分损失。

 

二、超卖问题行业主流解决方案

1、MySQL 悲观锁【不推荐】

悲观锁,指的是对数据被外界(包括当前系统的其它事务,以及来自外部系统的事务处理)修改持保守态度。

因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排它性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。

<?php
declare(strict_types = 1);

class OperateStock
{
    protected $pdo = null;
    CONST REDUCE_STOCK = 1;// 减少库存操作
    CONST INCREASE_STOCK = 2;// 增加库存操作

    /**
     * 下订单扣减库存
     *
     * @param int $productId
     * @param int $num
     */
    public function placeOrder(int $productId, int $num)
    {
        try {
            // 开启事务
            $this->getMySQL()->beginTransaction();
            $stock = $this->getProductStock($productId);
            if ($num > $stock) {
                return $this->response(0, '超出库存,无法下单');
            }
            // 执行扣减库存操作
            $res = $this->changeStock($productId, $num);
            // 记录日志
            $this->recordOrderLog($productId, $num);
            $this->getMySQL()->commit();
        } catch (\Exception $e) {
            $this->getMySQL()->rollBack();
            $this->response(0, $e->getMessage());
        }
        return $res;
    }

    private function changeStock(int $productId, int $num, int $action = self::REDUCE_STOCK)
    {
        $operateAction = $action == self::REDUCE_STOCK ? '-' : '+';
        try {
            $sql = 'update product_test set stock = stock '.$operateAction.' '.$num. " where product_id = $productId";
            $this->getMySQL()->query($sql);
        } catch (\Exception $e) {
            return $this->response(0, $e->getMessage());
        }
        return $this->response(1, 'success');
    }

    /**
     * 记录销售日志
     *
     * @param int $productId
     * @param int $num
     */
    private function recordOrderLog(int $productId, int $num)
    {
        $sql = "insert into order_test (product_id,sale) values ($productId,$num)";
        $this->getMySQL()->query($sql);
    }

    /**
     * 获取MySQL连接
     *
     * @return PDO
     */
    private function getMySQL()
    {
        if (false == $this->pdo) {
            $dsn = 'mysql:host=127.0.0.1;dbname=test';
            $this->pdo = new \PDO($dsn, 'root', '123456');
        }
        return $this->pdo;
    }

    /**
     * 获取商品库存数
     *
     * @param $productId
     * @return mixed
     */
    private function getProductStock($productId)
    {
        // 查询库存
        $sql = "select stock from product_test where product_id = $productId limit 1 for update";
        $stock = $this->getMySQL()->query($sql)->fetch(2);
        return $stock['stock'];
    }

    /**
     * 统一响应
     *
     * @param int $statusCode
     * @param string $msg
     * @param array $data
     * @return string
     */
    private function response(int $statusCode, string $msg, array $data = [])
    {
        $data = [
            'status' => $statusCode,
            'msg'    => $msg,
            'data'   => $data
        ];
        return json_encode($data);
    }

    /**
     * 获取日志表中销量总量
     *
     * @author cyf
     */
    public function getSaleSum(int $productId)
    {
        $sql = 'select sum(sale) from order_test where product_id = '.$productId;
        $data = $this->getMySQL()->query($sql)->fetch(2);
        return $this->response(1, 'success', [$data]);
    }

}
// 生成随机的商品下单数,使用ab压测工具测试并发下是否超卖 ab -n 10000 -c 5000 http://test.cn/
$num = rand(1, 300);
$object = new OperateStock();
print_r($object->placeOrder(1, $num));
//print_r($object->getSaleSum(1));

在事务中执行,并且在首次查商品剩余库存时,就将排它锁加上,注意,是查询时就加上,而不是操作时再加。

该方案无超卖情况,但是响应时间过长。我使用20000请求,8000并发的测试情况下,时间平均在11s ~ 13s 之间,响应速度感人,不推荐高并发下采用该方案。

 

2、使用Redis 队列

通过Redis的队列也可以实现防止商品超卖,虽然性能上较MySQL悲观锁方式提升很多,但面对高请求量高并发场景,速度仍然有一些慢,体验感并不太好。

<?php
declare(strict_types = 1);

class OperateStock
{
    protected $pdo = null;
    protected $redis = null;
    protected $stockKeyPre = 'product_stock_';// 商品库存redis key前缀
    CONST REDUCE_STOCK = 1;// 减少库存操作
    CONST INCREASE_STOCK = 2;// 增加库存操作

    /**
     * 下订单扣减库存
     *
     * @param int $productId
     * @param int $num
     * @return string
     * @throws Exception
     */
    public function placeOrder(int $productId, int $num)
    {
        try {
            $this->reduceStock($productId, $num);
            // 推送消息队列,对数据库中库存数据进行异步扣减
            // 记录订单销售日志
            $this->recordOrderLog($productId, $num);
            return $this->response(1, '下单成功');
        } catch (\Exception $e) {
            return $this->response(0, $e->getMessage());
        }

    }

    /**
     * 扣减库存
     *
     * @param int $productId
     * @param int $num
     * @return bool
     * @throws Exception
     */
    private function reduceStock(int $productId, int $num)
    {
        $redis = $this->getRedis();
        $key = $this->stockKeyPre.$productId;
        $valueArray = [];
        try {
            for ($i = 0; $i < $num; $i++) {
                $res = $redis->rPop($key);
                if (false == $res) {
                    throw new \Exception('库存不够啦');
                }
                $valueArray[] = $res;
            }
            return true;
        } catch (\Exception $e) {
            // 手动对已经下单的数据进行回滚,并抛出异常给上游调用方
            foreach ($valueArray as $v) {
                $redis->lPush($key, $v);
            }
            throw new \Exception('库存不够啦');
        }
    }

    /**
     * 增删改商品时,重置Redis内的该商品的库存【测试方法】
     *
     * @author cyf
     */
    public function resetStockToRedis(int $productId, int $num)
    {
        $redis = $this->getRedis();
        $key = $this->stockKeyPre.$productId;
        for($i = 1; $i <= $num; $i++) {
            $redis->lpush($key, $i);
        }
        return $this->response(1, 'success');
    }

    /**
     * 记录销售日志
     *
     * @param int $productId
     * @param int $num
     */
    private function recordOrderLog(int $productId, int $num)
    {
        $sql = "insert into order_test (product_id,sale) values ($productId,$num)";
        $this->getMySQL()->query($sql);
    }

    /**
     * 获取MySQL连接
     *
     * @return PDO
     */
    private function getMySQL()
    {
        if (false == $this->pdo) {
            $dsn = 'mysql:host=127.0.0.1;dbname=test';
            $this->pdo = new \PDO($dsn, 'root', '123456');
        }
        return $this->pdo;
    }

    /**
     * 获取Redis连接
     *
     * @return null|Redis
     */
    private function getRedis()
    {
        if (false == $this->redis) {
            $redis = new Redis();
            $redis->connect('127.0.0.1', 6379);
            $redis->auth('haveyb');
            $this->redis = $redis;
        }
        return $this->redis;
    }

    /**
     * 统一响应
     *
     * @param int $statusCode
     * @param string $msg
     * @param array $data
     * @return string
     */
    private function response(int $statusCode, string $msg, array $data = [])
    {
        $data = [
            'status' => $statusCode,
            'msg'    => $msg,
            'data'   => $data
        ];
        return json_encode($data);
    }

    /**
     * 获取日志表中销量总量
     *
     * @param int $productId
     * @return string
     */
    public function getSaleSum(int $productId)
    {
        $sql = 'select sum(sale) from order_test where product_id = '.$productId;
        $data = $this->getMySQL()->query($sql)->fetch(2);
        return $this->response(1, 'success', [$data]);
    }

}

$object = new OperateStock();
// 先生成商品的队列结构库存,这个数据一定是抢购前就生成好的,而不是查询redis数据查不到时才去生成的,否则并发情况下会出错
//$object->resetStockToRedis(1, 2000);

// 生成随机的商品下单数,使用ab压测工具测试并发下是否超卖 ab -n 30000 -c 6000 http://test.cn/
//$num = 1;
$num = rand(1, 299);
print_r($object->placeOrder(1, $num));

// 获取订单日志中该商品实际销售总数,主要用于核对校验并发状况下,是否超卖
//print_r($object->getSaleSum(1));

该方案无超卖情况,响应速度较MySQL排它锁方案响应速度提高很多。

使用20000总请求,8000并发,每个请求平均响应时间5.38秒
使用30000总请求,1000并发,每个请求平均响应时间3.77秒
使用10000总请求,1000并发,每个请求平均响应时间1.23秒
使用5000总请求,1000并发,每个请求平均响应时间0.61秒

需要注意,采用该方案,Redis中的商品库存数据一定要提前生成,而不是等查询时生成,应该增加商品数据时,就实时添加库存数据到Redis中,之后所有操作都从Redis操作(包括增删改查),之后持久化同步到数据库,可以采用异步消息队列方式。

如果是旧系统,则应该写个脚本,先把数据库上只读锁,然后将商品库存预热到Redis中,再解开MySQL的只读锁,之后所有库存操作都在Redis中进行。

 

3、Redis乐观锁实现防止秒杀超卖