商品秒殺問題的解決方案

來源:互聯網
上載者:User
引言

假設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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.