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 相关文章推荐
JAVA/JSP学习系列之六
Oct 09 PHP
在线竞拍系统的PHP实现框架(一)
Oct 09 PHP
php URL编码解码函数代码
Mar 10 PHP
探讨PHP中this,self,parent的区别详解
Jun 08 PHP
探讨:使用XMLSerialize 序列化与反序列化
Jun 08 PHP
php获取当前时间的毫秒数的方法
Jan 26 PHP
ThinkPHP3.1的Widget新用法
Jun 19 PHP
PHP中使用glob函数实现一句话删除某个目录下的所有文件
Jul 22 PHP
PHP中读取照片exif信息的方法
Aug 20 PHP
php中删除数组的第一个元素和最后一个元素的函数
Mar 07 PHP
php视频拍照上传头像功能实现代码分享
Oct 08 PHP
关于 Laravel Redis 多个进程同时取队列问题详解
Dec 25 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 XML数据解析代码
2010/05/26 PHP
用来解析.htgroup文件的PHP类
2012/09/05 PHP
一个经典实用的PHP图像处理类分享
2014/11/18 PHP
PHP中的使用curl发送请求(GET请求和POST请求)
2017/02/08 PHP
Laravel 6 将新增为指定队列任务设置中间件的功能
2019/08/06 PHP
Array.prototype.concat不是通用方法反驳[译]
2012/09/20 Javascript
jQuery实现下拉框左右选择的简单实例
2014/02/22 Javascript
javascript中interval与setTimeOut的区别示例介绍
2014/03/14 Javascript
JS 排序输出实现table行号自增前端动态生成的tr
2014/08/13 Javascript
js图片实时加载提供网页打开速度
2014/09/11 Javascript
详解Node.js包的工程目录与NPM包管理器的使用
2016/02/16 Javascript
倾力总结40条常见的移动端Web页面问题解决方案
2016/05/24 Javascript
纯JS焦点图特效实例(可一个页面多用)
2016/12/07 Javascript
Vue 过渡(动画)transition组件案例详解
2017/01/22 Javascript
JS简单获取当前年月日星期的方法示例
2017/02/07 Javascript
vue中选中多个选项并且改变选中的样式的实例代码
2020/09/16 Javascript
JS中循环遍历数组的四种方式总结
2021/01/23 Javascript
在Django的模型中添加自定义方法的示例
2015/07/21 Python
基于Python实现对PDF文件的OCR识别
2016/08/05 Python
Python实现可获取网易页面所有文本信息的网易网络爬虫功能示例
2018/01/15 Python
Python中的上下文管理器和with语句的使用
2018/04/17 Python
Python中偏函数用法示例
2018/06/07 Python
pytorch对梯度进行可视化进行梯度检查教程
2020/02/04 Python
Python3开发实例之非关系型图数据库Neo4j安装方法及Python3连接操作Neo4j方法实例
2020/03/18 Python
PyQt5 文本输入框自动补全QLineEdit的实现示例
2020/05/13 Python
浅谈keras中自定义二分类任务评价指标metrics的方法以及代码
2020/06/11 Python
Android Q之气泡弹窗的实现示例
2020/06/23 Python
Python return语句如何实现结果返回调用
2020/10/15 Python
农村婚礼证婚词
2014/01/08 职场文书
养殖项目策划书范文
2014/01/13 职场文书
行政文秘岗位职责范本
2014/02/10 职场文书
企业党员个人自我评价
2014/09/20 职场文书
Python如何使用logging为Flask增加logid
2021/03/30 Python
Vue实现导入Excel功能步骤详解
2021/07/03 Vue.js
剑指Offer之Java算法习题精讲二叉树专项训练
2022/03/21 Java/Android
pandas中pd.groupby()的用法详解
2022/06/16 Python