缓存穿透是Redis空查询未兜底所致,需在DB确认无数据后写入短TTL空占位符(如"empty"),并配合布隆过滤器分层防御。

缓存穿透在 Gin 框架里不是 Gin 本身的问题,而是你用 redis 做缓存时没兜住空查询导致的——只要请求一个数据库里根本不存在的 key(比如 user:9999999),每次都会打到 DB,QPS 上去就直接拖垮。
为什么 Gin handler 里直接查 Redis + DB 容易穿透
很多人写法是:先 rdb.Get(ctx, key),err == redis.Nil 就查 DB,DB 返回 sql.ErrNoRows 就直接返回错误,完全不往 Redis 写任何东西。这等于把「空结果」当成了「不该缓存」的信号。
- 真实场景中,恶意刷
/api/user/123456789这类非法 ID 是低成本攻击手段 - Gin 的
context是 request-scoped,没法跨请求共享状态,所以单靠 Gin 无法自动拦截或过滤 - 如果你用了
golang.org/x/sync/singleflight防击穿,它对穿透无效——因为所有请求查的key都不同,压根不会合并
在 Gin 中加一层空值缓存的实操要点
核心是在 DB 确认无数据后,主动往 Redis 写一个短 TTL 的占位符,让后续同 key 请求直接命中缓存并快速失败。
- 占位符值建议用字符串如
"nil"或"empty",避免用nil(Go 里nil在 JSON 序列化等环节容易引发 panic) - TTL 必须够短(比如
60 * time.Second),否则会把“暂时不存在”变成“长期不可用” - 不要在 Gin middleware 里统一做——因为不同接口的空值语义不同(用户不存在 vs 订单不存在,处理逻辑可能不一样)
- 务必检查
rdb.Get().Result()返回值是否等于你设定的占位符,而不是只看err == redis.Nil
示例片段:
func getUserHandler(c *gin.Context) {
userID := c.Param("id")
key := "user:" + userID
<pre class="brush:php;toolbar:false;">val, err := rdb.Get(c.Request.Context(), key).Result()
if err == nil {
if val == "empty" {
c.JSON(404, gin.H{"error": "user not found"})
return
}
c.JSON(200, gin.H{"data": val})
return
}
// 查 DB
user, err := db.GetUserByID(userID)
if err == sql.ErrNoRows {
// 【关键】写空占位符,TTL 设为 60 秒
rdb.Set(c.Request.Context(), key, "empty", 60*time.Second)
c.JSON(404, gin.H{"error": "user not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "db error"})
return
}
rdb.Set(c.Request.Context(), key, user, 30*time.Minute)
c.JSON(200, gin.H{"data": user})}
布隆过滤器作为前置校验的适用边界
布隆过滤器适合读多写少、key 空间可预估的场景(比如用户 ID 全局连续,或订单号有固定前缀)。但它在 Gin 中不是开箱即用的“开关”,而是一个需要维护的数据结构。
- 初始化阶段要把所有合法
key批量加入布隆过滤器(例如启动时从 DBSELECT id FROM users) - 写入新记录时,必须同步
bloom.Add([]byte(newID)),漏掉就会误判 - 布隆过滤器有误判率(false positive),但不会漏判(false negative);所以它只能用于「快速拒绝」,不能替代空值缓存
- Go 生态推荐
github.com/bits-and-blooms/bloom,但注意它的bloom.BloomFilter不是线程安全的,多个 Goroutine 并发 Add 需加锁
真正容易被忽略的是:空值缓存和布隆过滤器不是二选一,而是分层防御。布隆过滤器挡掉 95% 的恶意 key,空值缓存兜住剩下 5% 的漏网之鱼和业务异常——这两层都得有,且各自 TTL / 容量策略要独立调优。











