建構高效能 Laravel 搶票系統:深入理解 Swoole、Redis 原子操作與非同步佇列
對於中階 Laravel 工程師而言,開發一個功能完善的 CRUD 應用或許已駕輕就熟。但當面對如「秒殺」、「搶購」這類高併發場景時,傳統的 Laravel + PHP-FPM 架構往往會遇到效能瓶頸,甚至引發超賣、系統崩潰等嚴重問題。
點這裡前往 GitHub 專案今天,我們將深入探討一個實際案例:如何利用 Swoole、Redis 原子操作和 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
):
// 使用 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
):
// 檢查用戶是否已搶過此票 (簡單的重複搶購限制)
$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
):
// 在資料庫事務中生成訂單
$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
ProcessOrderJob
: 收到新訂單後,它會派發一個MonitorPaymentJob
。MonitorPaymentJob
: 這個 Job 會被設置成延遲執行,延遲時間就是訂單的支付超時時間(例如 15 分鐘)。- 當
MonitorPaymentJob
實際執行時,它會檢查對應訂單的狀態。如果訂單仍然是pending
(待支付),則說明用戶超時未支付,系統會自動將訂單狀態更新為cancelled
,並將庫存恢復到 Redis。
程式碼片段 (app/Jobs/MonitorPaymentJob.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 提供了更原生的解決方案。
- 代替 Laravel 內建的
5. 實踐與部署
SnapTicket 專案提供了完整的 Docker 環境配置,使得部署變得非常簡單:
- 環境搭建:確保已安裝 Docker 和 Docker Compose。
- 複製與依賴:
Bash
git clone https://github.com/BpsEason/SnapTicket.git cd SnapTicket composer install # 或在 Docker 容器內執行
- 啟動服務:
現在,您可以通過Bashdocker-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
):
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、成功率等報告 ...
}
執行命令:
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 或參與貢獻。
沒有留言:
張貼留言