Laravel Model Cache

分享一个实际工作中写来用的 Laravel 模型缓存机制。

为什么需要模型缓存?

Laravel 本身(5.5 以及之前)没有提供基于模型的缓存实现,虽然有提供通用缓存机制,但是有些场景我们可能更想要针对模型的一个查询在无需多余代码的情况下,能自动实现缓存,并在数据更新时,也能准确使缓存失效就好了。

否则数据操作逻辑就会出现一大片针对缓存的操作代码,看上去重复又累赘。

封装通用缓存调用

Laravel 的 Cache 实现基本已经够用了,也支持各种驱动,但是我们项目中实际作为缓存的其实只是 Memcached。

此外,Laravel Memcached 缓存的配置虽然也能作一些基于权重的负载均衡,但是我们实际项目中并不仅仅需要基于缓存服务器的权重大小来作负载,我们还想要的是根据缓存内容来指定缓存服务器组,并在组里面实现二次负载均衡。

于是,为实现上述负载需求,就有必要重新封装一个通用缓存调用。

基于 Cache Key 的双层 Memcached 负载缓存

首先,我们对 Memcached 集群的配置规定如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
return [
// Memcached 持久化连接 ID
'persistent_id' => env(
'SERVICE_CACHE_MEMCACHED_PERSISTENT_CONN_ID',
'memcached_persistent_prefix'
),

// 是否兼容 libketama
'libketama_compatible' => env(
'SERVICE_CACHE_MEMCACHED_LIBKETAMA_COMPATIBLE',
false
),

// 缓存集群组
'groups' => conf_list('cache_memcahced_servers'), // conf_list 的结构见注释
// 'groups' => [
// 组名(这里 default 是默认组名)
// // Group default
// 'default' => [

// 每个组里的一个子数组代表一台服务器实际配置
// [
// 'host' => '127.0.0.1', // 主机地址
// 'port' => 11211, // 主机端口
// 'weight' => 1, // 主机权重
// 'sasl' => [ // SASL 配置(需要正确按照 memcached 扩展)
// 'is_auth' => false, // 启用 SASL
// 'username' => '', // Memcached 用户名
// 'password' => '', // Memcached 用户密码
// ],
// ],

// // ...
// ],

// 下一个组名
// 'users' => [
// [
// 'host' => '',
// 'port' => 11211,
// 'weight' => 1,
// ],

// // ...
// ],

// 以此类推 ...
// ],
];
```

然后,实现一个基于 cache key 的负载策略,简而言之要求输入一个字符串,从配置的缓存集群中找到属于该字符串的一台服务器。

编码之前我们针对字符串规定好一个规则:英文分号 `:` 左边部分为组名,没有则从默认组名 `default` 中找服务器。

``` php
// 说明:为了简化代码 本示例均无检查/校验等逻辑

// 要缓存的 Key/字符串 $key
$key = 'group_1:key_staff_1';

// 获取集群组配置 $groups
$groups = [
// 假装配置 OK ...
];

// 检查是否含有组名定义 并确定出一个组名 $group
$arr = explode(':', $key);
$group = (1 < count($arr)) ? ($arr[0] ?? 'default') : 'default';

// 取出组名 $group 中的所有服务器 $servers
$servers = $groups[$group];

// 从组名 $group 中的所有服务器 $servers 中找到属于这个字符串的那台服务器 $target
$hash = md5($key);
$partion = count($servers);
$target = (hexdec(substr($hash, 0, 14)) % $partion);


// 拿到最终要操作的那台缓存服务器 $target 后
// 之后的代码便是针对 Memcached Server 的读写操作了 这里省略 ...

本示例完整代码见附录:app/Service/Cache/Memcached.php@selectNode()

使用说明

本示例中用到了一些辅助函数,其完整定义见附录:bootstrap/functions.php。

1
2
// 获取缓存操作对象/服务
$cache = service('cache');

本系统缓存统一以服务的形式调用,相比 Laravel 自身的缓存机制,缓存服务可以为我们自身的业务定制了一些调用,可提升了一定的扩展性。

当然,你仍然可以使用 \Cache 或者 cache() 等 Laravel 自身的缓存机制来操作缓存,但是建议统一,因为调用方式一致可以保证对缓存数据操作的一致性。

配置

config/service/cache.php 种指明默认的自定义缓存驱动,目前只有两套:

1
2
3
4
5
6
7
8
9
10
return [
// 依赖 Laravel 自身缓存配置
// 本地开发环境,和对操作无集群要求的碎片数据时,推荐使用
'default' => 'laravel',

// 不依赖 Laravel 自身配置
// 有自己专门的配置文件:_config/service/cache/memcached.php_
// 可以实现缓存分组和集群配置
// 'default' => 'memcached',
];

基本/通用接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

// 获取缓存
public function get(string $key);

// 设置缓存
public function set(string $key, $value, int $seconds = 0) : Cachable;

// 获取并删除缓存(返回缓存数据)
public function flush(string $key);

// 删除缓存
public function delete(string $key) : bool;

// 当日计数器递减:$step 为步长
public function decrementToday(string $key, int $step = 1) : int;

// 当日计数器递增:$step 为步长
public function incrementToday(string $key, int $step = 1) : int;

// 尝试获取缓存,如果获取不到则执行闭包获取缓存数据,然后设置到缓存,并返回缓存数据
public function getOrSet(string $key, \Closure $fetch, int $expire = 0);

// 尝试获取缓存,如果获取不到则返回闭包执行结果,不设置任何缓存
public function getOrReturn(string $key, \Closure $return);

对应的全局辅助函数

1
2
3
4
5
// 对应 ::getOrSet()
function rwcache(string $key, \Closure $datable, int $expire = 0);

// 对应 ::getOrReturn()
function rrcache(string $key, \Closure $return);

实现模型缓存

自定义模型类

想要在模型中避免重复又累赘的缓存集成代码,我们能想到的最少的改动就是封装一个自定义模型,这个自定义模型要能拥有 Laravel 模型类的所有功能,并在此基础上,加入全局的缓存控制机制。

这个自定义模型的详情这里忽略,可以从文末附录中查看完整代码。下面介绍下使用说明:

使用说明

基于我们重新封装通用缓存调用,在 Laravel 默认的 Model/Eloquent/Database Builder 的基础上,加入了内置缓存支持。

必要配置如下:

1
MODEL_CACHE_ENABLE=1

同时,要使用内置缓存的模型类必须继承 App\Custom\Model。举例说明:

1
2
3
4
5
6
namespace App\Models;

class User extends \App\Custom\Model
{
// ...
}

模型形式的数据查询代码无需做任何改动就能使用默认配置的缓存机制,像上面这样就够了。

目前支持的缓存相关操作有:

1
2
3
4
5
6
7
8
User::find([1,2,3]);
$user = User::first();
User::whereId(1)->get();
User::paginate();

// 使缓存失效
$user->sex = 'gay';
$user->save();

注意事项

统一使用模型来操作数据库

如果要让缓存有用,就使用模型这套机制来操作数据库,如果使用 \DB 来操作,或者直接改了数据库记录,可能出现数据不一致的情况。

使缓存失效的必要配置(重要)

在 Laravel 模型中使用缓存会遇到一个很尴尬的东西,如何在数据记录更新的时候使其失效?

大家都知道在获取到数据库数据进行缓存设置的时,缓存使用到的 key 是根据底层 SQL 的一些必要查询条件中获取的,其中的条件可能是主键,也可能不是主键,而我们使用模型更新数据的时候,一般只是根据主键来更新的。

举例说明:

1
2
3
4
5
6
//  获取一个会员记录
$member = Member::whereStatusAndUserId('active', 1)->first();

// 更新一个会员记录
$member->referrer = mt_rand(1, 100);
$member->save();

在这个栗子中,获取一个会员记录的时候,并没有按照主键来找记录,其对应的 SQL 如下:

1
select * from `member` where `status` = ? and `user_id` = ? limit 1

在将缓存做在 Laravel 框架内部数据操作层的前提下,那么在设置缓存的时候是无法将用来做缓存的 key 带上记录的主键的。

或许你会问怎么不先查出这个记录,然后从记录中的主键取出来再设置为缓存的 key 呢?

这其实是一个鸡生蛋的问题,你用缓存的时候,必然是先根据一个 key 而不是先查数据库,你都从数据库拿到记录来还有什么设置缓存的必要呢?

此外,Laravel 数据层里面对于查询的操作都会同意调用一个名为 get 的方法,这个方法的返回值会根据调用方法的不同而变化,可能是一个分页对象,可能是一个集合,等等。这导致就算想要做无用功——从 get 结果里面取主键 ID 也不得不多写一些判断代码。

为了确保查询时用于设置缓存的 key 和更新时用于删除缓存的 key 的绝对一致性,最终决定通过额外的代码配置来提高缓存失效的可用性:

其原理是,这个模型用到的所有查询类型,通过 debug 找出其对应 SQL 的条件规则并保存在模型内部,当该模型的 save() 方法被调用时,从模型自身中去除查询条件的模版,构造处可设置查询缓存是一模一样 key,这样就能准确无误地删除掉过期的缓存数据了。

其实也不能保证每种情况下都能做到绝对一致,这个需要覆盖完所有 Laravel 模型构造查询 SQL 的情况才能保证绝对一致。而目前,我只覆盖了个人用的最多的查询模式。

所以后端开发者在意图使用缓存时候的反馈很重要(见下),需要一种查询一种查询进行「设置-失效」的正反测试,调整到一定程度才能发挥缓存机制的最大价值。

其表现形式是在模型中配置一个方法 getExpireCacheKeysArr,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
namespace App\Models;

use App\Custom\Models;

class Member
{
public function getExpireCacheKeysArr()
{
return [
[
[
'type' => 'basic',
'column' => $this->getTable().'.status',
'operator' => '=',
'value' => 'active',
'boolean' => 'and',
],
[
'type' => 'basic',
'column' => $this->getTable().'user_id',
'operator' => '=',
'value' => $this->user_id,
'boolean' => 'and',
],
],

[
[
'type' => 'basic',
'column' => 'status',
'operator' => '=',
'value' => 'active',
'boolean' => 'and',
],
[
'type' => 'basic',
'column' => 'user_id',
'operator' => '=',
'value' => $this->user_id,
'boolean' => 'and',
],
],
];
}

}

如果模型中含有这个方法,那么在使用模型更新数据的时,就会从这个方法返回的规则中复原和查询过程中设置缓存时完全一样的 key,从而解决了模型更新后无法准确删除对应的缓存记录的尴尬。

其中,该数组的每第一级子数组均表示一种 key 的情况,或者一条查询 SQL 中的 where 条件集合,而每第二级子数组均表示同一个 SQL 中的一个 where 条件。

反馈

对 Laravel 的数据层缓存的监控会一直进行下去,可能会根据最新情况进行一些内部调整。

上面未能列举出来的,但又需要缓存的模型方法,可以让自己加上去,目前只测试了这几种。

此外,希望后端开发者在使用模型缓存的地方,开启缓存服务的 debug 模式,并查看缓存命中情况,检查可缓存数据是否是期望等状态,如果出现不准的地方,可以帮我或找我排查一下。

日志 Debug

分析数据查询时以及缓存是否影响到数据库实际查询时,可以在日志中开启 \DB::connection()->enableQueryLog() 来测试请求时到底有无数据查询。

举例说明,当开启缓存服务器 debug 模式时,使用日志记录后会显示如下输出:

1
2
3
4
5
6
[2018-03-23 11:35:16] local.DEBUG: Select datable into cache for 300 seconds: users:paginate_1_10
[2018-03-23 11:35:24] local.DEBUG: Oh~yeah~ cache hits: users:get_cf1384628b5ace6c2e65f915760f9a77
[2018-03-23 11:35:24] local.DEBUG: Oh~yeah~ cache hits: users:paginate_1_10
[2018-03-23 11:35:41] local.DEBUG: Oh~yeah~ cache hits: users:get_cf1384628b5ace6c2e65f915760f9a77
[2018-03-23 11:35:41] local.DEBUG: Select datable into cache for 300 seconds: users:get_6c73bffe4d4cb3358fc5fc43ce7a83cb
[2018-03-23 11:35:43] local.DEBUG: Empty data found: member_level_map:get_87056599df279f243b88b9956e9c6839

如果是 Oh~yeah 开头,代表缓存命中一次;
如果是 Select 开头,代表是执行了数据库查询+缓存写操作;
如果是 Empty data 开头,代表写缓存时数据为空,可能需要检查返回缓存数据的闭包方法。

附录: 完整代码-GitHub

其中,repo 目录接口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
.
├── app
│   ├── Console
│   │   └── Commands
│   │   └── ServiceConfig.php
│   ├── Contract
│   │   └── Service
│   │   └── Cachable.php
│   ├── Custom
│   │   ├── EloquentBuilder.php
│   │   ├── Model.php
│   │   ├── QueryBuilder.php
│   │   └── Validator.php
│   ├── Service
│   │   └── Cache
│   │   ├── Laravel.php
│   │   └── Memcached.php
│   └── Traits
│   └── ServicableWithConfig.php
├── bootstrap
│   └── functions.php
├── composer.json
└── config
└── service
└── cache.sample