缓存穿透是查询根本不存在的key导致请求直达数据库,empty值不能直接存因ThinkPHP Redis驱动会忽略null或误作永久缓存;正确做法是用'__NULL__'等占位符配合短TTL,并与布隆过滤器协同使用。

缓存穿透是什么,为什么empty值不能直接存
缓存穿透本质是查一个**根本不存在的 key**,比如恶意请求 user_id=999999999,数据库没这条记录,缓存也不存,每次请求都打到 DB。ThinkPHP 默认对未命中返回 null,而 Cache::get() 返回 null 时不会写入缓存——这就等于放行穿透。
有人试过手动存 Cache::set('user_999999999', null, 3600),但 ThinkPHP 的 Redis 驱动会把 null 序列化成空字符串或直接跳过,实际没存进去;更糟的是,某些版本还会把 null 当作“永久缓存”处理,导致后续真实数据无法覆盖。
- 正确做法:统一用特殊占位符,比如
'__NULL__'或json_encode(['_empty' => true]) - 必须配合 TTL,否则占位符长期残留,变成“假缓存”
- 注意
Cache::remember()不会自动处理空值,它只对“回调返回非空”才缓存
ThinkPHP 6/7 中实现空值缓存的三步落地
核心不是加中间件,而是改查询入口逻辑。以用户查询为例,在 Repository 或 Service 层封装:
// 示例:UserCacheService.php
public function getUserById(int $id)
{
$key = 'user_' . $id;
$data = Cache::get($key);
if ($data === '__NULL__') {
return null;
}
if ($data !== null) {
return $data;
}
$user = Db::name('user')->where('id', $id)->find();
// 关键:空结果也写缓存,且 TTL 缩短(比如 5 分钟,避免误伤)
Cache::set($key, $user ?: '__NULL__', $user ? 3600 : 300);
return $user;
}
- 不要依赖
Cache::remember(),它不拦截null回调结果 - TTL 区分设置:
$user ? 3600 : 300,防止空值锁死太久 - 所有读取点必须走同一套判断逻辑,否则布隆过滤器和空值缓存就脱节
Redis 布隆过滤器怎么接进 ThinkPHP,别碰 bloom 扩展
ThinkPHP 官方不支持布隆过滤器,第三方 topthink/think-bloom 扩展已多年未更新,依赖过时的 phpredis 版本,且在集群模式下失效。更稳的方案是直接调用 Redis 原生命令 + 封装简易工具类。
立即学习“PHP免费学习笔记(深入)”;
前提:Redis 服务已加载 redisbloom 模块(MODULE LIST 能看到 bf)。
- 初始化一次:用
BF.RESERVE user_bf 0.01 100000建过滤器(误差率 1%,预估容量 10w) - 写入时机:仅在用户创建成功后,调
$redis->bfAdd('user_bf', $id) - 查询前置:查缓存前先
$redis->bfExists('user_bf', $id),返回false直接拒掉,不查缓存也不查库 - 注意:布隆过滤器有误判率(
true不一定真存在),所以它只能用来“快速排除”,不能替代空值缓存
空值缓存 + 布隆过滤器组合使用的坑点
两者叠加不是简单“加法”,容易互相干扰。最常踩的三个点:
- 布隆过滤器漏写:用户软删除、后台导入等非主流程创建路径没调
bfAdd,导致大量“存在但过滤器说不存在”,全量穿透 - 空值缓存 TTL 过长:比如设了 24 小时,但布隆过滤器刚建好,旧空值还没过期,新 ID 又被误判为不存在,双重漏判
- Redis 连接复用冲突:ThinkPHP 的
Cache::store('redis')和直连Redis实例用的不是同一个连接池,事务或 pipeline 下可能不同步
真正要命的是:布隆过滤器本身不存业务语义,它只回答“可能存”,而空值缓存回答“确认不存在”。这两个答案一旦时间窗口错开,攻击者就能卡着空值过期前、布隆未更新后发起请求——这种边界情况,光靠配置解决不了,得靠日志埋点+定期比对 BF.CARD 和实际用户数来发现。


















