2025年6月24日 星期二

建構高效能 Laravel 搶票系統:深入理解 Swoole、Redis 原子操作與非同步佇列

建構高效能 Laravel 搶票系統:深入理解 Swoole、Redis 原子操作與非同步佇列

對於中階 Laravel 工程師而言,開發一個功能完善的 CRUD 應用或許已駕輕就熟。但當面對如「秒殺」、「搶購」這類高併發場景時,傳統的 Laravel + PHP-FPM 架構往往會遇到效能瓶頸,甚至引發超賣、系統崩潰等嚴重問題。

點這裡前往 GitHub 專案

今天,我們將深入探討一個實際案例:如何利用 SwooleRedis 原子操作Laravel 非同步佇列,共同打造一個高效能、高可用的搶票系統 SnapTicket。本文將從理論基礎到核心程式碼實現進行全面解析,助您在處理高併發挑戰時更上一層樓。

1. 傳統 Laravel + PHP-FPM 的痛點

在開始之前,我們先快速回顧一下為什麼傳統的 PHP-FPM 模式不適合高併發搶購:

  • 請求生命週期短:PHP-FPM 每次請求都會啟動一個新的程序(或從程序池複用),初始化整個 Laravel 框架,執行完畢後釋放。這個「啟動-銷毀」的過程在高併發下會產生巨大的資源消耗和延遲。
  • 阻塞 I/O:PHP 預設是同步阻塞 I/O。當遇到資料庫查詢、外部 API 請求等耗時操作時,當前程序會一直等待結果返回,無法處理其他請求,導致大量連線堆積。
  • 資料庫瓶頸:瞬時大量請求直接打擊關聯式資料庫(如 MySQL),鎖競爭激烈,極易出現死鎖、超賣等問題。

2. SnapTicket 的技術選型與架構概覽

為了解決上述痛點,SnapTicket 採用了以下核心技術棧:

  • 後端框架:Laravel 10.x
  • 高併發伺服器:Swoole 4.x (取代 PHP-FPM)
  • 快取與佇列:Redis 7.0 (核心用於庫存和佇列)
  • 資料庫:MySQL 8.0 (用於訂單和基礎數據)
  • 容器化:Docker (便於開發與部署)

其核心架構圖如下所示:

程式碼片段
graph TD
    A[使用者] -->|HTTP 請求| B[Nginx]
    B -->|代理| C[Swoole HTTP 伺服器]
    C -->|處理請求| D[Laravel 應用]
    D -->|搶票邏輯| E[TicketService]
    E -->|原子性扣減| F[Redis: 庫存管理]
    E -->|生成訂單| G[MySQL: 訂單與票務數據]
    D -->|非同步任務| H[Redis 佇列]
    H -->|處理訂單| I[Swoole 佇列工作程序]
    I -->|支付監控| J[MonitorPaymentJob]
    J -->|超時恢復庫存| E
    A -->|壓力測試| K[SimulatedClient 命令]
    K -->|併發請求| B

3. 核心設計解析

3.1 Redis 原子性庫存扣減:防止超賣的基石

這是搶票系統最關鍵的一環。我們必須確保在極端併發下,庫存不會被超賣。傳統的「查詢庫存 -> 判斷 -> 扣減庫存」三步操作存在時間差,容易在高併發時導致超賣。

解決方案:Redis Lua 腳本

Redis 是單執行緒的,其命令執行具有原子性。利用 Redis 的 Lua 腳本,我們可以將多個操作打包成一個原子命令,一次性發送給 Redis 伺服器執行,從而避免併發問題。

程式碼片段 (app/Services/TicketService.php):

PHP
// 使用 Lua 腳本確保庫存扣減的原子性,防止併發超賣
$luaScript = <<<LUA
    local stockKey = KEYS[1]
    local currentStock = tonumber(redis.call('get', stockKey))
    if currentStock and currentStock > 0 then
        redis.call('decr', stockKey) // 庫存減 1
        return 1 // 扣減成功
    end
    return 0 // 庫存不足
LUA;
$result = Redis::eval($luaScript, ["ticket:{$ticketId}:stock"], 0);

if ($result === 0) {
    // 如果 Lua 腳本返回 0,表示庫存不足
    // 同時釋放用戶鎖,防止用戶無法再次嘗試
    Redis::del($userTicketLockKey);
    throw ValidationException::withMessages(['ticket' => '抱歉,該票種庫存不足。']);
}

解析:

  • Redis::eval() 會執行一個 Lua 腳本。KEYS[1] 對應傳入的 ticket:{$ticketId}:stock 這個鍵。
  • 腳本邏輯很簡單:獲取當前庫存,如果大於 0,則執行 DECR 命令將庫存減 1 並返回 1;否則返回 0。
  • 整個 Lua 腳本的執行是原子性的,即使多個請求同時到達,Redis 也會串行執行這些腳本,從而保證庫存不會被超賣。

3.2 用戶重複搶票限制:避免惡意刷單

為了防止單個用戶惡意重複搶購,我們同樣利用 Redis 實現一個簡單的鎖機制。

程式碼片段 (app/Services/TicketService.php):

PHP
// 檢查用戶是否已搶過此票 (簡單的重複搶購限制)
$userTicketLockKey = "user:ticket:lock:{$userId}:{$ticketId}";
if (Redis::setnx($userTicketLockKey, 1)) {
    // 設置鎖的過期時間,例如 1 小時後自動釋放,避免死鎖
    Redis::expire($userTicketLockKey, 3600); // 1 小時
} else {
    throw ValidationException::withMessages(['ticket' => '您已搶過此票,請勿重複操作。']);
}

解析:

  • Redis::setnx() (SET if Not eXists) 是原子性的,如果鍵不存在則設置成功並返回 1,否則返回 0。
  • 如果 SETNX 成功,說明該用戶是第一次搶這張票,我們就設置一個過期時間。
  • 如果 SETNX 失敗,說明該用戶已經有鎖了(即已經搶過此票),直接拋出異常。

3.3 非同步訂單處理:解耦與提升響應

將耗時的資料庫寫入和後續業務邏輯從核心搶購流程中剝離,轉為非同步處理,可以大大提升前端響應速度。

程式碼片段 (app/Services/TicketService.php):

PHP
// 在資料庫事務中生成訂單
$orderId = 0;
try {
    DB::transaction(function () use ($ticket, $userId, &$orderId) {
        $order = Order::create([
            'user_id' => $userId,
            'ticket_id' => $ticket->id,
            'quantity' => 1,
            'total_price' => $ticket->price,
            'status' => Order::STATUS_PENDING, // 初始狀態為待支付
            'order_sn' => 'SN' . time() . uniqid(),
        ]);
        $orderId = $order->id;
    });

    // 派發非同步任務處理後續訂單流程
    // afterCommit() 確保只有當 Redis 扣減成功且 DB 事務提交後才派發 Job
    ProcessOrderJob::dispatch($orderId)->afterCommit();

} catch (\Exception $e) {
    // 如果 DB 事務失敗,需要補回 Redis 庫存並釋放用戶鎖
    Redis::incr("ticket:{$ticketId}:stock");
    Redis::del($userTicketLockKey);
    Log::error("訂單建立失敗,庫存已恢復。錯誤: " . $e->getMessage());
    throw new \Exception('訂單建立失敗,請重試。');
}

解析:

  • 在 Redis 庫存扣減成功後,我們將訂單創建放在了 MySQL 事務中。
  • afterCommit() 方法是 Laravel 提供的,它確保 ProcessOrderJob 只有在資料庫事務成功提交後才會被推送到佇列。這對於保證數據一致性至關重要:如果事務失敗,Job 不會被派發;如果事務成功,Job 才會處理後續邏輯。
  • 如果在事務中發生任何異常,我們會捕獲它,並立即回滾之前在 Redis 中扣減的庫存,同時釋放用戶鎖,確保數據一致性。

3.4 支付超時處理與庫存恢復:優化用戶體驗與資源回收

搶票成功後,用戶可能因各種原因未及時支付。為了避免庫存長時間被佔用,我們需要自動取消超時訂單並恢復庫存。

核心思想:延遲佇列 Job

  1. ProcessOrderJob: 收到新訂單後,它會派發一個 MonitorPaymentJob
  2. MonitorPaymentJob: 這個 Job 會被設置成延遲執行,延遲時間就是訂單的支付超時時間(例如 15 分鐘)。
  3. MonitorPaymentJob 實際執行時,它會檢查對應訂單的狀態。如果訂單仍然是 pending(待支付),則說明用戶超時未支付,系統會自動將訂單狀態更新為 cancelled,並將庫存恢復到 Redis。

程式碼片段 (app/Jobs/MonitorPaymentJob.php):

PHP
public function handle(TicketService $ticketService)
{
    $order = Order::find($this->orderId);

    if (!$order || $order->status !== Order::STATUS_PENDING) {
        Log::info("訂單 {$this->orderId} 不存在或已處理,跳過超時檢查。");
        return;
    }

    // 如果訂單仍然是 PENDING 狀態,則視為超時未支付,進行取消並恢復庫存
    $ticketService->restoreTicketStockForOrder($this->orderId);

    Log::info("訂單 {$this->orderId} 支付超時,庫存已恢復並訂單已取消。");
}

解析:

  • MonitorPaymentJob 依賴 TicketService 來執行恢復庫存的邏輯。
  • 它會再次檢查訂單狀態,確保只有 PENDING 狀態的訂單才進行處理,防止重複恢復或錯誤處理。
  • TicketService::restoreTicketStockForOrder() 會在一個 DB 事務中更新訂單狀態為 cancelled,並使用 Redis::incrby() 將庫存加回。

3.5 Swoole 在本系統中的應用:高效能的支柱

  • Swoole HTTP Server (app/Console/Commands/SwooleHttpServerStart.php):

    • 代替 PHP-FPM,作為常駐程序運行 Laravel 應用。
    • 優勢:Laravel 框架、配置、Service Container 等只會初始化一次並常駐記憶體,避免了每次請求的重複初始化開銷,大幅提升請求響應速度和吞吐量。
    • 直接響應 API 請求,內部通過協程處理 I/O 操作(如 Redis、MySQL),提高了併發處理能力。
  • Swoole Queue Worker (app/Console/Commands/SwooleQueueWorkerStart.php):

    • 代替 Laravel 內建的 queue:work 命令,提供更穩定、高效的佇列消費。
    • 優勢:Swoole Worker 作為常駐程序持續監聽 Redis 佇列,一旦有任務,立即消費。相比於傳統的 queue:work(可能需要額外工具如 Supervisor 來保證常駐),Swoole 提供了更原生的解決方案。

5. 實踐與部署

SnapTicket 專案提供了完整的 Docker 環境配置,使得部署變得非常簡單:

  1. 環境搭建:確保已安裝 Docker 和 Docker Compose。
  2. 複製與依賴
    Bash
    git clone https://github.com/BpsEason/SnapTicket.git
    cd SnapTicket
    composer install # 或在 Docker 容器內執行
    
  3. 啟動服務
    Bash
    docker-compose up -d --build
    docker-compose exec app php artisan key:generate
    docker-compose exec app php artisan migrate --seed # 獲取 TEST_API_TOKEN
    docker-compose exec app php artisan swoole:http start
    docker-compose exec app php artisan swoole:queue start
    
    現在,您可以通過 http://localhost 訪問應用,並使用 http://localhost/api/ticket/grab/{ticket_id} 進行搶票測試。

6. 壓力測試:驗證系統效能

SnapTicket 內建了一個壓力測試命令,幫助您驗證系統在實際高併發下的表現。

程式碼片段 (app/Console/Commands/SimulatedClient.php):

PHP
public function handle()
{
    // ... 初始化 Guzzle Client 和請求參數 ...
    $client = new Client([ /* ... */ ]);
    $totalRequests = $users * $requestsPerUser;

    $pool = new Pool($client, $requests, [
        'concurrency' => $concurrency, // 設置最大併發數
        'fulfilled' => function ($response) use (&$successCount) { /* ... */ },
        'rejected' => function ($reason) use (&$failureCount) { /* ... */ },
    ]);

    $promise = $pool->promise();
    $promise->wait(); // 等待所有請求完成

    // ... 計算並輸出 QPS、成功率等報告 ...
}

執行命令:

Bash
docker-compose exec app php artisan stress:grab 1 --users=100 --requests_per_user=10 --concurrency=50 --token=YOUR_API_TOKEN

通過調整 --users--requests_per_user--concurrency 參數,您可以模擬不同強度的併發場景,觀察系統的 QPS 和成功率,從而驗證您的設計是否能滿足效能要求。

7. 總結與展望

通過 SnapTicket 專案,我們深入理解了如何在高併發 Laravel 應用中利用以下關鍵技術:

  • Redis Lua 腳本:實現原子性的庫存扣減和數據一致性。
  • Redis 鎖:防止用戶重複操作。
  • Laravel 非同步佇列:將耗時操作非同步化,提高系統響應速度。
  • Swoole:作為高效能的 HTTP 伺服器和 Queue Worker,提升整體吞吐量和資源利用率。

這些技術不僅適用於搶票系統,在秒殺、限時搶購、高併發數據處理等眾多場景中都有廣泛的應用。掌握這些知識,將會使您在面對複雜的後端系統設計時,擁有更強的解決問題能力。

希望這篇文章能為您在高併發 Laravel 開發的道路上提供一些啟發和幫助!如果您對專案有任何疑問或建議,歡迎到 GitHub 專案頁面提出 Issue 或參與貢獻。


沒有留言:

張貼留言

網誌存檔