分块下载需手动构造带range头的httprequestmessage,严格格式为"bytes=start-end",服务器须支持accept-ranges;分块大小建议1–4mb并考虑存储对齐;须用semaphoreslim限并发、复用httpclient;写入文件必须按偏移定位而非完成顺序。

Range 请求必须手动设置 Range 头,HttpClient 默认不发
HttpClient 不会自动拆分请求或加 Range 头——哪怕你传了 HttpCompletionOption.ResponseHeadersRead。它只管发完整 GET,服务器返回 200 就完事。要实现分块下载,你得自己构造多个独立的 HttpRequestMessage,每个都显式设置 Range 头,比如 "bytes=0-1048575"。
常见错误是误以为用 WebClient 或 HttpClient.SendAsync 加个参数就能“启用范围下载”,结果抓包一看全是 200,压根没触发 206 Partial Content。
- 必须用
HttpRequestMessage+client.SendAsync()手动发请求 -
Range值格式严格:"bytes={start}-{end}",不能多空格、不能缺单位、不能写成"0~1024" - 服务器必须支持(响应头含
Accept-Ranges: bytes),否则返回 416 或 200 全量内容
分块大小不是越大越好,1–4MB 是多数场景的平衡点
单块太大(如 64MB),内存占用高、失败重试成本大、网络抖动时容易超时;太小(如 64KB),HTTP 头开销占比飙升,线程/连接调度压力大,实际吞吐反而下降。实测在千兆局域网和普通云对象存储上,2MB 分块通常比 512KB 快 15–30%,又比 8MB 更稳。
注意:分块大小需对齐文件系统或存储服务的最小读单元(如某些 CDN 要求 512KB 对齐),否则可能触发额外 IO 或降速。
- 起始偏移必须是整数,
end可等于文件总长减 1(最后一块) - 避免让块边界落在压缩流或加密块中间(如 ZIP 内部结构、AES-CBC 段),否则解压/解密失败
- 用
FileInfo.Length获取总大小前,先确认服务器返回了Content-Length或通过 HEAD 请求预取
并发控制别硬写 Task.WhenAll,用 SemaphoreSlim 限流更可靠
直接扔几十个 Task.Run(() => DownloadChunk(...)) 进 Task.WhenAll,极易打爆连接池、触发服务器限流(429)、或耗尽本地端口(SocketException: Only one usage of each socket address...)。.NET 的 HttpClient 连接池默认只允许 2–6 个并发连接(取决于 .NET 版本和配置)。
正确做法是用 SemaphoreSlim 控制同时进行的请求数(建议 4–8),并复用同一个 HttpClient 实例。
- 不要为每个分块 new 一个
HttpClient,会泄漏连接 -
SemaphoreSlim.WaitAsync()放在发送请求前,不是在回调里 - 记得 await
SemaphoreSlim.Release(),即使下载失败也要释放 - 超时统一设在
HttpRequestMessage.Properties["StartTime"]或用CancellationTokenSource管理
合并文件时顺序错乱是高频坑,别依赖 Task 完成顺序
分块下载完成后,各 Task 返回顺序完全不确定。如果按完成先后往文件里 Write,最终文件就是乱的——开头可能是第 5 块,中间夹着第 1 块。必须按逻辑偏移位置写入,而不是按执行顺序。
最简方案:每个分块下载完,把 byte[] 和对应 startOffset 存进线程安全集合(如 ConcurrentDictionary<long byte></long>),全部完成后遍历 offset 升序合并;更省内存的做法是打开 FileStream 并用 fileStream.Position = startOffset 定位写入。
- 写入前检查
FileStream.CanSeek == true,某些流(如NetworkStream)不支持 - 用
fileStream.Write(byteArray, 0, byteArray.Length),别用WriteAsync配合Position——异步写入时Position可能被其他线程改 - 最后一块长度可能小于分块大小,以响应头
Content-Range中的*/{total}为准,别信byteArray.Length









