缓存穿透导致大量 Sleep 连接,主因是空值未缓存;应显式缓存空结果并设合理 TTL,避免用 rememberForever,结合 DB::listen 定位穿透 SQL。
查 SHOW PROCESSLIST 时发现大量 Sleep 状态连接
缓存穿透发生时,大量请求绕过缓存直击数据库,而 laravel 默认的数据库连接池不主动回收空闲连接,show processlist 里就会堆满 sleep 状态的连接(尤其是用 pdo + mysql 默认配置时)。这不是慢查询,是连接被占着不动——本质是缓存没兜住,db 层被迫扛压。
- 先执行
SHOW PROCESSLIST LIMIT 20,重点看Command列是否密集出现Sleep,且Time值持续增长(>60s) - 对比缓存命中率:在关键查询前加
Cache::get('key', function () { ... })的同时,用Cache::has('key')打日志,确认是否频繁进入闭包(即缓存未命中) - 注意 Laravel 的
cache.driver配置是否为redis或memcached;若配成array或file,等于没开缓存,所有请求都穿透
Laravel 中哪些缓存 Key 容易被穿透
穿透不是随机发生的,集中在「查不到但又高频访问」的场景:比如用户 ID 不存在、商品 SKU 已下架、短链接跳转目标为空。Laravel 默认不会对 null 或 false 做缓存,所以这些“空结果”反复触发 DB 查询。
- 典型代码陷阱:
Cache::get('user_'.$id, function () use ($id) { return User::find($id); });—— 若User::find($id)返回null,整个表达式返回null,但该null不会写入缓存,下次还来 - 修复方式:显式缓存空值,例如
Cache::remember('user_'.$id, 300, function () use ($id) { $user = User::find($id); return $user ?: Cache::forever('user_'.$id.'_null', true); });,再配合读取时先判Cache::has('user_'.$id.'_null') - 更稳妥的做法是统一用
Cache::remember+ 自定义空值标记,避免业务层每次手动判断;不要依赖Cache::add,它只在 key 不存在时写入,无法覆盖已存在的 null 缓存
Cache::remember 和 Cache::rememberForever 的 timeout 陷阱
很多人以为设了 rememberForever 就一劳永逸,其实它只是把 TTL 设为 0,底层仍依赖驱动行为。Redis 里 SET key val EX 0 等价于 SET key val,但 Laravel 的 redis driver 在遇到 0 时会跳过 EX 参数,实际存的是无过期时间的 key——这会导致空值长期霸占内存,反而加剧穿透后难以恢复。
- 永远不要对空结果用
rememberForever;合理做法是给空值设较短 TTL(如 60–300 秒),既防穿透,又避免脏数据滞留 -
remember的第二个参数是秒数,不是字符串(别传'300'),否则 Laravel 会当作字符串处理并静默失败,最终 fallback 成永久缓存 - 检查 Redis 实例内存使用率:
INFO memory | grep used_memory_human,如果缓存中大量 key 带_null后缀且内存居高不下,大概率是空值缓存没设 TTL 或设得太长
用 DB::listen 快速定位穿透源头 SQL
光看连接数不够,得知道是谁在反复查不存在的数据。DB::listen 是 Laravel 内置的轻量钩子,比改模型或加中间件更快定位问题点。
- 在
AppServiceProvider::boot()里临时加上:DB::listen(function ($query) { if (str_contains($query->sql, 'WHERE id =') && $query->bindings && !$query->result) { \Log::warning('Null query detected', ['sql' => $query->sql, 'bindings' => $query->bindings]); } }); - 注意
$query->result并非原生返回值,需自行封装执行逻辑才能拿到;更可靠的是结合慢日志和EXPLAIN查执行计划,确认是否走了索引(没索引的 WHERE 条件更容易放大穿透影响) - 上线后务必移除或禁用该监听,否则日志爆炸且影响性能;生产环境建议用
DB::enableQueryLog()配合采样(如每千次查一次),而不是全量记录
DB::table()->where()->first() 的查询——它们几乎从不主动缓存空值,却恰恰是穿透重灾区。











