秒殺系統的設計

Max發表於2019-05-06

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

分析

秒殺時大量的流量湧入,秒殺開始前頻繁重新整理查詢,如果大量的流量瞬間衝擊到資料庫的話,非常容易造成資料庫的崩潰。所以秒殺的主要工作就是對流量進行層層篩選最後讓儘可能平緩的流量進入到資料庫。

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

但但但是,只是這樣的話好像沒什麼挑戰力呀!稍微加大一下難度,假設我們的秒殺是像搶小米手機一樣,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

public function snapUpCheckout(Request $request)
{
  $variantId = $request->input('variant_id');
  $quantity = $request->input('quantity', 1);
  $promotionVariant = \Cache::get('promotion_variants:' . $variantId);

  $lock = \Cache::lock('snap_up:' . $variantId);

  // 大量的使用者會在這個環節被過濾掉。
  if ($lock->get()) {
    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
    );

    $lock->release();
  }

  // 分發一個建立結賬訂單的任務,對於數量不大的秒殺我們可以把這裡換成 dispatchNow 同步執行
  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 test'
          }
        }
      })

            // 監聽私有頻道 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 同時消費訂單,使用者是完全感知不到排隊的過程的。

有問題或者有更好的思路歡迎留言討論呀~

相關文章