PHP实现电商秒杀超卖解决方案

文章简介

本文内容是对并发业务场景出现超卖情况而写的一片解决方案。主要是利用到了 Redis 中的队列技术。

超卖介绍

所谓的超卖,就是我们的售卖量大于了物品的库存量。该情况一般出现在电商系统中促销类的业务场景中。轻则只是部分商品超卖,较小的经济损失,但是当大量的超卖情况,例如淘宝双十一这样的业务场景下导致超卖,则损失是非常大的,同时给用户体验带来的也是负面影响,很有可能损失用户量。记得之前遇到一个公司,做电商项目,就是因为超卖导致公司倒闭。

常规的秒杀模式

首先,我们见下图.
常规的秒杀示例图

1.第一步是我们用户进入商品秒杀页面,点击秒杀按钮,向服务端发送秒杀请求。

2.服务端在接受到用户秒杀请求,根据请求的商品id参数,去查询数据库中该商品id的库存量。

3.当查询到该商品库存量后,进行判断。如果库存量不足,则返回给用户,商品库存不足的信息。

4.当查询到该商品的库存足够时,则生成订单数据并减少商品库存。接着将成功信息返回给用户。

5.用户接受到抢购成功消息后,才可进入下单页面。此时按照正常逻辑,进行下单支付。

这种模式为什么会出现超卖呢?

按照我们上面所讲的,按理来说是一种正常的逻辑流程。但是当并打量大的时候,就会出现超卖情况。在上图第 2 步骤中,是做商品库存的查询。假如此时我们查询到的商品库存为 1,这时候就会走 4 中上面的部分(插入抢购信息并减少库存),由于并发量大的情况下,下一个请求在上一个还未执行减库操作就去查询了商品库存,这时候查询出来的库存量依然是 1。同样的,会走到 4 上面的步骤中去。然后上一个请求执行了减库操作,此时库存为 0,第二个请求再去减库时,就会把库存量设置为-1,这样就出现了超卖情况。由于并发,同时会发生很多请求,因此减少的数量不仅仅是 1 了,或许是成百上千甚至上万等等。

解决超卖思路

网上有很多这样的思路,几乎是通过队列技术来解决的。先将商品库存信息缓存到我们的缓存中去,例如 Redis。(文章中示例也是通过该方案实现)。

秒杀实现

这里单独讲一讲示例代码中秒杀的解决思路。

在秒杀前将商品的库存信息加入到 Redis 缓存中。如下格式:

1
$redis->lpush('商品id',1);

当每一个商品有多少个库存则循环多少次,这样就可以保证每个商品队列中的长度就是商品库存长度。其实这里个人是有一个疑问的,如果商品少,我们加入到缓存的耗时是很小的,但是商品数量大,这样就很耗时,并且 redis 是放在内存中的,也暂用大量的内存。

当秒杀开始时,用户发送请求,每次去检测一下商品的队列是否为空,当非空时,则使用 lpop 减少一个长度,也就是减少一个库存量。这时候将秒杀的信息写入到缓存中去,给缓存信息配一个唯一的键,将该键返回给用户。(由于 lpop 是原子性的,即是大量并发来了,也是要在 Redis 内部进行排队执行的,假如在判断是否为空时,检测到是非空,进行 lpop 操作,由于队列是空,这时候去执行出队列也是返回错误的)。

返回给用户秒杀成功的信息,用户根据返回的键进行下单操作。利用该键,将秒杀中的缓存信息写入数据库并生成对应的订单。

接下来,我们可以结合上图,得出下面的流程图:
redis秒杀

代码具体实现

创建公共的 Redis 连接

1
2
3
4
5
6
7
8
9
<?php
/**
* Redis连接
*/
$redis = new Redis();
$result = $redis->connect('127.0.0.1',6379,2);
if(!$result){
die('redis connect fail');
}

秒杀前将商品库存写入缓存中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 模拟商品库存如队列
*/
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/>';
}
}

模拟客户发送请求,这里可以开多个窗口,增加请求量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<!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>

服务端接收秒杀请求并写入缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?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();
}
}

客户端在接收到秒杀请求结果后,进行支付

1
2
3
4
5
6
7
8
9
10
11
<?php
/**
* 用户下单界面
*/
require_once __DIR__.'/redis_connect.php';
$key = $_GET['key'];
$data = $redis->get($key);
/**
* 生成订单,订单入库
*
*/