簡述
搶購/秒殺是如今很常見的一個應用場景,那么高並發競爭下如何解決超搶(或超賣庫存不足為負數的問題)呢?
常規寫法:
查詢出對應商品的庫存,看是否大於0,然后執行生成訂單等操作,但是在判斷庫存是否大於0處,如果在高並發下就會有問題,導致庫存量出現負數
這里我就只談redis的解決方案
我們先來看以下php代碼是否能正確解決超搶/賣的問題:
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); //系統庫存量 $num = 10; //當前搶購用戶id,模擬數據 $user_id = rand(0,100); //檢查庫存,order:1 定義為健名 $len = $redis->llen('order:1'); if($len >= $num) return '已經搶光了哦'; //把搶到的用戶存入到列表中 $result =$redis->lpush('order:1',$user_id); if($result) return '恭喜您!搶到了哦';
如果代碼正常運行,按照預期理解的是列表order:1中最多只能存儲10個用戶的id,因為庫存只有10個。
然而,但是,在使用jmeter工具模擬多用戶並發請求時,最后發現order:1中總是超過10個用戶,也就是出現了“超賣”。
分析問題就出在這一段代碼:
$len = $redis->llen('order:1'); if($len >= $num) return '已經搶光了哦';
在搶購進行到一定程度,假如現在已經有9個人搶購成功,又來了3個用戶同時搶購,這時if條件將會被繞過(條件同時被滿足了),這三個用戶都能搶購成功。而實際上只剩下一件庫存可以搶了。
在高並發下,很多看似不大可能是問題的,都成了實際產生的問題了。要解決“超搶/超賣”的問題,核心在於保證檢查庫存時的操作是依次執行的,再形象的說就是把“多線程”轉成“單線程”。即使有很多用戶同時到達,也是一個個檢查並給與搶購資格,一旦庫存搶盡,后面的用戶就無法繼續了。
我們需要使用redis的原子操作來實現這個“單線程”。首先我們把庫存存在goods_store:1這個列表中,假設有10件庫存,就往列表中push10個數,這個數沒有實際意義,僅僅只是代表一件庫存。搶購開始后,每到來一個用戶,就從goods_store:1中pop一個數,表示用戶搶購成功。當列表為空時,表示已經被搶光了。因為列表的pop操作是原子的,即使有很多用戶同時到達,也是依次執行的。搶購的示例代碼如下:
比如這里我先把庫存(假設有10件)放入redis隊列:
$redis = new redis(); $redis->connect('127.0.0.1', 6379); //庫存 $num=10; //檢查庫存,goods_store:1 定義為健名 $len=$redis->llen('goods_store:1'); //實際庫存-被搶購的庫存 = 剩余可用庫存 $count = $num-$len; for($i=0;$i<$count;$i++) //往goods_store列表中,未搶購之前這里應該是默認滴push10個庫存數了 $redis->lpush('goods_store:1',1);
好吧,搶購時間到了:
$redis = new redis(); $redis->connect('127.0.0.1', 6379); $user_id = rand(0,100);//當前搶購用戶id /* 模擬搶購操作,搶購前判斷redis隊列庫存量 */ $count=$redis->lpop('goods_store:1'); if(!$count) return '已經搶光了哦'; $result = $redis->lpush('order:1',$user_id); if($result) return '恭喜您!搶到了哦';
注意:這里可以不必進行數據庫操作,而是先存入隊列,操作數據庫的時間是跟用戶無關的,所以應該立馬返回讓用戶知道是否搶到,之后再對這個隊列進行操作。
為了檢測實際效果,我使用jmeter工具模擬100、500、1000個用戶並發進行搶購,經過大量的測試,最終搶購成功的用戶始終為10,沒有出現“超賣”。
問題
上面雖然能夠解決超賣的現象,但是卻不能夠防止超搶的情況發生,就是一個用戶可以搶 到相同的多件商品
嘗試解決
$data = $redis->lRange('order:1',0,-1); //把搶到的用戶存入到列表中 if(!in_array($user_id,$data)) { $count=$redis->lpop('goods_store:1'); if(!$count) return '已經搶光了哦'; $result = $redis->lpush('order:1',$user_id); if($result) return '恭喜您!搶到了哦'; } else { return '已經搶購了哦'; }
上面這個代碼在沒有高並發情況下測試沒有問題,但是如果高並發情況下呢。答案是不可以的。這跟上面的
$len = $redis->llen('order:1'); if($len >= $num) return '已經搶光了哦';
是一樣道理的,所以行不通
這時有人提出了
$data = $redis->lRange('order:1',0,-1); //把搶到的用戶存入到列表中 if(!in_array($user_id,$data)) ...
這兩行代碼不就是判斷list中某個值是否存在嗎?為何不直接調用list的exist函數判斷,我剛開始也照着這樣去查找。不過並沒有找到這個內置函數。而我也自己寫了一個函數判斷是否存在list中,偽代碼如下
if(調用函數判斷id是否存在list中) { $count=$redis->lpop('goods_store:1'); if(!$count) return '已經搶光了哦'; $result = $redis->lpush('order:1',$user_id); if($result) return '恭喜您!搶到了哦'; } else { return '已經搶購了哦'; }
不過答案還是不行的,因為如果同時有兩個相同id進入if判斷,還是都會進入到if中。
解決
這時我將list轉成了hash,將代碼改成了下面
//把所有用戶都插入到這個隊列中 $wait_key = "user_wait:2"; //真正搶到的用戶信息隊列 $user_key = "user:1"; //庫存隊列 $store_key = "goods_store:1"; $result =$redis->hset($wait_key, $user_id, $user_id); if($result) { $count = $redis->lpop($store_key); if (!$count) echo '已經搶光了哦'.$user_id; else { $result =$redis->hset($user_key, $user_id, $user_id); echo '恭喜您!搶到了哦'.$user_id; } }
這時又有人說這樣干嘛不可以用list,代碼如下:
//把所有用戶都插入到這個隊列中 $wait_key = "user_wait:2"; //真正搶到的用戶信息隊列 $user_key = "user:1"; //庫存隊列 $store_key = "goods_store:1"; $result =$redis->rPush($wait_key, $user_id); if($result) { $count = $redis->lpop($store_key); if (!$count) echo '已經搶光了哦'.$user_id; else { $result =$redis->rPush($user_key, $user_id); echo '恭喜您!搶到了哦'.$user_id; } }
對比
list:

hash:

分析:list中的值是可以重復的,而hash里面的值是不可以重復的
所以
$result =$redis->rPush($wait_key, $user_id);
跟
$result =$redis->hset($wait_key, $user_id, $user_id);
當高並發的的情況下,無論id是否相同,list的rpush返回結果都是1,而hash的hset只有不同的時候才返回1.這樣就可以避免由於高並發而導致一個用戶搶到多件同種商品
測試結果
先加入10個庫存

$user_id = 1;//當前搶購用戶id $wait_key = "user_wait:2"; $user_key = "user:1"; $store_key = "goods_store:1"; $result =$redis->hset($wait_key, $user_id, $user_id); if($result) { $count = $redis->lpop($store_key); if (!$count) echo '已經搶光了哦'.$user_id; else { $result =$redis->hset($user_key, $user_id, $user_id); echo '恭喜您!搶到了哦'.$user_id; } } else { echo '已經搶到了'.$user_id; }
為了測試極限高並發情況下,我直接將用戶Id設置為1
我這里模擬1000個用戶同時進入秒殺

如果秒殺成功,應該是庫存為9,而真正的搶購隊列只有用戶1

使用jemter測試結果


結果也是符合預期的
現在來模擬真正不同id看看是否只是10個用戶能搶到
先補充倉庫

所有搶購用戶


真正搶購到

至此,已經算是實現了預期
