Go 并发实战:SingleFlight 踩坑与缓存代理优化复盘

背景

在开发基于 DeepSeek 的 AI 预测代理服务时,我使用 singleFlight 合并相同请求以避免缓存击穿,同时结合支付中间件(x402)控制访问。起初,在单次请求测试时一切正常,但当并发请求到达时,出现了"仅第一个请求收到数据,其他请求空响应并最终超时"的诡异现象。

本文复盘整个排查与优化过程,记录关键踩坑点和最终解决方案。


1. 问题现象

日志显示执行到 fetchAndCacheStream 的第 230 行(打印 "DeepSeek streaming: ...")后就再没有后续输出,然后 HTTP 返回 200 但耗时 36 秒(slow call)。实际上,只有并发请求中的第一个成功接收了流式数据,其他请求全部挂起直到超时

2. 初始代码架构

go 复制代码
// Handle 中调用 singleFlight
cacheValue, err := h.singleFlight.Do(cacheKey, func() (*cache.CacheValue, error) {
    _, err = h.fetchAndCacheStream(cacheKey, bodyBytes, w, r) // ❌ 传入了 w
    return nil, err
})
  • fetchAndCacheStream 内部直接向传入的 w 写入 SSE 流。
  • 回调永远返回 nil, nil,后续 Handle 再尝试用 cacheValue(nil) 调用 serveCachedStream,触发 panic 或写出空响应。

3. 根因分析:SingleFlight 的共享执行体陷阱

singleFlight 的核心机制:多个 goroutine 使用相同 key 并发调用时,只有一个真正执行回调函数,其余 goroutine 阻塞等待并共享同一个返回值

问题在于:

  • 回调中使用了第一个请求独有的 http.ResponseWriter 进行写入。
  • 等待的 goroutine 拿到的返回值是 nil,然后它们各自持有的 w 没有得到任何数据,最终客户端空等超时。

这本质上是把"计算共享结果"和"每个请求私有的输出通道"强行耦合在共享回调中,违反了并发安全原则。

4. 类比 Java 中的并发错误

在 Java 中,类似的错误场景包括:

  • CompletableFuture.supplyAsync 中直接写入某个线程私有的 HttpServletResponse
  • 错误使用 ThreadLocal 将请求独有对象暴露给共享线程池。

核心教训:共享的任务只应负责生产数据 ,每个请求独立负责数据分发

5. 逐步修复过程

5.1 第一次修正:回调中不传 w,但返回 nil

go 复制代码
cacheValue, err := h.singleFlight.Do(cacheKey, func() (*cache.CacheValue, error) {
    _, err = h.fetchAndCacheStream(cacheKey, bodyBytes, r) // 不再传 w
    return nil, err // 仍然返回 nil
})

虽然 fetchAndCacheStream 不再污染 w,但回调仍返回 nil,导致等待的请求拿到 nil,依旧走不到写入分支。失败

5.2 第二次修正:让回调返回真正的 CacheValue

go 复制代码
cacheValue, err := h.singleFlight.Do(cacheKey, func() (*cache.CacheValue, error) {
    if cv, ok := h.cache.Get(cacheKey); ok && cv != nil {
        return cv, nil
    }
    return h.fetchAndCacheStream(cacheKey, bodyBytes, r)
})

此时 fetchAndCacheStream 只构建 CacheValue 并存入 Redis,不触碰任何 w。所有等待的 goroutine 都能拿到同一份 cacheValue,然后各自调用 serveCachedStream 写入客户端。核心并发问题解决

5.3 X-Cache 头准确性问题

  • 在回调内部设置 X-Cache 只会影响执行回调的那个请求,其他等待请求拿不到正确标记。
  • 在 Handle 层预设 MISS 后,如果回调中的双重检查命中缓存,外部无法修正为 HIT

解决方案 :在 CacheValue 中添加 FromCache bool 字段,在回调内准确标记来源,在 Handle 统一设置响应头。

go 复制代码
// 回调内
if cv, ok := h.cache.Get(cacheKey); ok && cv != nil {
    cv.FromCache = true
    return cv, nil
}
cv, err := h.fetchAndCacheStream(cacheKey, bodyBytes, r)
if err == nil {
    cv.FromCache = false
}
return cv, err

// Handle 中
if cacheValue.FromCache {
    w.Header().Set("X-Cache", "HIT")
} else {
    w.Header().Set("X-Cache", "MISS")
}

5.4 防止 nil 指针

fetchAndCacheStream 出错时返回 nil, err,若在其后直接设置 cv.FromCache 会导致 panic。因此必须检查:

go 复制代码
if err == nil {
    cv.FromCache = false
}

5.5 细节完善

  • 错误信息从残缺的 " v %d" 改为完整的 "deepseek returned status %d"
  • JSON 响应分支同样需要 X-Cache
  • 移除冗余的预设 MISS,全部由统一逻辑设置。

6. 最终稳健方案

核心流程伪代码:

go 复制代码
func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) {
    // 支付逻辑略...
    cacheKey := generateKey(body)

    // 快速路径:缓存命中直接返回
    if cv, ok := h.cache.Get(cacheKey); ok && cv != nil {
        w.Header().Set("X-Cache", "HIT")
        h.serveCachedStream(w, r, cv)
        return
    }

    // 未命中,用 singleFlight 构建
    cv, err := h.singleFlight.Do(cacheKey, func() (*CacheValue, error) {
        if cv, ok := h.cache.Get(cacheKey); ok { // 双重检查
            cv.FromCache = true
            return cv, nil
        }
        cv, err := h.fetchAndCacheStream(cacheKey, body, r) // 只构建不写w
        if err == nil {
            cv.FromCache = false
        }
        return cv, err
    })
    if err != nil { ... }

    // 统一设置 X-Cache
    if cv.FromCache {
        w.Header().Set("X-Cache", "HIT")
    } else {
        w.Header().Set("X-Cache", "MISS")
    }

    if cv.Type == JSON {
        // 写 JSON
    } else {
        h.serveCachedStream(w, r, cv) // 各自重放
    }
}

7. 关键收获

  1. SingleFlight 的回调必须是纯计算 ,不能操作请求独有的资源(如 http.ResponseWriter)。
  2. 共享结果需要显式标记来源(FromCache),以便各请求独立设置准确的元数据(如缓存状态头)。
  3. 错误处理时要防止对 nil 返回值的访问。
  4. 并发优化不能牺牲可观测性,X-Cache 这样的调试信息必须每个请求都准确。

8. 结语

这次踩坑从表象(日志停止)深入到并发模型冲突,最终通过解耦计算与输出、完善状态标记,得到一个既高性能又可靠的缓存代理。这个案例再次证明:理解并发原语的执行上下文是所有优化的大前提


导出建议:可将本文保存为 Markdown 文件,直接用于技术博客或团队 Wiki。

相关推荐
唐青枫3 小时前
别再把 new 当构造函数:Go new 从零值指针到实战用法
go
用户398346161203 小时前
Go-Spring 实战第 17 课 —— App 运行模型:启动、运行与关闭
spring·go
9624565 小时前
Go 语言 x402 支付中间件与 DeepSeek 代理开发复盘
go
明月_清风5 小时前
图解 Socket 编程:一文吃透 TCP/UDP 编程模型(Go 实战版)
后端·tcp/ip·go
踏着七彩祥云的小丑19 小时前
Go学习第1天:入门
开发语言·学习·golang·go
用户743835613512 天前
无锁 Hub:我的 IM 系统为什么用 channel 而不是 mutex 管理在线用户
go
吴佳浩3 天前
Go史上最大“打脸”现场来了:泛型方法终于实现了
后端·go
明月_清风3 天前
深入 Go 并发编程:从 Goroutine 到 Channel 的系统性避坑指南
后端·go