一、核心功能实现
1. 秒杀前:库存缓存预热
秒杀开始前,将商品库存加载到 Redis,避免秒杀时直接查询数据库。
可通过命令行或定时任务执行预热(推荐秒杀开始前 10 分钟执行)。
// 将库存写入Redis(原子操作,防止重复预热)
php
Redis::setnx('seckill:stock:'.$goodId, $goods->stock)
2. 核心:秒杀逻辑实现(防超卖 + 防重复下单 + 限流)
php
namespace App\Services;
use App\Models\SeckillGoods;
use App\Models\SeckillOrder;
use Carbon\Carbon;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class SeckillService
{
// ... 原有代码
/**
* 秒杀核心逻辑
* @param int $userId 用户ID
* @param int $goodsId 商品ID
* @return array 结果数组
*/
public function seckill(int $userId, int $goodsId): array
{
//此次省略一些其他基本登录校验
//1.基础校验(秒杀时间、商品状态)
$goods = SeckillGoods::find($goodsId);
if (!$goods) {
return ['code' => 400, 'msg' => '商品不存在'];
}
if ($goods->status != 1) {
return ['code' => 400, 'msg' => '秒杀未开始或已结束'];
}
if (Carbon::now()->lt($goods->start_time) || Carbon::now()->gt($goods->end_time)) {
return ['code' => 400, 'msg' => '当前非秒杀时段'];
}
// 2. 用户限流(1分钟内最多5次请求,防止恶意刷请求)
$requestCount = Redis::incr('seckill:limit:user_'.$userId);
if ($requestCount == 1) {
Redis::expire($limitKey, 60); // 设置1分钟过期
}
if ($requestCount > 5) {
return ['code' => 400, 'msg' => '请求过于频繁,请稍后再试'];
}
// 3. 防重复下单(同一用户只能秒杀同一商品1次)
$orderKey = 'seckill:order:user_' . $userId . '_' . $goodsId;
if (Redis::exists($orderKey)) {
return ['code' => 400, 'msg' => '你已参与该商品秒杀,请勿重复下单'];
}
// 4. Redis原子扣减库存(核心:防止超卖,decr是原子操作)
$stockKey = 'seckill:stock:' . $goodsId;
$leftStock = Redis::decr($stockKey);
// 库存不足处理
if ($leftStock < 0) {
Redis::incr($stockKey); // 回滚库存,避免负数
return ['code' => 400, 'msg' => '库存不足,秒杀失败'];
}
// 5. 标记用户已下单(设置过期时间,与秒杀结束时间一致)
$expireSeconds = $goods->end_time->diffInSeconds(Carbon::now());
Redis::setex($orderKey, $expireSeconds, 1);
// 6. 异步创建订单(放入消息队列,提升响应速度)
$this->createOrderAsync($userId, $goodsId, $goods->price);
return ['code' => 200, 'msg' => '秒杀成功,正在为你创建订单'];
}
/**
* 异步创建订单
* @param int $userId
* @param int $goodsId
* @param float $price
*/
protected function createOrderAsync(int $userId, int $goodsId, float $price): void
{
// 分发任务到队列(需先创建订单任务类)
\App\Jobs\CreateSeckillOrderJob::dispatch($userId, $goodsId, $price)
->onQueue('seckill'); // 指定秒杀队列,优先处理
}
/**
* 生成唯一订单号
* @return string
*/
protected function generateOrderNo(): string
{
return date('YmdHis') . Str::random(6) . mt_rand(1000, 9999);
}
}
3. 异步订单任务(解决页面卡顿)
创建订单任务类,异步处理订单入库与数据库库存扣减:
php
namespace App\Jobs;
use App\Models\SeckillGoods;
use App\Models\SeckillOrder;
use App\Services\SeckillService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class CreateSeckillOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $userId;
protected $goodsId;
protected $price;
/**
* 任务超时时间
* @var int
*/
public $timeout = 60;
public function __construct(int $userId, int $goodsId, float $price)
{
$this->userId = $userId;
$this->goodsId = $goodsId;
$this->price = $price;
}
/**
* 执行任务
* @return void
*/
public function handle()
{
// 1. 数据库原子扣减库存(兜底校验,防止Redis与数据库不一致)
$service = new SeckillService();
$goods = SeckillGoods::find($this->goodsId);
if (!$goods) {
// 库存回滚
Redis::incr('seckill:stock:' . $this->goodsId);
return;
}
DB::beginTransaction();
try {
// 原子更新:只有库存>0时才扣减
$affectedRows = SeckillGoods::where('id', $this->goodsId)
->where('stock', '>', 0)
->update([
'stock' => DB::raw('stock - 1')
]);
if ($affectedRows == 0) {
throw new \Exception('数据库库存不足');
}
// 2. 创建订单
$orderNo = $service->generateOrderNo();
SeckillOrder::create([
'order_no' => $orderNo,
'user_id' => $this->userId,
'goods_id' => $this->goodsId,
'amount' => $this->price,
'pay_status' => 0,
'status' => 0
]);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
// 异常处理:Redis库存回滚,删除用户下单标记
Redis::incr('seckill:stock:' . $this->goodsId);
Redis::del('seckill:order:user_' . $this->userId . '_' . $this->goodsId);
\Log::error("创建秒杀订单失败:" . $e->getMessage());
}
}
}
4. 控制器实现(提供接口---前端异步请求获取库存接口)
php
namespace App\Http\Controllers;
use App\Services\SeckillService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SeckillController extends Controller
{
/**
* 秒杀接口
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function submit(Request $request)
{
// 验证用户登录(根据实际业务调整)
if (!Auth::check()) {
return response()->json(['code' => 401, 'msg' => '请先登录']);
}
$userId = Auth::id();
$goodsId = $request->input('goods_id');
// 参数验证
if (!$goodsId) {
return response()->json(['code' => 400, 'msg' => '商品ID不能为空']);
}
// 执行秒杀
$service = new SeckillService();
$result = $service->seckill($userId, (int)$goodsId);
return response()->json($result);
}
/**
* 获取商品库存(供前端异步查询 + 添加互斥锁,防止缓存击穿)
* @param int $goodsId
* @return JsonResponse
*/
public function getStock(int $goodsId): JsonResponse
{
// 1. 定义Redis键(库存键 + 互斥锁键)
$stockKey = 'seckill:stock:' . $goodsId;
$lockKey = "seckill:lock:stock:{$goodsId}"; // 互斥锁的Key(专门用于缓存击穿防护)
$lockExpire = 5; // 互斥锁过期时间5秒(防止死锁,需大于数据库查询耗时)
// 2. 先尝试获取Redis库存缓存
$stock = Redis::get($stockKey);
if ($stock !== false) {
// 缓存存在,直接返回(无需加锁)
return response()->json([
'code' => 200,
'msg' => 'success',
'data' => ['stock' => (int)$stock]
]);
}
// 3. 缓存不存在(可能失效/库存为0),添加互斥锁,防止大量请求穿透到数据库
try {
// 核心:使用SETNX实现互斥锁(原子操作,只有一个请求能获取到锁)
$isLockSuccess = Redis::setnx($lockKey, 1);
if (!$isLockSuccess) {
// 未获取到锁:其他请求正在查询数据库,短暂休眠后重试(或直接返回0,根据业务调整)
usleep(100000); // 休眠100毫秒,避免频繁请求
return $this->getStock($goodsId); // 递归重试,获取缓存数据
}
// 4. 获取到锁:唯一允许穿透到数据库查询的请求
Redis::expire($lockKey, $lockExpire); // 设置锁过期时间,防止死锁
// 从数据库查询真实库存
$goods = SeckillGoods::find($goodsId);
$realStock = $goods ? (int)$goods->stock : 0;
// 5. 将数据库查询结果重新写入Redis缓存(设置过期时间,避免再次立即失效)
// 针对缓存雪崩:添加随机过期时间(1800±300秒),避免所有商品库存缓存同时失效
$expireTime = 1800 + mt_rand(-300, 300);
Redis::setex($stockKey, $expireTime, $realStock);
// 6. 返回真实库存
return response()->json([
'code' => 200,
'msg' => 'success',
'data' => ['stock' => $realStock]
]);
} finally {
// 7. 释放互斥锁(无论是否异常,都要释放锁,避免死锁)
Redis::del($lockKey);
}
}
}
互斥锁的运行流程(直观理解缓存击穿防护)
假设 1000 个请求同时查询「商品 ID=1」的库存,此时 Redis 库存缓存恰好失效:
- 第 1 个请求:查询 Redis 缓存不存在 → 调用
setnx获取锁成功 → 穿透到数据库查询真实库存 → 写入 Redis 缓存 → 释放锁 → 返回结果; - 第 2-1000 个请求:查询 Redis 缓存不存在 → 调用
setnx获取锁失败 → 休眠 100 毫秒后递归重试 → 此时第 1 个请求已将库存写入 Redis → 重试后直接获取 Redis 缓存 → 返回结果,无需穿透数据库; - 最终效果:只有 1 个请求穿透到数据库,其余 999 个请求均从 Redis 获取数据,成功防止缓存击穿。
5、总结
- 互斥锁的具体体现:在上述
getStock(获取库存)方法中,通过seckill:lock:stock:{$goodsId}作为锁 Key,使用**setnx实现原子锁,**确保缓存失效时只有一个请求穿透到数据库; - 互斥锁的核心流程:「查询缓存→缓存不存在→获取锁→查库写缓存→释放锁→返回结果」,未获取锁的请求休眠后重试,复用缓存数据;
- 缓存雪崩防护:在写入库存缓存时设置「随机过期时间」,搭配 Redis 集群,避免缓存集中失效和服务单点故障。
二、关键优化与注意事项
1. 启动队列消费
异步订单依赖队列,需启动队列消费进程:
php
# 普通启动
php artisan queue:work --queue=seckill,default
# 守护进程启动(生产环境推荐)
php artisan queue:work --queue=seckill,default --daemon --timeout=60 --tries=3
2. 防止缓存击穿 / 雪崩
- 缓存击穿 :给库存 Key 添加互斥锁,当库存为 0 时,防止大量请求穿透到数据库(可在
getStock方法中添加锁逻辑)。 - 缓存雪崩:给 Redis 库存 Key 设置随机过期时间(避免所有商品库存同时失效),或使用 Redis 集群提高可用性。
3. 库存一致性保障
- 秒杀结束后,通过定时任务对比 Redis 库存与数据库库存,修正差异
4. Laravel 性能优化
- 开启 OPcache(
php.ini中配置opcache.enable=1),提升 PHP 代码执行效率。 - 优化 PHP-FPM 配置(根据服务器 CPU 核心数调整
pm.max_children等参数)。 - 使用 Redis 集群或哨兵模式,提高 Redis 可用性。
三、核心原理总结
- 防超卖 :通过 Redis的
decr原子操作扣减库存,配合数据库原子更新兜底,确保库存不会为负。 - 防重复下单:通过 Redis 键记录用户已下单状态,避免同一用户重复秒杀。
- 防请求穿透:前端限流 + 后端用户限流,减少无效请求;缓存预热减轻数据库压力。
- 解决页面卡顿:将订单创建等耗时操作异步化,通过消息队列削峰,快速返回秒杀结果。