大文件分片上传的核心思路是:前端将大文件切割成多个小分片,逐个发送到服务端暂存,全部接收完成后服务端按顺序合并还原。下面从前后端实现、数据库设计、断点续传、合并逻辑、并发优化和避坑指南六个维度来介绍。
一、核心原理
分片上传不是http协议的内置特性,需要业务层自行实现。前端使用file.slice()(浏览器)或filestream.read()(桌面端)将文件按固定大小切片,单片大小建议2~5 mb——太小增加http请求开销,太大降低失败重传效率。每次请求携带三个关键字段:fileid(全文件唯一标识)、chunkindex(从0开始的片序号)、totalchunks(总片数),服务端按fileid + chunkindex幂等写入,不能依赖请求顺序。
二、前端实现(c# 桌面端 / winforms / wpf)
/// <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)
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)
[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 | 上传时间 |
状态持久化策略:
内存维护活跃会话可以提升性能,但进程崩溃会丢失状态。生产环境应在关键节点落库:首次上传时插入记录,每个分片成功后更新 uploadedchunkscount 和 lastchunkindex,合并完成后将 status 改为 completed 并清理临时文件。
五、分片合并实现
/// <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 小时未完成上传的临时分片。
六、断点续传实现
断点续传的核心是 客户端在开始上传前先向服务端查询已接收的分片索引,跳过这些索引再上传剩余分片。
流程如下:
- 客户端计算
fileid(通常为文件名_文件大小_最后修改时间或文件内容的 md5); - 客户端发送 head/get 请求
get /api/upload/chunk/status?fileid=xxx,获取服务端已接收的chunkindex列表; - 客户端比对本地分片列表,跳过已上传的分片,仅上传缺失部分;
- 每上传成功一个分片,服务端立即持久化状态到数据库;
- 所有分片上传完成后,调用
/merge接口触发合并。
注意事项:
- 不要用本地文件修改时间或 md5 做续传依据,服务端可能清理过临时文件;
- 每个分片上传后必须检查 http 状态码和响应体中的明确确认信息,遇到 409 conflict(分片已存在)可直接跳过,遇到 500 错误则采用指数退避重试策略(最多 3 次);
- 断点续传需要服务端持久化状态,仅依赖磁盘临时文件是不够的——iis 或 kestrel 重启后已上传的分片会丢失。
七、并发上传优化
多个分片可以并发上传以提升效率,但需控制并发数避免带宽抢占:
// 使用 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),它们内置了分片上传、断点续传和错误重试机制。如果需要完全自建,请务必关注上述的数据库设计、幂等性、并发控制和临时文件清理等生产环境要点。
以上就是c#实现大文件分片上传完整指南的详细内容,更多关于c#大文件分片上传的资料请关注代码网其它相关文章!
发表评论