布隆过滤器必须前置强同步于缓存与数据库查询之前,全量预热合法key、格式严格对齐、参数按生命周期精准配置;Test为false时立即拦截,true时仍需二次查证。

布隆过滤器必须在缓存读取前拦截
缓存穿透不是并发高导致的,而是大量请求携带根本不存在的 key(比如 user:9999999999 或 id=abc)直接绕过缓存打到数据库。布隆过滤器是第一道防线,它不保证 100% 准确,但能以极小开销挡住 99% 的非法请求。
常见错误是把它放在 cache.Get() 之后,或者用运行时动态写入——布隆过滤器不支持删除,写错就废了。
- 初始化必须预热所有合法 key:比如用户 ID 全集,用
bloom.New(uint64(10_000_000), 0.01)预估容量和误判率 - key 格式必须和 DB 主键严格一致:都转成
string,都加前缀(如"user:" + strconv.Itoa(id)),大小写、编码都不能差 - 查询前先调
filter.Test([]byte(key)),返回false就直接 return,不碰 Redis、不查 DB - 别把 filter 存 Redis 或每次 new,应作为全局变量常驻内存
空值缓存不能写 nil,必须用显式占位结构体
很多人写 redis.Set(ctx, key, nil, 2*time.Second),结果 Go-Redis 把 nil 序列化成空字符串或零值,后续 redis.Get().Result() 返回非空,业务逻辑误判为“有数据”。空值缓存本质是临时兜底,不是永久黑名单。
正确做法是统一类型、显式序列化、短 TTL。
立即学习“go语言免费学习笔记(深入)”;
- 定义占位类型:
type Null struct{},而不是用nil或字符串"null" - 查 DB 返回空时,写
redis.Set(ctx, key, Null{}, 2*time.Second) - 读取后用
if _, ok := val.(Null); ok判断,避免类型混淆 - TTL 要短(通常
2 * time.Second到5 * time.Second),防止恶意 key 长期占内存 - 所有空值缓存必须走和正常值相同的序列化路径(如都过
json.Marshal)
singleflight 必须放在布隆 + 空值之后,且 Do key 要解耦
singleflight.Group.Do 不是防穿透的,它是防击穿的——只对“DB 里真有、但缓存刚过期”的热点 key 有效。如果放在布隆过滤器之前,等于给恶意 key 也做请求合并,反而放大攻击效果。
Do 的 key 也不能直接复用缓存 key,否则不同加载路径(DB / RPC / fallback)会互相阻塞。
- 标准顺序:布隆过滤器 → 缓存 Get → 未命中 →
singleflight.Do("db:get_user:"+idStr, fn) - Do key 应标识实际操作,如
"db:get_user_by_id:123",而非原始缓存 key"user:123" - fn 回调里必须先查 DB,再判断是否为空;只有空才写
Null{}到缓存,且要检查redis.Set是否成功 - 别忽略
Do返回的第三个 bool 值(shared),它表示结果是否被共享,可用于日志或监控
接口层校验比任何缓存策略都更早、更便宜
很多穿透请求根本不需要进缓存逻辑。ID 是负数、字符串含非法字符、长度超限——这些靠纯内存正则或数值判断就能拦下,成本远低于 Redis 网络调用或布隆哈希计算。
校验必须是同步、无依赖的,不能查 DB、不能连 Redis。
- 对数字 ID:
if id < 1 || id > 1e9或正则regexp.MustCompile(`^\d+$`).MatchString(idStr) - 对字符串 key(如用户名):限制长度(
len(s) > 32)、字符集(只允许字母数字下划线) - 单 IP 限流可选,但要用
go.uber.org/ratelimit这类纯内存实现,避免 Redis INCR 引入额外延迟 - 400 错误要早返回,不进业务 handler,更不进缓存层
布隆过滤器漏掉的 key、空值缓存过期瞬间的并发、以及校验放行但 DB 真为空的 case——这三类场景才是 singleflight 要兜底的真实战场。环环相扣,漏一环,穿透就可能生效。


















