C# 大文件分片上传完整实现指南

大文件分片上传的核心思路是:前端将大文件切割成多个小分片,逐个发送到服务端暂存,全部接收完成后服务端按顺序合并还原。下面从前后端实现、数据库设计、断点续传、合并逻辑、并发优化和避坑指南六个维度来介绍。

一、核心原理

分片上传不是HTTP协议的内置特性,需要业务层自行实现。前端使用File.slice()(浏览器)或FileStream.Read()(桌面端)将文件按固定大小切片,单片大小建议2~5 MB ------太小增加HTTP请求开销,太大降低失败重传效率。每次请求携带三个关键字段:fileId(全文件唯一标识)、chunkIndex(从0开始的片序号)、totalChunks(总片数),服务端按fileId + chunkIndex幂等写入,不能依赖请求顺序。

二、前端实现(C# 桌面端 / WinForms / WPF)

csharp 复制代码
/// <summary>
/// 大文件分片上传客户端(使用 HttpClient)
/// </summary>
public class ChunkUploader
{
    private static readonly HttpClient _httpClient = new HttpClient();
    private const int CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 每片
    private const string UPLOAD_URL = "https://localhost:5001/api/upload/chunk";
    private const string MERGE_URL = "https://localhost:5001/api/upload/merge";

    public async Task<bool> UploadLargeFileAsync(string filePath, string fileId)
    {
        using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, 
                                                FileShare.Read, 81920, FileOptions.Asynchronous);
        long fileSize = fileStream.Length;
        int totalChunks = (int)Math.Ceiling((double)fileSize / CHUNK_SIZE);
        
        // 1. 查询服务端已上传的分片(断点续传)
        var uploadedChunks = await GetUploadedChunksAsync(fileId);
        
        for (int chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++)
        {
            if (uploadedChunks.Contains(chunkIndex)) continue; // 跳过已上传的分片
            
            // 2. 读取分片数据
            int offset = chunkIndex * CHUNK_SIZE;
            int currentChunkSize = (int)Math.Min(CHUNK_SIZE, fileSize - offset);
            byte[] chunkData = new byte[currentChunkSize];
            
            fileStream.Seek(offset, SeekOrigin.Begin);
            await fileStream.ReadAsync(chunkData, 0, currentChunkSize);
            
            // 3. 计算当前分片的哈希值(用于完整性校验)
            string chunkHash = ComputeSha256Hash(chunkData);
            
            // 4. 上传分片
            bool success = await UploadChunkAsync(fileId, chunkIndex, totalChunks, 
                                                    chunkData, chunkHash);
            if (!success)
            {
                // 失败重试(带指数退避)
                success = await RetryUploadAsync(fileId, chunkIndex, totalChunks, chunkData, chunkHash);
                if (!success) return false;
            }
        }
        
        // 5. 所有分片上传完成,触发合并
        return await MergeChunksAsync(fileId, Path.GetFileName(filePath), fileSize);
    }
    
    private async Task<bool> UploadChunkAsync(string fileId, int chunkIndex, int totalChunks, 
                                                byte[] chunkData, string chunkHash)
    {
        using var content = new MultipartFormDataContent();
        content.Add(new ByteArrayContent(chunkData), "file", $"chunk_{chunkIndex}");
        content.Add(new StringContent(fileId), "fileId");
        content.Add(new StringContent(chunkIndex.ToString()), "chunkIndex");
        content.Add(new StringContent(totalChunks.ToString()), "totalChunks");
        content.Add(new StringContent(chunkHash), "chunkHash");
        
        var response = await _httpClient.PostAsync(UPLOAD_URL, content);
        return response.IsSuccessStatusCode;
    }
    
    private async Task<HashSet<int>> GetUploadedChunksAsync(string fileId)
    {
        var response = await _httpClient.GetAsync($"{UPLOAD_URL}/status?fileId={fileId}");
        if (!response.IsSuccessStatusCode) return new HashSet<int>();
        
        var json = await response.Content.ReadAsStringAsync();
        var uploaded = JsonSerializer.Deserialize<List<int>>(json);
        return new HashSet<int>(uploaded ?? new List<int>());
    }
    
    private async Task<bool> MergeChunksAsync(string fileId, string fileName, long fileSize)
    {
        var mergeData = new { fileId, fileName, fileSize };
        var content = new StringContent(JsonSerializer.Serialize(mergeData), 
                                        Encoding.UTF8, "application/json");
        var response = await _httpClient.PostAsync(MERGE_URL, content);
        return response.IsSuccessStatusCode;
    }
    
    private static string ComputeSha256Hash(byte[] data)
    {
        using var sha256 = SHA256.Create();
        byte[] hash = sha256.ComputeHash(data);
        return Convert.ToHexString(hash).ToLowerInvariant();
    }
}

关键要点

  • HttpClient 必须复用单例实例 或用 IHttpClientFactory,否则会导致 socket 耗尽;
  • 超时时间需要显式配置为较大值(如 30 分钟),默认 100 秒不足以完成大文件上传;
  • .NET 5+ 中 StreamContent 默认不会自动 Dispose 底层流,建议改用 ByteArrayContent 以确保安全。

三、服务端实现(ASP.NET Core)

3.1 服务配置(Program.cs)
csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

// 禁用默认请求体大小限制(两层都要配置)
builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxRequestBodySize = long.MaxValue; // 禁用 Kestrel 层限制
});

builder.Services.Configure<FormOptions>(options =>
{
    options.MultipartBodyLengthLimit = long.MaxValue; // 禁用 MVC 层限制
});

var app = builder.Build();

ASP.NET Core 中有两层请求体限制:Kestrel 自身的 MaxRequestBodySize(默认 30MB)和 MVC 层的 MultipartBodyLengthLimit两层必须同时调整才能生效。

3.2 分片上传 API(UploadController)
csharp 复制代码
[ApiController]
[Route("api/[controller]")]
[DisableRequestSizeLimit] // 禁用请求大小限制
public class UploadController : ControllerBase
{
    private readonly IUploadService _uploadService;
    
    public UploadController(IUploadService uploadService)
    {
        _uploadService = uploadService;
    }
    
    /// <summary>
    /// 上传单个分片(绕过 IFormFile,避免 OOM)
    /// </summary>
    [HttpPost("chunk")]
    public async Task<IActionResult> UploadChunk([FromForm] ChunkUploadRequest request)
    {
        // 验证参数
        if (string.IsNullOrEmpty(request.FileId) || request.ChunkIndex < 0)
            return BadRequest("Invalid parameters");
        
        // 验证分片哈希
        using var ms = new MemoryStream();
        await request.File.CopyToAsync(ms);
        byte[] chunkData = ms.ToArray();
        string computedHash = ComputeSha256Hash(chunkData);
        
        if (!computedHash.Equals(request.ChunkHash, StringComparison.OrdinalIgnoreCase))
            return BadRequest("Chunk hash mismatch");
        
        // 幂等保存:如果已存在则直接返回成功
        bool saved = await _uploadService.SaveChunkAsync(request.FileId, request.ChunkIndex, 
                                                          chunkData, request.ChunkHash);
        if (!saved)
            return Conflict(new { message = "Chunk already exists", index = request.ChunkIndex });
        
        return Ok(new { success = true, index = request.ChunkIndex });
    }
    
    /// <summary>
    /// 查询已上传的分片索引(断点续传核心)
    /// </summary>
    [HttpGet("chunk/status")]
    public async Task<IActionResult> GetUploadedChunks([FromQuery] string fileId)
    {
        var uploadedChunks = await _uploadService.GetUploadedChunkIndicesAsync(fileId);
        return Ok(uploadedChunks);
    }
    
    /// <summary>
    /// 合并所有分片
    /// </summary>
    [HttpPost("merge")]
    public async Task<IActionResult> MergeChunks([FromBody] MergeRequest request)
    {
        // 加锁防止并发合并
        bool merged = await _uploadService.MergeChunksAsync(request.FileId, request.FileName);
        if (!merged)
            return Conflict(new { message = "Merge failed or already in progress" });
        
        return Ok(new { success = true, filePath = $"/uploads/{request.FileName}" });
    }
}

public class ChunkUploadRequest
{
    public string FileId { get; set; }
    public int ChunkIndex { get; set; }
    public int TotalChunks { get; set; }
    public string ChunkHash { get; set; }
    public IFormFile File { get; set; }
}

public class MergeRequest
{
    public string FileId { get; set; }
    public string FileName { get; set; }
    public long FileSize { get; set; }
}

关键要点

  • 不要使用 IFormFile 直接处理 GB 级文件 ,它会触发完整文件读取和内存缓冲,导致 OOM。但分片上传场景下单片只有 2-5 MB,用 IFormFile 是可行的;
  • 每片保存后必须校验哈希,网络传输中单片出错很常见,仅靠文件大小无法判断内容正确性;
  • 接口必须支持幂等写入------重复上传同一片应直接返回成功,而非报错。

四、数据库设计(跟踪上传状态)

为支持断点续传和状态恢复,需要设计两张核心表:

上传会话表(UploadSession)

字段 类型 说明
SessionId GUID PK 文件上传会话唯一标识
FileName VARCHAR(255) 原始文件名
FileSize BIGINT 文件总大小(字节)
FileHash VARCHAR(128) 整个文件的 SHA256 值(秒传校验)
ChunkSize INT 分片大小(字节)
TotalChunks INT 总分片数
UploadedChunksCount INT 已上传分片数
Status TINYINT 状态:0-上传中,1-合并中,2-已完成,3-失败
CreatedAt DATETIME2 创建时间
UpdatedAt DATETIME2 更新时间

分片记录表(UploadedChunk)

字段 类型 说明
ChunkId BIGINT PK 自增主键
SessionId GUID FK 关联到 UploadSession
ChunkIndex INT 分片序号(从 0 开始)
ChunkSize INT 该分片大小(最后一片可能较小)
ChunkHash VARCHAR(128) 该分片的 SHA256 值
StoredPath VARCHAR(500) 分片在磁盘上的存储路径
UploadedAt DATETIME2 上传时间

状态持久化策略

内存维护活跃会话可以提升性能,但进程崩溃会丢失状态。生产环境应在关键节点落库:首次上传时插入记录,每个分片成功后更新 UploadedChunksCountlastChunkIndex,合并完成后将 Status 改为 Completed 并清理临时文件。

五、分片合并实现

csharp 复制代码
/// <summary>
/// 安全合并分片(使用 Seek 定位写入,避免内存溢出)
/// </summary>
public async Task<bool> MergeChunksAsync(string fileId, string finalFileName)
{
    var chunks = await GetChunksOrderedAsync(fileId);
    if (chunks.Count == 0) return false;
    
    // 检查是否所有分片都已到达
    int totalChunks = await GetTotalChunksCountAsync(fileId);
    if (chunks.Count != totalChunks) return false;
    
    string tempDir = Path.Combine(_config["Storage:ChunkPath"], fileId);
    string finalPath = Path.Combine(_config["Storage:FinalPath"], finalFileName);
    
    // 使用 FileStream 配合 Seek 定位写入,而非全量加载
    using var finalStream = new FileStream(finalPath, FileMode.Create, FileAccess.Write, 
                                           FileShare.None, 81920, useAsync: true);
    
    int chunkSize = _config.GetValue<int>("ChunkSize", 5 * 1024 * 1024);
    
    foreach (var chunk in chunks)
    {
        long offset = chunk.ChunkIndex * (long)chunkSize;
        finalStream.Seek(offset, SeekOrigin.Begin);
        
        string chunkPath = Path.Combine(tempDir, $"{fileId}_{chunk.ChunkIndex}.tmp");
        using var chunkStream = new FileStream(chunkPath, FileMode.Open, FileAccess.Read);
        await chunkStream.CopyToAsync(finalStream);
    }
    
    await finalStream.FlushAsync();
    
    // 合并完成后校验全文件哈希(可选)
    string finalHash = await ComputeFileSha256Async(finalPath);
    if (!finalHash.Equals(await GetExpectedFileHashAsync(fileId), StringComparison.OrdinalIgnoreCase))
    {
        File.Delete(finalPath);
        return false;
    }
    
    // 清理临时分片文件和目录
    foreach (var chunk in chunks)
    {
        File.Delete(Path.Combine(tempDir, $"{fileId}_{chunk.ChunkIndex}.tmp"));
    }
    Directory.Delete(tempDir);
    
    return true;
}

合并要点

  • 不要用 File.AppendAllBytes()File.ReadAllBytes() + File.WriteAllBytes(),大文件会内存溢出;
  • 必须使用 FileStream.Seek() 按分片编号计算偏移量后写入,确保写入位置精确;
  • 合并前必须校验三个条件:分片哈希完整、全部分片已到达、加锁防止并发合并;
  • 合并成功后立即清理临时文件,失败时也要清理并标记任务为失败状态;
  • 建议设置后台定时任务(如每 30 分钟执行一次),扫描并清理超过 2 小时未完成上传的临时分片。

六、断点续传实现

断点续传的核心是 客户端在开始上传前先向服务端查询已接收的分片索引,跳过这些索引再上传剩余分片

流程如下:

  1. 客户端计算 fileId(通常为 文件名_文件大小_最后修改时间 或文件内容的 MD5);
  2. 客户端发送 HEAD/GET 请求 GET /api/upload/chunk/status?fileId=xxx,获取服务端已接收的 chunkIndex 列表;
  3. 客户端比对本地分片列表,跳过已上传的分片,仅上传缺失部分;
  4. 每上传成功一个分片,服务端立即持久化状态到数据库;
  5. 所有分片上传完成后,调用 /merge 接口触发合并。

注意事项

  • 不要用本地文件修改时间或 MD5 做续传依据,服务端可能清理过临时文件;
  • 每个分片上传后必须检查 HTTP 状态码和响应体中的明确确认信息,遇到 409 Conflict(分片已存在)可直接跳过,遇到 500 错误则采用指数退避重试策略(最多 3 次);
  • 断点续传需要服务端持久化状态,仅依赖磁盘临时文件是不够的------IIS 或 Kestrel 重启后已上传的分片会丢失。

七、并发上传优化

多个分片可以并发上传以提升效率,但需控制并发数避免带宽抢占:

csharp 复制代码
// 使用 SemaphoreSlim 控制最大并发数
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(3); // 最多 3 个并发

public async Task UploadWithConcurrencyAsync(string filePath, string fileId, int totalChunks)
{
    var tasks = new List<Task>();
    
    for (int chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++)
    {
        await _semaphore.WaitAsync();
        int index = chunkIndex; // 捕获变量
        
        tasks.Add(Task.Run(async () =>
        {
            try
            {
                await UploadSingleChunkAsync(filePath, fileId, index, totalChunks);
            }
            finally
            {
                _semaphore.Release();
            }
        }));
    }
    
    await Task.WhenAll(tasks);
}

八、避坑指南

1. 服务端默认限制问题

ASP.NET Core 有两层请求体限制,必须同时调整才生效。Kestrel 默认 MaxRequestBodySize 为 30MB,MVC 层也有自己的限制,两层都要配置为 long.MaxValue

2. Stream 行为差异

.NET Framework 中 StreamContent 会自动 Dispose 底层流,而 .NET 5+ 默认不会。建议统一使用 ByteArrayContent 避免兼容性问题。

3. HTTP 顺序不可靠

HTTP 请求不保证顺序到达,服务端必须以 fileId + chunkIndex 为准进行幂等写入,不能依赖请求到达顺序进行合并。

4. 大文件哈希计算

计算整个文件的 SHA256 时,不要用 SHA256.Create().ComputeHash(fileStream) 一次性读入内存,而应使用 TransformBlock / TransformFinalBlock 增量分块计算,避免 OOM。

5. 合并时的并发控制

合并操作必须加锁防止并发多次触发。可使用文件锁(FileStream.Lock())或分布式锁(如 Redis SETNX)实现。

6. 临时文件清理

必须设置自动清理机制:用后台定时任务扫描 lastModified 超过设定时间(如 2 小时)的临时分片并删除,避免磁盘被残留文件占满。

九、方案选择建议

方案 适用场景 优点 缺点
自建分片上传 需要完全掌控、自定义业务逻辑 灵活可控、无外部依赖 开发成本高、需要处理所有边界情况
WebUploader + ASP.NET MVC Web 端大文件上传,历史项目 成熟稳定、社区资源多 前端依赖外部组件
阿里云 OSS / 腾讯云 COS 直接对接云存储 分片上传已内置、高可靠、支持断点续传 需要云服务账号、有流量费用
Azure Blob Storage 微软生态项目 与 .NET 集成好、原生支持块上传 仅限 Azure 环境

建议:如果项目已经使用云存储,优先使用云厂商的 SDK(如阿里云 OSS、Azure Blob、腾讯云 COS),它们内置了分片上传、断点续传和错误重试机制。如果需要完全自建,请务必关注上述的数据库设计、幂等性、并发控制和临时文件清理等生产环境要点。

相关推荐
jf加菲猫2 小时前
第12章 数据可视化
开发语言·c++·qt·ui
Lenyiin2 小时前
Python数据类型与运算符:深入理解Python世界的基石
java·开发语言·python
AI科技星2 小时前
张祥前统一场论中两个电荷定义的统一性解析
开发语言·线性代数·算法·数学建模·平面
代码地平线2 小时前
C语言实现堆与堆排序详解:从零手写到TopK算法及时间复杂度证明
c语言·开发语言·算法
西西学代码2 小时前
查找设备页面(amap_map)
开发语言·前端·javascript
迦南的迦 亚索的索2 小时前
PYTHON_DAY21_数据分析
开发语言·python·数据分析
枫叶丹42 小时前
【HarmonyOS 6.0】ArkWeb 手势获焦模式详解:告别点击获焦,迎接长按触发
开发语言·华为·harmonyos
ID_180079054733 小时前
如何使用 Python 调用小红书笔记评论 API 时进行并发控制?
开发语言·笔记·python
lsx2024063 小时前
PHP Error处理指南
开发语言