布隆过滤器必须在cache.Get()之前调用,因其是第一道闸机,BF.EXISTS返回0可100%拦截非法key,避免请求穿透至Redis和DB;若置于cache.Get()之后,则已造成穿透。

布隆过滤器必须前置,空值缓存必须显式序列化,singleflight 必须放在最后——三者顺序错一个,穿透防护就形同虚设。
布隆过滤器为什么必须在 cache.Get() 之前调用
常见错误是把 filter.Test() 放在 cache.Get() 后面,结果恶意 key 先打 Redis(miss),再查 DB(no rows),最后才进布隆判断——完全失去拦截意义。它不是“辅助校验”,而是“第一道闸机”。
- 初始化必须全量预热:写 DB 成功后立刻
filter.Add([]byte("user:" + strconv.Itoa(id))),key 格式和查询时严格一致 - 别存 Redis 或每次 new:
filter应为全局变量,常驻内存;golang.org/x/exp/bloom 不支持自定义哈希,[]byte最稳妥 - 误判率别设太低:0.01 比 0.0001 内存省一半,业务中 1% 误判已足够挡住绝大多数非法请求
空值缓存为什么不能写 nil 或字符串 "null"
redis.Set(ctx, key, nil, 2*time.Second) 看似简单,但 Go-Redis 会把 nil 序列化为空字符串或零值,后续 redis.Get().Result() 返回非空,业务逻辑直接误判为“有数据”。空值缓存本质是临时兜底,不是永久黑名单。
- 统一定义占位类型:
type Null struct{},而不是用nil、""或"empty" - 写入前必须走相同序列化路径:比如正常值用
json.Marshal,空值也得json.Marshal(Null{}) - TTL 要短:通常
2 * time.Second到5 * time.Second,防止恶意 key 长期占内存
singleflight.Do 的 key 为什么不能复用缓存 key
singleflight.Group.Do("user:123", fn) 看似合理,但一旦该 key 同时被用于 RPC 查询、DB 查询、fallback 加载,就会互相阻塞。它不是防穿透的,只防“DB 里真有、但缓存刚过期”的击穿场景。
立即学习“go语言免费学习笔记(深入)”;
- Do key 应标识实际操作:
"db:get_user_by_id:123",而非原始缓存 key"user:123" - 回调函数里必须包含完整加载逻辑:先查 DB,再判断是否为空;只有空才写
Null{}到缓存,且要检查redis.Set是否成功 - 别忽略第三个返回值:
shared是 bool 类型,true表示真去执行了,false表示等别人的结果——可用于打点或日志区分
三者串联时最容易被忽略的细节
整个链路不是“堆功能”,而是一条有状态的流水线:布隆过滤器筛掉非法 key → 缓存 Get 命中则直接返回 → 未命中且布隆返回 true 才进 singleflight → Do 回调里查 DB → DB 空则写 Null{},非空则写真实值。任何一环漏掉类型检查、序列化一致性或 TTL 控制,都会让穿透防护在高并发下悄然失效。


















