singleflight.Group 本身不防缓存击穿,仅做请求合并;真正起效需闭环“查缓存→miss后进Do→成功后写缓存”,且Group须全局复用、key需带命名空间前缀、loader内须用ctx控制超时并正确处理redis.Nil。

用 singleflight.Group 防缓存击穿,在 Echo 中不是加个中间件就能完事——关键在于 Do 调用时机、loader 函数的上下文传播、以及空值与错误的区分处理。它不自动写缓存,也不跨实例生效,纯内存级合并,必须嵌入到你的业务查询路径里。
为什么不能在 Echo 中间件里统一 wrap cache Get?
中间件对每个请求都走一遍,但 singleflight.Do 必须和「缓存未命中 → 回源加载」这个逻辑强绑定。如果在中间件里无差别调用 Do("key", loader),会导致:
- 所有请求(包括已命中的)都进 Do,徒增锁竞争开销
- loader 里没做缓存写入,结果只返回给当前请求,其他等待者拿到结果后仍不会落缓存
- 无法按 key 动态构造 loader(比如需从 c.Param("id") 提取),中间件缺乏路由上下文
loader 函数必须手动处理 context 超时和 redis.Nil
singleflight.Do 不透传 echo.Context,你得把 c.Request().Context() 显式传进去;同时 Redis 查询返回 redis.Nil 是正常现象,不是错误,不能直接当失败抛出:
Echo框架 5.1.0 版本源码包下载,适合关注 RealIP 行为变化、StartConfig.Listener、NewDefaultFS 和观测性中间件入口的开发团队。
result, err, shared := sg.Do("user:123", func() (interface{}, error) {
// ✅ 正确:用 ctx 控制 DB/Redis 超时
val, err := rdb.Get(ctx, "user:123").Result()
if errors.Is(err, redis.Nil) {
// ✅ 空值要显式返回 (nil, nil),否则会被当错误兜底
return nil, nil
}
if err != nil {
return nil, err
}
return val, nil
})-
errors.Is(err, redis.Nil)必须用,不能写err == redis.Nil(它是变量) - 返回
(nil, nil)表示“查到空”,后续可走空值缓存逻辑;返回(nil, someErr)才是真错误,需按业务策略降级 - loader 内部不做缓存写入,那是调用方的责任——等
Do返回后,由第一个成功加载者执行rdb.Set和本地缓存填充
全局复用一个 singleflight.Group 实例,但 key 要带命名空间前缀
多个业务共用一个 Group 没问题,但 key 冲突会导致无关请求互相阻塞。比如用户服务和订单服务都用 "123" 当 key,就会串线:
- ✅ 推荐 key 格式:
"load:user:" + userID、"load:order:" + orderID - ❌ 避免直接用
c.Param("id")或原始 ID 字符串,尤其当不同接口可能传相同数字但语义不同(如/users/123和/posts/123) - 不建议每次请求 new
singleflight.Group,它内部用 map + mutex 管理等待队列,new 太多会泄漏 goroutine
最容易被忽略的是:singleflight 只解决「谁去查」,不解决「查完写哪」和「写失败怎么办」。同一个 key 的等待者共享 loader 返回值,但如果 loader 成功后写 Redis 失败,下次缓存又失效,整套逻辑就重来一遍——所以写缓存那步必须幂等且带重试,且不能放在 Do 外面由调用方统一做(否则只有第一个 goroutine 会写,其余等待者看不到新缓存)。

















