深入剖析 Laravel 高併發防超賣方案:結合 Redis 原子操作與異步隊列的實踐與關鍵程式碼
在高流量電商系統中,商品超賣問題不僅損害用戶信任,更可能導致實際的經濟損失。本次分享將深入探討一個基於 Laravel 框架,利用 Redis 的原子性操作和異步隊列機制,構建的高性能、高一致性防超賣解決方案。我們將逐一解析核心設計理念與關鍵程式碼實現。
本專案的所有程式碼已開源並託管於 GitHub:
一、問題挑戰:高併發下的庫存競爭
傳統的資料庫悲觀鎖(SELECT ... FOR UPDATE
)雖然能保證數據一致性,但在高併發秒殺場景下,會導致大量請求在資料庫層面排隊,嚴重影響系統吞吐量和響應速度。而若僅依賴應用層判斷,則可能出現「幻讀」或「寫入丟失」的競爭條件,最終導致超賣。
本方案旨在解決:
- 高併發下的響應延遲: 避免資料庫成為單點瓶頸。
- 數據一致性問題: 確保 Redis 與 MySQL 庫存同步,杜絕超賣。
- 系統彈性與可擴展性: 讓系統能從容應對流量高峰。
二、核心設計理念:預扣減 + 異步隊列 + 最終一致性
我們採用了多層次的庫存扣減策略,實現性能與一致性的最佳平衡:
-
Redis 庫存預扣減 (Pre-deduction):第一道防線
- 將大部分高併發請求在 Redis 層面進行快速過濾。Redis 憑藉其內存級別的速度和原子操作,成為抵禦瞬時高流量的理想選擇。
-
Laravel Queue 異步訂單處理:緩衝與解耦
- 預扣成功後,不立即處理耗時的資料庫操作,而是將任務推入隊列,由後台 Worker 異步處理,提升 API 響應速度。
-
MySQL 最終庫存校驗與悲觀鎖:數據的最終保證
- 在異步任務中,對資料庫進行最終庫存校驗,並使用悲觀鎖保證資料庫操作的原子性,確保數據的最終一致性。
三、關鍵程式碼實現與解析
3.1 Redis 庫存服務 (app/Services/RedisInventoryService.php
)
這是整個防超賣方案的核心,負責與 Redis 進行庫存交互。
<?php
namespace App\Services;
use Illuminate\Support\Facades\Redis; // Laravel 內建的 Redis Facade,支援多個 Redis 連線
class RedisInventoryService
{
protected $redisConnection;
public function __construct()
{
// 確保使用專門為庫存配置的 Redis 連線
$this->redisConnection = Redis::connection('inventory');
}
/**
* 初始化 Redis 庫存。
*
* @param int $productId
* @param int $stock
* @return bool
*/
public function initStock(int $productId, int $stock): bool
{
// 使用 SETNX 確保只有第一次設置成功,或者直接 SET 覆蓋
// 本例使用 SET 確保每次初始化都覆蓋
return (bool)$this->redisConnection->set("product:{$productId}:stock", $stock);
}
/**
* 獲取 Redis 中的當前庫存。
*
* @param int $productId
* @return int
*/
public function getStock(int $productId): int
{
return (int)$this->redisConnection->get("product:{$productId}:stock");
}
/**
* 預扣減 Redis 庫存,原子性操作。
* 如果庫存不足,則不扣減並返回 false。
*
* @param int $productId
* @param int $quantity
* @return bool
*/
public function preDecrementStock(int $productId, int $quantity): bool
{
// Redis 的 DECRBY 命令會原子性地減少鍵的值。
// 如果減少後的值為負數,表示庫存不足,需要回滾並返回失敗。
$newStock = $this->redisConnection->decrby("product:{$productId}:stock", $quantity);
if ($newStock < 0) {
// 庫存不足,回滾之前的扣減
$this->redisConnection->incrby("product:{$productId}:stock", $quantity);
return false;
}
return true;
}
/**
* 回補 Redis 庫存。
*
* @param int $productId
* @param int $quantity
* @return void
*/
public function rollbackStock(int $productId, int $quantity): void
{
$this->redisConnection->incrby("product:{$productId}:stock", $quantity);
}
}
- 解析:
preDecrementStock
方法是核心,它利用DECRBY
的原子性。當newStock
為負時,表示預扣失敗,立即通過INCRBY
回滾,確保 Redis 庫存不會出現負數。這種「先扣再判斷,不足則回滾」的模式,是 Redis 防超賣的標準實踐。
3.2 訂單控制器 (app/Http/Controllers/OrderController.php
)
API 層面處理用戶請求,並將任務派發到隊列。
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessOrder;
use App\Models\Product;
use App\Services\RedisInventoryService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* @OA\Info(title="Anti-Overselling Demo API", version="1.0")
*/
class OrderController extends Controller
{
protected $redisInventoryService;
public function __construct(RedisInventoryService $redisInventoryService)
{
$this->redisInventoryService = $redisInventoryService;
}
/**
* @OA\Post(
* path="/api/place-order",
* summary="提交訂單",
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"product_id","quantity","user_id"},
* @OA\Property(property="product_id", type="integer", example=1),
* @OA\Property(property="quantity", type="integer", example=2),
* @OA\Property(property="user_id", type="integer", example=101)
* )
* ),
* @OA\Response(
* response=202,
* description="訂單已提交,正在處理中。",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="訂單已提交,正在處理中。"),
* @OA\Property(property="order_identifier", type="string", example="unique-order-uuid")
* )
* ),
* @OA\Response(
* response=400,
* description="庫存不足或請求無效。",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="商品庫存不足。")
* )
* ),
* @OA\Response(
* response=500,
* description="伺服器錯誤。",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="訂單提交失敗,請稍後再試。")
* )
* )
* )
*/
public function placeOrder(Request $request)
{
$validated = $request->validate([
'product_id' => 'required|integer|exists:products,id',
'quantity' => 'required|integer|min:1',
'user_id' => 'required|integer',
]);
$productId = $validated['product_id'];
$quantity = $validated['quantity'];
$userId = $validated['user_id'];
$orderIdentifier = (string) Str::uuid(); // 生成唯一訂單識別碼
try {
// 1. Redis 預扣減庫存
if (!$this->redisInventoryService->preDecrementStock($productId, $quantity)) {
Log::warning("商品 {$productId} Redis 庫存不足,訂單提交失敗。用戶ID: {$userId}");
return response()->json(['message' => '商品庫存不足。'], 400);
}
// 2. 派發異步 Job 進行後續處理
// 將訂單識別碼傳遞給 Job,便於追踪和外部查詢
ProcessOrder::dispatch($productId, $quantity, $userId, $orderIdentifier)->onQueue('order-processing-queue');
Log::info("訂單 {$orderIdentifier} 已提交,商品ID: {$productId}, 數量: {$quantity}, 用戶ID: {$userId}。等待異步處理。");
return response()->json([
'message' => '訂單已提交,正在處理中。',
'order_identifier' => $orderIdentifier // 返回識別碼,便於前端追踪
], 202);
} catch (\Exception $e) {
// 如果在 Redis 預扣減成功後,但在 Job 派發前或過程中發生異常,需要回滾 Redis 庫存
Log::error("訂單提交過程中發生異常,正在回滾 Redis 庫存。商品ID: {$productId}, 數量: {$quantity},錯誤: " . $e->getMessage());
$this->redisInventoryService->rollbackStock($productId, $quantity);
return response()->json(['message' => '訂單提交失敗,請稍後再試。'], 500);
}
}
// ... 其他初始化/查詢庫存的 API 函數 (如 create_project.sh 所示)
}
- 解析:
placeOrder
方法在收到請求後,首先進行 Redis 預扣。成功後,立即將處理任務推送到order-processing-queue
隊列中,並返回202 Accepted
。這避免了用戶長時間等待資料庫操作。錯誤處理部分也包含了對 Redis 庫存的回滾,確保在 API 層出現異常時,庫存狀態也能得到糾正。
3.3 異步處理 Job (app/Jobs/ProcessOrder.php
)
Queue Worker 實際執行資料庫操作。
<?php
namespace App\Jobs;
use App\Models\Order;
use App\Models\Product;
use App\Services\RedisInventoryService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProcessOrder implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $productId;
protected $quantity;
protected $userId;
protected $orderIdentifier; // 用於追踪的唯一標識
// 可設定 Job 的重試次數和超時時間
public $tries = 3;
public $timeout = 60;
/**
* 建立新的 Job 實例。
*
* @param int $productId
* @param int $quantity
* @param int $userId
* @param string $orderIdentifier
* @return void
*/
public function __construct(int $productId, int $quantity, int $userId, string $orderIdentifier)
{
$this->productId = $productId;
$this->quantity = $quantity;
$this->userId = $userId;
$this->orderIdentifier = $orderIdentifier;
// 指定 Job 使用的隊列名稱,必須與 .env 中配置的一致
$this->onQueue(config('queue.connections.redis.queue', 'order-processing-queue'));
}
/**
* 執行 Job。
*
* @param RedisInventoryService $redisInventoryService (Laravel 自動注入)
* @return void
*/
public function handle(RedisInventoryService $redisInventoryService)
{
Log::info("開始處理訂單 Job: {$this->orderIdentifier},商品ID: {$this->productId}, 數量: {$this->quantity}");
// 使用資料庫事務確保操作的原子性
DB::transaction(function () use ($redisInventoryService) {
// 1. 資料庫悲觀鎖:鎖定商品記錄,防止併發修改
$product = Product::lockForUpdate()->find($this->productId);
if (!$product) {
// 商品不存在,理論上前端驗證會擋住,但在 Job 中仍需處理
Log::critical("Job: {$this->orderIdentifier} 處理失敗,商品ID: {$this->productId} 不存在。");
// 這裡選擇不回補 Redis,因為商品不存在,預扣是基於錯誤的商品ID
throw new \Exception("商品不存在。");
}
// 2. 最終資料庫庫存檢查 (雙重確認)
if ($product->stock < $this->quantity) {
Log::warning("Job: {$this->orderIdentifier} 處理失敗,商品 {$this->productId} 資料庫庫存不足 (剩餘: {$product->stock})。用戶ID: {$this->userId}");
// 資料庫庫存不足,回滾 Redis 庫存
$redisInventoryService->rollbackStock($this->productId, $this->quantity);
throw new \Exception("商品 {$this->productId} 資料庫庫存不足。");
}
// 3. 扣減資料庫庫存
$product->stock -= $this->quantity;
$product->save();
// 4. 創建訂單記錄
Order::create([
'product_id' => $this->productId,
'quantity' => $this->quantity,
'user_id' => $this->userId,
'status' => 'pending_payment', // 設置初始狀態為待支付
'order_identifier' => $this->orderIdentifier,
]);
Log::info("訂單 {$this->orderIdentifier} 處理成功,商品ID: {$this->productId}, 數量: {$this->quantity}。資料庫庫存剩餘: {$product->stock}");
});
}
/**
* Job 失敗時的處理。
* 這是處理補償邏輯的關鍵點。
*
* @param \Throwable $exception
* @return void
*/
public function failed(\Throwable $exception)
{
// Job 最終失敗,需要將 Redis 庫存回補
// 這裡需要重新實例化 RedisInventoryService,因為 failed 方法不自動注入
$redisInventoryService = app(RedisInventoryService::class);
$redisInventoryService->rollbackStock($this->productId, $this->quantity);
Log::error("訂單 Job: {$this->orderIdentifier} 最終失敗,Redis 庫存已回補。商品ID: {$this->productId}, 數量: {$this->quantity},錯誤: " . $exception->getMessage());
// 可以發送通知給管理員,或將訂單標記為異常狀態
}
}
- 解析:
handle
方法在事務內執行,並使用lockForUpdate()
確保資料庫層的原子性。此處進行第二次庫存檢查,確保資料庫真實庫存足夠。failed
方法是系統健壯性的關鍵: 如果 Job 因任何原因(包括資料庫庫存不足而拋出異常、第三方服務調用失敗等)在所有重試後仍然失敗,此方法會被調用,它會確保 Redis 庫存得到回補,避免庫存永久丟失。
3.4 資料庫遷移 (database/migrations/..._create_products_table.php
& ..._create_orders_table.php
)
定義了必要的 products
和 orders
表結構。
// ..._create_products_table.php
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->integer('stock')->default(0); // 庫存
$table->timestamps();
});
}
// ..._create_orders_table.php
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('order_identifier')->unique(); // 唯一訂單識別碼,用於追蹤
$table->foreignId('product_id')->constrained()->onDelete('cascade');
$table->integer('quantity');
$table->foreignId('user_id'); // 簡單的用戶ID
$table->string('status')->default('pending_payment'); // 訂單狀態:pending_payment, paid, cancelled, completed, failed_payment
$table->timestamps();
});
}
- 解析: 關鍵是
products
表的stock
字段,以及orders
表的status
字段,後者將用於後續的金流狀態管理。
3.5 環境配置 (.env.example
和 config/database.php
)
確保 Redis 的 inventory
連線已正確配置。
# .env.example 相關部分
REDIS_CLIENT=predis # 或 phpredis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
REDIS_DB=0 # 預設 Redis DB,用於 Cache/Session
# 專用於庫存的 Redis 連線
REDIS_INVENTORY_HOST=127.0.0.1
REDIS_INVENTORY_PORT=6379
REDIS_INVENTORY_PASSWORD=null
REDIS_INVENTORY_DB=1 # 庫存使用獨立的 DB
REDIS_QUEUE=order-processing-queue # 隊列名稱
// config/database.php 相關部分
'redis' => [
// ... default connection ...
'inventory' => [
'host' => env('REDIS_INVENTORY_HOST', '127.0.0.1'),
'password' => env('REDIS_INVENTORY_PASSWORD', null),
'port' => env('REDIS_INVENTORY_PORT', 6379),
'database' => env('REDIS_INVENTORY_DB', 1), // 指定專用的資料庫
],
// ... queue connection ...
],
'queue' => [
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default', // 或者指定 'inventory' 如果你希望隊列也用庫存的連線
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
],
],
],
- 解析: 透過為 Redis 庫存配置獨立的資料庫(DB 1),可以有效隔離數據,避免與其他 Redis 應用(如緩存、Session)產生衝突,同時方便監控和管理。
四、高併發下的支付失敗處理:補償事務的設計
這個 Demo 雖然沒有實作金流,但實際應用中支付是核心環節。支付失敗的處理是典型的「補償事務 (Compensating Transaction)」場景。
核心思想: 確保在支付失敗時,所有在前面流程中被預佔用的資源(Redis 庫存、資料庫庫存)都能被安全地回補。
-
支付邏輯與 Job 緊密集成 (方案一:同步調用)
- 流程:
ProcessOrder
Job 執行 -> 鎖定商品 -> 檢查 DB 庫存 -> 扣減 DB 庫存 -> 創建訂單 (狀態pending_payment
) -> 調用金流支付 API。 - 支付成功: 將訂單狀態更新為
paid
或completed
。 - 支付失敗:
- 觸發資料庫事務回滾: 訂單創建和資料庫庫存扣減將被撤銷。
- 呼叫
RedisInventoryService::rollbackStock()
: 必須明確將 Redis 預扣的庫存回補。 - 日誌記錄與警報: 記錄詳細錯誤信息,並通知運營人員。
- 流程:
-
支付結果異步通知 (方案二:Webhook 回調)
- 流程:
ProcessOrder
Job 僅負責鎖定商品 -> 檢查 DB 庫存 -> 扣減 DB 庫存 -> 創建訂單 (狀態pending_payment
)。之後由前端引導用戶跳轉至金流頁面。 - 支付閘道回調: 金流閘道在支付完成(無論成功或失敗)後,會透過預設的 Webhook URL 回調您的系統。
- Webhook 接收器處理:
- 監聽到支付成功回調:更新訂單狀態為
paid
。 - 監聽到支付失敗回調:
- 更新訂單狀態為
failed_payment
或cancelled
。 - 呼叫
RedisInventoryService::rollbackStock()
: 將 Redis 預扣庫存回補。 - 回補資料庫庫存: 若訂單創建時已扣減 DB 庫存,則需執行
Product::where('id', $productId)->increment('stock', $quantity);
。 - 日誌與警報。
- 更新訂單狀態為
- 監聽到支付成功回調:更新訂單狀態為
- 優勢: 系統解耦度更高,支付系統的延遲或失敗不會直接阻塞訂單創建流程。
- 流程:
-
支付超時訂單的庫存回補:
- 對於長時間處於
pending_payment
狀態的訂單,設定超時機制(如 15-30 分鐘)。 - 定時任務 (Cron Job): 運行
php artisan schedule:run
,定期檢查超時訂單。 - Job 處理超時訂單: 將超時訂單狀態設為
cancelled
,並回補其對應的 Redis 和資料庫庫存。這確保了即使支付流程未完成,庫存也不會被永久佔用。
- 對於長時間處於
五、總結
此 Laravel 防超賣方案,透過 Redis 的高性能原子操作作為請求過濾器,結合 Laravel Queue 的異步處理能力與資料庫悲觀鎖的最終一致性保障,有效應對了高併發場景下的庫存競爭問題。同時,我們也深入探討了如何將支付失敗這一關鍵環節納入考量,並設計相應的補償事務機制,以確保在複雜業務流程中,系統數據的完整性與一致性。
此架構展現了如何在性能與數據強一致性之間取得平衡,為設計高可用電商系統提供了實用的技術思路。歡迎各位同仁對此方案進行探討與交流,共同推進技術實踐。
沒有留言:
張貼留言