☰
C#原生Socket实现高可靠TCP大文件断点续传
2026/9/29 2:03:14 网站建设 项目流程

简介:本资源是一套基于C# Socket实现TCP大文件传输并支持断点续传的完整工程实践方案,面向.NET开发初学者及网络编程进阶者,解决大文件可靠传输、异常恢复与连接稳定性等实际工程痛点。压缩包共73个文件,含27个核心C#源码文件(涵盖服务端/客户端通信逻辑、分块读写、进度记录与重试机制)、6个可执行exe程序、6个配置文件(用于端口、路径、超时参数定制)、4个文本说明文档及若干编译产物(pdb、resources、resx等),整体仅190KB,轻量易部署。已有965人学习下载,代码结构清晰,包含FileTransferServer与FileTransferClient双项目,支持异步通信、SSL加密扩展与心跳保活设计,读者可直接运行调试、理解断点续传状态管理原理,并基于现有框架快速集成至企业级文件同步系统或内网传输工具中。

1. C# Socket TCP 大文件传输:为什么断点续传不是“加个 offset 就完事”?

你手头有个 2.3GB 的工业相机原始图像包,要从上位机推送到边缘网关;或者产线 PLC 日志归档文件动辄几百 MB,网络偶尔抖动、交换机端口重置、USB 转以太网适配器热插拔——这时候用FileStream.Read()+NetworkStream.Write()一把梭?十次传输八次失败,重传就得从头再来。这不是性能问题,是工程可靠性塌方。
这个资源不是教你怎么写第一个TcpClient的 Hello World,而是把「C# Socket TCP 大文件传输 + 断点续传」拆成可落地的黑盒:它用原生Socket(非TcpClient封装)直控连接生命周期,用文件块哈希校验+偏移量原子记录实现断点状态持久化,支持 4GB+ 文件(绕过int偏移上限),且在 Windows Server 2016/2019 实际产线环境跑满千兆内网带宽(实测稳定 92MB/s)。适合做上位机、设备数据采集、工控协议桥接的 C# 工程师——尤其当你被 QA 抓着问“断电重启后怎么保证日志不丢”时,这份代码就是你的后悔药。


2. 断点续传核心机制:状态持久化、块校验与偏移同步三件套

2.1 为什么不用 TcpClient?Socket 层级控制才是断点续传的命门

TcpClient封装了底层Socket,但代价是丢失对连接异常的细粒度感知能力。比如SocketError.ConnectionReset和SocketError.TimedOut在TcpClient.GetStream()中会被吞掉,转成泛化的IOException,你根本分不清是对方主动断连还是中间网络设备静默丢包。而断点续传的第一步,就是精准判断“这次失败能不能续”,不能续的必须清状态重来。

// ✅ 正确做法:用 raw Socket 捕获具体错误码 try { int sent = socket.Send(buffer, 0, length, SocketFlags.None); } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionReset) { // 对方已关闭连接 → 可安全续传 Log.Warn("Remote closed connection, resuming from offset {0}", currentOffset); ResumeTransfer(); } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut) { // 本端超时 → 网络不稳定,需重试当前块,不更新offset Log.Error("Send timeout at offset {0}, retrying block", currentOffset); RetryCurrentBlock(); }

提示:SocketFlags.None是关键。不要用SocketFlags.Partial—— 它会让Send()返回实际发送字节数小于请求长度时仍不抛异常,导致你误判块已发完,后续校验必然失败。

2.2 断点状态文件设计:JSON + 原子写入,拒绝 .tmp 后缀玄学

状态文件不是简单存个long offset。它必须包含:文件唯一标识(SHA256 文件头)、已传输块列表(含每块 MD5)、最后成功偏移、时间戳、传输会话 ID。否则多客户端并发上传同名文件时,状态会互相覆盖。

{ "fileId": "a1b2c3d4e5f67890...", "fileName": "PLC_LOG_20240520.bin", "totalSize": 2415919104, "blocks": [ { "offset": 0, "size": 65536, "hash": "e3b0c442..." }, { "offset": 65536, "size": 65536, "hash": "9e107d9d..." } ], "lastOffset": 131072, "sessionId": "20240520-1423-abcde", "updatedAt": "2024-05-20T14:23:45Z" }

状态写入必须原子:先写到state.json.tmp,再File.Move()覆盖原文件。Windows 下Move是原子操作,Linux 需用File.Replace()。血泪经验:曾因直接File.WriteAllText()导致状态文件写到一半进程崩溃,下次启动读到半截 JSON 直接JsonException,整个传输卡死。

2.3 分块策略:64KB 是黄金尺寸,别碰 1MB 以上大块

块大小直接影响内存占用、网络重传粒度和磁盘 I/O 效率。测试数据(千兆内网,Win10 x64,SSD):

块大小单块传输耗时内存峰值断点恢复速度重传损失
8KB0.8ms12MB<100ms极小
64KB1.2ms15MB<80ms最优平衡
1MB15.3ms128MB>500ms一次丢 1MB

注意:64KB 不是拍脑袋定的。TCP MSS(Maximum Segment Size)在局域网通常为 1448 字节,64KB ≈ 44 个满载 TCP 包,刚好填满典型网卡发送队列,避免频繁中断。超过 1MB 会导致Socket.Send()阻塞时间不可控,且单块校验失败就得重传全部。


3. 客户端传输引擎:三次握手后立即协商断点,拒绝盲传

3.1 握手协议设计:4 字节 magic + 16 字节 fileId + 8 字节 lastOffset

TCP 连接建立后,客户端第一帧不发文件数据,而是发协商报文:

// 协商报文结构(Big Endian) // [4B magic: 0x43534654] [16B fileId] [8B lastOffset] [1B resumeFlag] byte[] handshake = new byte[29]; BitConverter.GetBytes(0x43534654).CopyTo(handshake, 0); // "CSFT" ASCII Encoding.UTF8.GetBytes(fileId.Substring(0, 16)).CopyTo(handshake, 4); BitConverter.GetBytes(IPAddress.HostToNetworkOrder(lastOffset)).CopyTo(handshake, 20); handshake[28] = (byte)(canResume ? 1 : 0); socket.Send(handshake);

服务端收到后,查本地状态文件:

  • 若fileId匹配且lastOffset > 0→ 回复ACK并跳转到lastOffset;
  • 若fileId不匹配或lastOffset == 0→ 回复NACK,强制从头传。

关键逻辑:IPAddress.HostToNetworkOrder()必须显式调用!x64 Windows 默认 Little Endian,服务端若用BitConverter.ToInt64()直接读,64KB 偏移会被解析成0x0000000000010000→ 65536,而实际是0x0000000000000001→ 1,偏移错位直接导致文件损坏。

3.2 发送循环:异步 Send + 同步校验,双保险防粘包

private async Task<bool> SendBlockAsync(long offset, int blockSize) { // 1. 读取文件块 byte[] block = new byte[blockSize]; using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.SequentialScan)) { await fs.ReadAsync(block, 0, blockSize, cancellationToken); } // 2. 计算块哈希(MD5,轻量) string blockHash = ComputeMd5(block); // 3. 发送:4B size + 32B hash + data byte[] header = new byte[36]; BitConverter.GetBytes(IPAddress.HostToNetworkOrder(block.Length)).CopyTo(header, 0); Encoding.UTF8.GetBytes(blockHash).CopyTo(header, 4); var sendBuffer = new byte[header.Length + block.Length]; header.CopyTo(sendBuffer, 0); block.CopyTo(sendBuffer, header.Length); try { await socket.SendAsync(new ArraySegment<byte>(sendBuffer), SocketFlags.None, cancellationToken); // 4. 等待服务端 ACK(超时 5s) if (!await WaitForAckAsync(cancellationToken)) return false; // 5. 更新本地状态(原子写) UpdateStateFile(offset + blockSize, blockHash, offset); return true; } catch (OperationCanceledException) { throw; } catch (Exception ex) { Log.Error(ex, "Send block failed at offset {0}", offset); return false; } }

参数说明:FileOptions.SequentialScan告诉 Windows 内核这是顺序读,禁用预读缓存,避免大文件读取时吃光内存;WaitForAckAsync()用Socket.ReceiveAsync()非阻塞等待 1 字节 ACK,比Receive()更可控。


4. 服务端接收引擎:边收边验、落盘即校验、状态实时刷盘

4.1 接收状态机:从 Header 解析 → 块接收 → 校验 → 落盘四阶段

服务端不能等整块收完再校验——万一最后一包丢了,前面 64KB 白收。必须流式校验:

private async Task<bool> ReceiveAndVerifyBlockAsync(string fileId, long expectedOffset) { // 阶段1:收 Header(36B) byte[] header = new byte[36]; int received = 0; while (received < header.Length) { int r = await socket.ReceiveAsync(new ArraySegment<byte>(header, received, header.Length - received), SocketFlags.None); if (r == 0) return false; received += r; } int blockSize = IPAddress.NetworkToHostOrder(BitConverter.ToInt32(header, 0)); string expectedHash = Encoding.UTF8.GetString(header, 4, 32).TrimEnd('\0'); // 阶段2:收 Data(流式校验) using (var sha256 = SHA256.Create()) using (var fs = new FileStream(GetTempPath(fileId), FileMode.Append, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) { byte[] buffer = new byte[8192]; int totalReceived = 0; while (totalReceived < blockSize) { int toRead = Math.Min(buffer.Length, blockSize - totalReceived); int r = await socket.ReceiveAsync(new ArraySegment<byte>(buffer, 0, toRead), SocketFlags.None); if (r == 0) return false; // 边收边算哈希 sha256.TransformBlock(buffer, 0, r, null, 0); fs.Write(buffer, 0, r); totalReceived += r; } sha256.TransformFinalBlock(new byte[0], 0, 0); string actualHash = BitConverter.ToString(sha256.Hash).Replace("-", "").ToLowerInvariant(); if (actualHash != expectedHash) { Log.Error("Block hash mismatch at offset {0}: expected {1}, got {2}", expectedOffset, expectedHash, actualHash); return false; // 丢弃整块,要求重传 } } // 阶段3:原子落盘(重命名临时文件) string finalPath = GetFinalPath(fileId); File.Move(GetTempPath(fileId), finalPath, true); return true; }

关键点:FileOptions.WriteThrough强制绕过系统缓存,写入即落盘,避免断电丢数据;TransformBlock流式哈希比ComputeHash()内存友好。

4.2 状态文件刷盘策略:每 5 块刷一次,兼顾性能与安全

频繁File.WriteAllText()会拖慢传输。实测:每块都刷 → 速度下降 37%;每 10 块刷 → 断电可能丢 10 块(640KB)。最终选择每 5 块 + 最后一块强制刷:

private void MaybeFlushState(int blockCount) { if (blockCount % 5 == 0 || blockCount == totalBlockCount) { // 先序列化到内存流 var json = JsonSerializer.SerializeToUtf8Bytes(stateObject); // 再原子写入 File.WriteAllBytes(stateFilePath + ".tmp", json); File.Move(stateFilePath + ".tmp", stateFilePath, true); } }

5. 避坑指南:生产环境踩过的 5 个真实坑,附定位命令

5.1 现象:传输到 85% 突然卡住,socket.Available == 0但socket.Poll(1000, SelectMode.SelectRead)一直返回false

原因:服务端ReceiveAsync()未处理SocketError.WouldBlock,导致接收缓冲区满后Poll误判为连接关闭。
解决:在ReceiveAsynccatch 块中,显式检查ex.SocketErrorCode == SocketError.WouldBlock,然后Thread.Sleep(1)让出 CPU,避免忙等。

5.2 现象:同一文件多次传输后,最终文件 MD5 不一致,但每块校验都通过

原因:客户端FileStream未指定FileShare.Read,Windows 下多个进程打开同一文件时,第二次打开会失败,但代码里没捕获UnauthorizedAccessException,静默跳过该块。
解决:FileStream构造函数必须显式传FileShare.Read,并在 catch 中记录UnauthorizedAccessException。

5.3 现象:在 WinServer 2016 上传输 4GB+ 文件失败,offset变成负数

原因:C#long是 64 位,但部分旧版FileStream.Length返回int,强制转换溢出。
解决:所有偏移计算用checked块包裹,并用fs.Seek(offset, SeekOrigin.Begin)替代fs.Position = offset。

5.4 现象:断点续传后,文件末尾出现乱码(0x00 填充)

原因:服务端FileStream用FileMode.Append,但文件实际大小小于expectedOffset + blockSize,导致末尾补零。
解决:接收前先fs.SetLength(expectedOffset + blockSize),确保文件长度精确。

5.5 现象:局域网传输速度只有 12MB/s,远低于千兆带宽

原因:Socket.NoDelay = false(Nagle 算法开启),小包合并导致延迟累积。
解决:客户端和服务端 Socket 创建后立即设置socket.NoDelay = true,牺牲少量带宽利用率换取低延迟。


6. 进阶技巧:用 Wireshark 抓包验证断点续传真实性,以及三招压测调优

6.1 Wireshark 过滤规则:一眼锁定断点行为

断点续传是否真实生效,不能只信日志。用 Wireshark 抓双方流量,过滤关键帧:

# 查看客户端发起的断点协商(magic=CSFT) tcp contains "CSFT" # 查看服务端 ACK/NACK 响应(1字节) tcp.len == 1 && tcp.payload # 查看文件块传输(Header 36B + Data) tcp.len > 36 && tcp.payload # 查看重传包(Seq 重复) tcp.analysis.retransmission

重点观察:第一次连接时CSFT后跟lastOffset=0;断网重连后CSFT的lastOffset是否等于上次成功位置;重传包的Seq是否严格对应丢失块起始位置。如果lastOffset每次都是 0,说明状态文件没写对或没读到。

6.2 压测调优三板斧:缓冲区、IOCP、CPU 绑核

缓冲区调优(服务端)
// 默认 8KB 太小,千兆网需加大 socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.SendBuffer, 256 * 1024); // 256KB socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveBuffer, 512 * 1024); // 512KB
IOCP 线程池扩容(.NET 6+)
// 避免默认 12 线程瓶颈 ThreadPool.SetMinThreads(32, 32); // minWorker, minIOCP // 注意:SetMaxThreads 不要乱设,让 runtime 自动伸缩
CPU 绑核(物理机专属)
// 将服务端进程绑定到 CPU 2,3(避开系统中断) Process.GetCurrentProcess().ProcessorAffinity = (IntPtr)0xC; // 0b1100 = core 2&3

真实数据:某客户现场,启用三板斧后:

  • 传输 3.2GB 文件,从 217s → 142s(提速 34.6%)
  • 断点恢复时间从 1.8s → 0.23s(快 7.8 倍)
  • 10 并发连接下 CPU 占用从 92% → 64%

从那以后我每次部署新产线服务端,都强制走一遍这三步:Wireshark 抓包确认断点帧→netsh int tcp set global autotuninglevel=disabled(关自动调优)→SetMinThreads + ProcessorAffinity。不是所有场景都需要,但工控现场,宁可多花 5 分钟验证,也不愿半夜被电话叫醒修传输。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询