博客 / 詳情

返回

高併發秒殺場景:Redis+MySQL數據同步與緩存更新防護文檔

高併發秒殺場景:Redis+MySQL數據同步與緩存更新防護文檔

一、文檔概述

1.1 場景背景

秒殺場景的核心特徵是“瞬時高併發”:短時間內大量用户同時請求同一商品,既要保證系統響應速度(扛住高併發讀/寫),又要避免超賣、緩存擊穿/雪崩、Redis與MySQL數據不一致等問題。

核心解決方案:Redis 前置抗併發(緩存商品/庫存+原子扣減)+ MySQL 異步落庫(最終數據持久化),結合針對性緩存防護策略,實現“高性能”與“數據一致性”平衡。

1.2 核心目標

  • 高性能:用Redis扛住秒殺瞬時讀/寫併發,避免MySQL直接承壓
  • 防超賣:確保庫存不出現負數,Redis與MySQL庫存最終一致
  • 緩存防護:防止熱點商品緩存擊穿、大量商品緩存同時過期導致的雪崩
  • 數據一致:保證Redis緩存與MySQL數據庫最終同步,避免數據偏差

二、核心流程總覽

秒殺場景的Redis+MySQL協同流程分為3個核心階段,全程圍繞“緩存優先、異步落庫、防護兜底”設計:

  1. 活動前準備:緩存預熱(將商品/庫存從MySQL加載到Redis)
  2. 秒殺進行時:Redis處理併發(查緩存+原子扣減庫存)+ 異步發消息
  3. 後續同步:消息隊列消費者異步更新MySQL + 緩存一致性補償

核心原則:秒殺請求不直接操作MySQL,僅通過Redis完成快速判斷,MySQL更新通過異步機制解耦,提升系統吞吐量。

三、分階段詳細實現(附代碼)

階段1:活動前準備 - 緩存預熱(防雪崩核心步驟)

3.1.1 核心目的

秒殺活動開始前,主動將熱點商品信息、庫存數據從MySQL加載到Redis,避免活動啓動時大量請求因緩存未命中直接穿透到MySQL,同時通過“隨機過期時間”避免緩存雪崩。

3.1.2 實現步驟

  1. 篩選秒殺活動的熱點商品(如活動表關聯商品表查詢)
  2. 批量查詢商品詳情(名稱、價格、圖片)和庫存數據
  3. 將數據序列化後寫入Redis,設置“基礎過期時間+隨機偏移”(防雪崩)
  4. 可選:預熱完成後,設置熱點商品緩存“永不過期”(活動期間),活動結束後清理

3.1.3 代碼示例(ThinkPHP8)


<?php
namespace app\command;

use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Cache;
use think\facade\Db;

// 命令行腳本:php think seckill:cache-warm {activityId}
class SeckillCacheWarm extends Command
{
    protected function configure()
    {
        $this->setName('seckill:cache-warm')->setDescription('秒殺活動緩存預熱');
        $this->addArgument('activityId', 0, '活動ID');
    }

    protected function execute(Input $input, Output $output)
    {
        $activityId = $input->getArgument('activityId');
        if (empty($activityId)) {
            $output->error('請傳入活動ID');
            return;
        }

        try {
            // 1. 查詢活動關聯的熱點商品(含庫存)
            $seckillProducts = Db::name('seckill_activity_product')
                ->alias('sap')
                ->join('product p', 'sap.product_id = p.id')
                ->where('sap.activity_id', $activityId)
                ->where('sap.status', 1) // 活動有效
                ->field('p.id, p.name, p.price, p.image, sap.stock as seckill_stock')
                ->select();

            if (empty($seckillProducts)) {
                $output->info('該活動無關聯商品,預熱結束');
                return;
            }

            // 2. 批量寫入Redis(防雪崩:過期時間=3小時+隨機0-300秒)
            foreach ($seckillProducts as $product) {
                $productKey = "seckill:product:{$product['id']}";
                $stockKey = "seckill:stock:{$product['id']}";
                $expire = 3600 * 3 + mt_rand(0, 300); // 隨機過期,避免雪崩

                // 商品詳情緩存(JSON序列化)
                Cache::store('redis')->set($productKey, json_encode($product), $expire);
                // 庫存緩存(字符串存儲,便於原子操作)
                Cache::store('redis')->set($stockKey, $product['seckill_stock'], $expire);

                $output->info("商品ID:{$product['id']} 預熱完成,庫存:{$product['seckill_stock']}");
            }

            $output->info("本次預熱完成,共預熱 " . count($seckillProducts) . " 個商品");
        } catch (\Exception $e) {
            $output->error("預熱失敗:" . $e->getMessage());
        }
    }
}

階段2:秒殺進行時 - Redis併發處理+緩存防護

3.2.1 核心邏輯

用户秒殺請求直接命中Redis,完成“商品查詢+庫存校驗+原子扣減”,全程不操作MySQL;僅當庫存扣減成功後,異步發送消息到隊列,後續由消費者更新MySQL。同時通過“互斥鎖”防緩存擊穿、“熱點永不過期”強化防護。

3.2.2 關鍵步驟

  1. 接收用户秒殺請求(攜帶商品ID、用户ID)
  2. 查詢Redis緩存:獲取商品詳情(防擊穿:緩存未命中則加互斥鎖查詢MySQL)
  3. Redis原子扣減庫存:使用DECRBY命令,確保併發安全(防超賣)
  4. 判斷庫存:扣減後≥0則秒殺成功,發送異步消息;否則失敗並回滾庫存
  5. 返回結果:秒殺成功/失敗(前端無需等待MySQL更新)

3.2.3 代碼示例(ThinkPHP8 控制器)


<?php
namespace app\controller;

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

class SeckillController
{
    /**
     * 秒殺核心接口
     * @param int $productId 秒殺商品ID
     * @param int $userId 用户ID(實際場景從登錄態獲取)
     * @return Json
     */
    public function doSeckill(int $productId, int $userId): Json
    {
        // 1. 定義緩存Key和鎖Key
        $productKey = "seckill:product:{$productId}";
        $stockKey = "seckill:stock:{$productId}";
        $lockKey = "seckill:lock:product:{$productId}"; // 防擊穿互斥鎖
        $userSeckillKey = "seckill:user:{$userId}:{$productId}"; // 防用户重複秒殺

        try {
            // 2. 防用户重複秒殺(Redis記錄已秒殺用户)
            if (Cache::store('redis')->exists($userSeckillKey)) {
                return json(['code' => 1, 'msg' => '你已參與過該商品秒殺,不可重複參與']);
            }

            // 3. 查詢商品詳情(防擊穿:緩存未命中則加互斥鎖查MySQL)
            $product = Cache::store('redis')->get($productKey);
            if ($product === false) {
                // 緩存未命中,嘗試獲取互斥鎖(10秒過期,避免死鎖)
                $isLocked = Cache::store('redis')->set($lockKey, 1, 10, ['NX']);
                if (!$isLocked) {
                    return json(['code' => 2, 'msg' => '系統繁忙,請稍後再試']);
                }

                try {
                    // 鎖內查詢MySQL,加載商品信息
                    $product = Db::name('seckill_activity_product')
                        ->alias('sap')
                        ->join('product p', 'sap.product_id = p.id')
                        ->where('sap.product_id', $productId)
                        ->where('sap.status', 1)
                        ->field('p.id, p.name, p.price, sap.stock as seckill_stock')
                        ->find();

                    if (empty($product)) {
                        throw new \Exception("秒殺商品不存在或已下架");
                    }

                    // 寫入Redis(活動期間熱點商品永不過期,防擊穿)
                    Cache::store('redis')->set($productKey, json_encode($product));
                    Cache::store('redis')->set($stockKey, $product['seckill_stock']);
                } finally {
                    // 釋放互斥鎖
                    Cache::store('redis')->delete($lockKey);
                }
            } else {
                $product = json_decode($product, true);
            }

            // 4. Redis原子扣減庫存(防超賣核心:DECRBY是原子操作)
            $newStock = Cache::store('redis')->decrby($stockKey, 1);
            if ($newStock < 0) {
                // 庫存不足,回滾扣減(避免庫存為負)
                Cache::store('redis')->incrby($stockKey, 1);
                return json(['code' => 1, 'msg' => '手慢了!商品已搶光']);
            }

            // 5. 秒殺成功:記錄用户已秒殺+發送異步消息到隊列(更新MySQL)
            Cache::store('redis')->set($userSeckillKey, 1, 86400); // 24小時過期
            $this->sendSeckillMsgToQueue($productId, $userId, $product['price']);

            return json(['code' => 0, 'msg' => '秒殺成功!請等待訂單生成']);
        } catch (\Exception $e) {
            return json(['code' => 1, 'msg' => $e->getMessage()]);
        }
    }

    /**
     * 發送秒殺消息到隊列(異步更新MySQL)
     * @param int $productId 商品ID
     * @param int $userId 用户ID
     * @param float $price 秒殺價
     */
    private function sendSeckillMsgToQueue(int $productId, int $userId, float $price): void
    {
        $orderSn = $this->generateOrderSn($userId); // 生成唯一訂單號
        $data = [
            'order_sn' => $orderSn,
            'user_id' => $userId,
            'product_id' => $productId,
            'price' => $price,
            'create_time' => time()
        ];

        // 推送消息到ThinkPHP隊列(驅動:Redis/RabbitMQ等,需提前配置)
        Queue::push('app\job\SeckillOrderJob', $data, 'seckill_queue');
    }

    /**
     * 生成唯一訂單號(用户ID+時間戳+隨機數)
     */
    private function generateOrderSn(int $userId): string
    {
        return $userId . date('YmdHis') . mt_rand(1000, 9999);
    }
}

階段3:異步落庫 - Redis→MySQL數據同步

3.3.1 核心目的

通過消息隊列解耦秒殺請求與MySQL更新,避免秒殺請求因等待MySQL操作而阻塞;消費者進程異步從隊列獲取消息,完成“MySQL庫存扣減+訂單創建”,同時通過“雙重校驗”“失敗重試”保證數據一致性。

3.3.2 關鍵步驟

  1. 消費者監聽秒殺消息隊列
  2. 獲取消息:解析訂單數據(用户ID、商品ID、價格等)
  3. MySQL事務操作:① 雙重校驗庫存(防超賣兜底)② 扣減MySQL庫存 ③ 創建訂單記錄
  4. 失敗處理:事務失敗則記錄日誌+重新入隊重試;重試多次失敗則人工介入

3.3.3 代碼示例(ThinkPHP8 隊列任務)


<?php
namespace app\job;

use think\facade\Db;
use think\queue\Job;

class SeckillOrderJob
{
    /**
     * 執行隊列任務
     * @param Job $job
     * @param array $data 秒殺訂單數據
     */
    public function fire(Job $job, array $data)
    {
        $isSuccess = $this->handle($data);
        if ($isSuccess) {
            // 任務執行成功,刪除任務
            $job->delete();
        } else {
            // 執行失敗,判斷是否需要重試(最多重試3次)
            if ($job->attempts() < 3) {
                $job->release(5); // 5秒後重新執行
            } else {
                // 重試次數用盡,記錄失敗日誌(人工介入)
                Db::name('seckill_order_fail')->insert([
                    'order_sn' => $data['order_sn'],
                    'user_id' => $data['user_id'],
                    'product_id' => $data['product_id'],
                    'error_msg' => '重試3次失敗',
                    'create_time' => time()
                ]);
                $job->delete();
            }
        }
    }

    /**
     * 核心處理:更新MySQL庫存+創建訂單
     * @param array $data
     * @return bool
     */
    private function handle(array $data): bool
    {
        // 開啓MySQL事務,保證原子性
        Db::startTrans();
        try {
            $productId = $data['product_id'];
            $userId = $data['user_id'];

            // 1. 雙重校驗庫存(防超賣兜底:避免Redis與MySQL數據不一致)
            $seckillProduct = Db::name('seckill_activity_product')
                ->where('product_id', $productId)
                ->lock(true) // 行鎖:防止併發更新衝突
                ->find();

            if (empty($seckillProduct) || $seckillProduct['stock'] <= 0) {
                throw new \Exception("MySQL庫存不足,商品ID:{$productId}");
            }

            // 2. 扣減MySQL中的秒殺庫存
            Db::name('seckill_activity_product')
                ->where('product_id', $productId)
                ->update(['stock' => Db::raw('stock - 1')]);

            // 3. 創建秒殺訂單記錄
            Db::name('seckill_order')->insert([
                'order_sn' => $data['order_sn'],
                'user_id' => $userId,
                'product_id' => $productId,
                'price' => $data['price'],
                'status' => 1, // 1-待支付
                'create_time' => $data['create_time']
            ]);

            // 提交事務
            Db::commit();
            return true;
        } catch (\Exception $e) {
            // 回滾事務
            Db::rollback();
            // 記錄錯誤日誌
            trace("秒殺訂單落庫失敗:" . $e->getMessage() . ",數據:" . json_encode($data), 'error');
            return false;
        }
    }
}

四、緩存更新防護專項方案

4.1 防止緩存擊穿(熱點商品緩存失效)

秒殺場景中,熱點商品緩存失效會導致大量請求瞬間穿透到MySQL,引發數據庫壓力激增。核心防護方案:

  1. 互斥鎖防護:緩存未命中時,僅允許一個請求通過互斥鎖查詢MySQL並更新緩存,其他請求等待或返回“系統繁忙”(對應階段2代碼中的lockKey邏輯)
  2. 熱點商品永不過期:活動期間,熱點秒殺商品的緩存不設置過期時間,避免緩存失效;活動結束後,通過命令行腳本批量清理緩存
  3. 緩存預熱強化:活動前10-30分鐘再次執行預熱腳本,確保緩存全量加載

4.2 防止緩存雪崩(大量緩存同時過期)

若多個秒殺商品緩存設置相同過期時間,到期後會同時失效,引發“緩存雪崩”。核心防護方案:

  1. 過期時間隨機化:緩存預熱時,為每個商品設置“基礎過期時間+隨機偏移”(如3小時±5分鐘),分散緩存過期時間(對應階段1代碼中的expire邏輯)
  2. 多級緩存防護:在應用層增加本地緩存(如PHP靜態數組),緩存熱點商品信息,即使Redis緩存失效,也能通過本地緩存兜底,減少穿透到MySQL的請求
  3. Redis高可用:部署Redis主從集羣+哨兵模式,避免Redis單點故障導致緩存全失效

五、Redis與MySQL數據一致性保障

秒殺場景中無法做到“強一致性”(會犧牲性能),採用“最終一致性”方案,通過以下機制保證數據偏差可控:

5.1 核心保障機制

  1. 雙重庫存校驗:Redis扣減庫存後,MySQL更新時再次校驗庫存(行鎖保護),避免Redis與MySQL數據不一致導致的超賣
  2. 異步補償任務:定時執行腳本,對比Redis庫存與MySQL庫存,發現偏差則自動修正(以MySQL為準,同步到Redis)
  3. 失敗重試機制:隊列任務執行失敗後,自動重試3次;重試失敗記錄失敗日誌,人工介入處理(避免訂單丟失)
  4. 用户重複秒殺限制:通過Redis記錄已秒殺用户,避免同一用户重複秒殺(即使MySQL未及時更新,也能通過Redis攔截)

5.2 庫存同步補償腳本(示例)


<?php
namespace app\command;

use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Cache;
use think\facade\Db;

// 命令行腳本:php think seckill:stock-sync {activityId}
class SeckillStockSync extends Command
{
    protected function configure()
    {
        $this->setName('seckill:stock-sync')->setDescription('秒殺庫存Redis與MySQL同步補償');
        $this->addArgument('activityId', 0, '活動ID');
    }

    protected function execute(Input $input, Output $output)
    {
        $activityId = $input->getArgument('activityId');
        $seckillProducts = Db::name('seckill_activity_product')
            ->where('activity_id', $activityId)
            ->field('product_id, stock')
            ->select();

        foreach ($seckillProducts as $item) {
            $productId = $item['product_id'];
            $mysqlStock = $item['stock'];
            $redisStock = Cache::store('redis')->get("seckill:stock:{$productId}");

            // 發現庫存偏差,以MySQL為準同步到Redis
            if ($redisStock !== $mysqlStock) {
                Cache::store('redis')->set("seckill:stock:{$productId}", $mysqlStock);
                $output->info("商品ID:{$productId} 庫存同步完成:Redis={$redisStock}→{$mysqlStock}(MySQL)");
            }
        }

        $output->info("庫存同步補償完成,共檢查 " . count($seckillProducts) . " 個商品");
    }
}

六、整體架構與最佳實踐

6.1 架構流程圖


用户請求 → CDN/負載均衡 → 應用層(Nginx+PHP)
                          ↓
                    本地緩存(熱點商品)
                          ↓
                    Redis集羣(主從+哨兵)
                     ↗        ↘
            商品查詢/庫存扣減   記錄已秒殺用户
                     ↓
               秒殺成功?
                ↗      ↘
             是        否
             ↓        ↓
        發送消息到隊列   返回“搶光”
             ↓
        隊列消費者
             ↓
        MySQL事務操作
        (校驗庫存→扣庫存→創建訂單)
             ↓
        失敗重試/日誌記錄
             ↓
        定時補償同步(Redis←MySQL)

6.2 最佳實踐總結

  • 優先用Redis原子操作(DECRBY、SETNX)保證併發安全,避免複雜鎖邏輯
  • 秒殺請求全程不直接操作MySQL,通過消息隊列異步解耦,提升吞吐量
  • 緩存防護要“多層兜底”:預熱+隨機過期+互斥鎖+本地緩存+Redis高可用
  • 數據一致性通過“雙重校驗+定時補償”保障,允許短期偏差但要可控
  • 提前壓測:重點測試Redis併發能力、隊列吞吐量、MySQL異步更新性能

七、擴展説明

  1. 隊列選型:中小規模秒殺用Redis隊列即可;大規模高併發場景建議用RabbitMQ/Kafka,支持更高吞吐量和消息可靠性
  2. 限流降級:可在Nginx或應用層增加限流(如令牌桶算法),避免超出系統承載能力
  3. 數據監控:實時監控Redis緩存命中率、隊列堆積量、MySQL事務成功率,異常時及時告警

🍵 寫在最後

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

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

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

發佈 評論

Some HTML is okay.