之前寫過一篇關於促銷系統的設計中提到了秒殺/直減/聚划算,但在實際工作中,並沒有真的做過秒殺系統,所以假想了一個簡單的秒殺系統來」解解饞「,促銷思路依舊順延之前的文章設計。
秒殺時大量的流量湧入,秒殺開始前頻繁刷新查詢,如果大量的流量瞬間衝擊到資料庫的話,非常容易造成資料庫的崩潰。所以秒殺的主要工作就是對流量進行層層篩選最後讓盡可能少且平緩的流量進入到資料庫。
通常的秒殺是大量的用戶搶購少量的商品,類似這樣的需求只需要簡單的進行庫存緩存,就能在實際創建訂單前過濾大量的流量。
但但但是,只是這樣的話好像沒什麼挑戰力呀!稍微加大一下難度,假設我們的秒殺是像搶小米手機一樣,100 萬人搶 10 萬台手機呢?小米搶購時的排隊是一種方法(雖然體驗不太好),後續將會按照這個想法進行我們的秒殺設計。
提到小米就不得不說一下,其讓我知道了什麼是「運氣也是實力的一部分!」
前端限流大法: random(0, 1) ? axios.post : wait(30, '搶完啦!')
下面開始從一些程式碼的細節進行分析,原則上是對原有業務邏輯盡可能小的改動。 另外後文沒有什麼服務熔斷,多層快取等進階的玩法,只是比較簡單的業務設計。
運營人員在後台將一個變體添加到秒殺促銷,並設定秒殺的庫存/秒殺折扣率/開始時間和結束時間等,我們能夠得到類似這樣的數據。
// promotion_variant (促销和变体表「sku」的一个中间表) { 'id': 1, 'variant_id': 1, 'promotion_id': 1, 'promotion_type': 'snap_up', 'discount_rate': 0.5, 'stock': 100, // 秒杀库存 'sold': 0, // 秒杀销量 'quantity_limit': 1, // 限购 'enabled': 1, 'product_id': 1, 'rest': { variant_name: 'xxx', // 秒杀期间变体名称 image: 'xxx', // 秒杀期间变体图片 } }
首先便是在秒殺促銷創建成功後將促銷的信息進行緩存
# PromotionVariantObserver.php public function saved(PromotionVariant $promotionVariant) { if ($promotionVariant->promotion_type === PromotionType::SNAP_UP) { $seconds = $promotionVariant->ended_at->getTimestamp() - time(); \Cache::put( "promotion_variants:$promotionVariant->id", $promotionVariant, $seconds ); } }
已有的下單接口,接收到變體信息後,並不知道目前變體清單哪些參與了促銷,這裡的判斷操作是需要大量的資料庫查詢操作的。
所以此處為秒殺編寫一個新的 api ,前端檢測到當前變體處於秒殺促銷時則切換到秒殺下單 api 。
當然依舊使用原有的下單 api ,前端傳遞一個標識也是沒有問題的。
需要解釋的一點時,下單通常分為兩個步驟
第一步是「結帳( checkout )」產生一個結帳訂單,使用者可以為結帳訂單選擇地址、優惠卷、付款方式等。
第二步是 “確認 ( confirm )”,此時訂單將變成確認狀態,對庫存進行鎖定,且用戶可以進行支付。通常如果在規定時間內沒有支付,則取消該訂單,並解鎖庫存。
所以在第一步時就會對使用者進行過濾和排隊處理,防止後續的選擇地址、優惠卷等操作對資料庫進行衝擊。
# CheckoutController.php /** * @param Request $request * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response * @throws StockException */ public function snapUpCheckout(Request $request) { $variantId = $request->input('variant_id'); $quantity = $request->input('quantity', 1); // 加锁防止超卖 $lock = \Cache::lock('snap_up:' . $variantId, 10); try { // 未获取锁的消费者将阻塞在这里 $lock->block(10); $promotionVariant = \Cache::get('promotion_variants:' . $variantId); if ($promotionVariant->quantity release(); throw new StockException('库存不足'); } $promotionVariant->quantity -= $quantity; $seconds = $promotionVariant->ended_at->getTimestamp() - time(); \Cache::put( "promotion_variants:$promotionVariant->id", $promotionVariant, $seconds ); } catch (LockTimeoutException $e) { throw new StockException('库存不足'); } finally { optional($lock)->release(); } CheckoutOrder::dispatch([ 'user_id' => \Auth::id(), 'variant_id' => $variantId, 'quantity' => $quantity ]); return response('结账订单创建中'); }
可以看到在秒殺結帳 api 中,並沒有涉及到資料庫的操作。並且透過 dispatch 將創建訂單的任務分發到佇列,使用者按照進入佇列的先後順序進行對應時間的排隊等待。
現在的問題是,訂單建立成功後如何通知客戶端呢?
這裡的方案無非就是輪詢或websocket, 這裡選擇對伺服器效能消耗較小的websocket ,且使用laravel 提供的laravel-echo ( laravel-echo-server ) 。當用戶秒殺成功後,前端和後端建立 websocket 鏈接,後端結帳訂單創建成功後通知前端可以進行下一步操作。
後端接下來要做的就是在「CheckoutOrder」Job 中的訂單創建成功後,向websocket 對應的頻道中發送一個「OrderChecked 」事件,來表明結帳訂單已經建立完成,使用者可以進行下一步操作。
# Job/CheckoutOrder.php // ... public function handle() { // 创建结账订单 // ... // 通知客户端. websocket 编程本身就是以事件为导向的,和 laravel 的 event 非常契合。 event(new OrderChecked($this->data->user_id)); } // ...
# Event/OrderChecked.php class OrderChecked implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; private $userId; /** * Create a new event instance. * * @param $userId */ public function __construct($userId) { $this->userId = $userId; } /** * App.User.{id} 是 laravel 初始化时,默认的私有频道,直接使用即可 * @return \Illuminate\Broadcasting\Channel|array */ public function broadcastOn() { return new PrivateChannel('App.User.' . $this->userId); } }
假設目前搶購的使用者 id 是 1,總結上面的程式碼就是向 websocket 的私人頻道「App.User.1」 推送一個 “OrderChecked” 事件。
下面的程式碼是使用 vue-cli 工具初始化的預設項目。
// views/products/show.vue <script> import Echo from 'laravel-echo' import io from 'socket.io-client' window.io = io export default { name: 'App', methods: { async snapUpCheckout () { try { // await post -> snap-up-checkout this.toCheckout() } catch (error) { // 秒杀失败 } }, toCheckout () { // 建立 websocket 连接 const echo = new Echo({ broadcaster: 'socket.io', host: 'http://api.e-commerce.test:6001', auth: { headers: { Authorization: 'Bearer ' + this.store.auth.token } } }) // 监听私有频道 App.User.{id} 的 OrderChecked 事件 echo.private('App.User.' + this.store.user.id).listen('OrderChecked', (e) => { // redirect to checkou page }) } } } </script>
laravel-echo 使用時需要注意的一點,由於使用了私有頻道,所以laravel-echo 預設會向服務端api /broadcasting/auth
發送一條post 請求進行身份驗證。但由於採用了前後端分類而不是 blade 模板,所以我們並不能方便的獲取 csrf token 和 session 來進行一些必要的認證。
因此需要稍微修改一下broadcast 和laravel-echo-server 的配置
# BroadcastServiceProvider.php public function boot() { // 将认证路由改为 /api/broadcasting/auth 从而避免 csrf 验证 // 添加中间件 auth:api (jwt 使用 api.auth) 进行身份验证,避免访问 session ,并使 Auth::user() 生效。 Broadcast::routes(["prefix" => "api", "middleware" => ["auth:api"]]); require base_path('routes/channels.php'); }
// laravel-echo-server.json // 认证路由添加 api 前缀,与上面的修改对应 "authEndpoint": "/api/broadcasting/auth"
在已經為該訂單鎖定」庫存「的情況下,用戶如果斷開websocket 連線或長時間離開時需要將庫存解鎖,防止庫存無意義佔用。
這裡的庫存指的是快取庫存,而非資料庫庫存。這是因為此時訂單即使建立成功也是結帳狀態(未選擇地址,付款方式等),在個人中心也是看不見的。只有當用戶確認訂單後,才會將資料庫庫存鎖定。
所以這裡的理想實作是,用戶斷開 websocket 連線後,將該訂單鎖定的庫存歸還。且結帳訂單建立後再建立一個延時佇列對長時間未操作的訂單進行庫存歸還。
但但但是,laravel-echo 是一個廣播系統,並沒有提供客戶端斷開連接事件的回調,有些方法可以實現laravel 監聽的客戶端事件,例如在laravel-echo-server 添加hook通知laravel,但是要修改laravel-echo-server 的實現,這裡就不細說了,重點還是提供秒殺思路。
上圖為秒殺系統的邏輯總結。至此整個秒殺流程就結束了,總的來說程式碼量不多,邏輯也較為簡單。
從圖中可以看出,整個流程中,只有在 queue 中才會和 mysql 交互,透過 queue 的限流從而最大限度的適應了 mysql 的承受能力。在 mysql 性能足夠的情況下,透過大量的 queue 同時消費訂單,用戶是完全感知不到排隊的過程的。
有問題或有更好的思路歡迎留言討論呀~
更多Laravel相關技術文章,請訪問Laravel教程欄目進行學習!
以上是秒殺系統的設計的詳細內容。更多資訊請關注PHP中文網其他相關文章!