文章來自-微信公眾號: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");
}
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結