2025年6月22日 星期日

探討 Laravel 高併發防超賣方案:基於 Redis 與異步隊列的實踐

深入剖析 Laravel 高併發防超賣方案:結合 Redis 原子操作與異步隊列的實踐與關鍵程式碼

在高流量電商系統中,商品超賣問題不僅損害用戶信任,更可能導致實際的經濟損失。本次分享將深入探討一個基於 Laravel 框架,利用 Redis 的原子性操作和異步隊列機制,構建的高性能、高一致性防超賣解決方案。我們將逐一解析核心設計理念與關鍵程式碼實現。

本專案的所有程式碼已開源並託管於 GitHub:https://github.com/BpsEason/anti-overselling-demo.git。歡迎您 Fork、探索並提出寶貴建議!

一、問題挑戰:高併發下的庫存競爭

傳統的資料庫悲觀鎖(SELECT ... FOR UPDATE)雖然能保證數據一致性,但在高併發秒殺場景下,會導致大量請求在資料庫層面排隊,嚴重影響系統吞吐量和響應速度。而若僅依賴應用層判斷,則可能出現「幻讀」或「寫入丟失」的競爭條件,最終導致超賣。

本方案旨在解決:

  1. 高併發下的響應延遲: 避免資料庫成為單點瓶頸。
  2. 數據一致性問題: 確保 Redis 與 MySQL 庫存同步,杜絕超賣。
  3. 系統彈性與可擴展性: 讓系統能從容應對流量高峰。

二、核心設計理念:預扣減 + 異步隊列 + 最終一致性

我們採用了多層次的庫存扣減策略,實現性能與一致性的最佳平衡:

  1. Redis 庫存預扣減 (Pre-deduction):第一道防線

    • 將大部分高併發請求在 Redis 層面進行快速過濾。Redis 憑藉其內存級別的速度和原子操作,成為抵禦瞬時高流量的理想選擇。
  2. Laravel Queue 異步訂單處理:緩衝與解耦

    • 預扣成功後,不立即處理耗時的資料庫操作,而是將任務推入隊列,由後台 Worker 異步處理,提升 API 響應速度。
  3. MySQL 最終庫存校驗與悲觀鎖:數據的最終保證

    • 在異步任務中,對資料庫進行最終庫存校驗,並使用悲觀鎖保證資料庫操作的原子性,確保數據的最終一致性。

三、關鍵程式碼實現與解析

3.1 Redis 庫存服務 (app/Services/RedisInventoryService.php)

這是整個防超賣方案的核心,負責與 Redis 進行庫存交互。

PHP
<?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
<?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
<?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)

定義了必要的 productsorders 表結構。

PHP
// ..._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.exampleconfig/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 # 隊列名稱
PHP
// 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 庫存、資料庫庫存)都能被安全地回補。

  1. 支付邏輯與 Job 緊密集成 (方案一:同步調用)

    • 流程: ProcessOrder Job 執行 -> 鎖定商品 -> 檢查 DB 庫存 -> 扣減 DB 庫存 -> 創建訂單 (狀態 pending_payment) -> 調用金流支付 API
    • 支付成功: 將訂單狀態更新為 paidcompleted
    • 支付失敗:
      • 觸發資料庫事務回滾: 訂單創建和資料庫庫存扣減將被撤銷。
      • 呼叫 RedisInventoryService::rollbackStock() 必須明確將 Redis 預扣的庫存回補。
      • 日誌記錄與警報: 記錄詳細錯誤信息,並通知運營人員。
  2. 支付結果異步通知 (方案二:Webhook 回調)

    • 流程: ProcessOrder Job 僅負責鎖定商品 -> 檢查 DB 庫存 -> 扣減 DB 庫存 -> 創建訂單 (狀態 pending_payment)。之後由前端引導用戶跳轉至金流頁面。
    • 支付閘道回調: 金流閘道在支付完成(無論成功或失敗)後,會透過預設的 Webhook URL 回調您的系統。
    • Webhook 接收器處理:
      • 監聽到支付成功回調:更新訂單狀態為 paid
      • 監聽到支付失敗回調:
        • 更新訂單狀態為 failed_paymentcancelled
        • 呼叫 RedisInventoryService::rollbackStock() 將 Redis 預扣庫存回補。
        • 回補資料庫庫存: 若訂單創建時已扣減 DB 庫存,則需執行 Product::where('id', $productId)->increment('stock', $quantity);
        • 日誌與警報
    • 優勢: 系統解耦度更高,支付系統的延遲或失敗不會直接阻塞訂單創建流程。
  3. 支付超時訂單的庫存回補:

    • 對於長時間處於 pending_payment 狀態的訂單,設定超時機制(如 15-30 分鐘)。
    • 定時任務 (Cron Job): 運行 php artisan schedule:run,定期檢查超時訂單。
    • Job 處理超時訂單: 將超時訂單狀態設為 cancelled,並回補其對應的 Redis 和資料庫庫存。這確保了即使支付流程未完成,庫存也不會被永久佔用。

五、總結

此 Laravel 防超賣方案,透過 Redis 的高性能原子操作作為請求過濾器,結合 Laravel Queue 的異步處理能力與資料庫悲觀鎖的最終一致性保障,有效應對了高併發場景下的庫存競爭問題。同時,我們也深入探討了如何將支付失敗這一關鍵環節納入考量,並設計相應的補償事務機制,以確保在複雜業務流程中,系統數據的完整性與一致性。

此架構展現了如何在性能與數據強一致性之間取得平衡,為設計高可用電商系統提供了實用的技術思路。歡迎各位同仁對此方案進行探討與交流,共同推進技術實踐。


沒有留言:

張貼留言

網誌存檔