空值缓存不能防穿透,仅是布隆过滤器失效后的兜底;真正防穿透靠前置布隆过滤器,需严格对齐key格式、哈希方式与初始化参数,并在get前拦截;nil缓存因序列化不一致易失效,应改用显式占位符、短ttl及统一反序列化;singleflight须置于布隆和缓存之后防击穿,且do key需与缓存key分离;接口层校验可拦截70%非法请求。

空值缓存本身不能防穿透,它只是布隆过滤器失效后的兜底手段;真正起效的是「前置拦截」——布隆过滤器必须在 Get 调用之前判断,且 key 格式、哈希方式、初始化范围必须严丝合缝。
为什么直接写 cache.Set(key, nil, ttl) 会失效
Go 的缓存库(如 github.com/patrickmn/go-cache 或 go-redis/redis/v9)对 nil 的处理不一致:nil 可能被序列化为空字符串、零值结构体,甚至触发 panic。读取时无法可靠区分“缓存未命中”和“缓存了空值”,导致后续请求仍打穿到 DB。
- 显式用占位符:写入
"NULL"、"empty"或结构体UserResp{NotFound: true} - TTL 必须短:建议
2 * time.Second到5 * time.Second,避免恶意 key 长期占位 - 读取时先判等再放行:
if val == "NULL" { return nil, errors.New("not found") },别依赖val == nil - 反序列化要统一:空值和正常值走同一套
json.Unmarshal流程,避免类型错乱
bloom.Test() 返回 false 却仍查了 Redis 和 DB?
说明布隆过滤器没拦在最前,或 key 格式根本没对齐。布隆不是“查缓存后补刀”,它是第一道门禁——Test 返回 false 就该直接返回错误,不碰任何下游。
- 调用顺序必须是:
if !bloomFilter.Test([]byte(key)) { return nil, ErrKeyNotFound } - 插入和查询的 key 字节必须完全一致:比如 DB 主键是
int64,那就全用strconv.FormatInt(id, 10)转成 string 再哈希,别混用fmt.Sprintf或原始 int - 别用默认参数初始化:
bloom.New(10_000_000, 0.01)显式传入预估总量和误判率,否则位图太小、误判飙升 - 布隆过滤器变量必须全局常驻,不能每次请求 new,也不能存在 Redis 里(序列化开销大)
singleflight.Group.Do 放错位置反而放大攻击
singleflight 是防击穿的,不是防穿透的。如果把它放在布隆过滤器之前,等于给所有非法 key 做请求合并——一个 user:-1 请求会卡住 N 个 goroutine 等结果,CPU 和内存全耗在无意义等待上。
- 正确链路:
bloom.Test() → false? return : cache.Get() → redis.Nil? singleflight.Do("db:"+key, fn) : return val -
Do的回调里必须先查 DB,再判断是否为空;仅当 DB 确实无结果,才写空值缓存,否则跳过 - 别在回调里
cache.Set后立刻return,要确保 set 成功(检查 err)再释放等待队列 -
Do的 key 和缓存 key 分开:比如缓存用"user:123",Do 用"db:user:123",避免和其他逻辑冲突
最难的不是堆三个组件,而是让它们咬合:布隆漏掉的 key,靠空值缓存兜底;空值缓存过期期间的并发请求,靠 singleflight 合并;而所有这些的前提,是接口层校验先拦掉 id="abc" 这类明显非法输入——它不耗 DB,不占内存,但能砍掉 70% 的穿透流量。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











