用ThinkPHP做项目,最大的痛点是啥?我猜很多人会说是缓存。没错,Redis快是快,可一旦数据更新了,缓存里还是老数据,用户看到的页面和数据库对不上,这种问题特别招人烦。以前我在代码里到处手写缓存更新逻辑,一会儿删一个key,一会儿又重建,不但累,还容易漏。
后来我试了下ThinkPHP8的模型事件,发现原来可以让模型自己在数据发生变化时去同步缓存。不需要在控制器里每次写一堆判断,模型内部就帮你把这事干了。今天我就把这个思路整理成一个完整的案例,从建表到写模型,再到加上防缓存击穿的锁,一步步说清楚。
一个缓存维护问题的真实重现
假设我们有个文章系统,文章存在tp_article表里。用户访问文章列表页时,我们不想每次都用SQL去count和order by,毕竟那样太伤数据库了。于是写了个缓存:键值article_list_top10,缓存10条热门文章,过期时间600秒。然后在ctrl里这样读取:
public function hotList()
{
$data = Cache::get('article_list_top10');
if (!$data) {
$data = Db::name('article')
->where('status', 1)
->order('views', 'desc')
->limit(10)
->select()
->toArray();
Cache::set('article_list_top10', $data, 600);
}
return json($data);
}
这种做法在业务变化少的时候没问题。可一旦有了“编辑文章浏览量”、“后台把某篇文章下架”、“用户发布新文章”,这个缓存就成了累赘。如果你忘了在更新文章的操作后删除这个缓存,用户就会一直看到旧列表。
所以,我们要解决的问题就是:如何保证数据库数据变化时,缓存自动失效或自动更新,并且代码不散落到各个业务方法里。
ThinkPHP8模型事件可以做什么
简单说,模型事件是ThinkPHP提供的一种钩子,允许你在模型进行insert、update、delete等操作之后,自动执行一段逻辑。TP8中比较常用的事件有afterInsert、afterUpdate、afterDelete等。这和我们平时在控制器里手动写代码完全不同——你只管改数据,缓存维护的事交给模型去操心。
可以把它想象成数据库的触发器。但比触发器好在,它是PHP代码,语法灵活,还能调用Redis、日志、队列等等。
第一步:准备数据表和模型
假设我们的文章表长这样(为了演示简洁,省略了一些非核心字段):
CREATE TABLE `tp_article` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL DEFAULT '', `content` text NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1显示 0隐藏', `views` int(11) NOT NULL DEFAULT '0' COMMENT '浏览量', `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
然后在TP8里创建一个Article.php模型文件,注意要对应的表名是tp_article,模型基类是thinkModel。
namespace appmodel;
use thinkModel;
class Article extends Model
{
protected $name = 'article';
protected $autoWriteTimestamp = true;
// 自动缓存都靠这里的事件
protected static function onAfterInsert(Article $model)
{
// 数据插入后清理列表缓存
Cache::delete('article_list_top10');
Cache::delete('article_list_new');
}
protected static function onAfterUpdate(Article $model)
{
// 更新后,除了列表缓存,还要清理详情缓存
Cache::delete('article_list_top10');
Cache::delete('article_list_new');
Cache::delete('article_detail_' . $model->id);
}
protected static function onAfterDelete(Article $model)
{
// 删除后,清理所有与这篇文章相关的缓存
Cache::delete('article_list_top10');
Cache::delete('article_list_new');
Cache::delete('article_detail_' . $model->id);
}
}
你可能会问,什么时候才定义这些事件?其实只要在模型类中加入上面几个静态方法,TP8就会在对应的数据库操作之后自动调用。不需要额外注册,也不需要在控制器里include。
第二步:缓存读取规则改造
事件写好了,缓存更新策略就有了。我们回到前面的列表接口。现在可以稍微改一下读取逻辑,加上“防止缓存击穿”的处理。这里我用的是Redis锁的方式。
所谓缓存击穿,指的是一个热点缓存过期了,然后突然有大量请求同时去读数据库,导致数据库压力瞬间上升。这个场景在热点数据里非常常见。解决思路是:在缓存失效时,只让一个请求去重建缓存,其他请求先等待一会儿再重新尝试。
我们看下改造后的代码:
public function hotList()
{
// 先尝试读取缓存
$data = Cache::get('article_list_top10');
if ($data !== false) {
return json($data);
}
// 缓存没命中,尝试获取锁
$lockKey = 'lock:article_list_top10';
$lockToken = md5(uniqid(rand(), true));
if (Cache::store('redis')->set($lockKey, $lockToken, ['expire' => 10, 'lock' => true])) {
// 拿到锁后,再查一次缓存(双重检查,防止别人已经重建了)
$data = Cache::get('article_list_top10');
if ($data !== false) {
// 释放锁
Cache::store('redis')->delete($lockKey);
return json($data);
}
// 真正去数据库查询
$data = Db::name('article')
->where('status', 1)
->order('views', 'desc')
->limit(10)
->select()
->toArray();
// 写入缓存,设置过期时间
Cache::set('article_list_top10', $data, 600);
// 释放锁
Cache::store('redis')->delete($lockKey);
return json($data);
} else {
// 没拿到锁,说明其他线程正在重建缓存,这里稍微小睡一下然后递归重试
usleep(200000); // 200ms
return $this->hotList();
}
}
这里要注意,TP8内置的Cache::set并不直接支持“锁”参数。上面代码中我用了Cache::store('redis'),并给了set一个带lock的expire参数,其实这是个示例写法。实际项目中,你可以用Redis::set('lock', '1', 'EX', 10, 'NX')这种原子性操作来实现锁。但重点不是锁的细节,而是配合模型事件后,这个锁只需要在这里加一次,数据更新时缓存会自动清掉,锁也只在重建时才有意义。
如果你的应用并发量没那么恐怖,甚至可以直接用文件锁,比如flock。但说实话,用Redis锁更通用。
第三步:给详情页也做类似处理
缓存自动维护不能只照顾列表页,详情页一般缓存压力更大。一个文章被访问时,如果没有缓存,每一次请求都是一个select。假设有1000人同时看一篇热门文章,数据库就得执行1000次完全相同的查询,太亏了。
所以我们在Article模型里增加一个方法用于读取详情,并且也利用上面的事件来维护缓存:
public function getDetailWithCache($id)
{
$cacheKey = 'article_detail_' . $id;
$data = Cache::get($cacheKey);
if ($data !== false) {
return $data;
}
// 防击穿锁(上面讲过的思路)
$lockKey = 'lock:' . $cacheKey;
$lockToken = md5(uniqid(rand(), true));
if (Cache::store('redis')->set($lockKey, $lockToken, ['expire' => 10, 'lock' => true])) {
$data = Cache::get($cacheKey);
if ($data !== false) {
Cache::store('redis')->delete($lockKey);
return $data;
}
$data = self::find($id);
if ($data) {
$data = $data->toArray();
Cache::set($cacheKey, $data, 600);
}
Cache::store('redis')->delete($lockKey);
return $data;
} else {
usleep(200000);
return $this->getDetailWithCache($id);
}
}
这样,详情页的缓存也和模型事件挂钩了。比如在后台点“编辑文章”,只要调用Article::update($data),触发onAfterUpdate,这个文章的详情缓存就会自动删除。下次有人访问,就会重新从数据库读,保证最新。
实际效果:我删了一堆手动清缓存代码
在我自己的一个项目里,原来有大概30多处地方手动调用Cache::delete。用了模型事件之后,这些代码基本没了。比如用户评论后更新文章评论数,我只需要在评论模型里更新文章的字段,文章模型的onAfterUpdate就会自动把相关缓存清掉。又比如后台批量修改文章状态,根本不用去管缓存,模型update完事。
而且,因为列表缓存和详情缓存是通过模型事件自动清理的,不同开发成员也不用再约定“以后改完数据要删哪个缓存”。这从源头上规避了缓存不一致的问题。
模型事件更灵活的玩法:自动更新关联缓存
除了简单的清理,我们还可以利用模型事件做更多事情。比如一篇文章被删除时,我们需要同时删除它关联的评论缓存。如果在onAfterDelete里写:
protected static function onAfterDelete(Article $model)
{
// 删除文章下的评论缓存(假设评论缓存的key中有文章id)
$commentKeys = Cache::get('article_comment_keys_' . $model->id);
if (!empty($commentKeys)) {
foreach ($commentKeys as $key) {
Cache::delete($key);
}
}
// 再删掉列表缓存
Cache::delete('article_list_top10');
}
这样就不需要每次在删除文章的地方手动去处理评论缓存了。挺好。
需要注意的几个坑
第一,模型事件对save()方法同样有效。但如果你用的是Db::name()直接操作数据库,而不是模型,那不会触发模型事件。所以,尽量统一使用模型来操作数据。
第二,事件里执行了Redis操作,可能会稍微增加数据库写操作的耗时。比如一次插入文章,本来只要20ms,现在因为要删几个缓存,变成30ms。这点开销在绝大多数业务里可以接受。如果你特别极端,写入量极大,你可以用延迟队列或消息去触发清理,而不是同步执行。但这就是另一个话题了。
第三,关于缓存穿透。上面我用了锁来解决击穿,但穿透是不同的概念——如果一个请求查询的id根本不存在,那数据库返回空,然后缓存null,也会出现每次都查库的问题。解决办法是在缓存里存一个空字符串或特殊标记。不过这不影响模型事件的用法,只在读取逻辑里判断即可。我在上面的find时如果没查到数据,没有缓存空值,你可以自己加上。
总结
模型事件不是ThinkPHP8才有的,但在TP8里它变得更加稳定,和模型生命周期结合得更紧密。利用好它,你可以把缓存同步这种横切关注点,优雅地收拢到模型内部。我自己的感受是:一个“自动缓存器”比在业务代码里手动清缓存要省心得多。
今天的案例虽然偏向文章模块,但思路完全通用。你完全可以用到用户资料、商品详情、分类菜单等各种场景。在动手写复杂缓存逻辑之前,先停下来想一想:这个模型的增删改到底影响了哪些缓存?把这些影响定义在模型事件里,你的代码会清爽很多。

