concurrency

并发

PHP 解决并发问题的几种实现

对于商品抢购、秒杀等并发场景下,可能会出现超卖的现象,这时就需要解决在高并发请求下容易产生两个问题:

  1. 数据出错,导致产品超卖;
  2. 频繁操作数据库,导致性能下降;

如果库存大于0,则库存减少1,同时生产订单,录入抢购者数据。

    // 常规代码处理高并发
    public function actionNormal(){
        // 查询库存
        $stock = Goods::find()->select('stock')->where(['goods_id'=>100001])->asArray()->one();
        // 判断该商品是否还有库存
        if ($stock['stock']>0) {
            // 库存减一
            Goods::updateAllCounters(['stock' => -1],['goods_id'=>100001]);

            // 生产订单(另外功能,暂且随机赋值)
            $order = $this->build_order();

            // 秒杀信息入库
            $model = new Highly();
            $model->order_id = $order;
            $model->goods_name = '秒杀商品';
            $model->buy_time = date('Y-m-d H:i:s',time());
            $model->mircrotime = microtime(true);
            if($model->save()===false){
                echo '未能成功抢购!';
            }else{
                echo '恭喜你,订单<b>'.$order.'</b>抢购成功';
            }

        }else{
            echo '已被抢购一空!';
        }
    }

将商品库存设置为20后,通过ab 配置200的并发请求。

ab -n 200 -c 200 http//localhost/highly/normal

执行结果发现库存变成了负值,商品超卖了。

20170819215114902

在PHP语言中并没有原生的提供并发的解决方案,因此就需要借助其他方式来实现并发控制。

  • 方案一:使用文件排它锁;
  • 方案二:使用 MySQL 数据库提供的悲观锁;
  • 方案三:使用队列;
  • 方案四:使用 Redis;

方案一:使用文件排它锁

flock 函数用于获取文件的锁,这个锁同时只能被一个线程获取到,其它没有获取到锁的线程要么阻塞,要么获取失败。

在获取到锁的时候,先查询库存,如果库存大于 0 ,则进行下单操作,减库存,然后释放锁。

在处理下单请求的时候,用flock锁定一个文件,如果锁定失败说明有其他订单正在处理,此时要么等待要么直接提示用户"服务器繁忙"。

阻塞(等待)模式:

$fp = fopen("lock.txt", "w+");
if(flock($fp,LOCK_EX))
{
    //..处理订单
    flock($fp,LOCK_UN);
}

fclose($fp);

非阻塞模式:

$fp = fopen("lock.txt", "w+");
if(flock($fp,LOCK_EX | LOCK_NB))
{
    //..处理订单
    flock($fp,LOCK_UN);
}
else
{
    echo "系统繁忙,请稍后再试";
}

fclose($fp);

方案二:使用 MySQL 数据库提供的悲观锁

Innodb存储引擎支持行级锁,当某行数据被锁定时,其他进程不能对这行数据进行操作

// 先查询库存并锁定行
// select stock_num from table where id=1 for update

if(stock_num > 0){
    // 下订单
    // update table set stock_num=stock-1 where id=1;
}

方案三:使用队列

将用户的下单请求依次存入一个队列中,后台用一个单独的进程处理队列中的下单请求。

方案四:使用 Redis 队列

使用 Redis 队列,因为 POP 操作是原子性的,即使有很多用户同时到达,也是依次执行,推荐使用。

MySQL 事务在高并发下性能下降得厉害,文件锁的方式也是。

先将商品库存如队列:

$redis=new Redis();  

$store=1000;  
$result = $redis->connect('127.0.0.1',6379);  
$res= $redis->llen('goods_store');  
echo $res;  
$count = $store-$res;  
for($i = 0; $i < $count; $i++){  
    $redis->lpush('goods_store',1);  
}  
echo $redis->llen('goods_store');

抢购秒杀的逻辑:

$conn=mysql_connect("localhost","big","123456");    
if(!$conn){    
    echo "connect failed";    
    exit;    
}   
mysql_select_db("big",$conn);   
mysql_query("set names utf8");  
  
$price=10;  
$user_id=1;  
$goods_id=1;  
$sku_id=11;  
$number=1;  
  
//生成唯一订单号  
function build_order_no(){  
    return date('ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 8);  
}  
//记录日志
function insertLog($event,$type=0){  
    global $conn;  
    $sql="insert into ih_log(event,type)   
    values('$event','$type')";    
    mysql_query($sql,$conn);    
}  
  
//模拟下单操作
//下单前判断redis队列库存量
$redis=new Redis();
$result=$redis->connect('127.0.0.1',6379);
$count=$redis->lpop('goods_store');
if(!$count){
    insertLog('error:no store redis');
    return;
}
  
//生成订单
$order_sn=build_order_no();
$sql="insert into ih_order(order_sn,user_id,goods_id,sku_id,price)
values('$order_sn','$user_id','$goods_id','$sku_id','$price')";
$order_rs=mysql_query($sql,$conn);
  
//库存减少
$sql="update ih_store set number=number-{$number} where sku_id='$sku_id'";
$store_rs=mysql_query($sql,$conn);
if(mysql_affected_rows()){
    insertLog('库存减少成功');
}else{
    insertLog('库存减少失败');
}

模拟5000高并发测试

ab -c 5000 -r -n 6000 http://192.168.1.198/big/index.php

通过测试,数据库生产的订单数量正常,并没有出现问题。而又避免了请求数据库造成性能下降的问题。同时内存数据库redis查询的速度要比mysql快很多。

其他并发问题

在现实应用中,很多情况下会把数据存入缓存,当缓存失效时,去数据库取数据并重新设置缓存,如果这时并发量很大,会有很多进程同时去数据库取数据,导致很多请求

说白了,要解决并发问题就必须要加锁,各种方案的本质都是加锁

测试数据库:

--  
-- 数据库: `big`  
--  
  
-- --------------------------------------------------------  
  
--  
-- 表的结构 `ih_goods`  
--  
  
  
CREATE TABLE IF NOT EXISTS `ih_goods` (
  `goods_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `cat_id` int(11) NOT NULL,
  `goods_name` varchar(255) NOT NULL,
  PRIMARY KEY (`goods_id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8;
  
  
--  
-- 转存表中的数据 `ih_goods`  
--  
  
  
INSERT INTO `ih_goods` (`goods_id`, `cat_id`, `goods_name`) VALUES  
(1, 0, '小米手机');  
  
-- --------------------------------------------------------  
  
--  
-- 表的结构 `ih_log`  
--  
  
CREATE TABLE IF NOT EXISTS `ih_log` (  
  `id` int(11) NOT NULL AUTO_INCREMENT,  
  `event` varchar(255) NOT NULL,  
  `type` tinyint(4) NOT NULL DEFAULT '0',  
  `addtime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,  
  PRIMARY KEY (`id`)  
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;  
  
--  
-- 转存表中的数据 `ih_log`  
--  
  
  
-- --------------------------------------------------------  
  
--  
-- 表的结构 `ih_order`  
--  
  
CREATE TABLE IF NOT EXISTS `ih_order` (  
  `id` int(11) NOT NULL AUTO_INCREMENT,  
  `order_sn` char(32) NOT NULL,  
  `user_id` int(11) NOT NULL,  
  `status` int(11) NOT NULL DEFAULT '0',  
  `goods_id` int(11) NOT NULL DEFAULT '0',  
  `sku_id` int(11) NOT NULL DEFAULT '0',  
  `price` float NOT NULL,  
  `addtime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,  
  PRIMARY KEY (`id`)  
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='订单表';  
  
--  
-- 转存表中的数据 `ih_order`  
--  
  
  
-- --------------------------------------------------------  
  
--  
-- 表的结构 `ih_store`  
--  
  
CREATE TABLE IF NOT EXISTS `ih_store` (  
  `id` int(11) NOT NULL AUTO_INCREMENT,  
  `goods_id` int(11) NOT NULL,  
  `sku_id` int(10) unsigned NOT NULL DEFAULT '0',  
  `number` int(10) NOT NULL DEFAULT '0',  
  `freez` int(11) NOT NULL DEFAULT '0' COMMENT '虚拟库存',  
  PRIMARY KEY (`id`)  
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COMMENT='库存';  
  
--  
-- 转存表中的数据 `ih_store`  
--  
  
INSERT INTO `ih_store` (`id`, `goods_id`, `sku_id`, `number`, `freez`) VALUES  
(1, 1, 11, 500, 0);