在 Laravel 6 中,缓存无故被删除的排查分析(Cache::tags)
1、设置与获取缓存的代码如下
const TAG_THEME_EDITOR = 'theme_editor'; // 缓存标签:主题编辑
const THEME_EDITOR_SESSION_PREFIX = 'theme_editor_session:'; // 缓存前缀:主题编辑会话
const TTL = 86400; // 缓存的过期时间(秒)
public static function saveSessionMetadata(string $sessionId, array $data): bool
{
return Cache::tags([
ThemePreviewInterface::TAG_THEME_EDITOR,
ThemePreviewInterface::TAG_THEME_EDITOR . '_' . $sessionId]
)
->put(ThemePreviewInterface::THEME_EDITOR_SESSION_PREFIX . $sessionId, json_encode($data), ThemePreviewInterface::TTL);
}
public static function getSessionMetadata(string $sessionId): array
{
$data = Cache::tags([
ThemePreviewInterface::TAG_THEME_EDITOR,
ThemePreviewInterface::TAG_THEME_EDITOR . '_' . $sessionId
])
->get(ThemePreviewInterface::THEME_EDITOR_SESSION_PREFIX . $sessionId);
if (empty($data)) {
return [];
}
return JsonDecoder::jsonToArray($data);
}
2、缓存的过期时间(秒)为 86400,但是在生产环境中,有极低概率出现不到 24 小时就过期的情况,甚至有才过去几个小时就无法获取缓存的情况。打印 $data,其值为 NULL。
3、决定先排除掉外部因素的干扰,先从程序本身入手分析。初步怀疑是程序本身的某处操作了缓存,导致了缓存被清空。
4、在 .env 的缓存相关配置如下
CACHE_DRIVER=redis REDIS_CLIENT=phpredis
5、打印配置项,database.reids.client=phpredis;cache.default=redis。如图1
/object # php artisan tinker
Psy Shell v0.9.12 (PHP 7.4.26 — cli) by Justin Hileman
>>> echo config('database.redis.client');
>>> echo config('database.redis.client');
phpredis⏎
>>> echo config('cache.default');
redis⏎
>>>
6、在 /config/cache.php 中的配置如下
'stores' => [
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
],
],
7、在 /config/database.php 中的配置如下
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'predis'),
],
'cache' => [
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'),
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_CACHE_DB', 1),
'persistent'=>env('REDIS_PERSISTENT', true),
'persistent_id'=>env('REDIS_PERSISTENT_ID', 'cache'),
'retry_interval'=>env('REDIS_PERSISTENT_RETRY', 3000),
],
],
8、在 /config/app.php 配置文件的别名部分,未重命名 Illuminate \ Support \ Facades \ Redis 外观别名。但是,并未抛出异常:Please remove or rename the Redis facade alias in your “app” configuration file in order to avoid collision with the PHP Redis extension.。决定暂时忽略此问题。
'aliases' => [
'Redis' => Illuminate\Support\Facades\Redis::class,
],
9、仍然决定重命名为其他内容,例如 RedisManager,以避免与 Redis 类发生冲突。
'aliases' => [
'RedisManager' => Illuminate\Support\Facades\Redis::class,
],
10、决定登录至 Redis 中确认一下,先在本地环境确认一下执行命令的顺序。如图2
127.0.0.1:1>GET object_cache:tag:theme_editor_48wJgWKHnabdr3AznlDhP5onmEVedTz67ULb:key
"s:22:"631ae7ba62b83808649247";"
127.0.0.1:1>SMEMBERS object_cache:631ae7ba62b83808649247:standard_ref
1) "object_cache:c613daaf6b394e496382fd07bc808ea8a497556f:theme_editor_session:9734e008-f539-4f76-9e9f-d8037334b968:48wJgWKHnabdr3AznlDhP5onmEVedTz67ULb:product_detail"
2) "object_cache:c613daaf6b394e496382fd07bc808ea8a497556f:theme_editor_session:48wJgWKHnabdr3AznlDhP5onmEVedTz67ULb"
127.0.0.1:1>GET object_cache:c613daaf6b394e496382fd07bc808ea8a497556f:theme_editor_session:48wJgWKHnabdr3AznlDhP5onmEVedTz67ULb
"s:9329:"{"id":372}";"
127.0.0.1:1>
11、决定登录至 Redis 中确认一下,基于 Tinker 查询。按照如上的执行命令的顺序,以确认本地环境中是否存在对应的 key。如图3
PS E:\wwwroot\object> php artisan tinker
Psy Shell v0.9.12 (PHP 7.4.27 — cli) by Justin Hileman
>>> var_dump(\Illuminate\Support\Facades\Redis::connection('cache')->get('object_cache:tag:theme_
editor_48wJgWKHnabdr3AznlDhP5onmEVedTz67ULb:key'));
string(30) "s:22:"631ae7ba62b83808649247";"
=> null
>>> var_dump(\Illuminate\Support\Facades\Redis::connection('cache')->SMEMBERS('object_cache:631ae
7ba62b83808649247:standard_ref'));
array(2) {
[0]=>
string(186) "object_cache:c613daaf6b394e496382fd07bc808ea8a497556f:theme_editor_session:9734e008-f539-4f76-9e9f-d8037334b968:48wJgWKHnabdr3AznlDhP5onmEVedTz67ULb:product_detail"
[1]=>
string(134) "object_cache:c613daaf6b394e496382fd07bc808ea8a497556f:theme_editor_session:48wJgWKHnabdr3AznlDhP5onmEVedTz67ULb"
}
=> null
>>> var_dump(\Illuminate\Support\Facades\Redis::connection('cache')->GET('object_cache:c613daaf6b
394e496382fd07bc808ea8a497556f:theme_editor_session:48wJgWKHnabdr3AznlDhP5onmEVedTz67ULb'));
string(9339) "s:9329:"{"id":372}";"
=> null
>>> var_dump(\Illuminate\Support\Facades\Redis::connection('cache')->TTL('object_cache:c613daaf6b
394e496382fd07bc808ea8a497556f:theme_editor_session:48wJgWKHnabdr3AznlDhP5onmEVedTz67ULb'));
int(81060)
=> null
>>>
12、TTL key,以秒为单位,返回给定 key 的剩余生存时间(TTL, time to live)。一个 key 是 2022/09/09 15:14 生成的。现在时间是 2022/09/09 16:44 。已经过去了 1 小时 30 分钟。86400 – 1 * 3600 – 30 * 60 = 81000。剩余生存秒数为:int(81060)。符合预期。如图4
13、决定在线上环境重新生成一个,然后查看剩余生存时间秒数。刚生成的一个 key 的剩余生存秒数为:int(85730)。符合预期。如图5
14、决定登录至 Redis 中确认一下,基于 Tinker 查询。按照如上的执行命令的顺序,以确认线上环境中是否存在对应的 key。确定对应的 key 未被完全清空。如果执行了 Cache::tags(ThemePreviewInterface::TAG_THEME_EDITOR . ‘_’ . $sessionId)->flush() 或者 Cache::flush(),则 执行查询 var_dump(\Illuminate\Support\Facades\Redis::connection(‘cache’)->get(‘arrest9_cache:tag:theme_editor_Uq6BLXO7GsbWHQovCXQAshX8SdaS6BXh2lWE:key’)); 时,会返回 null,但是现在是有值的。如图6、图6.1
>>> var_dump(\Illuminate\Support\Facades\Redis::connection('cache')->get('arrest9_cache:tag:theme_editor_Uq6BLXO7GsbWHQovCXQAshX8SdaS6BXh2lWE:key'));
string(30) "s:22:"63243db5c01db958165221";
=> null
>>> var_dump(\Illuminate\Support\Facades\Redis::connection('cache')->TTL('arrest9_cache:tag:theme_editor_Uq6BLXO7GsbWHQovCXQAshX8SdaS6BXh2lWE:key'));
int(-1)
=> null
>>> var_dump(\Illuminate\Support\Facades\Redis::connection('cache')->SMEMBERS('arrest9_cache:63243db5c01db958165221:standard_ref'));
array(0) {
}
=> null
>>>
>>> var_dump(\Illuminate\Support\Facades\Redis::connection('cache')->get('object_cache:tag:theme_
editor_ERo5GoALUnDuxcMtW3yPYPKsjwci8DVZR2ij:key'));
NULL
=> null
>>> var_dump(\Illuminate\Support\Facades\Redis::connection('cache')->SMEMBERS('object_cache:632a7
fda56e94065934346:standard_ref'));
array(0) {
}
=> null
>>>
15、决定基于事件监听,添加相应的日志。新建监听器文件 /app/Listeners/Cache/LogKeyWrittenListener.php ,当写入 key 时。
<?php
namespace App\Listeners\Cache;
use Illuminate\Cache\Events\KeyWritten;
use Illuminate\Support\Facades\Log;
use Modules\OnlineStoreThemeGraphQL\Preview\ThemePreviewInterface;
class LogKeyWrittenListener
{
/**
* 处理事件
*
* @param KeyWritten $event
* @return void
*/
public function handle(KeyWritten $event)
{
if (config('app.debug') === true && strpos($event->key, ThemePreviewInterface::THEME_EDITOR_SESSION_PREFIX) !== false) {
Log::debug('theme_editor_session_key_written', [
'key' => $event->key,
'tags' => $event->tags,
'value' => $event->value,
'seconds' => $event->seconds
]);
}
}
}
16、生成的日志信息如下。如图7
[2022-09-15 17:26:05] local.DEBUG: theme_editor_session_key_written {
"key": "theme_editor_session:7JivgR1WqXVeE4hsyA335ILc60k153yTynIN",
"tags": [
"theme_editor",
"theme_editor_7JivgR1WqXVeE4hsyA335ILc60k153yTynIN"
],
"value": "",
"seconds": 86400
}
17、新建监听器文件 /app/Listeners/Cache/LogCacheHitListener.php ,当命中 key 时。
/**
* 处理事件
*
* @param CacheHit $event
* @return void
*/
public function handle(CacheHit $event)
{
if (config('app.debug') === true && strpos($event->key, ThemePreviewInterface::THEME_EDITOR_SESSION_PREFIX) !== false) {
Log::debug('theme_editor_session_cache_hit', [
'key' => $event->key,
'tags' => $event->tags,
'value' => $event->value
]);
}
}
[2022-09-15 18:09:08] local.DEBUG: theme_editor_session_cache_hit {
"key": "theme_editor_session:KrV0Nf5rMOiS5ZdF6i2uDandZZZWPPpXcQ4D",
"tags": [
"theme_editor",
"theme_editor_KrV0Nf5rMOiS5ZdF6i2uDandZZZWPPpXcQ4D"
],
"value": ""
}
18、新建监听器文件 /app/Listeners/Cache/LogCacheMissedListener.php ,当未命中 key 时。
/**
* 处理事件
*
* @param CacheMissed $event
* @return void
*/
public function handle(CacheMissed $event)
{
if (config('app.debug') === true && strpos($event->key, ThemePreviewInterface::THEME_EDITOR_SESSION_PREFIX) !== false) {
Log::debug('theme_editor_session_cache_missed', [
'key' => $event->key,
'tags' => $event->tags
]);
}
}
[2022-09-15 18:10:15] local.DEBUG: theme_editor_session_cache_missed {
"key": "theme_editor_session:KrV0Nf5rMOiS5ZdF6i2uDandZZZWPPpXcQ4D",
"tags": [
"theme_editor",
"theme_editor_KrV0Nf5rMOiS5ZdF6i2uDandZZZWPPpXcQ4D"
]
}
19、但是,当移除带有标签的缓存数据时,使用 Cache::tags(ThemePreviewInterface::TAG_THEME_EDITOR . ‘_’ . $sessionId)->flush(); ,监听器 /app/Listeners/Cache/LogKeyForgottenListener.php 并未生成日志,当清除 key 时。
/**
* 处理事件
*
* @param KeyForgotten $event
* @return void
*/
public function handle(KeyForgotten $event)
{
if (config('app.debug') === true && strpos($event->key, ThemePreviewInterface::THEME_EDITOR_SESSION_PREFIX) !== false) {
Log::debug('theme_editor_session_key_forgotten', [
'key' => $event->key,
'tags' => $event->tags
]);
}
}
20、决定使用 flush 方法清空所有的缓存,Cache::flush();。以确认是否会触发清除 key 的事件,确认未触发。但是当执行了清空所有的缓存操作后,已经无法获取带有标签的缓存数据了。不论是否带有标签,所有的缓存数据皆被清空。如图8
21、所以即使有 4 个缓存日志监听器,仍然很难判断 key 是什么时候被清掉的。只能够先在预计会被清掉的地方,记录一下相应的日志。
22、针对第 16 步骤,决定咨询一下运维同事,有时候运维会针对 Redis 的 死 key ,运行 Python 脚本,手动执行清理操作。因此,决定将同样的 Python 脚本在本地环境模拟复现。执行:python batch-remove-rediskey.py /tmp。本地未复现,分析脚本,线上有可能复现。不过,运维已经确定,在最近出现问题的时间段内,并未手动运行过此脚本。如图9
PS E:\wwwroot\object> python batch-remove-rediskey.py /tmp /tmp* All done
23、由于设置了2个 tag,其中一个 tag 下的 key 的数量会不断地递增,其数量最终可能在一周内达到几千甚至几万个。最终决定仅设置1个 tag。设置与获取缓存的代码如下
const TAG_THEME_EDITOR = 'theme_editor'; // 缓存标签:主题编辑
const THEME_EDITOR_SESSION_PREFIX = 'theme_editor_session:'; // 缓存前缀:主题编辑会话
const TTL = 86400; // 缓存的过期时间(秒)
public static function saveSessionMetadata(string $sessionId, array $data): bool
{
return Cache::tags([
ThemePreviewInterface::TAG_THEME_EDITOR . '_' . $sessionId]
)
->put(ThemePreviewInterface::THEME_EDITOR_SESSION_PREFIX . $sessionId, json_encode($data), ThemePreviewInterface::TTL);
}
public static function getSessionMetadata(string $sessionId): array
{
$data = Cache::tags([
ThemePreviewInterface::TAG_THEME_EDITOR . '_' . $sessionId
])
->get(ThemePreviewInterface::THEME_EDITOR_SESSION_PREFIX . $sessionId);
if (empty($data)) {
return [];
}
return JsonDecoder::jsonToArray($data);
}
24、在仅设置了1个 tag 后,且这1个 tag 中的 key 的数量一般就维持在 20 个左右,缓存未再出现无法命中的情况,整体运行正常。初步分析得出结论:在使用 Cache::tags 时,其下的 tag 的数量不宜过多,建议仅设置 1 个,具体到每个 tag 下的 key 的数量也不宜过多,建议不要超过 100。










近期评论