直接复用同一http.Client并发Range请求会因连接复用导致206/200混杂或超时,应为每分块创建独立Client或禁用连接复用,配合Context超时、io.ReadFull+WriteAt精准写入,并行写入需预分配文件大小且避免O_TRUNC。

为什么直接用 http.Client 并发请求分块会失败
常见错误是为每个分块起一个 goroutine,直接复用同一个 *http.Client 实例并设置 Range 请求头,结果部分请求返回 206 Partial Content,另一些却返回 200 OK 或直接超时。根本原因是:HTTP/1.1 默认复用连接,而服务端对 Range 请求的响应行为依赖于底层 TCP 连接状态和服务器实现(比如 Nginx 有 limit_rate 或连接复用策略),并发下多个 Range 请求挤在同一个连接上,容易触发协议错乱或被限流。
解决办法很明确:每个分块请求必须使用独立的、带显式超时的 *http.Client,或者至少禁用连接复用:
- 设置
Transport的MaxIdleConnsPerHost = 1,避免多个请求抢同一连接 - 给每个请求单独设置
Context超时,防止某个分块卡死拖垮整个任务 - 不要复用
Request对象——每次都要用http.NewRequestWithContext()新建
io.CopyN 和 io.ReadFull 在写入分块时怎么选
分块下载后需把数据精准写入文件指定偏移位置。这里不能用 io.Copy,它不保证字节数;也不能只靠 os.WriteAt,因为网络读取可能提前 EOF。
推荐组合:io.ReadFull + os.WriteAt:
立即学习“go语言免费学习笔记(深入)”;
-
io.ReadFull严格读取指定字节数,返回io.ErrUnexpectedEOF表示服务端数据不足(此时应重试或报错) - 拿到完整字节后,用
file.WriteAt(data, offset)写入,注意offset是该分块在文件中的起始位置,不是从 0 开始 - 避免用
io.CopyN:它内部用io.Read,无法确保读满,且返回实际读取数难与期望对齐
如何安全地合并多个 goroutine 写入的文件片段
多个 goroutine 并发调用 file.WriteAt 是线程安全的,但有两个关键前提:
- 文件必须以
os.O_CREATE | os.O_RDWR打开,且**不能加os.O_TRUNC**——否则可能清空其他 goroutine 已写入的内容 - 写入前确保文件已预分配大小:
file.Truncate(totalSize),否则某些文件系统(如 ext4)在稀疏写入时性能极差,甚至触发磁盘碎片 - 不需要额外加锁,但建议用
sync.WaitGroup等待所有写入完成,再关闭文件句柄
如果跳过 Truncate,首次 WriteAt 会扩展文件,但后续写入可能因文件系统延迟导致 WriteAt 返回 ENOSPC 或阻塞。
重试逻辑必须绑定到单个分块,而不是整个任务
整块失败就重试全部?这是最常见也最浪费的做法。正确做法是每个分块独立重试,且重试次数、间隔、是否切换代理都可差异化配置。
实操要点:
- 用结构体封装分块信息:
type Chunk struct { URL string; Start, End int64; Retry int } - 重试时重新生成
Request,并更新Retry计数,避免重复使用旧请求头(例如过期的认证 token) - 记录失败分块的
Start和End,便于调试时快速定位是哪一段出问题,而不是笼统说“下载失败” - 最大重试次数建议设为 3,超过则直接标记失败,由上层决定是放弃还是降级为单线程重下
并发调度本身不复杂,真正麻烦的是边界条件:服务端不支持 Range、磁盘空间突然不足、时钟漂移导致 TLS 握手失败——这些都得在单个分块维度捕获和隔离,不然一个异常就能让整个并发流水线停摆。


















