他山之石 使用二级缓存提高缓存命中率和内存使用效率

起因

一直都没找到二级缓存在php中应用的比较好的资料和案例,由于范凯RobbinWeb 应用的缓存设计模式Hibernate二级缓存的启示,记下这篇二级缓存在Eloquent ORM中的应用。

过程

比如博客的首页调用最新的20篇文章,相信不少同学在刚开始使用缓存的时候,会写下如下代码:

# 控制器
public function index()
{
    $articles = Article::latestArticles(20);

    return view('articles.index', ['articles' => $articles]);
}

# 模型
class Article extends Model
{
    public static function latestArticles($amount = 20)
    {
        return Cache::remember('articles:latest', 10, function () use ($amount) {
            return static::latest('id')->take($amount)->get();
        });
    }
}

当然,模型中还能预加载每篇文章的分类,作者和tag信息,看起来没有任何问题,而且非常符合人类直觉。但是,放大到全站缓存来看,还是有很大的改善空间。

首先,首页缓存的是一个包含20article对象的集合,集合的每一个单独的article对象除了在首页出现,还会在分类、作者和tag等列表页出现,还有文章详情页,而缓存的集合数据没办法在这些页面间共用,重复缓存大量相同的article对象是对内存资源的很大浪费,要是article中的text字段content没有单独拆分出去,内存浪费得就更严重了。

其次,不像详情页数据改动很少,首页作为列表页来说,更新频率很高,设置的缓存时间比较短,一般是分钟级别,缓存命中率并不高。

为了有效解决这两个问题,二级缓存就派上用场了,先说下自己对二级缓存的理解。

一级缓存可以看成是数据库里存的数据的一个镜像,只不过把数据从数据库搬到内存,一个key对应一条记录。key一般为表的标识符,比如keyarticles:1存的value就是id=1article对象。一级缓存时间可以设得比较长,甚至forever也行,对象修改删除时,只要删除对应的key就行。

二级缓存可以看成业务逻辑的缓存,首页最新20条文章 就属于业务逻辑,只缓存这20条文章的id,极大地节省了内存占用。等需要用到具体的数据再去一级缓存取,一级缓存没有才去查询数据库,由于都是主键查询,不会造成表的描述,查询效率非常高。即使二级缓存很快过期,一级缓存也不会失效。

个人觉得理解二级缓存最难的是要接受n+1查询这点,这个问题争议很大,明明各种ORM为了避免n+1使用了预加载,我们反而要抛弃它。包括我当初阅读范凯的《Web 应用的缓存设计模式》也心存疑惑,直到去了解了Hibernate二级缓存机制和自己在项目中的实践发现,还真是他说的那样。

拆分n+1条查询的方式,看起来似乎非常违反大家的直觉,但实际上这是真理,我实践经验证明:数据库服务器的瓶颈往往是磁盘IO,而不是SQL并发数量。因此 拆分n+1条查询本质上是以增加n条SQL语句为代价,简化复杂SQL,换取数据库服务器磁盘IO的降低 当然这样做以后,对于ORM来说,有额外的好处,就是可以高效的使用缓存了。

使用二级缓存来重构latestArticles方法


public static function latestArticles($amount = 20)
{
    // 二级缓存
    $ids = Cache::remember('articles:latest:ids', 10, function () use ($amount) {
        return static::latest('id')->take($amount)->pluck('id');
    });

    return $ids->map(function ($id) {
        // 一级缓存
        return static::findById($id); 
    });

}

public static function findById($id)
{
    return Cache::rememberForever("articles:{$id}", function () use ($id) {
        return static::find($id);
    });
}

除了返回Collection,还可以返回Generator

public static function latestArticles($amount = 20)
{
    // 二级缓存
    $ids = Cache::remember('articles:latest:ids', 10, function () use ($amount) {
        return static::latest('id')->take($amount)->pluck('id');
    });

    foreach ($ids as $id) {
        // 一级缓存
        yield static::findById($id); 
    }
}

更新与删除

一级缓存的更新和删除可能通过模型的updateddeleted事件来清除对应的缓存。二级缓存由于缓存时间比较短,影响不大。

关联关系

关联模型的缓存可能通过accessor来设置一个虚拟的属性来设置,比如在Article模型与Content模型是一对一的关系。
Article中:


// 一对一关联
public function content()
{
    return $this->hasOne(Content::class);
}

// contents表字段: article_id, body
public function getContentAttribute()
{
    return Cache::rememberForever("contents:{$this->id}", function () {
        return $this->content->body;
    });
}
本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由 Summer 于 6年前 加精
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 6
Summer

我测试了一下,刷新是还有本地缓存的?你还能重现吗?什么浏览器?

6年前 评论
Summer

我的测试流程:

  1. 书写内容 + 标题;
  2. 刷新页面 √
  3. Mac 上鼠标手势倒退,然后在前进返回 √
6年前 评论

@Summer 世界之窗7.0 Chrome太占内存 平时只开发和翻墙时候用 以防万一 以后还是先用有道云(支持markdown)写完再粘贴过来吧 :relaxed:

6年前 评论
Summer

其他浏览器不保证兼容性哦,LC 百分之 82 的用户使用的是 Chrome 浏览器,供你参考哈。

6年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!