首頁 >後端開發 >php教程 >怎麼解決PHP高併發(商品秒殺)問題?兩種解決方案分享

怎麼解決PHP高併發(商品秒殺)問題?兩種解決方案分享

青灯夜游
青灯夜游轉載
2022-02-23 10:52:217239瀏覽

怎麼解決PHP高併發(商品秒殺)問題?以下這篇文章就來給大家分享兩種解決方案(基於mysql或基於Redis),希望對大家有幫助。

怎麼解決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(&#39;goods&#39;,$i);
  }
  $this->redis->del(&#39;result&#39;);
  echo &#39;init done&#39;;
}
public function run(){
  $goods_id = $this->redis->rPop(&#39;goods&#39;);
  usleep(100);
  if($goods_id == false) {
    echo "fail1";
  }else{
    $res = $this->redis->lPush(&#39;result&#39;,$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(&#39;num&#39;);
  if($num > 0) {
    usleep(100);
    $retNum = $this->redis->decr(&#39;num&#39;);
    if($retNum >= 0){
      $res = $this->redis->lPush(&#39;result&#39;,$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 &#39;fail1&#39;;
  }else{
    $num = $this->redis->get(&#39;num&#39;);
    if($num > 0) {
      $this->redis->decr(&#39;num&#39;);
      usleep(100);
      $res = $this->redis->lPush(&#39;result&#39;,$num);
      if($res == false){
        echo "fail2";
      }else{
        echo "success:".$num;
      }
    }else{
      echo "fail3";
    }
    $this->redis->del("numKey");
  }
}

推荐学习:《PHP视频教程

以上是怎麼解決PHP高併發(商品秒殺)問題?兩種解決方案分享的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:微信公众号- PHP自学中心。如有侵權,請聯絡admin@php.cn刪除