如果你家店里某商品庫存只有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,並沒有出現超賣的現象。
order.php如下:
<?php date_default_timezone_set('PRC'); /** * @Author: Helloweba * @Date: 2019-09-20 16:03:47 * @Last Modified by: Helloweba * @Last Modified time: 2019-09-28 19:42:45 */ 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; } // 將商品庫存循環到lpush的num里 public function saveNum() { $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); if (!empty($row)) { for($i=1; $i <= $row['inventory']; $i++){ $redis->lpush('num', $i); } die('成功!'); } else { $this->log(0, '商品不存在。'); } } // 搶購下單 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); } } // 生成訂單號 public function orderNo() { return date('Ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8); } // 下單更新庫存 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,: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, '下單失敗'); } } // 保存日志 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->saveNum(); $order->goodsOrder();