背景
在开发基于 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. 关键收获
- SingleFlight 的回调必须是纯计算 ,不能操作请求独有的资源(如
http.ResponseWriter)。 - 共享结果需要显式标记来源(
FromCache),以便各请求独立设置准确的元数据(如缓存状态头)。 - 错误处理时要防止对
nil返回值的访问。 - 并发优化不能牺牲可观测性,
X-Cache这样的调试信息必须每个请求都准确。
8. 结语
这次踩坑从表象(日志停止)深入到并发模型冲突,最终通过解耦计算与输出、完善状态标记,得到一个既高性能又可靠的缓存代理。这个案例再次证明:理解并发原语的执行上下文是所有优化的大前提。
导出建议:可将本文保存为 Markdown 文件,直接用于技术博客或团队 Wiki。