簡單實現redis實現高併發下的搶購/秒殺功能(轉)
簡述
搶購/秒殺是如今很常見的一個應用場景,那麼高併發競爭下如何解決超搶(或超賣庫存不足為負數的問題)呢?
常規寫法:
查詢出對應商品的庫存,看是否大於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條件將會被繞過(條件同時被滿足了),這三個使用者都能搶購成功。而實際上只剩下一件庫存可以搶了。
在高併發下,很多看似不大可能是問題的,都成了實際產生的問題了。要解決“超搶/超賣”的問題,核心在於保證檢查庫存時的操作是依次執行的,再形象的說就是把“多執行緒”轉成“單執行緒”。即使有很多使用者同時到達,也是一個個檢查並給與搶購資格,一旦庫存搶盡,後面的使用者就無法繼續了。
比如這裡我先把庫存(假設有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個使用者能搶到
先補充倉庫
所有搶購使用者
真正搶購到
至此,已經算是實現了預期