redis秒杀实现

一、核心功能实现

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. 第 1 个请求:查询 Redis 缓存不存在 → 调用setnx获取锁成功 → 穿透到数据库查询真实库存 → 写入 Redis 缓存 → 释放锁 → 返回结果;
  2. 第 2-1000 个请求:查询 Redis 缓存不存在 → 调用setnx获取锁失败 → 休眠 100 毫秒后递归重试 → 此时第 1 个请求已将库存写入 Redis → 重试后直接获取 Redis 缓存 → 返回结果,无需穿透数据库;
  3. 最终效果:只有 1 个请求穿透到数据库,其余 999 个请求均从 Redis 获取数据,成功防止缓存击穿。

5、总结

  1. 互斥锁的具体体现:在上述getStock(获取库存)方法中,通过seckill:lock:stock:{$goodsId}作为锁 Key,使用**setnx实现原子锁,**确保缓存失效时只有一个请求穿透到数据库;
  2. 互斥锁的核心流程:「查询缓存→缓存不存在→获取锁→查库写缓存→释放锁→返回结果」,未获取锁的请求休眠后重试,复用缓存数据;
  3. 缓存雪崩防护:在写入库存缓存时设置「随机过期时间」,搭配 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 可用性。

三、核心原理总结

  1. 防超卖 :通过 Redis的decr原子操作扣减库存,配合数据库原子更新兜底,确保库存不会为负。
  2. 防重复下单:通过 Redis 键记录用户已下单状态,避免同一用户重复秒杀。
  3. 防请求穿透:前端限流 + 后端用户限流,减少无效请求;缓存预热减轻数据库压力。
  4. 解决页面卡顿:将订单创建等耗时操作异步化,通过消息队列削峰,快速返回秒杀结果。
相关推荐
消失的旧时光-19432 小时前
用 Drift 实现 Repository 无缝接入本地缓存/数据库(SWR:先快后准)
数据库·flutter·缓存
Tony Bai2 小时前
【API 设计之道】08 流量与配额:构建基于 Redis 的分布式限流器
数据库·redis·分布式·缓存
三七吃山漆2 小时前
攻防世界——Web_php_wrong_nginx_config
开发语言·nginx·安全·web安全·网络安全·php·ctf
北漂燕郊杨哥2 小时前
Laravel中Tymon\JWTAuth 的用法示例
php·laravel
想学后端的前端工程师2 小时前
【Redis实战与高可用架构设计:从缓存到分布式锁的完整解决方案】
redis·分布式·缓存
川贝枇杷膏cbppg11 小时前
Redis 的 RDB 持久化
前端·redis·bootstrap
源代码•宸12 小时前
goframe框架签到系统项目(BITFIELD 命令详解、Redis Key 设计、goframe 框架教程、安装MySQL)
开发语言·数据库·经验分享·redis·后端·mysql·golang
川贝枇杷膏cbppg12 小时前
Redis 的 AOF
java·数据库·redis
今晚务必早点睡14 小时前
Redis——快速入门第二课:Redis 常用命令 + 能解决实际问题
数据库·redis·bootstrap