ThinkPHP8模型事件实战:写了个自动刷新关联缓存的观察者,终于不用手动清缓存了

2026-08-26 0 685

上个月维护一个资讯系统,里面有个“文章详情”页,展示文章标题、作者信息、所属栏目,还有文章内的标签列表。每个标签页又要展示文章数。为了性能,我们把详情页和标签统计都做了Redis缓存。一开始日子过得还行,直到运营同事经常在后台改了文章分类,但前台怎么刷新都不变,最后只能上服务器执行一条 redis-cli flushall 草草了事。

后来我实在受不了了,趁着一次迭代,直接用ThinkPHP8的模型事件做了一个观察者,专门负责在文章增删改的时候自动刷新相关缓存。做完之后,这类问题彻底消失。这篇文章就把完整的思路和实现写出来。

为什么用模型事件而不是手动清理

ThinkPHP8的模型事件包括 beforeInsertafterInsertafterUpdateafterDelete 等等。它们可以让你在不入侵业务代码的情况下,在模型发生变动时触发一些额外的操作。如果你在控制器里手动调用清缓存,很容易漏,因为业务代码总是会越来越多,总有几个地方会绕过你的清理函数。

但模型事件是绑定在模型类上的,只要最终是通过这个模型类进行的数据库操作,都能触发对应事件。这就像一个钩子,把“缓存同步”这件事从业务逻辑里剥离出来,集中管理。

我用的是“观察者”的方式,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的模型事件把缓存刷新做成自动挡。

ThinkPHP8模型事件实战:写了个自动刷新关联缓存的观察者,终于不用手动清缓存了
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP8模型事件实战:写了个自动刷新关联缓存的观察者,终于不用手动清缓存了 https://www.taomawang.com/server/thinkphp/2643.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务