秒殺系統的設計

秒殺系統的設計

之前寫過一篇關于 促銷系統的設計 中提到了秒殺/直減/聚劃算,但在實際工作中,并沒有真的做過秒殺系統,所以假想了一個簡單的秒殺系統來”解解饞“,促銷思路依舊順延之前的文章設計。

分析

秒殺時大量的流量涌入,秒殺開始前頻繁刷新查詢,如果大量的流量瞬間沖擊到數據庫的話,非常容易造成數據庫的崩潰。所以秒殺的主要工作就是對流量進行層層篩選最后讓盡可能平緩的流量進入到數據庫。

通常的秒殺是大量的用戶搶購少量的商品,類似這樣的需求只需要簡單的進行庫存緩存,就能在實際創建訂單前過濾大量的流量。

但但但是,只是這樣的話好像沒什么挑戰力呀!稍微加大一下難度,假設我們的秒殺是像搶小米手機一樣,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 IlluminateContractsRoutingResponseFactory|IlluminateHttpResponse  * @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 < $quantity) {              $lock->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 IlluminateBroadcastingChannel|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教程欄目進行學習!

? 版權聲明
THE END
喜歡就支持一下吧
點贊14 分享