PHP项目缓存预热与更新

wen PHP项目 3

本文目录导读:

PHP项目缓存预热与更新

  1. 目录导读
  2. 为什么你的缓存总是在关键时刻“掉链子”?
  3. 缓存预热:把“被动挨打”变成“主动出击”
  4. 缓存更新策略:别再无脑删除键了
  5. 实战代码:基于Redis的完整预热与失效方案
  6. 高频问答:解决你最后的三个疑问

PHP项目缓存预热与更新:从“缓存雪崩”到“秒级生效”的实战指南

目录导读

  1. 为什么你的缓存总是在关键时刻“掉链子”?
  2. 缓存预热:把“被动挨打”变成“主动出击”
  3. 缓存更新策略:别再无脑删除键了
  4. 实战代码:基于Redis的预热与失效方案
  5. 高频问答:解决你最后的三个疑问

为什么你的缓存总是在关键时刻“掉链子”?

常见场景:凌晨0点大促开始,流量瞬间涌入,缓存中热点数据全部过期,PHP进程集体回源数据库,MySQL连接数瞬间打满,页面响应从50ms飙升到5s,甚至直接返回502,这就是缓存雪崩——大量key同时过期导致的击穿数据库。

另一个常见问题:缓存穿透,恶意请求一个不存在的商品ID,每次都会绕过缓存直接打DB,加上缓存击穿,单个热点key过期瞬间,百倍请求同时击穿一个点。

很多PHP开发者会这样写:

$data = Redis::get($key);
if (!$data) {
    $data = DB::query(...);
    Redis::setex($key, 3600, $data);
}

这段代码存在三个致命问题:

  • 没有分布式锁,击穿时1000个请求同时查库
  • 没有过期时间抖动,容易雪崩
  • 没有预热的主动性

缓存预热:把“被动挨打”变成“主动出击”

什么是缓存预热? 在流量高峰到来前,提前将热点数据写入缓存,这就像演唱会开场前,先把所有饮品摆上货架,而不是等观众口渴了再临时补货。

1 预热的核心方法论

  1. 数据筛选:基于历史访问日志(Nginx、ELK),分析出TOP 10%的高频访问key。
  2. 定时任务预热:使用crontab或Swoole定时器,在流量低谷期(如凌晨3点)跑预热脚本。
  3. 发布前预热:在代码上线部署的post-deploy钩子中,触发预热命令。
  4. 增量预热:配合消息队列,当数据库新插入数据时,异步写入缓存。

2 推荐架构(代码示例)

// 预热脚本:preheat.php
public function preheatTopProducts(): void
{
    // 1. 从MySQL统计昨日TOP1000商品ID
    $productIds = DB::table('product_access_log')
        ->orderBy('count', 'desc')
        ->limit(1000)
        ->pluck('product_id');
    // 2. 分批预热,每批100个
    foreach (array_chunk($productIds, 100) as $chunk) {
        $pipeline = Redis::pipeline();
        foreach ($chunk as $id) {
            $data = $this->buildProductData($id); // 回源DB组装
            $pipeline->setex("product:{$id}", 3600, json_encode($data));
        }
        $pipeline->exec();
        usleep(50000); // 防抖动,避免内存峰值
    }
    // 3. 记录日志,便于监控
    Log::info('preheat finished');
}

3 预热注意事项

  • 过期时间必须加随机抖动$ttl = 3600 + mt_rand(0, 300); 防止同一时刻全部过期。
  • 冷热分离:把缓存分成hot(1小时过期)和warm(24小时过期)两个层次,预热时优先填充hot层。

缓存更新策略:别再无脑删除键了

传统的更新方式是:

// 更新DB后删除缓存
DB::update(...);
Redis::del($key);

这种策略的问题:如果删除缓存后、下一次请求之前,DB更新还没提交呢? 会导致临时的不一致,更严重的是,并发下会导致缓存永远无法更新(删了又写回旧数据)。

1 业界验证的三种方案

方案A:Cache Aside + 延迟双删(最常用)

public function updateProduct($id, $data)
{
    // 1. 先更新DB
    DB::table('product')->where('id', $id)->update($data);
    // 2. 删除缓存
    Redis::del("product:{$id}");
    // 3. 延迟500ms再次删除(解决并发写回旧值的问题)
    swoole_timer_after(500, function () use ($id) {
        Redis::del("product:{$id}");
    });
}

优点:简单实用,兼容所有PHP框架,缺点:极端情况下仍有极小时间窗的不一致。

方案B:版本号标记(强一致性)

// 更新时递增版本号
$version = Redis::incr("product:version:{$id}");
Redis::setex("product:data:{$id}:{$version}", 3600, newData);
// 查询时先读当前版本
$v = Redis::get("product:version:{$id}");
$data = Redis::get("product:data:{$id}:{$v}");

这种方式查询时需要两次Redis,且旧数据会堆积,适合写少读极多的场景。

方案C:订阅Binlog异步更新(终极方案) 使用Canal订阅MySQL binlog,变更后推送到Redis,PHP不做更新逻辑,只做消费者,适合高并发、强一致要求的系统(但实现成本高)。


实战代码:基于Redis的完整预热与失效方案

第一步:封装统一的缓存操作类

// app/Support/CacheWarmer.php
class CacheWarmer
{
    public static function populate(string $key, callable $dbCallback, int $ttl = 3600): mixed
    {
        // 加锁防击穿
        $lockKey = "lock:{$key}";
        $lock = Redis::set($lockKey, 1, 'EX', 10, 'NX');
        try {
            // 再查一次缓存(双重检测)
            $cached = Redis::get($key);
            if ($cached !== false) {
                return json_decode($cached, true);
            }
            if ($lock) {
                // 只有拿到锁的请求才回源
                $data = $dbCallback();
                Redis::setex($key, $ttl + mt_rand(0, 300), json_encode($data));
                Redis::del($lockKey);
                return $data;
            }
            // 没拿到锁的请求短暂等待后重试
            usleep(50000);
            return self::populate($key, $dbCallback, $ttl);
        } catch (\Exception $e) {
            Redis::del($lockKey);
            throw $e;
        }
    }
}

第二步:定时任务定时预热

// crontab 每10分钟跑一次
// */10 * * * * php artisan cache:preheat
public function handle(): void
{
    $hotKeys = $this->getHotKeysFromClickHouse(); // 分析最近10分钟热点
    foreach ($hotKeys as $key) {
        // 只预热尚未过期的(减少无效DB查询)
        if (!Redis::exists($key)) {
            CacheWarmer::populate($key, fn() => $this->dbQuery($key));
        }
    }
}

第三步:发布时强制全量预热

// deploy process
git pull && composer install
php artisan cache:clear          // 清旧缓存
php artisan cache:preheat --all  // 全量预热

高频问答:解决你最后的三个疑问

Q1:缓存预热会不会导致数据库压力更大? 不会,预热是在流量低谷或发布窗口执行,且使用pipeline批量写入,不会对DB造成瞬间压力,反而避免了高峰期的雪崩。

Q2:锁的粒度怎么选? 针对单个key加锁,粒度太小浪费内存;粒度太大(整个表一个锁)会阻塞其他key的写入,实践中建议key维度锁,锁失效时间设为2-5秒。

Q3:如果预热的数据已经过期了,但数据库已删除,怎么防止穿透? 两个措施:1. 针对空数据也缓存,设置short_ttl=60s(防止穿透),2. 使用布隆过滤器,在写入前先判断key是否存在,布隆过滤器用PHP的BF模块或RedisBloom扩展即可。

Q4:更新缓存时,是用“先更新DB再删缓存”还是“先删缓存再更新DB”? 强烈建议先更新DB再删缓存(Cache Aside模式),因为先删缓存再更新DB,期间其他请求会读到旧数据,而先更新DB再删缓存,即使删除失败,也只是暂时读到旧数据(下次删除会修正),不会出现数据永久不一致。


最后总结: 真正的缓存治理不是写完代码就结束,你需要一套包含预热(主动填充)、失效(抖动过期)、防击穿(分布式锁)、更新(延迟双删)的整体方案,从今天开始,把你的Redis::get/set换成上述封装,并加上一个定时预热脚本,你会发现线上告警大幅减少,如果你还是担心极端场景,配合上版本的灰度发布和监控大盘,基本就能把缓存相关问题降低80%以上。

抱歉,评论功能暂时关闭!