Skip to content

秒杀系统

MineShop 的秒杀系统采用活动 → 场次 → 商品三级管理模式,结合 Redis + Lua 原子操作,实现高并发下的库存安全扣减。

🎯 系统架构

┌─────────────────────────────────────────────────────────────┐
│                      秒杀活动 (Activity)                      │
│                    • 活动名称、时间范围                         │
│                    • 活动状态、启用开关                         │
├─────────────────────────────────────────────────────────────┤
│     场次1 (Session)    │    场次2 (Session)    │    ...      │
│   10:00-12:00         │   14:00-16:00        │             │
├─────────────────────────────────────────────────────────────┤
│  商品A  │  商品B  │  商品C  │  商品D  │  商品E  │    ...     │
│  ¥99   │  ¥199  │  ¥299  │  ¥399  │  ¥499  │            │
└─────────────────────────────────────────────────────────────┘

✨ 核心特性

1. 三级管理结构

层级说明主要字段
活动秒杀活动的顶层容器名称、描述、开始/结束时间、状态
场次活动下的时间段开始/结束时间、限购数量、状态
商品场次下的秒杀商品原价、秒杀价、库存、已售数量

2. Lua 原子扣库存

lua
-- deduct_stock.lua
-- 原子扣减库存,防止超卖

-- 第一轮:检查所有 SKU 库存是否充足
for i = 1, #ARGV, 2 do
    local field = ARGV[i]
    local quantity = tonumber(ARGV[i + 1])
    local current = tonumber(redis.call('HGET', KEYS[1], field) or 0)
    if current < quantity then
        return 0  -- 库存不足
    end
end

-- 第二轮:全部扣减
for i = 1, #ARGV, 2 do
    local field = ARGV[i]
    local quantity = tonumber(ARGV[i + 1])
    redis.call('HINCRBY', KEYS[1], field, -quantity)
end

return 1  -- 扣减成功

3. 缓存预热机制

php
// SeckillCacheService.php
public function warmSession(int $sessionId): void
{
    // 1. 缓存场次信息
    $this->cache->set(
        $this->sessionKey($sessionId), 
        json_encode($model->toArray()), 
        ['EX' => self::TTL]
    );

    // 2. 缓存商品列表 (Hash)
    $this->cache->hMset($productsKey, $fields);

    // 3. 预热库存 (Hash)
    $this->warmStockHash($sessionId, $products);
}

4. 30分钟编辑锁定

活动开始前 30 分钟进入缓存预热期,此时禁止编辑和删除:

php
// SeckillSessionEntity.php
public function isWithinCacheWarmupPeriod(): bool
{
    $startTime = Carbon::parse($this->startTime);
    $now = Carbon::now();

    // 开始时间已过,不在预热期
    if ($startTime->lte($now)) {
        return false;
    }

    // 开始前 30 分钟内
    return $startTime->diffInMinutes($now) <= 30;
}

public function canBeEdited(): bool
{
    // 已激活或已结束不可编辑
    if (in_array($this->status, ['active', 'ended'])) {
        return false;
    }
    
    // 预热期内不可编辑
    return !$this->isWithinCacheWarmupPeriod();
}

🔄 状态流转

┌──────────┐    开始时间到达    ┌──────────┐    结束时间到达    ┌──────────┐
│  pending │ ───────────────→ │  active  │ ───────────────→ │  ended   │
│  待开始   │                  │  进行中   │                  │  已结束   │
└──────────┘                  └──────────┘                  └──────────┘
      │                             │
      │ 手动取消                     │ 库存售罄
      ↓                             ↓
┌──────────┐                  ┌──────────┐
│ cancelled│                  │ sold_out │
│  已取消   │                  │  已售罄   │
└──────────┘                  └──────────┘

📦 缓存结构

Redis Key 结构:

seckill:session:{id}           # 场次信息 (String/JSON)
seckill:session:{id}:products  # 场次商品 (Hash, field=skuId)
seckill:stock:{sessionId}      # 库存数据 (Hash, field=skuId, value=剩余库存)

🕐 定时任务

系统通过定时任务自动推进活动状态:

php
// SeckillActivityStatusCrontab.php
#[Crontab(
    name: 'seckill-activity-status', 
    rule: '* */10 * * * *',  // 每10分钟执行
    callback: 'execute'
)]
class SeckillActivityStatusCrontab
{
    public function execute(): void
    {
        // 1. 处理待开始的场次(30分钟内)
        $this->processPendingSessions();
        
        // 2. 处理已过期的场次
        $this->processExpiredSessions();
        
        // 3. 联动激活活动
        $this->processActivityStart();
        
        // 4. 联动结束活动
        $this->processActivityEnd();
    }
}

💻 API 接口

后台管理

接口方法说明
/admin/seckill/activityGET活动列表
/admin/seckill/activityPOST创建活动
/admin/seckill/activity/{id}PUT更新活动
/admin/seckill/sessionGET场次列表
/admin/seckill/sessionPOST创建场次
/admin/seckill/productPOST添加商品

前端 API

接口方法说明
/api/seckill/sessionsGET获取进行中的场次
/api/seckill/productsGET获取场次商品列表
/api/order/submitPOST秒杀下单

🛡️ 防超卖策略

  1. Lua 原子操作: 检查和扣减在同一脚本中完成
  2. 分布式锁: 可选的 SKU 级别锁
  3. 库存预热: 活动开始前加载到 Redis
  4. 双重校验: 下单时再次校验库存
php
// DomainOrderStockService.php
public function reserve(array $items, string $stockHashKey): void
{
    // Lua 脚本原子扣减
    $result = $this->redis()->eval(self::DEDUCT_SCRIPT, $payload, 1);
    
    if ((int) $result !== 1) {
        throw new \RuntimeException('库存不足或商品已下架');
    }
}

📊 数据统计

  • 活动销售额统计
  • 场次转化率分析
  • 商品热度排行
  • 库存消耗趋势

⚠️ 注意事项

  1. 预热时间: 建议活动开始前 30 分钟完成缓存预热
  2. 库存同步: 活动结束后需同步 Redis 库存到数据库
  3. 限购控制: 支持场次级别和商品级别的限购
  4. 降级策略: 高并发时可启用排队机制

📚 相关文档

基于 Apache-2.0 许可发布 | 感谢 MineAdmin 提供的优秀基础框架