用SET存带过期时间的空值(如"null" EX 600),避免不存或分步设过期;配合布隆过滤器拦截非法key;回源时加分布式锁或本地缓存合并请求;空值统一标识并监控突增。

Redis缓存穿透:空值怎么存才不被绕过
直接说结论:用 SET 存一个带过期时间的空值(比如 "null" 或 "{}"),而不是不存、也不用 EXPIRE 单独设——否则并发请求仍可能打穿缓存。
空值必须带过期时间,否则会永久占用内存;但也不能过短(比如 1 秒),否则高并发下刚过期就又被击穿。常见做法是设为正常缓存 TTL 的 1/3~1/2,比如业务数据缓存 30 分钟,空值就设 10 分钟。
-
SET key "null" EX 600是安全写法;SET key "null"+EXPIRE key 600在主从异步复制或命令重试时可能漏设过期,导致脏空值长期滞留 - 客户端读到
"null"要主动转成null或抛业务异常,不能原样返回给上层 - 别用
""(空字符串)代替"null",某些序列化库(如 Jackson)对空串和 null 处理逻辑不同,容易埋坑
用布隆过滤器提前拦截非法 key
空值缓存只能防“存在但查无结果”的穿透,对“根本不存在的 key”(比如恶意构造的 user:9999999999)效果有限——这时候布隆过滤器(Bloom Filter)才是第一道防线。
它不存真实数据,只存 key 的指纹,空间小、查询快、支持亿级 key。但要注意:它有误判率(可能把不存在的 key 判为“可能存在”),所以只能用来快速拒绝,不能用来确认存在。
- 推荐用 Redis 官方模块
redisbloom,加载后可用BF.ADD/BF.EXISTS;自己实现易出哈希冲突或扩容 bug - 初始化阶段要把所有合法 key 全量写入布隆过滤器,漏掉一个,那个 key 就永远被拦截(判为不存在)
- 布隆过滤器不支持删 key,如果业务有 key 删除场景(如用户注销),得换用支持删除的变种(如 Cuckoo Filter),或者加一层本地缓存兜底
GET 返回 nil 时,别直接回源 DB
很多代码写成 “GET key → nil → 查 DB → 写缓存”,这在穿透场景下等于把压力原封不动导给数据库。关键不是“查没查 DB”,而是“有没有限流 / 熔断 / 合并请求”。
- 用分布式锁(如
SET key lock_value NX EX 5)包裹回源逻辑,让同一 key 的多个并发请求中只有一个去查 DB,其余等待并复用结果 - 锁过期时间必须明显短于 DB 查询耗时上限,否则可能死锁;建议设为 DB P99 耗时的 2 倍
- 更稳妥的做法是加一层本地缓存(如 Caffeine),对同一个 key 的重复请求在进程内合并,避免反复打 Redis 锁
警惕 JSON 序列化导致的“假空值”
后端常把 DB 查询结果 JSON.stringify() 后塞进 Redis,但如果对象字段全为 null 或空数组,有些序列化配置会输出 {},而前端或下游服务把它当有效数据用了——实际是“伪命中”,本质还是穿透。
- 存之前显式判断原始数据是否为空:比如 Java 里用
Objects.isNull(user) || user.getId() == null,而不是依赖 JSON 字符串内容 - Redis 里统一用
"NULL"(全大写)或预定义占位符(如"__MISSING__"),避免和业务正常返回的空对象混淆 - 监控要单独埋点:统计
GET返回"NULL"的频次,如果突增,说明上游数据写入异常或布隆过滤器没更新
真正麻烦的不是“怎么存空值”,而是“怎么确保所有写路径都走同一套空值判定逻辑”。不同接口、不同 SDK、不同运维脚本,只要有一处漏了,穿透就从那条缝里漏出来。










