在高并发 Web 应用中,缓存是提升性能的利器,但引入缓存后,数据一致性问题也随之而来。ThinkPHP 作为国内流行的 PHP 框架,提供了完善的缓存抽象层,然而面试中考察的往往不是 Cache::get() 和 Cache::set() 的用法,而是你对缓存预热、更新时机以及一致性保障的整体设计能力。本文将从面试实战角度出发,梳理 ThinkPHP 场景下的缓存核心策略。
一、缓存预热:让系统启动即巅峰
缓存预热指的是在系统上线或重启后,提前将热点数据加载到缓存中,避免用户请求首次穿透到数据库。
1.1 为什么需要预热
- 避免缓存击穿:热点 key 过期瞬间,大量请求直接打到数据库。
- 降低冷启动延迟:新扩容的节点如果缓存为空,响应时间会明显升高。
- 削峰填谷:在流量高峰到来前完成加载,防止数据库瞬时压力过大。
1.2 ThinkPHP 中的预热实现方式
方式一:命令行脚本 + 定时任务
// app/command/CacheWarmup.php
namespace app\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Cache;
class CacheWarmup extends Command
{
protected function configure()
{
$this->setName('cache:warmup')
->setDescription('预热热点数据缓存');
}
protected function execute(Input $input, Output $output)
{
// 预热商品分类
$categories = \app\model\Category::where('status', 1)->select();
Cache::tag('category')->set('category_list', $categories, 3600);
// 预热首页推荐商品
$hotGoods = \app\model\Goods::where('is_hot', 1)
->order('sales', 'desc')
->limit(100)
->select();
Cache::tag('goods')->set('hot_goods', $hotGoods, 1800);
$output->writeln('缓存预热完成');
}
}
注册命令后,可通过 php think cache:warmup 手动执行,也可结合 crontab 在低峰期定时运行。
方式二:服务启动时触发
在 app\event\AppInit 或中间件中判断缓存是否为空,若为空则异步触发预热逻辑。但需注意:预热逻辑不应阻塞正常请求,建议放入队列异步执行。
1.3 面试加分点
- 预热数据的选择:根据历史访问日志(如 Redis 的
hotkeys)确定热点 key。 - 预热频率:全量预热适合数据量小的场景;增量预热适合大数据量,只加载最近活跃数据。
- 预热与过期的配合:预热时设置合理的过期时间,避免所有 key 同时失效。
二、缓存更新:时机与策略的选择
缓存更新是面试中最容易拉开差距的部分。常见的三种模式各有优劣,ThinkPHP 中都能落地。
2.1 Cache Aside(旁路缓存)
读流程:先读缓存,未命中则读数据库并回写缓存。
写流程:先更新数据库,再删除缓存。
public function updateGoods($id, $data)
{
Db::startTrans();
try {
Goods::where('id', $id)->update($data);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
// 事务提交后删除缓存
Cache::tag('goods')->delete('goods_' . $id);
}
为什么是删除而不是更新?
- 更新缓存可能写入脏数据(并发写导致旧值覆盖新值)。
- 删除操作更简单,且懒加载模式下缓存利用率更高。
先更新数据库还是先删除缓存?
- 先删缓存再更新数据库:并发读可能将旧数据回写缓存,导致长期不一致。
- 先更新数据库再删缓存:极端情况下(读请求在删缓存前查库并回写)仍可能不一致,但概率极低,推荐此方案。
2.2 Read/Write Through(读写穿透)
由缓存层代理数据库读写,应用只与缓存交互。ThinkPHP 本身不直接提供,但可通过自定义缓存驱动或 Repository 模式封装。面试中可提及,但需说明其实现复杂度较高,中小项目不推荐。
2.3 Write Behind(异步写回)
写操作只写缓存,由后台任务批量同步到数据库。性能最高,但一致性最弱,宕机可能丢数据。适用于日志、计数等场景。
2.4 延迟双删
针对“先更新数据库再删缓存”仍可能不一致的问题,可在删缓存后延迟一段时间(如 500ms)再次删除。
Cache::delete('goods_' . $id);
// 延迟双删,可放入队列延迟执行
Queue::later(1, function () use ($id) {
Cache::delete('goods_' . $id);
});
面试中能说出延迟双删及其适用场景,通常能获得不错评价。
三、一致性策略:从最终一致到强一致
缓存与数据库的一致性没有银弹,需要根据业务容忍度选择。
3.1 最终一致性方案
- 消息队列 + 重试:数据库变更后发送消息,消费者删除缓存,失败则重试。
- Binlog 订阅:通过 Canal 监听 MySQL binlog,解析后删除对应缓存。这是大厂常用方案,实时性高且与业务解耦。
- 版本号/时间戳:缓存中存储数据版本号,读取时校验版本,不一致则重新加载。
3.2 强一致性方案
- 分布式锁:更新时加锁,读请求也需获取锁,保证串行化。性能差,仅适用于极少数场景。
- 读写锁:读读不互斥,读写互斥,比普通锁略好,但 ThinkPHP 中需自行基于 Redis 实现。
3.3 ThinkPHP 中的实践建议
- 合理设置过期时间:即使出现不一致,也能通过过期自动修复。
- 使用缓存标签:ThinkPHP 的
Cache::tag()可批量管理相关缓存,更新时按标签清除,减少遗漏。 - 避免大事务:事务提交后再操作缓存,防止事务回滚导致缓存误删。
- 监控与告警:对缓存命中率、数据库 QPS 进行监控,及时发现不一致引发的异常。
四、面试高频问题速答
Q:缓存穿透、击穿、雪崩的区别及解决方案?
- 穿透:查不存在的数据。布隆过滤器或缓存空值。
- 击穿:热点 key 过期。互斥锁或永不过期+逻辑过期。
- 雪崩:大量 key 同时过期。过期时间加随机值,或集群部署。
Q:ThinkPHP 缓存驱动如何选择?
- 小项目:File 驱动,简单但性能一般。
- 中大型项目:Redis 驱动,支持标签、持久化、分布式。
- 注意:File 驱动不支持标签功能,
Cache::tag()会失效。
Q:如何保证缓存与数据库双写一致性?
- 优先采用 Cache Aside + 先更新数据库再删缓存 + 延迟双删。
- 对一致性要求极高时,考虑 Binlog 订阅或分布式锁。
五、总结
缓存预热解决的是“冷启动”问题,缓存更新解决的是“写后一致性”问题,而一致性策略则是贯穿始终的设计哲学。在 ThinkPHP 面试中,面试官更关注你是否理解每种方案的适用场景与取舍,而非死记硬背代码。建议结合实际项目经验,说明你为何选择某种策略,以及如何处理异常情况,这样才能在面试中脱颖而出。
未经允许不得转载:任鹏个人博客 » ThinkPHP 面试精讲:缓存预热、更新与一致性策略

