Skip to content

缓存预热

MineShop 在秒杀、团购等高并发场景中使用缓存预热机制,确保活动开始时热点数据已在 Redis 中就绪。

🎯 预热机制

┌─────────────────────────────────────────────────────────────┐
│                      活动时间线                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  T-30min        T-0          T+N          T+End            │
│     │            │            │             │               │
│     ▼            ▼            ▼             ▼               │
│  ┌──────┐    ┌──────┐    ┌──────┐    ┌──────┐             │
│  │ 预热  │    │ 开始  │    │ 进行  │    │ 结束  │             │
│  │ 缓存  │    │ 活动  │    │  中   │    │ 清理  │             │
│  └──────┘    └──────┘    └──────┘    └──────┘             │
│                                                             │
│  • 加载数据    • 状态激活    • 处理请求    • 清理缓存          │
│  • 禁止编辑    • 开放购买    • 扣减库存    • 同步数据          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

✨ 秒杀缓存预热

缓存结构

Redis Key 结构:

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

预热服务

php
// SeckillCacheService.php
final class SeckillCacheService
{
    private const PREFIX = 'seckill';
    private const TTL = 7200;  // 2小时

    /**
     * 预热场次缓存
     */
    public function warmSession(int $sessionId): void
    {
        $model = $this->sessionRepository->findById($sessionId);
        if (!$model) {
            return;
        }

        // 1. 缓存场次信息
        $this->cache->set(
            $this->sessionKey($sessionId),
            json_encode($model->toArray(), JSON_UNESCAPED_UNICODE),
            ['EX' => self::TTL]
        );

        // 2. 缓存商品列表
        $products = $this->productRepository->findBySessionId($sessionId);
        $productsKey = $this->sessionProductsKey($sessionId);
        $this->cache->delete($productsKey);

        $fields = [];
        foreach ($products as $product) {
            $fields[(string) $product->product_sku_id] = json_encode(
                SeckillProductMapper::fromModel($product)->toArray(),
                JSON_UNESCAPED_UNICODE
            );
        }
        
        if ($fields !== []) {
            $this->cache->hMset($productsKey, $fields);
        }

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

    /**
     * 预热库存 Hash
     */
    private function warmStockHash(int $sessionId, array $products): void
    {
        $hashKey = sprintf('stock:%d', $sessionId);
        $stockFields = [];
        
        foreach ($products as $product) {
            if (!$product->is_enabled) {
                continue;
            }
            $remaining = max(0, $product->quantity - $product->sold_quantity);
            $stockFields[(string) $product->product_sku_id] = (string) $remaining;
        }
        
        $this->cache->delete($hashKey);
        if ($stockFields !== []) {
            $this->cache->hMset($hashKey, $stockFields);
        }
    }
}

✨ 团购缓存预热

php
// GroupBuyCacheService.php
final class GroupBuyCacheService
{
    private const PREFIX = 'groupbuy';

    public function warmStock(int $groupBuyId): void
    {
        $model = $this->repository->findById($groupBuyId);
        if (!$model) {
            return;
        }
        
        // 计算剩余库存
        $remaining = max(0, $model->total_quantity - $model->sold_quantity);
        $skuId = (int) $model->sku_id;
        
        // 写入 Redis Hash
        $hashKey = sprintf('stock:%d', $groupBuyId);
        $this->cache->delete($hashKey);
        $this->cache->hMset($hashKey, [(string) $skuId => (string) $remaining]);
    }

    public function evictStock(int $groupBuyId): void
    {
        $this->cache->delete(sprintf('stock:%d', $groupBuyId));
    }
}

🕐 定时预热

秒杀定时任务

php
// SeckillActivityStatusCrontab.php
#[Crontab(name: 'seckill-activity-status', rule: '* */10 * * * *')]
class SeckillActivityStatusCrontab
{
    private function processPendingSessions(): void
    {
        // 查找 30 分钟内即将开始的场次
        $sessions = $this->sessionRepository->findPendingSessionsWithinMinutes(30);
        
        foreach ($sessions as $session) {
            $startTime = Carbon::parse($session->start_time);
            
            if ($startTime->lte(Carbon::now())) {
                // 已到开始时间,立即激活并预热
                $this->sessionService->start($session->id);
                $this->cacheService->warmSession($session->id);
            } else {
                // 未到开始时间,投递延迟 Job
                $delaySeconds = $startTime->diffInSeconds(Carbon::now());
                $this->driverFactory->get('default')->push(
                    new SeckillSessionStartJob($session->id, $session->activity_id),
                    $delaySeconds
                );
            }
        }
    }
}

团购定时任务

php
// GroupBuyActivityStatusCrontab.php
#[Crontab(name: 'group-buy-activity-status', rule: '*/10 * * * *')]
class GroupBuyActivityStatusCrontab
{
    private function processPendingActivities(): void
    {
        $activities = $this->repository->findPendingActivitiesWithinMinutes(30);
        
        foreach ($activities as $activity) {
            $startTime = Carbon::parse($activity->start_time);
            
            if ($startTime->lte(Carbon::now())) {
                // 立即激活并预热
                $this->groupBuyService->start($activity->id);
                $this->cacheService->warmStock($activity->id);
            } else {
                // 延迟 Job
                $delaySeconds = $startTime->diffInSeconds(Carbon::now());
                $this->driverFactory->get('default')->push(
                    new GroupBuyStartJob($activity->id),
                    $delaySeconds
                );
            }
        }
    }
}

🔒 编辑锁定

预热期间禁止编辑活动,防止缓存与数据库不一致:

php
// SeckillSessionEntity.php
public function isWithinCacheWarmupPeriod(): bool
{
    if (empty($this->startTime)) {
        return false;
    }

    $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', 'sold_out'])) {
        return false;
    }

    // 预热期内不可编辑
    return !$this->isWithinCacheWarmupPeriod();
}

📊 缓存读取

php
// SeckillCacheService.php
public function getSession(int $sessionId): ?SeckillSessionEntity
{
    // 优先从缓存读取
    $json = $this->cache->get($this->sessionKey($sessionId));
    
    if (is_string($json) && $json !== '') {
        $data = json_decode($json, true);
        if (is_array($data)) {
            return $this->hydrateSession($data);
        }
    }
    
    // 缓存未命中,从数据库加载并预热
    $model = $this->sessionRepository->findById($sessionId);
    if (!$model) {
        return null;
    }
    
    $this->warmSession($sessionId);
    return SeckillSessionMapper::fromModel($model);
}

⚠️ 注意事项

  1. 预热时机: 活动开始前 30 分钟自动预热
  2. 数据一致性: 预热期间禁止编辑,避免缓存不一致
  3. 缓存过期: 设置合理的 TTL,活动结束后自动过期
  4. 失败重试: 预热失败时定时任务会兜底重试
  5. 清理机制: 活动结束后及时清理缓存

📚 相关文档

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