高併發秒殺場景:Redis+MySQL數據同步與緩存更新防護文檔
一、文檔概述
1.1 場景背景
秒殺場景的核心特徵是“瞬時高併發”:短時間內大量用户同時請求同一商品,既要保證系統響應速度(扛住高併發讀/寫),又要避免超賣、緩存擊穿/雪崩、Redis與MySQL數據不一致等問題。
核心解決方案:Redis 前置抗併發(緩存商品/庫存+原子扣減)+ MySQL 異步落庫(最終數據持久化),結合針對性緩存防護策略,實現“高性能”與“數據一致性”平衡。
1.2 核心目標
- 高性能:用Redis扛住秒殺瞬時讀/寫併發,避免MySQL直接承壓
- 防超賣:確保庫存不出現負數,Redis與MySQL庫存最終一致
- 緩存防護:防止熱點商品緩存擊穿、大量商品緩存同時過期導致的雪崩
- 數據一致:保證Redis緩存與MySQL數據庫最終同步,避免數據偏差
二、核心流程總覽
秒殺場景的Redis+MySQL協同流程分為3個核心階段,全程圍繞“緩存優先、異步落庫、防護兜底”設計:
- 活動前準備:緩存預熱(將商品/庫存從MySQL加載到Redis)
- 秒殺進行時:Redis處理併發(查緩存+原子扣減庫存)+ 異步發消息
- 後續同步:消息隊列消費者異步更新MySQL + 緩存一致性補償
核心原則:秒殺請求不直接操作MySQL,僅通過Redis完成快速判斷,MySQL更新通過異步機制解耦,提升系統吞吐量。
三、分階段詳細實現(附代碼)
階段1:活動前準備 - 緩存預熱(防雪崩核心步驟)
3.1.1 核心目的
秒殺活動開始前,主動將熱點商品信息、庫存數據從MySQL加載到Redis,避免活動啓動時大量請求因緩存未命中直接穿透到MySQL,同時通過“隨機過期時間”避免緩存雪崩。
3.1.2 實現步驟
- 篩選秒殺活動的熱點商品(如活動表關聯商品表查詢)
- 批量查詢商品詳情(名稱、價格、圖片)和庫存數據
- 將數據序列化後寫入Redis,設置“基礎過期時間+隨機偏移”(防雪崩)
- 可選:預熱完成後,設置熱點商品緩存“永不過期”(活動期間),活動結束後清理
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 關鍵步驟
- 接收用户秒殺請求(攜帶商品ID、用户ID)
- 查詢Redis緩存:獲取商品詳情(防擊穿:緩存未命中則加互斥鎖查詢MySQL)
- Redis原子扣減庫存:使用DECRBY命令,確保併發安全(防超賣)
- 判斷庫存:扣減後≥0則秒殺成功,發送異步消息;否則失敗並回滾庫存
- 返回結果:秒殺成功/失敗(前端無需等待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 關鍵步驟
- 消費者監聽秒殺消息隊列
- 獲取消息:解析訂單數據(用户ID、商品ID、價格等)
- MySQL事務操作:① 雙重校驗庫存(防超賣兜底)② 扣減MySQL庫存 ③ 創建訂單記錄
- 失敗處理:事務失敗則記錄日誌+重新入隊重試;重試多次失敗則人工介入
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,引發數據庫壓力激增。核心防護方案:
- 互斥鎖防護:緩存未命中時,僅允許一個請求通過互斥鎖查詢MySQL並更新緩存,其他請求等待或返回“系統繁忙”(對應階段2代碼中的lockKey邏輯)
- 熱點商品永不過期:活動期間,熱點秒殺商品的緩存不設置過期時間,避免緩存失效;活動結束後,通過命令行腳本批量清理緩存
- 緩存預熱強化:活動前10-30分鐘再次執行預熱腳本,確保緩存全量加載
4.2 防止緩存雪崩(大量緩存同時過期)
若多個秒殺商品緩存設置相同過期時間,到期後會同時失效,引發“緩存雪崩”。核心防護方案:
- 過期時間隨機化:緩存預熱時,為每個商品設置“基礎過期時間+隨機偏移”(如3小時±5分鐘),分散緩存過期時間(對應階段1代碼中的expire邏輯)
- 多級緩存防護:在應用層增加本地緩存(如PHP靜態數組),緩存熱點商品信息,即使Redis緩存失效,也能通過本地緩存兜底,減少穿透到MySQL的請求
- Redis高可用:部署Redis主從集羣+哨兵模式,避免Redis單點故障導致緩存全失效
五、Redis與MySQL數據一致性保障
秒殺場景中無法做到“強一致性”(會犧牲性能),採用“最終一致性”方案,通過以下機制保證數據偏差可控:
5.1 核心保障機制
- 雙重庫存校驗:Redis扣減庫存後,MySQL更新時再次校驗庫存(行鎖保護),避免Redis與MySQL數據不一致導致的超賣
- 異步補償任務:定時執行腳本,對比Redis庫存與MySQL庫存,發現偏差則自動修正(以MySQL為準,同步到Redis)
- 失敗重試機制:隊列任務執行失敗後,自動重試3次;重試失敗記錄失敗日誌,人工介入處理(避免訂單丟失)
- 用户重複秒殺限制:通過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異步更新性能
七、擴展説明
- 隊列選型:中小規模秒殺用Redis隊列即可;大規模高併發場景建議用RabbitMQ/Kafka,支持更高吞吐量和消息可靠性
- 限流降級:可在Nginx或應用層增加限流(如令牌桶算法),避免超出系統承載能力
- 數據監控:實時監控Redis緩存命中率、隊列堆積量、MySQL事務成功率,異常時及時告警
🍵 寫在最後
我是 網絡乞丐,熱愛代碼,目前專注於 Web 全棧領域。
歡迎關注我的微信公眾號「乞丐的項目」,我會不定期分享一些開發心得、最佳實踐以及技術探索等內容,希望能夠幫到你!