新建商品表
CREATE TABLE `zlsn_concurrency_goods` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`goods_code` varchar(10) NOT NULL COMMENT '商品编码',
`num` int(10) NOT NULL COMMENT '商品剩余数量',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='模拟高并发-商品表';
新建订单表
CREATE TABLE `zlsn_concurrency_user_order` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_code` varchar(10) NOT NULL COMMENT '用户编码',
`order_code` varchar(10) NOT NULL COMMENT '订单编码',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=114 DEFAULT CHARSET=utf8mb4 COMMENT='模拟高并发-用户订单表';
模型类
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* 测试高并发商品表
* Class TestGoods
* @package App\Models
*/
class TestGoods extends Model
{
protected $table = "concurrency_goods";
}
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* 测试高并发用户订单表
* Class UserOrder
* @package App\Models
*/
class TestUserOrder extends Model
{
protected $table = "concurrency_user_order";
}
控制器
namespace App\Http\Controllers\Test;
use App\Models\TestGoods;
use App\Models\TestUserOrder;
/**
* 测试高并发接口
* Class ConcurrencyController
* @package App\Http\Controllers\Test
*/
class ConcurrencyController
{
/**
* Notes:秒杀功能
* Author:zlsn <[email protected]>
* DateTime: 2022/7/9 10:32
*/
public function seckill()
{
$detail = TestGoods::where('id', 1)->select('id', 'num')->get()->toArray();
//模拟存放用户id
$ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
$userId = "测试用户" . $ids[rand(0, 9)];
if (!empty($detail) && $detail[0]['num'] > 0) {
//商品id和商品数量
$id = $detail[0]['id'];
$number = $detail[0]['num'];
//商品数量减一
$number -= 1;
$testGoodModel = new TestGoods();
$param['num'] = $number;
$testGoodModel->where('id', $id)->update($param);
//保存到订单表
$testUserOrderModel = new TestUserOrder();
$testUserOrderModel->user_code = $userId;
$testUserOrderModel->order_code = rand(111111, 999999);
$testUserOrderModel->save();
echo '抢购成功';
} else {
echo '商品数量不足';
}
}
}
jmeter依赖于jdk,需要本地配置一下。jdk配置方法
首先去下载jemter 下载链接
重现超卖情况以及商品数量变成负数的情况
设置商品数量初始值为10
点击jemter菜单栏的start按钮,然后选择一下测试文件保存的路径即可。会发现订单表出现了超卖的情况,并且还会出现一个用户秒杀到多个商品的情况。(我这里出现了15份订单)。
这里要说明一下为什么商品表的数量没有变成负数?
因为秒杀方法中有对于商品数量的判断,即使最后剩余1件商品,然后同时进来了多个请求,此时它们都会把商品数量修改成0,然后再去保存到订单表。
如果在判断内,重新获取商品数量,再去减一的话,就会出现负数。如果改成如下代码
public function seckill()
{
$detail = TestGoods::where('id', 1)->select('id', 'num')->get()->toArray();
//模拟存放用户id
$ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
$userId = "测试用户" . $ids[rand(0, 9)];
if (!empty($detail) && $detail[0]['num'] > 0) {
//商品id和商品数量
$id = $detail[0]['id'];
//商品数量减一
//再次获取当前的商品数量,再进行减一,而不是直接用判断外获取的商品数量减一
$data = TestGoods::where('id', 1)->select('num')->get()->toArray();
$number = $data[0]['num'];
$number -= 1;
$testGoodModel = new TestGoods();
$param['num'] = $number;
$testGoodModel->where('id', $id)->update($param);
//保存到订单表
$testUserOrderModel = new TestUserOrder();
$testUserOrderModel->user_code = $userId;
$testUserOrderModel->order_code = rand(111111, 999999);
$testUserOrderModel->save();
echo '抢购成功';
} else {
echo '商品数量不足';
}
}
解决同一个用户秒杀多件商品的情况—添加判断(但是判断有时候未生效,还会存在一个用户抢到两件商品的情况,表示费解。。)
解决方法一 悲观锁
public function seckill()
{
// $detail = TestGoods::where('id', 1)->select('id', 'num')->get()->toArray();
DB::beginTransaction();
$detail = DB::select("select num,id from zlsn_concurrency_goods where id = 1 for update ");
//模拟存放用户id
$ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
$userId = "测试用户" . $ids[rand(0, 9)];
if (!empty($detail) && $detail[0]->num > 0) {
//商品id和商品数量
$number = $detail[0]->num;
$id = $detail[0]->id;
$testUserOrderModel = new TestUserOrder();
$check = $testUserOrderModel->where('user_code', $userId)->get()->toArray();
//判断当前用户是否已经抢购过--这判断不一定会生效,依然可能存在一个用户购买多次的情况
if (empty($check)) {
//商品数量减一
$number -= 1;
$testGoodModel = new TestGoods();
$param['num'] = $number;
$res = $testGoodModel->where('id', $id)->update($param);
if ($res) {
//保存到订单表
$testUserOrderModel->user_code = $userId;
$testUserOrderModel->order_code = rand(111111, 999999);
$testUserOrderModel->save();
echo '抢购成功';
DB::commit();
} else {
echo '商品数量修改失败';
DB::rollBack();
}
} else {
echo '您已经购买过此商品';
DB::rollBack();
}
} else {
echo '商品数量不足';
DB::rollBack();
}
}
在修改数据的时候,采用锁定状态,排斥外部请求的修改。遇到加锁的状态,其余请求必须等待。
缺点:虽然可以解决线程安全的问题,但是对于高并发并不适用。对于很多没有抢到锁的请求,会陷入一直等待的状态,同时,这种请求很多的话,瞬间增大系统的平均响应时间,结果可能是数据库连接数耗尽,系统陷入异常。
解决方法二 FIFO队列思路
FIFO(first input first output):即先进先出。这样的话就会避免有些请求永远获取不到锁的情况。但是有种将多线程强行变成单线程的感觉。就像是一条八车道的路,强行并称了单行道。
缺点:如果使用这种情况,可能一瞬间把队列内存撑爆,然后系统陷入异常。假如设计一个超级大的内存队列,但是系统处理队列内请求的速度,根本无法和疯狂涌入队列内的数目相比。也就是说队列内的请求会越积累越多,最终web系统平均相应时长还是会增大,造成系统异常。
解决方法三 文件锁思路
使用非阻塞的文件排他锁
缺点:在我们对文件进行读写操作时,很有可能多个进程对进一文件进行操作,如果这时不对文件的访问进行相应的独占,就容易造成数据丢失
public function seckill()
{
$fp = fopen("lock.txt", "w+");
if (!flock($fp, LOCK_EX | LOCK_NB)) {
echo "系统繁忙,请稍后再试";
return;
}
$detail = TestGoods::where('id', 1)->select('id', 'num')->get()->toArray();
//模拟存放用户id
$ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
$userId = "测试用户" . $ids[rand(0, 9)];
if (!empty($detail) && $detail[0]['num'] > 0) {
//商品id和商品数量
$number = $detail[0]['num'];
$id = $detail[0]['id'];
$testUserOrderModel = new TestUserOrder();
$check = $testUserOrderModel->where('user_code', $userId)->get()->toArray();
//判断当前用户是否已经抢购过--这判断不一定会生效,依然可能存在一个用户购买多次的情况
if (empty($check)) {
//商品数量减一
$number -= 1;
$testGoodModel = new TestGoods();
$param['num'] = $number;
$res = $testGoodModel->where('id', $id)->update($param);
if ($res) {
//保存到订单表
$testUserOrderModel->user_code = $userId;
$testUserOrderModel->order_code = rand(111111, 999999);
$testUserOrderModel->save();
echo '抢购成功';
flock($fp, LOCK_UN);//释放锁
} else {
echo '商品数量修改失败';
}
} else {
echo '您已经购买过此商品';
}
} else {
echo '商品数量不足';
}
fclose($fp);
}
解决方法四 乐观锁
在数据表中添加version字段。
所有进来的请求都可以去修改数据,但是在修改数据之前会先获得该数据的一个版本号,修改之后如果和修改前的版本号不一致,则进行事务回滚。
缺点:增大CPU的计算开销。
public function seckill()
{
DB::beginTransaction();
$detail = TestGoods::where('id', 1)->select('id', 'num','version')->get()->toArray();
//模拟存放用户id
$ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
$userId = "测试用户" . $ids[rand(0, 9)];
if (!empty($detail) && $detail[0]['num'] > 0) {
//商品id和商品数量
$number = $detail[0]['num'];
$id = $detail[0]['id'];
$version = $detail[0]['version'];
$testUserOrderModel = new TestUserOrder();
$check = $testUserOrderModel->where('user_code', $userId)->get()->toArray();
//判断当前用户是否已经抢购过--这判断不一定会生效,依然可能存在一个用户购买多次的情况
if (empty($check)) {
//商品数量减一
$number -= 1;
$testGoodModel = new TestGoods();
$param['num'] = $number;
$res = $testGoodModel->where('id', $id)->update($param);
$data = TestGoods::where('id', 1)->select('version')->get()->toArray();
$version_after = $data[0]['version'];
if($version_after != $version){
DB::rollBack();
}
if ($res) {
//保存到订单表
$testUserOrderModel->user_code = $userId;
$testUserOrderModel->order_code = rand(111111, 999999);
$testUserOrderModel->save();
echo '抢购成功';
DB::commit();
} else {
echo '商品数量修改失败';
DB::rollBack();
}
} else {
echo '您已经购买过此商品';
DB::rollBack();
}
} else {
echo '商品数量不足';
DB::rollBack();
}
}
Redis中的watch
这种方法也属于乐观锁之一。(模拟了多次,其中有两次还是出现了超卖的情况,不知道是不是自己操作的问题。)
public function seckill()
{
$myWatchKey = Redis::get('myWatchKey'); //不需要提前set,本就是获取一个null值。如果提前set的话,那每次请求接口的时候,这个可以都是固定的值
$number = 20;//商品数量
if ($myWatchKey < $number) {
Redis::watch("myWatchKey");
Redis::multi(); //开启事务
//插入抢购数据
Redis::set("myWatchKey", $myWatchKey + 1);
$rob_result = Redis::exec();//执行事务
if ($rob_result) {
Redis::hSet("watchKeyList", "user_" . mt_rand(1, 9999), $myWatchKey);
$myWatchList = Redis::hGetAll("watchKeyList");
echo "抢购成功!
";
echo "剩余数量:" . ($number - $myWatchKey - 1) . "
";
echo "用户列表:"
;
var_dump($myWatchList);
} else {
Redis::hSet("watchKeyList", "user_" . mt_rand(1, 9999), '没抢到');
echo "手气不好,再抢购!";
exit;
}
}else{
echo '商品数量不足,抢购失败';
}
}
apache安装目录下的bin目录可以使用ab命令测试高并发
[root@myhost vhost]# ab -c 10000 -n 100000 http://www.baidu.com
-c 表示并发用户数
-n 表示请求总数
postman也可以测试高并发
步骤:
参考链接:
如何用PHP解决高并发问题?(附源码)
推荐一篇文章 PHP+Redis怎么解决商品超卖问题