高并发秒杀场景: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 全栈领域。
欢迎关注我的微信公众号「乞丐的项目」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!