☰
C# 实现 SFTP 上传下载:带进度条、断点续传与生产级避坑指南
2026/10/12 4:04:12 网站建设 项目流程

简介:这份资源面向使用C#进行网络编程的开发者,尤其是需要在桌面应用中实现安全文件传输并实时反馈进度的场景。它基于Renci.SshNet开源库,演示了SFTP协议下文件上传与下载的完整实现,并通过回调机制将传输字节数转换为百分比,驱动进度条或控制台输出,解决了传统同步传输缺乏过程反馈的问题。压缩包共26个文件,约533KB,包含6个cs源码文件、3个exe可执行程序、2个dll依赖库以及resx、resources等资源文件,另有csproj、sln工程配置与suo用户选项,结构上覆盖了从源码到编译产物的完整工程。已有1680人学习下载,说明该示例在同类需求中具有参考价值。读者可从中获取可直接运行的SFTPtest工程,理解UploadFile与DownloadFile带进度回调的重载用法,并借鉴WinForms或WPF进度展示的扩展思路,快速迁移到自己的项目中。

1. 用 C# 把 SFTP 上传下载做成带进度条的生产级组件

做过文件传输的同行大概都有过这种体验:需求文档上轻描淡写一句“支持 SFTP 上传下载”,真动手才发现坑全在细节里——进度条卡在 99% 不动、大文件传到一半连接被掐、中文文件名在服务器上变成乱码、断点续传算错偏移量导致文件损坏。C# 实现 SFTP 文件上传和下载本身不难,难的是把进度反馈做准、把异常兜住、把大文件场景跑稳。这篇笔记面向需要在自己项目里落地 SFTP 传输能力的 .NET 开发者,从选型、连接管理、分块读写、进度计算一路讲到断点续传和排错,代码可以直接抄进项目改参数就用。读完你应该能判断:这套方案值不值得投入,以及投入后哪些参数必须调、哪些坑必须绕。

2. 选型与连接:SSH.NET 为什么是当前最稳的落点

2.1 三种主流方案对比与选型理由

在 .NET 生态里做 SFTP,绕不开三个候选:Renci.SshNet(SSH.NET)、WinSCP .NET Assembly、以及基于libssh2的自封装。我一般会先看项目约束再定,而不是上来就选最熟的。

方案依赖形态进度回调跨平台适用场景
SSH.NET纯托管 NuGet 包需自行分块实现好服务端、跨平台客户端
WinSCP .NET附带原生程序集内置事件一般Windows 桌面工具
libssh2 封装原生库 P/Invoke需自行封装需分平台编译特殊性能诉求

选 SSH.NET 的核心理由是纯托管、无原生依赖,部署到 Linux 容器或 Windows 服务都不用额外拷 DLL。代价是它自带的UploadFile/DownloadFile是整文件一把梭,没有进度回调,所以进度条必须靠我们自己分块读写来实现。这恰恰是后面几章的重点。

安装只需要一条命令:

dotnet add package SSH.NET

提示:包名是SSH.NET,命名空间是Renci.SshNet,别被搜索结果里各种改名分支带偏,认准这个命名空间即可。

2.2 连接对象该怎么建、怎么复用

新手最容易犯的错是每次传文件都new SftpClient(...)再Connect(),传完Dispose()。小文件看不出问题,一旦批量传几百个文件,握手开销会把整体耗时拉高一个量级,还容易触发服务端的连接数限制。

正确做法是把SftpClient当作长生命周期对象复用,用一个连接管理器包起来:

using Renci.SshNet; public sealed class SftpConnection : IDisposable { private readonly SftpClient _client; private readonly object _sync = new(); public SftpConnection(string host, int port, string user, string password) { // KeepAliveInterval 让空闲连接定期发心跳,避免被服务端静默断开 var info = new ConnectionInfo(host, port, user, new PasswordAuthenticationMethod(user, password)) { Timeout = TimeSpan.FromSeconds(15) }; _client = new SftpClient(info) { OperationTimeout = TimeSpan.FromMinutes(5), KeepAliveInterval = TimeSpan.FromSeconds(30), BufferSize = 32 * 1024 }; } public SftpClient Client => _client; public void EnsureConnected() { lock (_sync) { if (!_client.IsConnected) { _client.Connect(); } } } public void Dispose() { lock (_sync) { if (_client.IsConnected) _client.Disconnect(); _client.Dispose(); } } }

逻辑说明:ConnectionInfo承载认证信息,Timeout控制 TCP 握手超时;OperationTimeout是单次 SFTP 操作的超时,传大文件时必须放大,否则默认值会在传输中途抛超时异常;KeepAliveInterval是保命参数,很多“传到一半断了”的玄学问题根源就是空闲连接被防火墙或服务端回收。BufferSize设成 32KB 是经验值,太小则系统调用频繁,太大则单次内存占用高,32KB 到 64KB 之间比较平衡。

参数怎么改:内网低延迟可以BufferSize拉到 64KB;跨公网高延迟链路反而要适当调小到 16KB,减少单次阻塞时间,让进度回调更平滑。

3. 分块读写实现进度条:把百分比算准的三个关键点

3.1 上传:用 Stream 分块 + 回调驱动进度

SSH.NET 的UploadFile没有进度事件,但它的底层SftpFileStream支持Write,我们可以自己读源文件、分块写、每块回调一次进度。

public async Task UploadWithProgressAsync( SftpClient client, string localPath, string remotePath, IProgress<TransferProgress> progress, CancellationToken ct = default) { var fileInfo = new FileInfo(localPath); long total = fileInfo.Length; long sent = 0; // 远端以 Create 模式打开,覆盖同名文件 using var remoteStream = client.Open(remotePath, FileMode.Create, FileAccess.Write); using var localStream = File.OpenRead(localPath); byte[] buffer = new byte[32 * 1024]; int read; var sw = System.Diagnostics.Stopwatch.StartNew(); while ((read = await localStream.ReadAsync(buffer, ct)) > 0) { await remoteStream.WriteAsync(buffer.AsMemory(0, read), ct); sent += read; // 每块都回调,由 UI 层决定节流频率 progress.Report(new TransferProgress { TotalBytes = total, TransferredBytes = sent, Percent = total == 0 ? 100 : (double)sent / total * 100, BytesPerSecond = sent / Math.Max(sw.Elapsed.TotalSeconds, 0.001) }); } remoteStream.Flush(); }

逻辑说明:client.Open返回的是SftpFileStream,它把每次Write转成 SFTP 的 WRITE 包发出去,所以每写完一块我们就能拿到准确的已传字节数。TransferProgress是个自定义结构,字段含义分别是总字节、已传字节、百分比、瞬时速率。速率用累计字节除以累计耗时算,比用相邻两块差值算更稳,不会因为某一块卡顿导致速率数字乱跳。

参数说明:buffer大小直接决定进度回调的粒度。32KB 意味着一个 100MB 文件会回调约 3200 次,对 UI 来说太频繁,所以下面要讲节流。ct是取消令牌,用户点“取消”时能立刻中断循环,这是生产环境必备的后悔药。

3.2 下载:注意远端文件长度要用 Stat 拿

下载和上传对称,但有个坑:远端文件大小不能靠本地猜测,必须用client.GetAttributes或client.Exists配合SftpFileAttributes.Size拿。

public async Task DownloadWithProgressAsync( SftpClient client, string remotePath, string localPath, IProgress<TransferProgress> progress, CancellationToken ct = default) { var attrs = client.GetAttributes(remotePath); long total = attrs.Size; long received = 0; using var remoteStream = client.OpenRead(remotePath); using var localStream = File.Create(localPath); byte[] buffer = new byte[32 * 1024]; int read; var sw = System.Diagnostics.Stopwatch.StartNew(); while ((read = await remoteStream.ReadAsync(buffer, ct)) > 0) { await localStream.WriteAsync(buffer.AsMemory(0, read), ct); received += read; progress.Report(new TransferProgress { TotalBytes = total, TransferredBytes = received, Percent = total == 0 ? 100 : (double)received / total * 100, BytesPerSecond = received / Math.Max(sw.Elapsed.TotalSeconds, 0.001) }); } }

逻辑说明:GetAttributes返回的Size是服务端权威值,用它做分母算百分比才准。如果远端文件在传输过程中被别的进程追加写入,total会偏小,百分比可能超过 100,所以 UI 层要对Percent做Math.Min(100, ...)兜底。OpenRead内部会处理读取偏移,不需要我们手动 seek。

3.3 进度节流:别让 UI 线程被回调淹没

每 32KB 回调一次,传大文件时 UI 线程会被Report刷爆,进度条反而卡顿。正确做法是在 UI 侧做时间节流,而不是在传输侧降低回调频率——传输侧保持细粒度,UI 侧按需丢弃。

public sealed class ThrottledProgress : IProgress<TransferProgress> { private readonly IProgress<TransferProgress> _inner; private readonly TimeSpan _minInterval; private DateTime _lastReport = DateTime.MinValue; public ThrottledProgress(IProgress<TransferProgress> inner, int intervalMs = 100) { _inner = inner; _minInterval = TimeSpan.FromMilliseconds(intervalMs); } public void Report(TransferProgress value) { var now = DateTime.UtcNow; // 完成时必须放行,否则进度条停在 99% bool isFinal = value.TransferredBytes >= value.TotalBytes; if (isFinal || now - _lastReport >= _minInterval) { _lastReport = now; _inner.Report(value); } } }

逻辑说明:isFinal判断是血泪经验——如果只按时间节流,最后一块回调可能刚好落在节流窗口内被丢弃,进度条就永远停在 99%。加上完成态强制放行,才能保证 UI 收到 100%。intervalMs默认 100ms,即每秒最多刷新 10 次,人眼已经足够流畅。

4. 大文件与断点续传:偏移量算错就是文件损坏

4.1 断点续传的偏移量从哪来

断点续传的核心是“已传了多少字节”,上传时看远端文件当前大小,下载时看本地文件当前大小。SSH.NET 的Open支持指定FileMode.Append,但更可控的做法是手动 seek。

public async Task ResumeUploadAsync( SftpClient client, string localPath, string remotePath, IProgress<TransferProgress> progress, CancellationToken ct = default) { long localTotal = new FileInfo(localPath).Length; long remoteSize = client.Exists(remotePath) ? client.GetAttributes(remotePath).Size : 0; // 远端比本地还大,说明是脏数据,直接重传 if (remoteSize > localTotal) { client.DeleteFile(remotePath); remoteSize = 0; } using var remoteStream = client.Open(remotePath, remoteSize > 0 ? FileMode.Append : FileMode.Create, FileAccess.Write); using var localStream = File.OpenRead(localPath); localStream.Seek(remoteSize, SeekOrigin.Begin); long sent = remoteSize; byte[] buffer = new byte[32 * 1024]; int read; while ((read = await localStream.ReadAsync(buffer, ct)) > 0) { await remoteStream.WriteAsync(buffer.AsMemory(0, read), ct); sent += read; progress.Report(new TransferProgress { TotalBytes = localTotal, TransferredBytes = sent, Percent = (double)sent / localTotal * 100 }); } }

逻辑说明:remoteSize > localTotal这个判断是必须的,否则本地文件被截断后远端残留更大,续传会从错误位置开始,最终文件损坏。FileMode.Append让服务端从文件末尾继续写,配合本地Seek到相同偏移,两边对齐。localTotal作为分母而不是localTotal - remoteSize,这样进度条显示的是整体进度,符合用户直觉。

4.2 校验:传完不算完,要比对大小和哈希

进度条走到 100% 不代表文件完整。网络抖动、磁盘写满、服务端配额限制都可能导致实际写入字节数少于预期。生产环境必须做校验。

public bool VerifyUpload(SftpClient client, string localPath, string remotePath) { long localSize = new FileInfo(localPath).Length; long remoteSize = client.GetAttributes(remotePath).Size; if (localSize != remoteSize) { return false; } // 关键文件再算一次哈希,普通文件比大小即可 using var localStream = File.OpenRead(localPath); using var remoteStream = client.OpenRead(remotePath); string localHash = ComputeSha256(localStream); string remoteHash = ComputeSha256(remoteStream); return localHash == remoteHash; }

逻辑说明:大小比对是廉价的第一道关,哈希比对是昂贵的第二道关。我一般只对配置文件、数据库备份这类“错了就出事”的文件算哈希,普通日志文件比大小就够。ComputeSha256是标准SHA256.Create().ComputeHash(stream)封装,这里不展开。

5. 避坑与排查:五个真实踩过的坑

5.1 中文文件名变乱码

现象:本地叫“报表.xlsx”,传到服务器变成“???.xlsx”或乱码。原因:SSH.NET 默认用 UTF-8 编码文件名,但部分老服务端按本地字符集解析。解决:在ConnectionInfo上显式设置编码,或统一在传输前把文件名转成 ASCII 安全形式(如时间戳+序号),中文名只存在数据库里做映射。

5.2 进度条卡在 99% 不动

现象:文件明明传完了,进度条就是不到 100%。原因:最后一块回调被时间节流丢弃,或Flush之后没有补发一次完成态。解决:如 3.3 节所示,完成态强制放行;另外在Flush后手动Report一次Percent = 100。

5.3 大文件传到一半抛 OperationTimeout

现象:传 500MB 以上文件,几分钟后抛超时异常。原因:OperationTimeout默认值太小,单次 WRITE 操作在慢链路上耗时超过阈值。解决:把OperationTimeout调到 5 到 10 分钟,同时开启KeepAliveInterval防止空闲断开。注意这是单次操作超时,不是总时长超时,调大不会导致死等。

5.4 并发上传时连接被服务端拒绝

现象:同时开 10 个任务上传,部分任务报连接失败。原因:每个任务各建一个SftpClient,超过服务端MaxSessions限制。解决:用连接池复用 2 到 4 个连接,任务排队取连接;或者干脆串行化上传,SFTP 本身不是为高并发设计的。

5.5 断点续传后文件损坏

现象:续传完成的文件打不开或内容错位。原因:偏移量算错,常见于远端文件被其他进程修改过,或本地文件在续传前被追加过内容。解决:续传前比对远端大小和本地已传大小是否一致,不一致就删远端重传;续传完成后必须做哈希校验,别省这一步。

6. 进阶技巧:把传输封装成可观测、可重试的组件

走到这里,单次上传下载已经能跑通了。但生产环境真正难的是“批量、可观测、可重试”。我一般会把前面几章的能力包成一个SftpTransferService,对外只暴露UploadAsync/DownloadAsync,内部统一处理连接复用、进度节流、失败重试和日志埋点。

重试策略上,我踩过的坑是无脑重试三次——如果失败原因是磁盘满或权限不足,重试一百次也没用,反而拖慢队列。正确做法是区分异常类型:SshConnectionException、SshOperationTimeoutException这类网络问题才重试,SftpPermissionDeniedException直接失败并告警。重试间隔用指数退避,第一次等 1 秒,第二次 2 秒,第三次 4 秒,避免雪崩。

可观测性上,至少埋四个指标:单文件传输耗时、瞬时速率、重试次数、校验失败次数。速率突然掉到接近零,通常是链路问题;校验失败次数上升,通常是服务端磁盘或配额问题。这两个信号比任何日志都直观。

最后给一个我自己的习惯:任何 SFTP 传输任务上线前,先用一个 1GB 左右的测试文件在目标链路上完整跑一遍上传、下载、断点续传、校验四条路径,把耗时和速率记下来当基线。之后线上速率偏离基线超过 30%,就值得查一查。这套流程帮我提前拦下过好几次服务端磁盘写满和网络限速的问题。希望帮到你。

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

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

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

立即咨询