高并发秒杀场景:Redis+MySQL数据同步与缓存更新防护文档

71 阅读8分钟

高并发秒杀场景: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 全栈领域。

欢迎关注我的微信公众号「乞丐的项目」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!