在 Go 中让并行 HTTP 请求更稳定:构建 Markdown Linter 的经验教训
Source: Dev.to
在构建 gomarklint——一个基于 Go 的 Markdown 检查工具时,我遇到了一个挑战:要检查超过 100,000 行文档中的失效链接。使用 goroutine 并行化看起来是个“显而易见”的做法,但它立刻在 CI 环境中导致了不稳定的测试。Go 的速度很容易实现,稳定性才是真正的难点。下面是我实现的三种模式,兼顾了速度和稳定性。
URL 缓存
在大型文档集中,同一个 URL 会出现数十次。一个天真的并发实现会对每一次出现都发送请求,这看起来像是对目标主机的 DoS 攻击。使用 sync.Map,我实现了一个简单的 URL 缓存,确保每个唯一的 URL 只检查一次。
var urlCache sync.Map
// Check if we've seen this URL before
if val, ok := urlCache.Load(url); ok {
return val.(*checkResult), nil
}
限制并发度
即使有缓存,同时检查大量唯一 URL 仍可能耗尽本地文件描述符或触发速率限制。使用信号量通道可以限制并发检查的数量。
maxConcurrency := 10
sem := make(chan struct{}, maxConcurrency)
for _, url := range urls {
sem = 500 {
time.Sleep(retryDelay * time.Duration(attempt))
// retry...
}
缓存完整结果
最难定位的 bug 是只缓存了状态码。如果请求超时,我会存储 status: 0。后续检查取到 0 时却不知道发生了错误,导致逻辑不一致。解决办法是缓存整个结果,包括错误信息。
type checkResult struct {
status int
err error
}
// Store the pointer to this struct in your cache
处理缓存击穿
即使采取了上述措施,“缓存击穿”(多个 goroutine 在同一毫秒内同时访问同一个未缓存的 URL)仍然是个潜在问题。我目前正在探索使用 golang.org/x/sync/singleflight 来解决这个问题。
如果你有为大规模并行检查调优 http.Client 的经验,欢迎在评论区或 GitHub 上分享你的想法!