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作的文本留言本的例子(三)
Oct 09 PHP
php empty函数 使用说明
Aug 10 PHP
基于MySQL体系结构的分析
May 02 PHP
完整删除ecshop中获取店铺信息的API
Dec 24 PHP
WordPress网站性能优化指南
Nov 18 PHP
PHP中strnatcmp()函数“自然排序算法”进行字符串比较用法分析(对比strcmp函数)
Jan 07 PHP
PHP入门教程之表单与验证实例详解
Sep 11 PHP
php封装的smartyBC类完整实例
Oct 19 PHP
Yii2使用$this-&gt;context获取当前的Module、Controller(控制器)、Action等
Mar 29 PHP
php实现的pdo公共类定义与用法示例
Jul 19 PHP
Laravel框架运行出错提示RuntimeException No application encryption key has been specified.解决方法
Apr 02 PHP
php写入txt乱码的解决方法
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 抽象类的简单应用
2011/09/06 PHP
php判断文件上传类型及过滤不安全数据的方法
2014/12/17 PHP
php接口数据加密、解密、验证签名
2015/03/12 PHP
PHP编译安装时常见错误解决办法
2015/05/28 PHP
php 截取中英文混合字符串的方法
2018/05/31 PHP
javascript[js]获取url参数的代码
2007/10/17 Javascript
javascript 放大镜 v1.0 基于Yui2 实现的放大镜效果
2010/03/08 Javascript
js+JQuery返回顶部功能如何实现
2012/12/03 Javascript
ANGULARJS中用NG-BIND指令实现单向绑定的例子
2014/12/08 Javascript
javascript判断数组内是否重复的方法
2015/04/21 Javascript
JS实现复制内容到剪贴板功能兼容所有浏览器(推荐)
2016/06/17 Javascript
浅谈Node.js:fs文件系统模块
2016/12/08 Javascript
在javascript中,null>=0 为真,null==0却为假,null的值详解
2017/02/22 Javascript
jQuery中绑定事件bind() on() live() one()的异同
2017/02/23 Javascript
vue.js开发环境搭建教程
2017/05/04 Javascript
vue实现选项卡及选项卡切换效果
2018/04/24 Javascript
JavaScript设计模式之工厂模式简单实例教程
2018/07/03 Javascript
ant design vue中表格指定格式渲染方式
2020/10/28 Javascript
简单谈谈Python中的元祖(Tuple)和字典(Dict)
2017/04/21 Python
python与caffe改变通道顺序的方法
2018/08/04 Python
python 随机生成10位数密码的实现代码
2019/06/27 Python
Python英文文章词频统计(14份剑桥真题词频统计)
2019/10/13 Python
Pycharm快捷键配置详细整理
2020/10/13 Python
西班牙第一的网上药房:PromoFarma.com
2017/04/17 全球购物
客服实习的个人自我鉴定
2013/10/20 职场文书
商务英语毕业生自荐信范文
2013/11/08 职场文书
校三好学生主要事迹
2014/01/11 职场文书
餐厅经理岗位职责和岗位目标
2014/02/13 职场文书
驾驶员培训方案
2014/05/01 职场文书
领导班子四风对照检查材料思想汇报
2014/09/26 职场文书
2014年仓管员工作总结
2014/11/18 职场文书
党员民主生活会材料
2014/12/15 职场文书
个人先进材料范文
2014/12/30 职场文书
Java基础之线程锁相关知识总结
2021/06/30 Java/Android
python多线程方法详解
2022/01/18 Python
java中为什么说子类的构造方法默认访问的是父类的无参构造方法
2022/04/13 Java/Android