PHP商品秒杀问题解决方案实例详解【mysql与redis】


Posted in PHP onJuly 22, 2019

本文实例讲述了PHP商品秒杀问题解决方案。分享给大家供大家参考,具体如下:

引言

假设num是存储在数据库中的字段,保存了被秒杀产品的剩余数量。

if($num > 0){
  //用户抢购成功,记录用户信息
  $num--;
}

假设在一个并发量较高的场景,数据库中num的值为1时,可能同时会有多个进程读取到num为1,程序判断符合条件,抢购成功,num减一。这样会导致商品超发的情况,本来只有10件可以抢购的商品,可能会有超过10个人抢到,此时num在抢购完成之后为负值。

解决该问题的方案由很多,可以简单分为基于mysql和redis的解决方案,redis的性能要由于mysql,因此可以承载更高的并发量,不过下面介绍的方案都是基于单台mysql和redis的,更高的并发量需要分布式的解决方案,本文没有涉及。

基于mysql的解决方案

商品表 goods

CREATE TABLE `goods` (
 `id` int(11) NOT NULL,
 `num` int(11) DEFAULT NULL,
 `version` int(11) DEFAULT NULL,
 PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

抢购结果表 log

CREATE TABLE `log` (
 `id` int(11) NOT NULL AUTO_INCREMENT,
 `good_id` int(11) DEFAULT NULL,
 PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

悲观锁

悲观锁的方案采用的是排他读,也就是同时只能有一个进程读取到num的值。事务在提交或回滚之后,锁会释放,其他的进程才能读取。该方案最简单易懂,在对性能要求不高时,可以直接采用该方案。要注意的是,SELECT … FOR UPDATE要尽可能的使用索引,以便锁定尽可能少的行数;排他锁是在事务执行结束之后才释放的,不是读取完成之后就释放,因此使用的事务应该尽可能的早些提交或回滚,以便早些释放排它锁。

$this->mysqli->begin_transaction();
$result = $this->mysqli->query("SELECT num FROM goods WHERE id=1 LIMIT 1 FOR UPDATE");
$row = $result->fetch_assoc();
$num = intval($row['num']);
if($num > 0){
  usleep(100);
  $this->mysqli->query("UPDATE goods SET num=num-1");
  $affected_rows = $this->mysqli->affected_rows;
  if($affected_rows == 1){
    $this->mysqli->query("INSERT INTO log(good_id) VALUES({$num})");
    $affected_rows = $this->mysqli->affected_rows;
    if($affected_rows == 1){
      $this->mysqli->commit();
      echo "success:".$num;
    }else{
      $this->mysqli->rollback();
      echo "fail1:".$num;
    }
  }else{
    $this->mysqli->rollback();
    echo "fail2:".$num;
  }
}else{
  $this->mysqli->commit();
  echo "fail3:".$num;
}

乐观锁

乐观锁的方案在读取数据是并没有加排他锁,而是通过一个每次更新都会自增的version字段来解决,多个进程读取到相同num,然后都能更新成功的问题。在每个进程读取num的同时,也读取version的值,并且在更新num的同时也更新version,并在更新时加上对version的等值判断。假设有10个进程都读取到了num的值为1,version值为9,则这10个进程执行的更新语句都是UPDATE goods SET num=num-1,version=version+1 WHERE version=9,然而当其中一个进程执行成功之后,数据库中version的值就会变为10,剩余的9个进程都不会执行成功,这样保证了商品不会超发,num的值不会小于0,但这也导致了一个问题,那就是发出抢购请求较早的用户可能抢不到,反而被后来的请求抢到了。

$result = $this->mysqli->query("SELECT num,version FROM goods WHERE id=1 LIMIT 1");
$row = $result->fetch_assoc();
$num = intval($row['num']);
$version = intval($row['version']);
if($num > 0){
  usleep(100);
  $this->mysqli->begin_transaction();
  $this->mysqli->query("UPDATE goods SET num=num-1,version=version+1 WHERE version={$version}");
  $affected_rows = $this->mysqli->affected_rows;
  if($affected_rows == 1){
    $this->mysqli->query("INSERT INTO log(good_id) VALUES({$num})");
    $affected_rows = $this->mysqli->affected_rows;
    if($affected_rows == 1){
      $this->mysqli->commit();
      echo "success:".$num;
    }else{
      $this->mysqli->rollback();
      echo "fail1:".$num;
    }
  }else{
    $this->mysqli->rollback();
    echo "fail2:".$num;
  }
}else{
  echo "fail3:".$num;
}

where条件(原子操作)

悲观锁的方案保证了数据库中num的值在同一时间只能被一个进程读取并处理,也就是并发的读取进程到这里要排队依次执行。乐观锁的方案虽然num的值可以被多个进程同时读取到,但是更新操作中version的等值判断可以保证并发的更新操作在同一时间只能有一个更新成功。

还有一种更简单的方案,只在更新操作时加上num>0的条件限制即可。通过where条件限制的方案虽然看似和乐观锁方案类似,都能够防止超发问题的出现,但在num较大时的表现还是有很大区别的。假如此时num为10,同时有5个进程读取到了num=10,对于乐观锁的方案由于version字段的等值判断,这5个进程只会有一个更新成功,这5个进程执行完成之后num为9;对于where条件判断的方案,只要num>0都能够更新成功,这5个进程执行完成之后num为5。

$result = $this->mysqli->query("SELECT num FROM goods WHERE id=1 LIMIT 1");
$row = $result->fetch_assoc();
$num = intval($row['num']);
if($num > 0){
  usleep(100);
  $this->mysqli->begin_transaction();
  $this->mysqli->query("UPDATE goods SET num=num-1 WHERE num>0");
  $affected_rows = $this->mysqli->affected_rows;
  if($affected_rows == 1){
    $this->mysqli->query("INSERT INTO log(good_id) VALUES({$num})");
    $affected_rows = $this->mysqli->affected_rows;
    if($affected_rows == 1){
      $this->mysqli->commit();
      echo "success:".$num;
    }else{
      $this->mysqli->rollback();
      echo "fail1:".$num;
    }
  }else{
    $this->mysqli->rollback();
    echo "fail2:".$num;
  }
}else{
  echo "fail3:".$num;
}

基于redis的解决方案

基于watch的乐观锁方案

watch用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。这种方案跟mysql中的乐观锁方案类似,具体表现也是一样的。

$num = $this->redis->get('num');
if($num > 0) {
  $this->redis->watch('num');
  usleep(100);
  $res = $this->redis->multi()->decr('num')->lPush('result',$num)->exec();
  if($res == false){
    echo "fail1";
  }else{
    echo "success:".$num;
  }
}else{
  echo "fail2";
}

基于list的队列方案

基于队列的方案利用了redis出队操作的原子性,抢购开始之前首先将商品编号放入响应的队列中,在抢购时依次从队列中弹出操作,这样可以保证每个商品只能被一个进程获取并操作,不存在超发的情况。该方案的优点是理解和实现起来都比较简单,缺点是当商品数量较多是,需要将大量的数据存入到队列中,并且不同的商品需要存入到不同的消息队列中。

public function init(){
  $this->redis->del('goods');
  for($i=1;$i<=10;$i++){
    $this->redis->lPush('goods',$i);
  }
  $this->redis->del('result');
  echo 'init done';
}
public function run(){
  $goods_id = $this->redis->rPop('goods');
  usleep(100);
  if($goods_id == false) {
    echo "fail1";
  }else{
    $res = $this->redis->lPush('result',$goods_id);
    if($res == false){
      echo "writelog:".$goods_id;
    }else{
      echo "success".$goods_id;
    }
  }
}

基于decr返回值的方案

如果我们将剩余量num设置为一个键值类型,每次先get之后判断,然后再decr是不能解决超发问题的。但是redis中的decr操作会返回执行后的结果,可以解决超发问题。我们首先get到num的值进行第一步判断,避免每次都去更新num的值,然后再对num执行decr操作,并判断decr的返回值,如果返回值不小于0,这说明decr之前是大于0的,用户抢购成功。

public function run(){
  $num = $this->redis->get('num');
  if($num > 0) {
    usleep(100);
    $retNum = $this->redis->decr('num');
    if($retNum >= 0){
      $res = $this->redis->lPush('result',$retNum);
      if($res == false){
        echo "writeLog:".$retNum;
      }else{
        echo "success:".$retNum;
      }
    }else{
      echo "fail1";
    }
  }else{
    echo "fail2";
  }
}

基于setnx的排它锁方案

redis没有像mysql中的排它锁,但是可以通过一些方式实现排它锁的功能,就类似php使用文件锁实现排它锁一样。

setnx实现了exists和set两个指令的功能,若给定的key已存在,则setnx不做任何动作,返回0;若key不存在,则执行类似set的操作,返回1。我们设置一个超时时间timeout,每隔一定时间尝试setnx操作,如果设置成功就是获得了相应的锁,执行num的decr操作,操作完成删除相应的key,模拟释放锁的操作。

public function run(){
  do {
    $res = $this->redis->setnx("numKey",1);
    $this->timeout -= 100;
    usleep(100);
  }while($res == 0 && $this->timeout>0);
  if($res == 0){
    echo 'fail1';
  }else{
    $num = $this->redis->get('num');
    if($num > 0) {
      $this->redis->decr('num');
      usleep(100);
      $res = $this->redis->lPush('result',$num);
      if($res == false){
        echo "fail2";
      }else{
        echo "success:".$num;
      }
    }else{
      echo "fail3";
    }
    $this->redis->del("numKey");
  }
}

上述代码都在本地测试通过,完整代码地址:https://github.com/qianshou/SeckillSolution

希望本文所述对大家PHP程序设计有所帮助。

PHP 相关文章推荐
PHP 表单提交给自己
Jul 24 PHP
基于PHP Socket配置以及实例的详细介绍
Jun 13 PHP
php利用新浪接口查询ip获取地理位置示例
Jan 20 PHP
php删除数组元素示例分享
Feb 17 PHP
thinkphp验证码显示不出来的解决方法
Mar 29 PHP
Yii编程开发常见调用技巧集锦
Jul 15 PHP
php执行多个存储过程的方法【基于thinkPHP】
Nov 08 PHP
PHP面向对象程序设计(OOP)之方法重写(override)操作示例
Dec 21 PHP
PHP html_entity_decode()函数讲解
Feb 25 PHP
php设计模式之策略模式应用案例详解
Jun 17 PHP
php写入文件不覆盖的实例讲解
Sep 17 PHP
PhpStorm2020 + phpstudyV8 +XDebug的教程详解
Sep 17 PHP
php多进程应用场景实例详解
Jul 22 #PHP
PHP实现的多进程控制demo示例
Jul 22 #PHP
php+lottery.js实现九宫格抽奖功能
Jul 21 #PHP
在 Laravel 项目中使用 webpack-encore的方法
Jul 21 #PHP
Smarty缓存机制实例详解【三种缓存方式】
Jul 20 #PHP
PHP INT类型在内存中占字节详解
Jul 20 #PHP
PHP检测一个数组有没有定义的方法步骤
Jul 20 #PHP
You might like
PHP+DBM的同学录程序(4)
2006/10/09 PHP
php学习笔记 面向对象的构造与析构方法
2011/06/13 PHP
php 文件上传实例代码
2012/04/19 PHP
php在window iis的莫名问题的测试方法
2013/05/14 PHP
PHP删除目录及目录下所有文件的方法详解
2013/06/06 PHP
PHP数组与对象之间使用递归实现转换的方法
2015/06/24 PHP
symfony2.4的twig中date用法分析
2016/03/18 PHP
php微信公众账号开发之前五个坑(一)
2016/09/18 PHP
在Laravel5.6中使用Swoole的协程数据库查询
2018/06/15 PHP
jQuery1.6 类型判断实现代码
2011/09/01 Javascript
关于IE BUG与字符串截取substr的解决办法
2013/04/10 Javascript
解析URI与URL之间的区别与联系
2013/11/22 Javascript
jquery网页回到顶部效果(图标渐隐,自写)
2014/06/16 Javascript
非常优秀的JS图片轮播插件Swiper的用法
2017/01/03 Javascript
Vuejs入门教程之Vue生命周期,数据,手动挂载,指令,过滤器
2017/04/19 Javascript
js编写选项卡效果
2017/05/23 Javascript
关于 angularJS的一些用法
2017/11/29 Javascript
通过vue-cli来学习修改Webpack多环境配置和发布问题
2017/12/22 Javascript
php 解压zip压缩包内容到指定目录的实例
2018/01/23 Javascript
Laravel整合Bootstrap 4的完整方案(推荐)
2018/01/25 Javascript
vuejs实现折叠面板展开收缩动画效果
2018/09/06 Javascript
自定义Vue组件打包、发布到npm及使用教程
2019/05/22 Javascript
微信小程序webview 脚手架使用详解
2019/07/22 Javascript
layui关闭层级、简单监听的实例
2019/09/06 Javascript
[48:32]2018DOTA2亚洲邀请赛 3.31 小组赛 A组 LGD vs VG
2018/04/01 DOTA
简单介绍Python的轻便web框架Bottle
2015/04/08 Python
Python绘制频率分布直方图的示例
2019/07/08 Python
Python类和实例的属性机制原理详解
2020/03/21 Python
python中scipy.stats产生随机数实例讲解
2021/02/19 Python
优秀求职信
2014/05/29 职场文书
五水共治一句话承诺
2014/05/30 职场文书
工程催款通知书
2015/04/17 职场文书
创业计划书之农家乐
2019/10/09 职场文书
spring cloud eureka 服务启动失败的原因分析及解决方法
2022/03/17 Java/Android
B站评分公认最好看的动漫,你的名字评分9.9,第六备受喜欢
2022/03/18 日漫
Ruby使用Mysql2连接操作MySQL
2022/04/19 Ruby