上个月维护一个资讯系统,里面有个“文章详情”页,展示文章标题、作者信息、所属栏目,还有文章内的标签列表。每个标签页又要展示文章数。为了性能,我们把详情页和标签统计都做了Redis缓存。一开始日子过得还行,直到运营同事经常在后台改了文章分类,但前台怎么刷新都不变,最后只能上服务器执行一条 redis-cli flushall 草草了事。
后来我实在受不了了,趁着一次迭代,直接用ThinkPHP8的模型事件做了一个观察者,专门负责在文章增删改的时候自动刷新相关缓存。做完之后,这类问题彻底消失。这篇文章就把完整的思路和实现写出来。
为什么用模型事件而不是手动清理
ThinkPHP8的模型事件包括 beforeInsert、afterInsert、afterUpdate、afterDelete 等等。它们可以让你在不入侵业务代码的情况下,在模型发生变动时触发一些额外的操作。如果你在控制器里手动调用清缓存,很容易漏,因为业务代码总是会越来越多,总有几个地方会绕过你的清理函数。
但模型事件是绑定在模型类上的,只要最终是通过这个模型类进行的数据库操作,都能触发对应事件。这就像一个钩子,把“缓存同步”这件事从业务逻辑里剥离出来,集中管理。
我用的是“观察者”的方式,TP8的文档也推荐这种用法:把事件监听定义单独放到一个类里,用 observe 方法注册。这样模型文件保持干净,可维护性也高。
我的模型结构
假设有两个表:文章表 article 和标签表 tag,以及中间表 article_tag。文章详情页需要显示文章内容、关联标签列表。标签页需要显示每个标签下的文章数量。
模型文件如下:
// appmodelArticle.php
<?php
declare(strict_types=1);
namespace appmodel;
use thinkModel;
class Article extends Model
{
protected $name = 'article';
// 关联标签(多对多)
public function tags()
{
return $this->belongsToMany(Tag::class, 'article_tag');
}
}
// appmodelTag.php
<?php
declare(strict_types=1);
namespace appmodel;
use thinkModel;
class Tag extends Model
{
protected $name = 'tag';
// 关联文章
public function articles()
{
return $this->belongsToMany(Article::class, 'article_tag');
}
}
缓存设计是这样的:
- 文章详情缓存 key 为
article_detail_100,缓存完整的文章信息和标签列表。 - 标签统计缓存 key 为
tag_count_5,缓存对应标签的文章数量。 - 首页列表缓存 key 为
article_list_page_1,缓存文章分页数据。
一旦运营修改了文章的标题、所属分类,或者新增了文章,上面这些缓存全部要作废。如果漏掉任何一处,就会又是之前那种“数据不对”的反馈。
观察者类怎么写
我创建了一个 ArticleObserver,它负责处理文章模型发生增删改之后需要做的缓存清理工作。下面是这个类的完整代码:
<?php
declare(strict_types=1);
namespace appobserver;
use appmodelArticle;
use thinkfacadeCache;
class ArticleObserver
{
/**
* 文章新增后触发
* @param Article $article
*/
public function afterInsert(Article $article)
{
$this->clearArticleRelated($article);
}
/**
* 文章更新后触发
* @param Article $article
*/
public function afterUpdate(Article $article)
{
$this->clearArticleRelated($article);
}
/**
* 文章删除后触发
* @param Article $article
*/
public function afterDelete(Article $article)
{
$this->clearArticleRelated($article);
}
/**
* 核心清理逻辑
* @param Article $article
*/
private function clearArticleRelated(Article $article)
{
$articleId = $article->id;
// 1. 清理文章详情缓存
Cache::delete('article_detail_' . $articleId);
// 2. 清理列表缓存(简单粗暴,删除所有以 article_list_ 开头的缓存)
$this->clearByPrefix('article_list_');
// 3. 获取关联标签的ID集合
$tagIds = $article->tags->column('id');
// 4. 清理每一个标签的统计缓存
foreach ($tagIds as $tagId) {
Cache::delete('tag_count_' . $tagId);
}
// 5. 额外清理一个全站标签云缓存
Cache::delete('tag_cloud');
}
/**
* 按前缀删除缓存
* @param string $prefix
*/
private function clearByPrefix(string $prefix)
{
// 这里是简化的实现
// 生产环境如果用 Redis,可以配合 scan 命令做模糊删除
$keys = Cache::has('KEYS') ? [] : [];
// 实际上更通用的办法是手动记录所有列表缓存的key,或者使用 Cache::clear() 但那样太粗暴
// 下面是我实际采用的办法:维护一个缓存key清单
$keys = Cache::get('cache_key_list', []);
foreach ($keys as $key) {
if (strpos($key, $prefix) === 0) {
Cache::delete($key);
}
}
// 注意:这里需要在写入列表缓存时,把 key 注册到这个数组中
}
}
上面注释里提到了一个“缓存key清单”的办法。因为TP8的Cache组件没有直接提供按前缀扫描删除的通用功能(如果用Redis扩展会简单些),所以我在写入列表缓存的逻辑处,顺手把这个key放进了一个全局数组里,用于快速查找。这是我的土办法,但非常管用。
注册观察者
观察者写好后,需要在某个地方把它注册到模型上。Tp8里我们可以在服务类或者公共文件中注册,我这里写在 appcommon.php 里,确保任何入口都会加载。
// appcommon.php
use appmodelArticle;
use appobserverArticleObserver;
// 注册观察者
Article::observe(ArticleObserver::class);
这样只要你在控制器里使用Article::create()或Article::update(),或者$article->delete(),对应的观察者方法就会自动执行。不需要在控制器里额外引入任何类。
写入缓存时如何配合
因为观察者里清理列表缓存是靠“缓存key清单”,所以写入缓存的地方必须配合维护这个清单。比如在文章列表查询时,我会这样写:
$page = request()->get('page', 1);
$cacheKey = 'article_list_' . $page;
$this->rememberCacheKey($cacheKey);
$list = Cache::remember($cacheKey, function () {
return Article::with('tags')->paginate(15);
}, 3600);
rememberCacheKey 是我公共函数里一个简单的方法,用来把key存到那个清单中:
if (!function_exists('rememberCacheKey')) {
function rememberCacheKey($key)
{
$keys = Cache::get('cache_key_list', []);
if (!in_array($key, $keys)) {
$keys[] = $key;
Cache::set('cache_key_list', $keys, 30 * 24 * 3600);
}
}
}
这样观察者在清缓存时,可以遍历这个清单找到所有以 article_list_ 开头的key,执行删除。虽然这个清单会越积越多,但一个月顶多几千个key,用Redis存完全没问题。而且每隔30天清理一次过期的key。
一个容易被忽略的地方:附加字段更新
文章和标签是多对多关联,中间表 article_tag 发生变化时(比如运营给文章新增一个标签),实际上触发的是中间表模型的写入,而不是文章模型的更新。如果只监听Article的afterUpdate,可能漏掉这个场景。
后来我单独绑定了一个中间模型的观察者:
// appmodelArticleTagPivot.php
namespace appmodel;
use thinkModel;
class ArticleTagPivot extends Model
{
protected $name = 'article_tag';
protected $autoWriteTimestamp = false;
}
然后写一个 PivotObserver:
// appobserverPivotObserver.php
namespace appobserver;
use appmodelArticle;
use thinkfacadeCache;
class PivotObserver
{
public function afterInsert($pivot)
{
$this->clear($pivot);
}
public function afterDelete($pivot)
{
$this->clear($pivot);
}
protected function clear($pivot)
{
$articleId = $pivot->article_id;
$tagId = $pivot->tag_id;
// 删除文章详情缓存
Cache::delete('article_detail_' . $articleId);
// 删除标签统计缓存
Cache::delete('tag_count_' . $tagId);
// 删除列表缓存
$this->clearListCache();
}
private function clearListCache()
{
$keys = Cache::get('cache_key_list', []);
foreach ($keys as $key) {
if (strpos($key, 'article_list_') === 0) {
Cache::delete($key);
}
}
}
}
注册方式一样,在_common.php 里加一行:
ArticleTagPivot::observe(PivotObserver::class);
这样做以后,无论运营是在文章页改标签,还是直接操作中间表,都能把相关缓存打掉。我就再也不用半夜收到“缓存没更新”的消息了。
上线上跑了一周,效果如何
用了这套观察者之后,最直观的感受就是“无感”。运营在后台随便改,前台最多一分钟内更新。为什么不是即时?因为业务代码里我并没有像以前那样修改后立刻删除缓存,而是依赖观察者去删,但有些缓存可能是下一次请求时自动重建的,这个重建的过程如果恰好碰上慢SQL,就会有一瞬间的空白。但实际测试下来,基本上看不到问题。
还有一个意外收获:因为很多缓存删除逻辑被集中了,代码中再也不会出现那种“一边写缓存,一边又起个定时任务去刷新”的尴尬做法。
一些小建议
如果你也想在项目里这样用,有几个坑提醒一下:
- 不要在模型事件里做太多耗时的操作,比如远程调用。如果一定要做,建议投递到消息队列,否则会影响正常的数据库增删改请求的响应时间。
- 观察者里用到关联关系,比如
$article->tags,注意此时模型可能还没有关联数据,或者关联已经被修改。建议只依赖主键和大条件判断,不要读取太复杂的动态关联。 - 如果用了模型事件,别忘了调试阶段把SQL日志打开,否则你会以为事件没有生效。
这组观察者我已经放在项目里一个半多月了,没有造成任何事故。当时花了一晚上写,但换来的是运维同事再也不用半夜flushall。如果你也受够了缓存不一致,强烈推荐用ThinkPHP8的模型事件把缓存刷新做成自动挡。

