前言
在商品秒杀活动中,比如商品库存只有 100,但是在抢购活动中可能有 200 人同时抢购,这样就出现了并发,在 100 件商品下单完成库存为 0 了还有可能继续下单成功,就出现了超卖。
为了解决这个问题,今天我主要讲一下用 redis 队列的方式处理。redis 有 list 类型,list 类型其实就是一个双向链表。通过 lpush,pop 操作从链表的头部或者尾部添加删除元素。这使得 list 即可以用作栈,也可以用作队列。先进先出,一端进,一端出,这就是队列。在队列里前一个走完之后,后一个才会走,所以 redis 的队列能完美的解决超卖并发的问题。
解决秒杀超卖问题的方法还有比如:1. 使用 mysql 的事务加排他锁来解决;2. 使用文件锁实现。3. 使用 redis 的 setnx 来实现锁机制等。查看点击:避免商品超卖的 4 种方案
实现原理
将商品库存循环 lpush 到 num 里,然后在下单的时候通过 rpop 每次取出 1 件商品,当 num 的值为 0 时,停止下单。
第 1 步创建表
一共有三张表,分别是:订单表、商品表、日志表。
1. 订单表
CREATE TABLE `ims_order` (`id` int(11) NOT NULL AUTO_INCREMENT,
`order_sn` char(32) NOT NULL,
`user_id` int(11) NOT NULL,
`status` int(11) NOT NULL DEFAULT '0',
`goods_id` int(11) NOT NULL DEFAULT '0',
`sku_id` int(11) NOT NULL DEFAULT '0',
`number` int(11) NOT NULL,
`price` int(10) NOT NULL COMMENT '价格:单位为分',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5820 DEFAULT CHARSET=utf8 COMMENT='订单表'
2. 商品表
CREATE TABLE `ims_hotmallstore_goods` (`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '商品名称',
`type_id` int(11) NOT NULL COMMENT '商品分类',
`img` text NOT NULL COMMENT '商品图片',
`money` decimal(10,2) NOT NULL COMMENT '售价',
`money2` decimal(10,2) NOT NULL COMMENT '原价',
`is_show` int(11) NOT NULL DEFAULT '1' COMMENT '1. 上架 2. 下架',
`uniacid` int(11) NOT NULL COMMENT '小程序 id',
`inventory` int(11) NOT NULL COMMENT '库存',
`details` text NOT NULL COMMENT '详情',
`store_id` int(11) NOT NULL COMMENT '商家 id',
`sales` int(11) NOT NULL COMMENT '销量',
`logo` varchar(100) NOT NULL,
`num` int(11) NOT NULL,
`is_gg` int(11) NOT NULL DEFAULT '2' COMMENT '是否开启规格',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8
3. 日志表
CREATE TABLE `ims_order_log` (`id` int(11) NOT NULL AUTO_INCREMENT,
`status` int(11) NOT NULL DEFAULT '0',
`msg` text CHARACTER SET utf8,
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `status` (`status`)
) ENGINE=InnoDB AUTO_INCREMENT=4562 DEFAULT CHARSET=gb2312 COMMENT='订单日志表'
第 2 步写代码
class Test {
private static $instance = null;
// 用单列模式 实例化 Redis
public static function Redis()
{if (self::$instance == null) {$redis=new \Redis();
$redis->connect('127.0.0.1',6379);
self::$instance = $redis;
}
return self::$instance;
}
// 将商品库存循环到 lpush 的 num 里
public function doPageSaveNum()
{$redis=self::Redis();
$goods_id=1;
$sql="select id, num, money from ims_hotmallstore_goods where id=".$goods_id;
$goods=pdo_fetch($sql);
if(!empty($goods)){for($i=1; $i<=$goods['num']; $i++){$redis->lpush('num',$i);
}
die('成功!');
}else{$this->echoMsg(0,'商品不存在。');
}
}
// 抢购下单
public function doPageGoodsStore()
{
$goods_id=1;
$sql="select id, num, money from ims_hotmallstore_goods where id=".$goods_id;
$goods=pdo_fetch($sql);
$redis=self::Redis();
$count=$redis->rpop('num');// 每次从 num 取出 1
if($count==0){$this->echoMsg(0,'no num redis');
}
$this->doPageGoodsOrder($goods,1);
}
// 保存日志
public function echoMsg($status,$msg,$_data="")
{$data=json_encode(array('status'=>$status,'msg'=>$msg,'data'=>$_data),JSON_UNESCAPED_UNICODE);
$order_log['status']=$status;
$order_log['msg']=$msg;
$order_log['create_time']=time();
pdo_insert('order_log',$order_log);
die($data);
}
public function orderNo()
{return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
}
// 下单更新库存
public function doPageGoodsOrder($goods,$goods_number)
{$orderNo=$this->orderNo();
$number=$goods['num']-$goods_number;
if($number<0){$this->echoMsg(0,'已没有库存');
}
$user_id=rand(1,500);
$order['user_id']=$user_id;
$order['goods_id']=$goods['id'];
$order['number']=$goods_number;
$order['price']=$goods['money'];
$order['status']=1;
$order['sku_id']=2;
$order['order_sn']=$orderNo;
$order['create_time']=date('Y-m-d H:i:s');
pdo_insert('order',$order);
$sql="update ims_hotmallstore_goods set num=num-".$goods_number."where num>0 and id=".$goods['id'];
$res=pdo_query($sql);
if(!empty($res)){$this->echoMsg(1,'库存扣减成功'.$number);
}
$redis=self::Redis();
$redis->lpush('num',$goods_number);
$this->echoMsg(0,'库存扣减失败'.$number);
}
}
// 调用 -- 将商品库存循环到 lpush 的 num 里
if($_GET['i']==1){
$model = new Test;
$model->doPageSaveNum();}
// 调用 -- 高并发抢购下单
if($_GET['i']==2){
$model = new Test;
$model->doPageGoodsStore();}
第 3 步并发测试
1. 先手动执行:http://127.0.0.1/wqchunjingsvn/web/index.php?i=1
,将商品库存循环保存到 lpush 的 num 里。
2. 这里我用 Apache 的 ab 测试,安装方法本文最后做补充。打开终端,然后执行:ab -n 1000 -c 200 http://127.0.0.1/wqchunjingsvn/web/index.php?i=2
(- n 发出 1000 个请求,- c 模拟 200 并发,请求数要大于或等于并发数。相当 1000 人同时访问,后面是测试 url)
3. 观察是否执行成功,执行结果如下图,说明执行成功。
第 4 步查看数据表
1. 查看订单表,总订单数量为 100,如下图,没问题。
2. 查看商品库存,已经由原来的 100 变成 0,也没问题。
3. 查看日志表,总共 137 条记录,其中 status 为 1 的只有 100 条,也没问题。
总结分析
1. 方案可行,库存为 0,没有出现超卖。
2. 用 Apache 的 ab 测试高并发时需要注意 Url 地址不能拼接上带 & 号的参数,否则执行失败。
相关资料
php 下用 redis 解决秒杀超卖问题
如何解决高并发秒杀的超卖问题
Mac 安装 Apache http 服务器(用 Apache 的 ab 测试,安装方法)
使用 ab 进行压力测试详解
mysql 并发更新