在高性能 Web 系统的开发中,“Redis 缓存 + DB 持久化” 是最经典的读写架构。 然而
拒绝缓存击穿!用 Go 的 SingleFlight 护航你的 Redis 缓存
发布时间: 2026-06-20 (a month ago)
GO

在高性能 Web 系统的开发中,“Redis 缓存 + DB 持久化” 是最经典的读写架构。

然而,在这个架构下,隐藏着一个足以让系统瞬间瘫痪的致命暗礁——缓存击穿 (Cache Stampede / Cache Breakdown)

想象一下这个场景:你的系统中有一个极度热门的商品(或者热门新闻),它的缓存数据由于设置了生存时间 (TTL),在某一个瞬间刚好过期失效。而就在这一毫秒,成千上万个并发读请求排山倒海般涌来。由于 Redis 中没有缓存,所有的请求都会像洪水一样直接冲向数据库去执行查询,并试图重新写入 Redis。

后果:数据库连接池瞬间被占满,CPU 飙升到 100%,系统开始大面积响应超时,最终引发雪崩,整个服务直接瘫痪。

传统的解决办法是使用互斥锁(Mutex)或分布式锁,但锁机制太重,不仅增加了网络开销,还极大地牺牲了吞吐量。

今天,我们就来聊聊 Go 官方提供的一个极其轻量、优雅的并发利器——golang.org/x/sync/singleflight。它能在零网络开销的前提下,让高并发下的数据库毫发无损。

1. 什么是 SingleFlight?

singleflight 的核心思想极其朴素:合并重复请求

在同一个进程内,如果多个 Goroutine 同时发起同一个 key(比如 goods_1001)的查询请求,singleflight 会确保有且仅有一个 Goroutine 真正去执行这个查询(比如查数据库),其他的 Goroutine 都会进入阻塞等待状态。

当那个幸运的 Goroutine 完成查询并返回结果时,singleflight 会把这个结果广播分发给所有正在等待的 Goroutine。

[Goroutine 1] ──┐ [ Goroutine 2 ] ──┼─▶ [ singleflight ] ── (仅 1 次查询) ──▶ [ 数据库 DB ] [ Goroutine 3 ] ──┘ │└─▶ 广播共享结果给 1, 2, 3

这样,原本 1000 次对数据库的查询,在这一瞬间被骤降到了 1 次!

2. 快速上手:SingleFlight 的基础使用

使用 singleflight 非常简单。首先需要引入官方包:

bash 复制代码
go get golang.org/x/sync/singleflight

其核心接口只有一个 Group 结构体,以及它的 Do 方法:

GO 复制代码
package main

import (
  "fmt"
  "sync"
  "time"

  "golang.org/x/sync/singleflight"
)

var g singleflight.Group

func main() {
  var wg sync.WaitGroup
  
  // 模拟 10 个并发请求同时获取同一个 Key 的数据
  for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(id int) {
      defer wg.Done()
      
      // Do 方法:第一个参数是 key,第二个参数是实际要执行的函数
      v, err, shared := g.Do("get_user_info", func() (any, error) {
        // 模拟慢查询
        fmt.Printf("[Goroutine %d] 正在查询数据库...\n", id)
        time.Sleep(500 * time.Millisecond)
        return "User_Data_From_DB", nil
      })

      if err != nil {
        fmt.Printf("错误: %v\n", err)
        return
      }
      
      // v: 返回的数据;shared: 表示该结果是否被其他协程共享
      fmt.Printf("[Goroutine %d] 获取结果: %v, 是否共享: %t\n", id, v, shared)
    }(i)
  }

  wg.Wait()
}

运行结果

bash 复制代码
[Goroutine 3] 正在查询数据库...
[Goroutine 3] 获取结果: User_Data_From_DB, 是否共享: true
[Goroutine 0] 获取结果: User_Data_From_DB, 是否共享: true
[Goroutine 1] 获取结果: User_Data_From_DB, 是否共享: true
...

可以看到,虽然有 10 个协程同时并发,但 “正在查询数据库...” 这行字只输出了一次。所有协程都成功拿到了数据,并且标记了 shared: true。

3. 实战:Redis + DB + SingleFlight 的标准模板

在生产环境中,我们如何将 singleflight 嵌入到原有的缓存查询链路中?以下是一个标准的工业级 Go 模板:

GO 复制代码
package service

import (
  "context"
  "encoding/json"
  "fmt"
  "time"

  "github.com/go-redis/redis/v9"
  "golang.org/x/sync/singleflight"
)

type ProductService struct {
  redisClient *redis.Client
  sfGroup     singleflight.Group
}

type Product struct {
  ID    int64  `json:"id"`
  Name  string `json:"name"`
  Price float64 `json:"price"`
}

// GetProductDetail 获取商品详情(防击穿版本)
func (s *ProductService) GetProductDetail(ctx context.Context, productID int64) (*Product, error) {
  cacheKey := fmt.Sprintf("product:%d", productID)

  // 1. 尝试从 Redis 缓存读取
  val, err := s.redisClient.Get(ctx, cacheKey).Result()
  if err == nil {
    // 命中缓存,直接反序列化返回
    var product Product
    if json.Unmarshal([]byte(val), &product) == nil {
      return &product, nil
    }
  }

  // 2. 缓存未命中(过期或不存在),使用 SingleFlight 进行回源拦截
  v, sfErr, _ := s.sfGroup.Do(cacheKey, func() (any, error) {
    // 【注意】这里需要进行“二次检查(Double-Check)”
    // 因为在等待 SingleFlight 锁的期间,可能有其他协程已经完成了回源并把结果写入了缓存
    val, innerErr := s.redisClient.Get(ctx, cacheKey).Result()
    if innerErr == nil {
      var product Product
      if json.Unmarshal([]byte(val), &product) == nil {
        return &product, nil
      }
    }

    // 真正去查数据库
    product, dbErr := s.queryProductFromDB(productID)
    if dbErr != nil {
      return nil, dbErr
    }

    // 查出数据后,异步或同步将其写回 Redis,并设置 TTL 防止缓存穿透
    pBytes, _ := json.Marshal(product)
    s.redisClient.Set(ctx, cacheKey, pBytes, 10*time.Minute)

    return product, nil
  })

  if sfErr != nil {
    return nil, sfErr
  }

  return v.(*Product), nil
}

// 模拟数据库查询
func (s *ProductService) queryProductFromDB(id int64) (*Product, error) {
  time.Sleep(200 * time.Millisecond) // 模拟慢 SQL
  return &Product{ID: id, Name: "MacBook Pro", Price: 19999.0}, nil
}

4. 高级避坑:生产环境必须知道的 SingleFlight 细节

虽然 singleflight 只有几十行代码,但在高并发、复杂的生产环境中使用它,必须注意以下三个隐藏细节:

⚠️ 天坑 1:慢查询引起的 “大面积挂起(Goroutine 泄露)”

Do 方法是阻塞的。如果数据库由于负载过高、网络卡顿,导致回源查询函数执行了 30 秒还没有返回。那么在这 30 秒内,所有涌入的请求都会被挂在 Do 内部无法释放,这会导致系统的 Goroutine 数量呈线性暴涨,直到内存耗尽挂掉。

💡 解决办法:使用 DoChan 结合 Context 实现超时控制

singleflight 提供了另一个非阻塞的 API:DoChan。它会返回一个通道,当执行完毕时向通道内发送数据。我们可以结合 select 和带有超时的 Context 来控制超时释放:

GO 复制代码
func (s *ProductService) GetProductDetailWithTimeout(ctx context.Context, productID int64) (*Product, error) {
  cacheKey := fmt.Sprintf("product:%d", productID)

  // 1. 查缓存(逻辑同上文,此处省略)

  // 2. 使用 DoChan 异步获取
  ch := s.sfGroup.DoChan(cacheKey, func() (any, error) {
    return s.queryProductFromDB(productID)
  })

  // 3. 监听通道,同时支持 context 超时控制
  select {
  case <-ctx.Done():
    // 如果调用方 context 超时或被取消,立刻返回,不再无限期等待
    return nil, ctx.Err()
  case result := <-ch:
    if result.Err != nil {
      return nil, result.Err
    }
    return result.Val.(*Product), nil
  }
}

⚠️ 天坑 2:共享指针修改引发的 “数据竞争(Data Race)”

singleflight 的 Do 返回的是 any 类型的接口。如果你回源时返回的是一个指针(如 *Product),且有多个 Goroutine 拿到了相同的指针,只要其中任何一个 Goroutine 随后对这个指针指向的对象进行了写操作,就会产生可怕的多协程数据竞争(Data Race)。

💡 解决办法:

回源函数中尽量返回值拷贝而不是指针,或者在拿到数据后,由接收方进行一次反序列化(Deep Copy)。
保证返回的对象是只读的,严禁在后续业务中修改它。

⚠️ 天坑 3:单次执行失败导致所有协程一起 “吃土”

如果回源函数(比如查 DB)在执行时因为网络瞬时抖动报错了,那么 singleflight 会把这个 error 分发给所有等待的协程,导致所有协程在同一瞬间拿不到数据。
💡 解决办法:如果回源执行失败,可以考虑在 Do 完毕后,快速清理掉这个 key 的状态(使用 s.sfGroup.Forget(cacheKey)),让后续请求能够立即发起重试,而不是一直等待。

5. 总结

在面对 “高并发缓存击穿” 这一经典后端挑战时,Go 语言通过内置的 singleflight 包提供了一个近乎零成本的优雅答案。
相比于在网络层引入 Redis 分布式锁,singleflight 在单机 / 微服务网关层做请求合并,效率高、无外部依赖。用好它,你的高并发系统离 “坚不可摧” 就又近了一步!