[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-38":3},{"id":4,"title":5,"title_en":6,"abstract":7,"abstract_en":8,"content":9,"content_en":10,"category":11,"banner_id":12,"banner_path":13,"tags":14,"is_recommend":15,"prev_article":16,"next_article":20,"created_at":24},38," 拒绝缓存击穿！用 Go 的 SingleFlight 护航你的 Redis 缓存"," Cache breakdown denied! Protect your Redis cache with Go's SingleFlight","在高性能 Web 系统的开发中，“Redis 缓存 + DB 持久化” 是最经典的读写架构。\n\n然而","In the development of high-performance Web systems,\"Redis Cache + DB Persistence\" is the most classic read-write architecture.\n\nhowever","在高性能 Web 系统的开发中，“Redis 缓存 + DB 持久化” 是最经典的读写架构。\n\n然而，在这个架构下，隐藏着一个足以让系统瞬间瘫痪的致命暗礁——**缓存击穿 (Cache Stampede \u002F Cache Breakdown)**。\n\n想象一下这个场景：你的系统中有一个极度热门的商品（或者热门新闻），它的缓存数据由于设置了生存时间 (TTL)，在某一个瞬间刚好过期失效。而就在这一毫秒，成千上万个并发读请求排山倒海般涌来。由于 Redis 中没有缓存，所有的请求都会像洪水一样直接冲向数据库去执行查询，并试图重新写入 Redis。\n\n**后果**：数据库连接池瞬间被占满，CPU 飙升到 100%，系统开始大面积响应超时，最终引发雪崩，整个服务直接瘫痪。\n\n传统的解决办法是使用互斥锁（Mutex）或分布式锁，但锁机制太重，不仅增加了网络开销，还极大地牺牲了吞吐量。\n\n今天，我们就来聊聊 Go 官方提供的一个极其轻量、优雅的并发利器——`golang.org\u002Fx\u002Fsync\u002Fsingleflight`。它能在零网络开销的前提下，让高并发下的数据库毫发无损。\n\n## 1. 什么是 SingleFlight？\nsingleflight 的核心思想极其朴素：**合并重复请求**。\n\n在同一个进程内，如果多个 Goroutine 同时发起同一个 key（比如 `goods_1001`）的查询请求，singleflight 会确保有且仅有一个 Goroutine 真正去执行这个查询（比如查数据库），其他的 Goroutine 都会进入阻塞等待状态。\n\n当那个幸运的 Goroutine 完成查询并返回结果时，singleflight 会把这个结果广播分发给所有正在等待的 Goroutine。\n```mermaid\n[Goroutine 1] ──┐\n[ Goroutine 2 ] ──┼─▶ [ singleflight ] ── (仅 1 次查询) ──▶ [ 数据库 DB ]\n[ Goroutine 3 ] ──┘ \n│└─▶ 广播共享结果给 1, 2, 3\n```\n\n这样，原本 1000 次对数据库的查询，在这一瞬间被骤降到了 1 次！\n\n## 2. 快速上手：SingleFlight 的基础使用\n使用 singleflight 非常简单。首先需要引入官方包：\n```bash\ngo get golang.org\u002Fx\u002Fsync\u002Fsingleflight\n```\n\n其核心接口只有一个 Group 结构体，以及它的 Do 方法：\n\n```GO\npackage main\n\nimport (\n  \"fmt\"\n  \"sync\"\n  \"time\"\n\n  \"golang.org\u002Fx\u002Fsync\u002Fsingleflight\"\n)\n\nvar g singleflight.Group\n\nfunc main() {\n  var wg sync.WaitGroup\n  \n  \u002F\u002F 模拟 10 个并发请求同时获取同一个 Key 的数据\n  for i := 0; i \u003C 10; i++ {\n    wg.Add(1)\n    go func(id int) {\n      defer wg.Done()\n      \n      \u002F\u002F Do 方法：第一个参数是 key，第二个参数是实际要执行的函数\n      v, err, shared := g.Do(\"get_user_info\", func() (any, error) {\n        \u002F\u002F 模拟慢查询\n        fmt.Printf(\"[Goroutine %d] 正在查询数据库...\\n\", id)\n        time.Sleep(500 * time.Millisecond)\n        return \"User_Data_From_DB\", nil\n      })\n\n      if err != nil {\n        fmt.Printf(\"错误: %v\\n\", err)\n        return\n      }\n      \n      \u002F\u002F v: 返回的数据；shared: 表示该结果是否被其他协程共享\n      fmt.Printf(\"[Goroutine %d] 获取结果: %v, 是否共享: %t\\n\", id, v, shared)\n    }(i)\n  }\n\n  wg.Wait()\n}\n```\n### 运行结果\n```bash\n[Goroutine 3] 正在查询数据库...\n[Goroutine 3] 获取结果: User_Data_From_DB, 是否共享: true\n[Goroutine 0] 获取结果: User_Data_From_DB, 是否共享: true\n[Goroutine 1] 获取结果: User_Data_From_DB, 是否共享: true\n...\n```\n可以看到，虽然有 10 个协程同时并发，但 “正在查询数据库...” 这行字只输出了一次。所有协程都成功拿到了数据，并且标记了 shared: true。\n\n## 3. 实战：Redis + DB + SingleFlight 的标准模板\n在生产环境中，我们如何将 singleflight 嵌入到原有的缓存查询链路中？以下是一个标准的工业级 Go 模板：\n```GO\npackage service\n\nimport (\n  \"context\"\n  \"encoding\u002Fjson\"\n  \"fmt\"\n  \"time\"\n\n  \"github.com\u002Fgo-redis\u002Fredis\u002Fv9\"\n  \"golang.org\u002Fx\u002Fsync\u002Fsingleflight\"\n)\n\ntype ProductService struct {\n  redisClient *redis.Client\n  sfGroup     singleflight.Group\n}\n\ntype Product struct {\n  ID    int64  `json:\"id\"`\n  Name  string `json:\"name\"`\n  Price float64 `json:\"price\"`\n}\n\n\u002F\u002F GetProductDetail 获取商品详情（防击穿版本）\nfunc (s *ProductService) GetProductDetail(ctx context.Context, productID int64) (*Product, error) {\n  cacheKey := fmt.Sprintf(\"product:%d\", productID)\n\n  \u002F\u002F 1. 尝试从 Redis 缓存读取\n  val, err := s.redisClient.Get(ctx, cacheKey).Result()\n  if err == nil {\n    \u002F\u002F 命中缓存，直接反序列化返回\n    var product Product\n    if json.Unmarshal([]byte(val), &product) == nil {\n      return &product, nil\n    }\n  }\n\n  \u002F\u002F 2. 缓存未命中（过期或不存在），使用 SingleFlight 进行回源拦截\n  v, sfErr, _ := s.sfGroup.Do(cacheKey, func() (any, error) {\n    \u002F\u002F 【注意】这里需要进行“二次检查（Double-Check）”\n    \u002F\u002F 因为在等待 SingleFlight 锁的期间，可能有其他协程已经完成了回源并把结果写入了缓存\n    val, innerErr := s.redisClient.Get(ctx, cacheKey).Result()\n    if innerErr == nil {\n      var product Product\n      if json.Unmarshal([]byte(val), &product) == nil {\n        return &product, nil\n      }\n    }\n\n    \u002F\u002F 真正去查数据库\n    product, dbErr := s.queryProductFromDB(productID)\n    if dbErr != nil {\n      return nil, dbErr\n    }\n\n    \u002F\u002F 查出数据后，异步或同步将其写回 Redis，并设置 TTL 防止缓存穿透\n    pBytes, _ := json.Marshal(product)\n    s.redisClient.Set(ctx, cacheKey, pBytes, 10*time.Minute)\n\n    return product, nil\n  })\n\n  if sfErr != nil {\n    return nil, sfErr\n  }\n\n  return v.(*Product), nil\n}\n\n\u002F\u002F 模拟数据库查询\nfunc (s *ProductService) queryProductFromDB(id int64) (*Product, error) {\n  time.Sleep(200 * time.Millisecond) \u002F\u002F 模拟慢 SQL\n  return &Product{ID: id, Name: \"MacBook Pro\", Price: 19999.0}, nil\n}\n```\n## 4. 高级避坑：生产环境必须知道的 SingleFlight 细节\n\n虽然 singleflight 只有几十行代码，但在高并发、复杂的生产环境中使用它，必须注意以下三个隐藏细节：\n### ⚠️ 天坑 1：慢查询引起的 “大面积挂起（Goroutine 泄露）”\nDo 方法是阻塞的。如果数据库由于负载过高、网络卡顿，导致回源查询函数执行了 30 秒还没有返回。那么在这 30 秒内，所有涌入的请求都会被挂在 Do 内部无法释放，这会导致系统的 Goroutine 数量呈线性暴涨，直到内存耗尽挂掉。\n\n### 💡 解决办法：使用 DoChan 结合 Context 实现超时控制\nsingleflight 提供了另一个非阻塞的 API：DoChan。它会返回一个通道，当执行完毕时向通道内发送数据。我们可以结合 select 和带有超时的 Context 来控制超时释放：\n```GO\nfunc (s *ProductService) GetProductDetailWithTimeout(ctx context.Context, productID int64) (*Product, error) {\n  cacheKey := fmt.Sprintf(\"product:%d\", productID)\n\n  \u002F\u002F 1. 查缓存（逻辑同上文，此处省略）\n\n  \u002F\u002F 2. 使用 DoChan 异步获取\n  ch := s.sfGroup.DoChan(cacheKey, func() (any, error) {\n    return s.queryProductFromDB(productID)\n  })\n\n  \u002F\u002F 3. 监听通道，同时支持 context 超时控制\n  select {\n  case \u003C-ctx.Done():\n    \u002F\u002F 如果调用方 context 超时或被取消，立刻返回，不再无限期等待\n    return nil, ctx.Err()\n  case result := \u003C-ch:\n    if result.Err != nil {\n      return nil, result.Err\n    }\n    return result.Val.(*Product), nil\n  }\n}\n```\n### ⚠️ 天坑 2：共享指针修改引发的 “数据竞争（Data Race）”\n\nsingleflight 的 Do 返回的是 any 类型的接口。如果你回源时返回的是一个指针（如 *Product），且有多个 Goroutine 拿到了相同的指针，只要其中任何一个 Goroutine 随后对这个指针指向的对象进行了写操作，就会产生可怕的多协程数据竞争（Data Race）。\n\n### 💡 解决办法：\n回源函数中尽量返回值拷贝而不是指针，或者在拿到数据后，由接收方进行一次反序列化（Deep Copy）。\n保证返回的对象是只读的，严禁在后续业务中修改它。\n\n### ⚠️ 天坑 3：单次执行失败导致所有协程一起 “吃土”\n如果回源函数（比如查 DB）在执行时因为网络瞬时抖动报错了，那么 singleflight 会把这个 error 分发给所有等待的协程，导致所有协程在同一瞬间拿不到数据。\n💡 解决办法：如果回源执行失败，可以考虑在 Do 完毕后，快速清理掉这个 key 的状态（使用 s.sfGroup.Forget(cacheKey)），让后续请求能够立即发起重试，而不是一直等待。\n\n## 5. 总结\n在面对 “高并发缓存击穿” 这一经典后端挑战时，Go 语言通过内置的 singleflight 包提供了一个近乎零成本的优雅答案。\n相比于在网络层引入 Redis 分布式锁，singleflight 在单机 \u002F 微服务网关层做请求合并，效率高、无外部依赖。用好它，你的高并发系统离 “坚不可摧” 就又近了一步！\n","在高性能 Web 系统的开发中，“Redis 缓存 + DB 持久化” 是最经典的读写架构。\n\n然而，在这个架构下，隐藏着一个足以让系统瞬间瘫痪的致命暗礁--**缓存击穿 (Cache Stampede \u002F Cache Breakdown)**。\n\n想象一下这个场景：你的系统中有一个极度热门的商品（或者热门新闻），它的缓存数据由于设置了生存时间 (TTL)，在某一个瞬间刚好过期失效。而就在这一毫秒，成千上万个并发读请求排山倒海般涌来。由于 Redis 中没有缓存，所有的请求都会像洪水一样直接冲向数据库去执行查询，并试图重新写入 Redis。\n\n**后果**：数据库连接池瞬间被占满，CPU 飙升到 100%，系统开始大面积响应超时，最终引发雪崩，整个服务直接瘫痪。\n\n传统的解决办法是使用互斥锁（Mutex）或分布式锁，但锁机制太重，不仅增加了网络开销，还极大地牺牲了吞吐量。\n\n今天，我们就来聊聊 Go 官方提供的一个极其轻量、优雅的并发利器--`golang.org\u002Fx\u002Fsync\u002Fsingleflight`。它能在零网络开销的前提下，让高并发下的数据库毫发无损。\n\n## 1. 什么是 SingleFlight？\nsingleflight 的核心思想极其朴素：**合并重复请求**。\n\n在同一个进程内，如果多个 Goroutine 同时发起同一个 key（比如 `goods_1001`）的查询请求，singleflight 会确保有且仅有一个 Goroutine 真正去执行这个查询（比如查数据库），其他的 Goroutine 都会进入阻塞等待状态。\n\n当那个幸运的 Goroutine 完成查询并返回结果时，singleflight 会把这个结果广播分发给所有正在等待的 Goroutine。\n```mermaid\n[Goroutine 1] ──┐\n[ Goroutine 2 ] ──┼─▶ [ singleflight ] ── (仅 1 次查询) ──▶ [ 数据库 DB ]\n[ Goroutine 3 ] ──┘ \n│└─▶ 广播共享结果给 1, 2, 3\n```\n\n这样，原本 1000 次对数据库的查询，在这一瞬间被骤降到了 1 次!\n\n## 2. 快速上手：SingleFlight 的基础使用\n使用 singleflight 非常简单。首先需要引入官方包：\n```bash\ngo get golang.org\u002Fx\u002Fsync\u002Fsingleflight\n```\n\n其核心接口只有一个 Group 结构体，以及它的 Do 方法：\n\n```GO\npackage main\n\nimport (\n  \"fmt\"\n  \"sync\"\n  \"time\"\n\n  \"golang.org\u002Fx\u002Fsync\u002Fsingleflight\"\n)\n\nvar g singleflight.Group\n\nfunc main() {\n  var wg sync.WaitGroup\n  \n  \u002F\u002F 模拟 10 个并发请求同时获取同一个 Key 的数据\n  for i := 0; i \u003C 10; i++ {\n    wg.Add(1)\n    go func(id int) {\n      defer wg.Done()\n      \n      \u002F\u002F Do 方法：第一个参数是 key，第二个参数是实际要执行的函数\n      v, err, shared := g.Do(\"get_user_info\", func() (any, error) {\n        \u002F\u002F 模拟慢查询\n        fmt.Printf(\"[Goroutine %d] 正在查询数据库...\\n\", id)\n        time.Sleep(500 * time.Millisecond)\n        return \"User_Data_From_DB\", nil\n      })\n\n      if err != nil {\n        fmt.Printf(\"错误: %v\\n\", err)\n        return\n      }\n      \n      \u002F\u002F v: 返回的数据;shared: 表示该结果是否被其他协程共享\n      fmt.Printf(\"[Goroutine %d] 获取结果: %v, 是否共享: %t\\n\", id, v, shared)\n    }(i)\n  }\n\n  wg.Wait()\n}\n```\n### 运行结果\n```bash\n[Goroutine 3] 正在查询数据库...\n[Goroutine 3] 获取结果: User_Data_From_DB, 是否共享: true\n[Goroutine 0] 获取结果: User_Data_From_DB, 是否共享: true\n[Goroutine 1] 获取结果: User_Data_From_DB, 是否共享: true\n...\n```\n可以看到，虽然有 10 个协程同时并发，但 “正在查询数据库...” 这行字只输出了一次。所有协程都成功拿到了数据，并且标记了 shared: true。\n\n## 3. 实战：Redis + DB + SingleFlight 的标准模板\n在生产环境中，我们如何将 singleflight 嵌入到原有的缓存查询链路中？以下是一个标准的工业级 Go 模板：\n```GO\npackage service\n\nimport (\n  \"context\"\n  \"encoding\u002Fjson\"\n  \"fmt\"\n  \"time\"\n\n  \"github.com\u002Fgo-redis\u002Fredis\u002Fv9\"\n  \"golang.org\u002Fx\u002Fsync\u002Fsingleflight\"\n)\n\ntype ProductService struct {\n  redisClient *redis.Client\n  sfGroup     singleflight.Group\n}\n\ntype Product struct {\n  ID    int64  `json:\"id\"`\n  Name  string `json:\"name\"`\n  Price float64 `json:\"price\"`\n}\n\n\u002F\u002F GetProductDetail 获取商品详情（防击穿版本）\nfunc (s *ProductService) GetProductDetail(ctx context.Context, productID int64) (*Product, error) {\n  cacheKey := fmt.Sprintf(\"product:%d\", productID)\n\n  \u002F\u002F 1. 尝试从 Redis 缓存读取\n  val, err := s.redisClient.Get(ctx, cacheKey).Result()\n  if err == nil {\n    \u002F\u002F 命中缓存，直接反序列化返回\n    var product Product\n    if json.Unmarshal([]byte(val), &product) == nil {\n      return &product, nil\n    }\n  }\n\n  \u002F\u002F 2. 缓存未命中（过期或不存在），使用 SingleFlight 进行回源拦截\n  v, sfErr, _ := s.sfGroup.Do(cacheKey, func() (any, error) {\n    \u002F\u002F 【注意】这里需要进行“二次检查（Double-Check）”\n    \u002F\u002F 因为在等待 SingleFlight 锁的期间，可能有其他协程已经完成了回源并把结果写入了缓存\n    val, innerErr := s.redisClient.Get(ctx, cacheKey).Result()\n    if innerErr == nil {\n      var product Product\n      if json.Unmarshal([]byte(val), &product) == nil {\n        return &product, nil\n      }\n    }\n\n    \u002F\u002F 真正去查数据库\n    product, dbErr := s.queryProductFromDB(productID)\n    if dbErr != nil {\n      return nil, dbErr\n    }\n\n    \u002F\u002F 查出数据后，异步或同步将其写回 Redis，并设置 TTL 防止缓存穿透\n    pBytes, _ := json.Marshal(product)\n    s.redisClient.Set(ctx, cacheKey, pBytes, 10*time.Minute)\n\n    return product, nil\n  })\n\n  if sfErr != nil {\n    return nil, sfErr\n  }\n\n  return v.(*Product), nil\n}\n\n\u002F\u002F 模拟数据库查询\nfunc (s *ProductService) queryProductFromDB(id int64) (*Product, error) {\n  time.Sleep(200 * time.Millisecond) \u002F\u002F 模拟慢 SQL\n  return &Product{ID: id, Name: \"MacBook Pro\", Price: 19999.0}, nil\n}\n```\n## 4. 高级避坑：生产环境必须知道的 SingleFlight 细节\n\n虽然 singleflight 只有几十行代码，但在高并发、复杂的生产环境中使用它，必须注意以下三个隐藏细节：\n### ⚠️ 天坑 1：慢查询引起的 “大面积挂起（Goroutine 泄露）”\nDo 方法是阻塞的。如果数据库由于负载过高、网络卡顿，导致回源查询函数执行了 30 秒还没有返回。那么在这 30 秒内，所有涌入的请求都会被挂在 Do 内部无法释放，这会导致系统的 Goroutine 数量呈线性暴涨，直到内存耗尽挂掉。\n\n### 💡 解决办法：使用 DoChan 结合 Context 实现超时控制\nsingleflight 提供了另一个非阻塞的 API：DoChan。它会返回一个通道，当执行完毕时向通道内发送数据。我们可以结合 select 和带有超时的 Context 来控制超时释放：\n```GO\nfunc (s *ProductService) GetProductDetailWithTimeout(ctx context.Context, productID int64) (*Product, error) {\n  cacheKey := fmt.Sprintf(\"product:%d\", productID)\n\n  \u002F\u002F 1. 查缓存（逻辑同上文，此处省略）\n\n  \u002F\u002F 2. 使用 DoChan 异步获取\n  ch := s.sfGroup.DoChan(cacheKey, func() (any, error) {\n    return s.queryProductFromDB(productID)\n  })\n\n  \u002F\u002F 3. 监听通道，同时支持 context 超时控制\n  select {\n  case \u003C-ctx.Done():\n    \u002F\u002F 如果调用方 context 超时或被取消，立刻返回，不再无限期等待\n    return nil, ctx.Err()\n  case result := \u003C-ch:\n    if result.Err != nil {\n      return nil, result.Err\n    }\n    return result.Val.(*Product), nil\n  }\n}\n```\n### ⚠️ 天坑 2：共享指针修改引发的 “数据竞争（Data Race）”\n\nsingleflight 的 Do 返回的是 any 类型的接口。如果你回源时返回的是一个指针（如 *Product），且有多个 Goroutine 拿到了相同的指针，只要其中任何一个 Goroutine 随后对这个指针指向的对象进行了写操作，就会产生可怕的多协程数据竞争（Data Race）。\n\n### 💡 解决办法：\n回源函数中尽量返回值拷贝而不是指针，或者在拿到数据后，由接收方进行一次反序列化（Deep Copy）。\n保证返回的对象是只读的，严禁在后续业务中修改它。\n\n### ⚠️ 天坑 3：单次执行失败导致所有协程一起 “吃土”\n如果回源函数（比如查 DB）在执行时因为网络瞬时抖动报错了，那么 singleflight 会把这个 error 分发给所有等待的协程，导致所有协程在同一瞬间拿不到数据。\n💡 解决办法：如果回源执行失败，可以考虑在 Do 完毕后，快速清理掉这个 key 的状态（使用 s.sfGroup.Forget(cacheKey)），让后续请求能够立即发起重试，而不是一直等待。\n\n## 5. 总结\n在面对 “高并发缓存击穿” 这一经典后端挑战时，Go 语言通过内置的 singleflight 包提供了一个近乎零成本的优雅答案。\n相比于在网络层引入 Redis 分布式锁，singleflight 在单机 \u002F 微服务网关层做请求合并，效率高、无外部依赖。用好它，你的高并发系统离 “坚不可摧” 就又近了一步!\n","GO",33,"https:\u002F\u002Fblog4-1316398321.cos.ap-nanjing.myqcloud.com\u002Fblog5\u002F20260621005326__NASA-地球-太空.png",[11],false,{"id":17,"title":18,"title_en":19},37,"GopherGraph v1.1.0 升级与安全修复实战","GopherGraph v1.1.0 Upgrade and Security Fix Practice",{"id":21,"title":22,"title_en":23},39,"当 Go 遇见 Gemini：构建高可靠 AI 向量化流水线的实战","When Go Meets Gemini: Building a High-Reliability AI Vectorization Pipeline in Practice","2026-06-20T23:21:13.754885+08:00"]