PHP商品秒殺問題解決方案實例詳解【mysql與redis】

本文實例講述了PHP商品秒殺問題解決方案。分享給你們供你們參考,具體以下:php

引言mysql

假設num是存儲在數據庫中的字段,保存了被秒殺產品的剩餘數量。redis

if($num > 0){
  //用戶搶購成功,記錄用戶信息
  $num--;
}

  

假設在一個併發量較高的場景,數據庫中num的值爲1時,可能同時會有多個進程讀取到num爲1,程序判斷符合條件,搶購成功,num減一。這樣會致使商品超發的狀況,原本只有10件能夠搶購的商品,可能會有超過10我的搶到,此時num在搶購完成以後爲負值。sql

解決該問題的方案由不少,能夠簡單分爲基於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

悲觀鎖fetch

悲觀鎖的方案採用的是排他讀,也就是同時只能有一個進程讀取到num的值。事務在提交或回滾以後,鎖會釋放,其餘的進程才能讀取。該方案最簡單易懂,在對性能要求不高時,能夠直接採用該方案。要注意的是,SELECT … FOR UPDATE要儘量的使用索引,以便鎖定儘量少的行數;排他鎖是在事務執行結束以後才釋放的,不是讀取完成以後就釋放,所以使用的事務應該儘量的早些提交或回滾,以便早些釋放排它鎖。this

$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");
  }
}
相關文章
相關標籤/搜索