如果你家店里某商品库存只有100件,现在店庆活动5折优惠大酬宾,假如现在有200个人疯狂涌入你家店里,为了避免发生疯抢和踩踏事件发生,店长您采取了排队限购的办法,1人限购1件,排队先到先买,卖完为止。
这个是实体店我们会看到的场景,100件商品,1人1件,最后200人中只有100人能买到商品,剩下100人只能空手而归。如果您开了家网店,同样你开起了秒杀的活动,可能同时会有1000人通过不同的终端访问你的商品秒杀活动页面,你的商品可以会在瞬间秒杀完毕,库存清零。可是如果网店秒杀活动程序设计出问题,会导致秒杀库存超卖的现象,比如100件库存,实际订单有120件,原因就处在并发同时程序处理的问题上。
其实我们也可以采取排队限购的办法解决网店秒杀活动商品超卖的问题。今天我们给大家讲解采用PHP+Redis+MySQL解决商品秒杀活动中超卖问题。
实现原理
把商品库存数量加到redis队列的num里,下单的时候通过rpop从队列中每次取1件商品,当num为0时,停止下单。
下面我们来看具体实现过程。
创建数据表
我们一共准备3张表,分别是:商品表、订单表、日志表。
1.商品表
CREATE TABLE `hw_goods` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(128) DEFAULT NULL COMMENT '商品名称',
`price` decimal(10,2) DEFAULT NULL COMMENT '商品价格',
`pic` varchar(128) DEFAULT NULL COMMENT '商品图片',
`inventory` int(11) DEFAULT NULL COMMENT '库存',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of hw_goods
-- ----------------------------
INSERT INTO `hw_goods` VALUES ('1', 'Apple iPhone 11 (A2223) 64GB 黑色 移动联通电信4G手机 双卡双待', '5499.00', null, '100', '2019-09-20 16:21:05', '2019-09-20 16:21:08');
我们在商品表中添加商品Apple iPhone 11,设置库存为100。
2.订单表
CREATE TABLE `hw_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_sn` varchar(32) DEFAULT NULL COMMENT '订单号',
`user_id` int(11) DEFAULT NULL COMMENT '购买者ID',
`status` tinyint(1) DEFAULT '0' COMMENT '订单状态1-已下单,2-已处理,3-已发货,4-已收货,5-订单完成',
`goods_id` int(11) DEFAULT '0' COMMENT '商品id',
`o_num` int(11) DEFAULT NULL COMMENT '购买数量',
`price` int(10) DEFAULT NULL COMMENT '价格,分',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.日志表
CREATE TABLE `hw_order_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`status` int(11) DEFAULT '0',
`msg` text,
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
加入库存队列
我们在Redis中加入商品库存队列。由商品表中我们可知商品Apple iPhone 11库存有100件。我们可以写个脚本将商品库存加入到Redis队列中。
for($i=1; $i <= 100; $i++){
$redis->lpush('num', $i);
}
执行完成后,我们可以看到redis队列。
下单购买
我们建立下单文件Order.php
首先是连接redis和mysql的代码。
class Order
{
private static $redis = null;
private static $pdo = null;
public static function Redis()
{
if (self::$redis == null) {
$redis = new Redis();
$redis->connect('127.0.0.1',6379);
self::$redis = $redis;
}
return self::$redis;
}
public static function mysql()
{
$dbhost = '127.0.0.1'; //数据库服务器
$dbport = 3306; //端口
$dbname = 'demo'; //数据库名称
$dbuser = 'root'; //用户名
$dbpass = ''; //密码
// 连接
try {
$db = new PDO('mysql:host='.$dbhost.';port='.$dbport.';dbname='.$dbname, $dbuser, $dbpass);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); //设置错误模式
$db->query('SET NAMES utf8;');
self::$pdo = $db;
} catch (PDOException $e) {
$this->log(0, '连接数据库失败!');
exit;
}
return self::$pdo;
}
}
接着就是抢购下单。我们从商品可以中取出商品信息,然后从redis队列num中rpop出列一个商品数,接着马上处理商品购买的过程。
// 抢购下单
public function goodsOrder()
{
$redis = self::Redis();
$db = self::mysql();
$goodsId = 1;
$sql = "select id,inventory,price from hw_goods where id=".$goodsId;
$stmt = $db->query($sql);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$redis = self::Redis();
$count = $redis->rpop('num');//每次从num取出1
if($count == 0){
$this->log(0, 'no num redis');
echo '已没有库存';
} else {
$this->doOrder($row, 1);
}
}
上述代码中,如果redis队列数量变成0了,就是没有库存了,这个时候不做订单处理了,如果不是0就要更新库存,生成订单。
// 下单更新库存
public function doOrder($goods, $goodsNum)
{
$orderNo = $this->orderNo();
$number = $goods['inventory'] - $goodsNum;
if ($number < 0) {
$this->log(0, '已没有库存');
echo '已没有库存';
return false;
}
$db = self::mysql();
try {
$db->beginTransaction(); //启动事务
$sql = "INSERT INTO `hw_order` (user_id,order_sn,status,goods_id,o_num,price,created_at) VALUES (:user_id,:order_sn,:status,:goods_id,:sku_id,:o_num,:price,:created_at)";
$stmt = $db->prepare($sql);
$stmt->execute([
':user_id' => rand(1, 500),
':order_sn' => $orderNo,
':status' => 1,
':goods_id' => $goods['id'],
':o_num' => $goodsNum,
':price' => $goods['price'] * 100,
':created_at' => date('Y-m-d H:i:s'),
]);
$sql2 = "update hw_goods set inventory=inventory-".$goodsNum." where inventory>0 and id=".$goods['id'];
$res = $db->exec($sql2);
$db->commit(); //提交事务
$this->log(1, '下单和库存扣减成功');
} catch (Exception $e) {
$db->rollBack(); //回滚事务
$this->log(0, '下单失败');
}
}
在下单过程中,我们采用了MySQL的事物机制,每次当订单表中写入订单数据并且商品表扣除库存-1成功,才算下单完成。
最后附上生产订单号的代码,以及日志记录代码。
// 生成订单号
public function orderNo()
{
return date('Ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);
}
// 保存日志
public function log($status, $msg)
{
$db = self::mysql();
$sql = "INSERT INTO `hw_order_log` (status,msg,created_at) VALUES (:status,:msg,:created_at)";
$stmt = $db->prepare($sql);
$stmt->execute([
':msg' => $msg,
':status' => $status,
':created_at' => date('Y-m-d H:i:s')
]);
}
调用下单代码:
$order = new Order();
$order->goodsOrder();
并发测试
我们Apache的ab测试,ab是apachebench命令的缩写,是Apache自带的压力测试工具,假如你安装了Apache软件后,在他的bin目录下可以找到ab这个程序。
保证你的order.php在你的站点能访问到,然后启动ab测试,输入以下命令:
ab -n 1000 -c 200 http://localhost/order.php
(-n发出1000个请求,-c模拟200并发,请求数要大于或等于并发数。相当1000人同时访问,后面是测试url )。
验证结果
分别查看商品表hw_goods,检验库存字段inventory是否由100变成0了。
查看订单表hw_order,查询该商品的订单总数是否为100。
查看日志表hw_order_log,查询状态status为1的订单日志记录是否是100条,其余的状态均为0。
经验证,库存为0,订单总数为100,并没有出现超卖的现象。