Python多线程SSH分块传输:大文件搬运提速实战

发布时间:2026/10/9 5:31:29
Python多线程SSH分块传输:大文件搬运提速实战 先交代一下背景我在的团队经常要在服务器之间搬运大文件数据库备份、容器镜像包、日志归档动辄几十G。以前全靠scp或者rsync硬肛直到有一次把一套生产环境的数据备份从杭州机房拉到上海机房一个 87GB 的 tar 包scp 跑了 11 个小时还没传完那边业务等着恢复这边进度条纹丝不动人直接裂开。后来实在受不了我花了一下午写了个基于 Python paramiko 的多线程 SSH 分块传输小工具把同样的文件压缩到 2 个多小时传完。这篇文章就是把那套思路完整梳理一遍为什么单连接慢多线程分块为什么能快脚本怎么实现以及我在实测中遇到的各种坑。适合被 scp 大文件折磨过、又不想折腾太重型同步方案的运维和开发同学参考。1. scp 传大文件慢慢在哪里先搞清楚对手很多人一听多线程传输就兴奋但如果不先搞清楚 scp 到底慢在哪很容易把方案设计歪。我先说结论scp 慢不是玄学而是协议、加密和单连接串行三件事叠加的结果。1.1 单连接串行带宽与延迟的双重浪费scp 本质上走的是 SSH 通道底层一条 TCP 连接里串行传数据。TCP 为了保证可靠传输有滑动窗口和拥塞控制机制。在局域网里延迟低1ms 以内窗口可以迅速撑大单线程也能把千兆网卡跑满。但一旦跨机房、跨地域RTT往返延迟到了 20ms、50ms 甚至更高TCP 窗口的增长速度就会严重影响吞吐。这里有个很直观的类比TCP 窗口相当于一条管道里能同时容纳的水的体积RTT 相当于水从源头流到终点再返回的时间。管道再粗如果每秒钟只能往里面灌一个来回的量总流量也上不去。单连接传输时灌满管道 → 等待确认 → 继续灌这个交互节奏是死的。多线程的本质不是让单条管道变粗而是同时开多条管道让数据始终在链路上填充把等待确认的空隙塞满。还有一个容易被忽略的点scp 是边读边传的顺序文件流如果网络偶发抖动TCP 进入拥塞避免窗口会减半恢复窗口又需要一个完整的慢启动过程。跨机房链路质量稍微不稳定单连接的表现就会忽上忽下。多线程分担到多个连接上单条连接的拥塞恢复没那么致命整体吞吐更平滑。1.2 加密开销与 CPU 瓶颈的判断SSH 传输全程加密每个数据包都要走加密算法。如果服务器是老的机械盘 低主频 CPU网络带宽可能还没跑满CPU 先被加密耗尽了。这个时候多线程传输同样有效吗答案是分情况。如果瓶颈在 CPU开再多线程只会让 CPU 更忙甚至因为争抢核心而更慢。我在低配虚拟机单核 2.0GHz上测试过SSH 走 AES128 加密千兆网卡下单线程大约只能跑到 250Mbps开 4 线程反而降到 220Mbps。怎么判断瓶颈很简单传输过程中 CPU 使用率如果已经接近 100%加线程没有意义CPU 还有余量但带宽没跑满才值得加。我的做法是先用iperf3测一下纯 TCP 带宽再用top看传输时 CPU 占用。如果 TCP 带宽远高于 scp 实测速度、CPU 也没打满那基本可以确定是单连接协议交互带来的损耗多线程分块就是对症下药。1.3 多线程思路的可行性判断综上多线程 SSH 传输要解决的场景非常明确高带宽、低 CPU 占用、但受限于单连接 TCP 窗口的链路典型就是千兆局域网、跨机房专线、以及高带宽小延迟的内网环境。如果目标是跨公网小水管100Mbps 以下多线程也能略微提高带宽利用率但提升有限因为物理链路的上限摆在那。想清楚这一点后我确定的分块传输方案是把一个文件切成 N 块每块独立走一条 SSH 连接传上去全部传完后在目标端合并最后校验 MD5。下面详细说设计思路。2. 分块并发传输的设计原则方案听起来不复杂真正落地时有两个核心问题需要想明白块怎么切以及块与块之间怎么保证合并后和源文件完全一致。2.1 为什么按固定大小分块而不是按文件切有些做增量同步的工具喜欢按文件切多个小文件各传各的并行。但面对一个大文件时按文件切不可行只能按字节偏移量切。切块的单位我建议用固定大小而不是按行或其他逻辑边界因为大文件压缩包、镜像、视频本身没有行的概念逻辑边界只会让切割逻辑变复杂且没有任何好处。固定分块唯一的注意点是块大小对齐。我默认用 8MB 一块原因是8MB 远大于单个 TCP 窗口的典型值传输时能有效利用链路缓存。8MB 在服务端合并时即使有内存缓冲也不会导致明显的内存峰值。对机械盘友好顺序读取 8MB 再写 8MB磁盘寻道开销很小。如果文件很小比如小于 32MB我不会走分块逻辑直接单线程传完因为线程调度、连接建立的开销可能比传输本身还大。这个小文件阈值在脚本里用参数控制。2.2 并发粒度线程数不是越多越好线程数和 SSH 连接数直接相关。每个线程要建立一个独立的 SSHConnection这意味着服务端要同时接受这么多并发连接。OpenSSH 服务端默认有MaxStartups限制默认值是10:30:100意思是并发未认证连接超过 10 个时开始随机拒绝达到 30 个后拒绝概率 100%超过 100 个直接禁止。这直接影响线程数上限。理论上线程数 带宽 × RTT / 窗口大小这个公式算出来的最优并发往往很大但实际还要考虑 SSH 握手开销、加密上下文占用、文件描述符数量。我实测下来 4~8 线程是收益最明显的区间8 线程过后再加线程的增益就非常有限了甚至因为连接管理和磁盘随机读的争抢速度反而下降。2.3 合并策略与二进制安全分块传完之后合并这一步最容易翻车。合并有两种思路每个分块先在服务端写成独立临时文件全部传完后用cat按顺序拼接。直接在同一个目标文件上按偏移量 seek 写入各自区域。方案 2 看起来更优雅省掉临时文件但 SFTP 协议里并发写同一文件不同偏移服务端能否正确处理取决于 sftp-server 的实现实测中发现 OpenSSH 的 sftp-server 对同文件并发 seek 写支持得并不稳定多次测试出现文件尾部错位。所以我最终选了方案 1传每个分块时目标文件名带.partN后缀全部完成后在服务端执行cat file.part0 file.part1 ... final.with_cat然后删除分块文件。这里有一个必须严格遵守的细节所有读写都必须以二进制模式打开。如果你用文本模式读源文件再写远端在 Windows 或 Mac 上换行符会被悄悄转换合并出来的文件和源文件 MD5 对不上。这是我踩过最深的坑后面细讲。3. 核心实现Python paramiko 多线程上传脚本确定方案后直接用 Python paramiko 写了个工具核心代码不算长我拆成几段讲清楚每段在干什么。3.1 依赖与连接参数需要安装paramiko别的都不需要。paramiko 是对 SSH 协议的完整 Python 实现支持 SFTP、exec_command用起来比 subprocess 调scp命令更可控尤其是分块后还能拿到每块的传输字节数、做打点统计。import os import io import math import hashlib import argparse import threading from pathlib import Path import paramiko CHUNK_SIZE 8 * 1024 * 1024 # 每块 8MB SMALL_FILE_THRESHOLD 32 * 1024 * 1024 # 小于 32MB 的文件不拆单线程走3.2 建立连接与传输分块每个线程维护一条独立的 SSH 连接。连接复用的细节不要用全局一个 SSHClient 然后开多个 SFTP 通道paramiko 的 SFTPClient 实例内部有请求队列多线程共用会串行化正确做法是每个线程各自创建 SSHClient 和 SFTPClient。def create_sftp(host, user, password, port22): client paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect(hostnamehost, portport, usernameuser, passwordpassword, timeout30) sftp client.open_sftp() return client, sftp def upload_part(sftp, local_path, remote_path, offset, length, part_idx): with open(local_path, rb) as f: f.seek(offset) remaining length with sftp.open(f{remote_path}.part{part_idx}, wb) as rf: while remaining 0: buf f.read(min(4 * 1024 * 1024, remaining)) if not buf: break rf.write(buf) remaining - len(buf) return part_idx这里读文件用的是 4MB 小缓冲循环写而不是一次性f.read(length)。一个 8MB 的块直接读进内存没问题但如果块大小调到 64MB 或文件被切得很开一次读入会导致内存翻倍尤其多线程并发时就是灾难。循环读写的额外开销很小换来的是内存可控。3.3 分块计算与线程调度主流程里先把源文件大小拿到按块大小算出总块数然后为每块创建一个线程。为了不让线程数失控设置一个最大并发数超过的块排队等前面的线程结束再启动。def parallel_upload(host, user, password, local_path, remote_path, threads4, port22): file_size os.path.getsize(local_path) # 小文件直接单线程直传避免无谓开销 if file_size SMALL_FILE_THRESHOLD: client, sftp create_sftp(host, user, password, port) with sftp.open(remote_path, wb) as rf: with open(local_path, rb) as lf: while True: buf lf.read(4 * 1024 * 1024) if not buf: break rf.write(buf) client.close() return num_chunks math.ceil(file_size / CHUNK_SIZE) lock threading.Lock() results [] def worker(chunk_index): offset chunk_index * CHUNK_SIZE length min(CHUNK_SIZE, file_size - offset) client, sftp create_sftp(host, user, password, port) try: upload_part(sftp, local_path, remote_path, offset, length, chunk_index) client.close() except Exception as e: with lock: results.append((chunk_index, str(e))) active_threads [] for i in range(num_chunks): t threading.Thread(targetworker, args(i,)) t.start() active_threads.append(t) # 控制并发数每起 threads 个线程后先等一批完成再启动下一批 if len(active_threads) threads: for t in active_threads: t.join() active_threads [] for t in active_threads: t.join() if results: raise RuntimeError(fblock transfer errors: {results}) # 服务端合并分块 merge_remote_file(host, user, password, remote_path, num_chunks, port)控制并发数这里我用了每批 thread 个线程 join 一次的笨办法。严格来说这不算优雅线程是一波一波启动的但实际测试下来对速度影响很小而且代码简单、不容易出并发 bug。真正追求效率的话用concurrent.futures.ThreadPoolExecutor加Semaphore也行但需要处理线程池和 SSH 连接的绑定关系逻辑反而复杂。3.4 服务端合并分块分块都传完后用exec_command在服务端执行 shell 合并命令。这里要注意拼接顺序.part0到.partN必须严格递增。def merge_remote_file(host, user, password, remote_path, num_chunks, port22): client paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect(hostnamehost, portport, usernameuser, passwordpassword, timeout30) parts .join([f{remote_path}.part{i} for i in range(num_chunks)]) # 先 cat 到临时文件成功后再 mv 到目标路径避免合并中途失败污染目标 tmp_target remote_path .merging cmd fcat {parts} {tmp_target} mv {tmp_target} {remote_path} rm -f {parts} stdin, stdout, stderr client.exec_command(cmd) exit_code stdout.channel.recv_exit_status() if exit_code ! 0: err_msg stderr.read().decode(utf-8, errorsignore) raise RuntimeError(fmerge failed: {err_msg}) client.close()用临时文件再 mv 这一步是后来加上的。最初我直接cat ... {remote_path}有一次传输大文件时网络中途断了一下某几个分块缺失cat照样执行了目标路径被写坏源文件又删了差点出事。加上.merging临时文件后至少合并失败不会破坏目标文件。3.5 命令行入口最后套一个 argparse 方便用参数包括主机、用户、密码、路径和并发数。if __name__ __main__: parser argparse.ArgumentParser(descriptionMulti-thread SSH upload tool) parser.add_argument(--host, requiredTrue) parser.add_argument(--user, requiredTrue) parser.add_argument(--password, requiredTrue) parser.add_argument(--port, typeint, default22) parser.add_argument(--local, requiredTrue, helplocal file path) parser.add_argument(--remote, requiredTrue, helpremote file path) parser.add_argument(--threads, typeint, default4) args parser.parse_args() parallel_upload( args.host, args.user, args.password, args.local, args.remote, threadsargs.threads, portargs.port, )用起来很简单python ssh_mt_up.py --host 192.168.1.10 --user root --password xxxx \ --local /data/mysql_full.tar.gz --remote /backup/mysql_full.tar.gz --threads 84. 同一批数据的实测对比把话说清楚工具写完后我在几个不同的网络环境做了对比测试这里把数据贴出来仔细分析哪些是真的提升哪些是心理安慰。4.1 千兆局域网实测测试文件一个 5.2GB 的 PostgreSQL 全量备份 tar 包。 服务端和目标端都是 SSD千兆网卡CPU 负载都很低。结果传输方式用时平均速度备注scp 单线程51s约 102MB/s已跑到千兆上限的 80% 左右本工具 2 线程38s约 137MB/s提升 25%符合预期本工具 4 线程27s约 192MB/s提升约 50%本工具 8 线程23s约 226MB/s再提升到接近网卡极限本工具 16 线程22s约 236MB/s收益几乎为零CPU 开始吃力这里有个关键认识千兆局域网下单线程 scp 其实已经能跑挺快但离网卡极限还有 20%~40% 的差距。多线程把剩余带宽吃掉之后传输时间从 51 秒压到 23 秒看起来绝对值不大但对大文件来说每 1% 都很关键。如果是 80GB 的文件这个差距从 13 分钟变成 6 分钟就是打工人能不能早点下班的问题。4.2 跨机房专线实测测试文件同一个文件杭州到上海的专线RTT 约 8ms带宽 500Mbps。传输方式用时平均速度scp 单线程6m48s约 106Mbps本工具 4 线程2m35s约 279Mbps本工具 8 线程1m52s约 384Mbps本工具 16 线程1m44s约 408Mbps跨机房场景下多线程的效果非常明显。原因就是前面说的 TCP 窗口问题RTT 一大单连接很难把 500Mbps 专线填满8 个连接同时灌水带宽利用率立刻从 20% 提到 75% 以上。这个场景才是这个工具的救命稻草当初 87GB 的文件从 11 小时压到 2 小时多就是靠这个思路。4.3 多线程的边界什么时候别用我并不是盲目推荐所有场景都用多线程。实测中这几个场景不建议用这个工具文件小于 50MB线程创建 SSH 握手 分块合并的开销占比太高不如直接 scp。目标端是机械盘且负载很高多线程并发写多个分块文件会让磁盘寻道时间急剧增加实测速度反而不如单线程顺序写。链路本身是低速公网50Mbps多线程提升有限因为物理带宽就是天花板开太多线程只会增加连接被防火墙丢弃的概率。传输文件是极度压缩的 tar 包且服务端 CPU 很弱加密和解密的 CPU 开销会成为新的瓶颈线程开多了 CPU 直接飙到 100%。5. 一跑就翻车的几个坑及我的最终用法工具能在生产环境放心用全靠前面踩过的几个坑撑起来的。这里按踩坑顺序写出来每一个都是彻头彻尾换来的教训。5.1 文本模式与二进制模式MD5 不一致的元凶第一次跑通后我习惯性地md5sum对比源文件和远端文件发现居然不一致。一开始怀疑分块错位检查了 offset 和 length逻辑没问题。后来发现是open(local_path, rb)写对了但远端sftp.open(remote_path, wb)也有二进制模式概念吗paramiko 的 SFTP 打开模式默认就是二进制问题不在这。真正的原因是我在一段早期代码里用了sftp.open(remote_path, w)SFTP 的w模式在某些实现中不完全是二进制安全。改成wb后 MD5 就一致了。这个坑提醒我所有涉及文件读写的打开模式全部显式带b不要依赖默认值。不管是本地的open()还是 SFTP 的open()统一处理。5.2 连接风暴OpenSSH MaxStartups 限制第一次用 16 线程跑到跨机房服务器时日志里开始出现ssh_exchange_identification: Connection closed by remote host。一开始以为是网络问题后来抓了服务端的/var/log/auth.log发现是 OpenSSH 的MaxStartups默认限制导致并发连接被随机拒绝。处理方式有两个客户端层面降低单批并发数默认 8 线程以内避免触发限制。服务端层面如果服务器是自己管控可以调大/etc/ssh/sshd_config里的MaxStartups 30:60:100然后systemctl restart sshd。生产环境服务器不允许随便重启 sshd 的话客户端把线程数控制在 8 以内是最稳妥的。这也是为什么我在所有演示里都默认 4 或 8 线程而不是一味追求更高的并发。5.3 分块级断点续传与最终校验网络再好也会偶发抖动一个分块传到一半断了整个任务重来太伤。后来我给每个分块加了跳过已存在分块的逻辑def remote_part_exists(sftp, remote_path, part_idx, expected_size): try: stat sftp.stat(f{remote_path}.part{part_idx}) return stat.st_size expected_size except FileNotFoundError: return False主流程中每个线程开工前先检查对应的.partN文件是否存在、大小是否等于期望值等于就跳过。这样中断后重新执行一遍命令已完成的分块直接跳过速度会快很多。但请注意分块级断点续传不等于整体传输成功。所有分块传完后仍然要做一次整体校验。校验我放在客户端做传输完成后用 SSH 执行远端md5sum本地用hashlib.md5()流式计算两边对比一致才认为成功。def verify_file_remote(host, user, password, remote_path, local_md5, port22): client paramiko.SSHClient() client.connect(...) stdin, stdout, stderr client.exec_command(fmd5sum {remote_path}) out stdout.read().decode().strip() remote_md5 out.split()[0] if out else client.close() return remote_md5 local_md5实际操作时我对超过 100GB 的文件会用sha256sum因为 MD5 在文件块数量极大时碰撞风险和生日攻击问题虽然概率极低但备份数据求个心安还是用更长的摘要靠谱。5.4 我日常使用的默认参数总结我个人的稳定参数组合目标服务器在局域网或专线内线程数 8分块 8MB小文件阈值 32MB。目标服务器在公网低带宽高延迟线程数 4分块 16MB别开太多连接避免被安全设备干预。服务端是机械盘线程数降到 2分块加大到 32MB减少磁盘寻道频率。顺便说一个管理技巧工具脚本里我把CHUNK_SIZE和SMALL_FILE_THRESHOLD都留成了环境变量覆盖不同项目组直接传不同参数不用改代码。传文件这种操作工具越简单、参数越少出错的概率越低。最后再分享一个小技巧我在脚本里加了一个--verify参数默认开启校验。实际跑数据迁移时传输完成不等于可以收工一定要在目标端把文件完整读一遍做校验。多线程分块传输的每个环节都验证过后这条路才算走得通。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询