本文目录导读:

PHP项目缓存预热与更新:从“缓存雪崩”到“秒级生效”的实战指南
目录导读
- 为什么你的缓存总是在关键时刻“掉链子”?
- 缓存预热:把“被动挨打”变成“主动出击”
- 缓存更新策略:别再无脑删除键了
- 实战代码:基于Redis的预热与失效方案
- 高频问答:解决你最后的三个疑问
为什么你的缓存总是在关键时刻“掉链子”?
常见场景:凌晨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 预热的核心方法论
- 数据筛选:基于历史访问日志(Nginx、ELK),分析出TOP 10%的高频访问key。
- 定时任务预热:使用crontab或Swoole定时器,在流量低谷期(如凌晨3点)跑预热脚本。
- 发布前预热:在代码上线部署的
post-deploy钩子中,触发预热命令。 - 增量预热:配合消息队列,当数据库新插入数据时,异步写入缓存。
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%以上。