博客 / 詳情

返回

ThinkPHP8 常見併發場景解決方案文檔

ThinkPHP8 常見併發場景解決方案文檔

一、文檔説明

本文檔針對開發中高頻出現的4類併發問題,提供基於 ThinkPHP8 的可直接運行解決方案,涵蓋代碼實現、核心原理、適用場景及關鍵注意事項,旨在幫助開發者快速解決併發場景下的數據一致性和系統穩定性問題。

適用範圍:ThinkPHP8 開發者、需要處理併發場景(防超賣、防重複提交等)的後端開發人員

前置依賴:已配置 ThinkPHP8 環境,涉及 Redis 的場景需完成 Redis 擴展及配置

二、核心併發場景解決方案

場景1:庫存扣減(防止超賣)

1.1 場景説明

高併發場景下(如秒殺、促銷活動),多個用户同時搶購同一商品,若未做併發控制,會出現庫存扣減為負數的“超賣”問題,導致業務邏輯異常。核心需求:保證庫存數據一致性,不出現超賣。

1.2 方案選型:MySQL 悲觀鎖(行鎖)

採用 MySQL InnoDB 存儲引擎的行級排他鎖,結合事務機制,確保“檢查庫存+扣減庫存”的原子性,同一時間僅允許一個請求修改目標商品的庫存記錄。適用於庫存一致性要求高、併發量中等的場景(如普通電商訂單)。

1.3 實現代碼


<?php
namespace app\controller;

use think\facade\Db;
use think\response\Json;

class StockController
{
    /**
     * 商品庫存扣減接口
     * @param int $productId 商品ID(URL參數)
     * @param int $num 扣減數量(默認1)
     * @return Json
     */
    public function reduceStock(int $productId, int $num = 1): Json
    {
        // 開啓數據庫事務,保證操作原子性
        Db::startTrans();
        try {
            // 1. 鎖定目標商品行記錄(排他鎖,防止併發修改)
            // lock(true) 等價於 SQL 中的 SELECT ... FOR UPDATE
            $product = Db::name('product')
                ->where('id', $productId)
                ->lock(true)
                ->find();

            // 2. 基礎校驗
            if (empty($product)) {
                throw new \Exception("商品不存在");
            }
            if ($product['stock'] < $num) {
                throw new \Exception("庫存不足,當前庫存:{$product['stock']}");
            }

            // 3. 扣減庫存(使用 Db::raw 確保SQL語法正確)
            $updateRes = Db::name('product')
                ->where('id', $productId)
                ->update(['stock' => Db::raw("stock - {$num}")]);

            if (!$updateRes) {
                throw new \Exception("庫存扣減失敗");
            }

            // 4. 提交事務
            Db::commit();
            return json([
                'code' => 0,
                'msg' => '庫存扣減成功',
                'data' => ['product_id' => $productId, 'remain_stock' => $product['stock'] - $num]
            ]);
        } catch (\Exception $e) {
            // 5. 異常回滾事務
            Db::rollback();
            return json([
                'code' => 1,
                'msg' => $e->getMessage(),
                'data' => []
            ]);
        }
    }
}

1.4 關鍵解析

  • 行鎖機制lock(true) 會對查詢的商品記錄加排他鎖,同一時間只有一個事務能獲取該鎖,其他請求需等待鎖釋放,避免併發修改衝突。
  • 事務保障:通過 Db::startTrans()Db::commit()Db::rollback() 確保“查庫存+扣庫存”是原子操作,要麼全部成功,要麼全部回滾。
  • 表結構要求:product 表需包含 id(主鍵)、stock(庫存字段),主鍵索引確保行鎖能精準鎖定單條記錄(無索引會退化為表鎖,降低併發)。
  • 優化方向:高併發秒殺場景可改用“Redis 預扣減+MQ 異步落庫”方案,進一步提升系統吞吐量。

場景2:訂單創建(防止重複提交)

2.1 場景説明

用户因網絡延遲、重複點擊按鈕、惡意重試等原因,可能導致同一訂單請求被多次提交,出現重複創建訂單、重複扣減庫存的問題。核心需求:確保同一訂單請求僅被處理一次。

2.2 方案選型:Token 令牌驗證

基於“一次性 Token”機制,前端請求訂單創建前先獲取令牌,提交訂單時攜帶令牌,後端驗證令牌有效性(未使用過、未過期),驗證通過後立即失效令牌,防止重複使用。適用於表單提交、API 接口防重放等場景。

2.3 實現代碼


<?php
namespace app\controller;

use think\facade\Cache;
use think\facade\Db;
use think\facade\Session;
use think\request\Request;
use think\response\Json;

class OrderController
{
    /**
     * 1. 獲取防重複提交 Token(前端調用)
     * @return Json
     */
    public function getOrderToken(): Json
    {
        // 生成隨機唯一 Token(md5+uniqid 保證唯一性)
        $token = md5(uniqid(mt_rand(), true));
        // 存儲 Token:單機用 Session,分佈式用 Redis(此處兼容兩種場景)
        if (config('cache.default') === 'redis') {
            // Redis 存儲,設置 10 分鐘過期(避免令牌堆積)
            Cache::store('redis')->set("order_token:".Session::getId(), $token, 600);
        } else {
            // Session 存儲
            Session::set('order_token', $token);
        }
        return json([
            'code' => 0,
            'msg' => 'Token 獲取成功',
            'data' => ['token' => $token]
        ]);
    }

    /**
     * 2. 創建訂單接口(前端攜帶 Token 提交)
     * @param Request $request
     * @return Json
     */
    public function createOrder(Request $request): Json
    {
        $postData = $request->post();
        // 必要參數校驗
        $validateRes = $this->validate($postData, [
            'user_id' => 'require|integer',
            'product_id' => 'require|integer',
            'amount' => 'require|float|gt:0',
            'token' => 'require'
        ]);
        if ($validateRes !== true) {
            return json(['code' => 1, 'msg' => $validateRes, 'data' => []]);
        }

        $token = $postData['token'];
        $tokenKey = config('cache.default') === 'redis' 
            ? "order_token:".Session::getId() 
            : 'order_token';

        // 3. 驗證 Token 有效性
        $storedToken = config('cache.default') === 'redis' 
            ? Cache::store('redis')->get($tokenKey) 
            : Session::get('order_token');

        if (empty($storedToken) || $storedToken !== $token) {
            return json(['code' => 1, 'msg' => '重複提交或 Token 已失效', 'data' => []]);
        }

        try {
            // 4. 驗證通過,立即銷燬 Token(核心:確保一次性使用)
            if (config('cache.default') === 'redis') {
                Cache::store('redis')->delete($tokenKey);
            } else {
                Session::delete('order_token');
            }

            // 5. 執行創建訂單邏輯(此處可調用庫存扣減接口)
            $orderId = Db::name('order')->insertGetId([
                'user_id' => $postData['user_id'],
                'product_id' => $postData['product_id'],
                'amount' => $postData['amount'],
                'order_sn' => $this->generateOrderSn(), // 生成訂單號
                'status' => 1, // 1-待支付
                'create_time' => time()
            ]);

            return json([
                'code' => 0,
                'msg' => '訂單創建成功',
                'data' => ['order_id' => $orderId, 'order_sn' => $this->generateOrderSn()]
            ]);
        } catch (\Exception $e) {
            return json(['code' => 1, 'msg' => $e->getMessage(), 'data' => []]);
        }
    }

    /**
     * 輔助方法:生成唯一訂單號
     * @return string
     */
    private function generateOrderSn(): string
    {
        return date('YmdHis') . mt_rand(1000, 9999);
    }
}

2.4 關鍵解析

  • Token 唯一性:通過 uniqid(mt_rand(), true) 生成隨機字符串,結合 md5 加密,確保 Token 唯一且不可預測。
  • 存儲適配:兼容單機(Session)和分佈式(Redis)部署,Redis 存儲時通過 SessionID 區分用户,避免 Token 混淆。
  • 一次性有效性:訂單創建成功前立即銷燬 Token,即使同一 Token 被重複提交,也會因 Token 不存在而被拒絕。
  • 過期機制:Redis 存儲的 Token 設置 10 分鐘過期,避免因用户未提交訂單導致 Token 長期堆積。

場景3:分佈式任務調度(防止重複執行)

3.1 場景説明

分佈式部署環境下(多台服務器),定時任務(如生成日報表、數據同步)若未做控制,會出現多台服務器同時執行同一任務的情況,導致數據重複處理、資源浪費。核心需求:同一任務同一時間僅被一台服務器執行。

3.2 方案選型:Redis 分佈式鎖

基於 Redis 的 SET NX(Set if Not Exists)原子命令實現分佈式鎖,任務執行前嘗試獲取鎖,獲取成功則執行任務,失敗則説明其他節點正在執行,直接返回。適用於分佈式定時任務、跨服務數據同步等場景。

3.3 實現代碼


<?php
namespace app\controller;

use think\facade\Cache;
use think\response\Json;

class TaskController
{
    /**
     * 分佈式定時任務執行入口
     * 示例:每日凌晨2點執行日報表生成任務
     * @param string $taskName 任務名稱(如:daily_report)
     * @return Json
     */
    public function runDistributedTask(string $taskName): Json
    {
        // 1. 鎖相關配置
        $lockKey = "distributed_lock:task:{$taskName}"; // 鎖 Key(按任務名稱區分)
        $lockExpire = 300; // 鎖過期時間(5分鐘),防止節點掛掉導致鎖永久不釋放
        $lockValue = uniqid(); // 鎖值(用於釋放鎖時校驗,避免誤刪其他鎖)

        // 2. 嘗試獲取 Redis 分佈式鎖(SET NX + EX 原子操作)
        // NX:僅當 Key 不存在時設置成功;EX:設置過期時間(秒)
        $isLocked = Cache::store('redis')->set($lockKey, $lockValue, $lockExpire, ['NX']);

        if (!$isLocked) {
            // 未獲取到鎖,説明其他節點正在執行任務
            return json([
                'code' => 1,
                'msg' => "任務【{$taskName}】已在其他節點執行中",
                'data' => []
            ]);
        }

        try {
            // 3. 獲取鎖成功,執行任務邏輯
            switch ($taskName) {
                case 'daily_report':
                    $this->generateDailyReport(); // 生成日報表
                    break;
                case 'data_sync':
                    $this->syncData(); // 數據同步(示例方法)
                    break;
                default:
                    throw new \Exception("未知任務名稱:{$taskName}");
            }

            return json([
                'code' => 0,
                'msg' => "任務【{$taskName}】執行完成",
                'data' => []
            ]);
        } catch (\Exception $e) {
            return json([
                'code' => 1,
                'msg' => "任務【{$taskName}】執行失敗:" . $e->getMessage(),
                'data' => []
            ]);
        } finally {
            // 4. 任務執行完成/失敗,釋放鎖(Lua腳本保證原子性)
            // 避免因任務執行時間超過鎖過期時間,導致誤刪其他節點的鎖
            $luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            Cache::store('redis')->eval($luaScript, [$lockKey, $lockValue], 1);
        }
    }

    /**
     * 任務1:生成日報表(示例實現)
     */
    private function generateDailyReport(): void
    {
        // 模擬耗時任務(實際場景:查詢昨日數據、生成Excel、推送郵件等)
        sleep(3);
        // 此處可添加報表生成邏輯(如寫入 report 表、存儲文件等)
    }

    /**
     * 任務2:數據同步(示例實現)
     */
    private function syncData(): void
    {
        // 模擬數據同步邏輯(如同步第三方數據到本地庫)
        sleep(2);
    }
}

3.4 關鍵解析

  • 原子鎖獲取set($lockKey, $lockValue, $lockExpire, ['NX']) 是原子操作,避免“檢查鎖是否存在”和“設置鎖”兩步操作之間出現併發問題。
  • 鎖過期保護:設置 5 分鐘過期時間,即使執行任務的節點意外掛掉,鎖也會自動過期釋放,避免死鎖。
  • 安全釋放鎖:通過 Lua 腳本校驗鎖值後再刪除,確保僅釋放當前節點持有的鎖,避免因任務執行超時導致鎖被其他節點誤刪。
  • 任務調度配置:可結合 ThinkPHP 定時任務(think-cron),在多台服務器部署相同定時任務,通過分佈式鎖實現“單點執行”。

場景4:緩存更新(防止緩存擊穿/雪崩)

4.1 場景説明

  • 緩存擊穿:一個熱點 Key 過期時,大量請求同時穿透到數據庫,導致數據庫瞬間壓力劇增。
  • 緩存雪崩:大量 Key 同時過期,或緩存服務宕機,導致所有請求直接打到數據庫,可能引發數據庫崩潰。

核心需求:保護數據庫,避免緩存失效時的流量衝擊。

4.2 方案選型:Redis 互斥鎖(防擊穿)+ 緩存空值(防穿透)+ 過期時間隨機化(防雪崩)

通過互斥鎖確保只有一個請求去數據庫查詢熱點數據並更新緩存,其他請求等待或重試;對不存在的 Key 緩存空值,防止緩存穿透;給 Key 設置隨機過期時間,避免大量 Key 同時過期。

4.3 實現代碼


<?php
namespace app\controller;

use think\facade\Cache;
use think\facade\Db;
use think\response\Json;

class CacheController
{
    /**
     * 獲取商品詳情(帶緩存防擊穿/雪崩/穿透)
     * @param int $productId 商品ID
     * @return Json
     */
    public function getProductDetail(int $productId): Json
    {
        // 1. 緩存 Key 定義(按業務類型+ID 命名)
        $cacheKey = "product:detail:{$productId}";
        // 鎖 Key(按緩存 Key 衍生,確保一一對應)
        $lockKey = "lock:cache:{$productId}";
        $lockExpire = 10; // 鎖過期時間(10秒)
        $cacheExpire = 3600 + mt_rand(0, 600); // 緩存過期時間(1小時+隨機0-10分鐘,防雪崩)

        // 2. 優先從緩存獲取數據
        $productDetail = Cache::get($cacheKey);
        if ($productDetail !== false) {
            // 緩存命中:若為緩存的空值,返回“商品不存在”
            if (empty($productDetail)) {
                return json(['code' => 1, 'msg' => '商品不存在', 'data' => []]);
            }
            return json(['code' => 0, 'msg' => 'success', 'data' => $productDetail]);
        }

        // 3. 緩存未命中,嘗試獲取互斥鎖
        $isLocked = Cache::store('redis')->set($lockKey, 1, $lockExpire, ['NX']);
        if (!$isLocked) {
            // 未獲取到鎖:返回“系統繁忙”,前端可重試
            return json(['code' => 2, 'msg' => '系統繁忙,請稍後再試', 'data' => []]);
        }

        try {
            // 4. 獲取鎖成功,從數據庫查詢數據
            $productDetail = Db::name('product')
                ->where('id', $productId)
                ->find();

            // 5. 處理查詢結果:緩存真實數據或空值(防穿透)
            if (empty($productDetail)) {
                // 商品不存在,緩存空值(1分鐘過期,避免長期佔用緩存)
                Cache::set($cacheKey, '', 60);
                return json(['code' => 1, 'msg' => '商品不存在', 'data' => []]);
            }

            // 商品存在,緩存真實數據(帶隨機過期時間,防雪崩)
            Cache::set($cacheKey, $productDetail, $cacheExpire);

            return json(['code' => 0, 'msg' => 'success', 'data' => $productDetail]);
        } catch (\Exception $e) {
            return json(['code' => 1, 'msg' => $e->getMessage(), 'data' => []]);
        } finally {
            // 6. 釋放鎖(無論成功失敗,都要釋放)
            Cache::store('redis')->delete($lockKey);
        }
    }
}

4.4 關鍵解析

  • 防擊穿:互斥鎖確保只有一個請求去數據庫查詢熱點數據,其他請求因獲取不到鎖而返回重試提示,避免大量請求同時穿透到數據庫。
  • 防穿透:對不存在的商品(數據庫無記錄),緩存空值(1分鐘過期),避免惡意請求(如遍歷商品ID)反覆穿透到數據庫。
  • 防雪崩:緩存過期時間設置為“1小時+隨機0-10分鐘”,使大量熱點 Key 的過期時間分散,避免同時過期導致緩存雪崩。
  • 降級策略:未獲取到鎖時返回“系統繁忙”,引導用户重試,避免系統過載。

三、總結:場景-方案對應表

併發場景 推薦方案 核心技術 適用場景
庫存扣減(防超賣) MySQL 悲觀鎖+事務 行級排他鎖、數據庫事務 庫存一致性要求高、併發量中等
訂單創建(防重複提交) Token 令牌驗證 一次性 Token、Session/Redis 存儲 表單提交、API 接口防重放
分佈式任務調度(防重複執行) Redis 分佈式鎖 SET NX 原子命令、Lua 腳本釋放鎖 多服務器部署定時任務、跨服務數據同步
緩存更新(防擊穿/雪崩) Redis 互斥鎖+緩存空值+隨機過期 分佈式鎖、緩存空值、隨機過期時間 熱點數據查詢、高併發緩存訪問場景

四、擴展説明

  1. 所有示例代碼均基於 ThinkPHP8 開發,需確保項目已正確配置數據庫、Redis(涉及 Redis 的場景)。
  2. 高併發場景下(如秒殺),建議結合消息隊列(如 RabbitMQ、RocketMQ)進一步削峯填谷,提升系統穩定性。
  3. 分佈式鎖除 Redis 外,還可使用 ZooKeeper 實現(可靠性更高,但性能略低),根據業務需求選擇。
  4. 實際開發中需結合日誌記錄、監控告警(如鎖競爭情況、緩存命中率),便於問題排查和系統優化。

🍵 寫在最後

我是 網絡乞丐,熱愛代碼,目前專注於 Web 全棧領域。

歡迎關注我的微信公眾號「乞丐的項目」,我會不定期分享一些開發心得、最佳實踐以及技術探索等內容,希望能夠幫到你!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.