1. 慢在哪:先搞清楚瓶颈是网络还是协议
用 SSH 连远程仓库,git fetch、git pull、git push卡半天,进度条一格一格挪,甚至干脆卡在Resolving deltas或者Writing objects不动——这几乎是每个用 Git 做日常协作的人都遇到过的场景。我最早碰到这个问题是在一个跨地域协作的项目里,本地仓库和远端隔着几千公里,一次git pull要等两三分钟,改一行代码推一次,心态直接崩。后来花了不少时间翻日志、抓包、换协议、调参数,才算把这套东西摸清楚。
这篇文章想聊的就是这件事:SSH 模式下 Git 传输慢的成因,以及我实测下来真正有效的那几类解决办法。它适合所有需要频繁和远程仓库打交道的开发者,不管你是刚学会git clone的新手,还是天天跑 CI 的老手,都能从这里找到能用上的东西。需要说明的是,我不会给你一堆"理论上可能有用"的偏方,只讲我自己验证过、并且能解释清楚原理的方案——因为 Git 传输慢的原因往往不是一个,而是好几个叠加在一起,找错方向调半天参数也是白搭。
先建立一个基本认知:Git over SSH 的完整链路是"本地 Git 进程 → SSH 加密通道 → 远端 Git 服务进程(可能是 git-receive-pack / git-upload-pack)→ 磁盘 IO"。这条链上任何一环出问题都会表现为"慢",但表象都是进度条不动,所以第一步永远是定位瓶颈在链路的哪一段,而不是上来就改配置。下面几节会按"定位 → 优化传输 → 优化服务端 → 规避场景"的顺序展开,这也是我实际排查时用的顺序。
2. 先做诊断:几个能快速锁定瓶颈的命令
2.1 用 GIT_TRACE 和 GIT_SSH_COMMAND 看清时间花在哪
Git 自带了一个非常好用但很少人用的调试开关:环境变量GIT_TRACE。它会把 Git 内部各个阶段的耗时打印出来,尤其是连接建立、协商、传输这几个环节。用法很简单:
GIT_TRACE=1 GIT_TRACE_PACKET=1 git fetch origin输出里会看到类似trace: run_command: ssh -o ...这样的行,后面跟着时间戳。我一般会重点看两个东西:一是从"开始执行 ssh 命令"到"连接真正建立"之间的间隔,二是从"开始传输"到"传输完成"之间的间隔。
如果第一段间隔特别长(比如十几秒),说明问题在SSH 连接建立阶段,多半和 DNS 解析、认证方式、服务端的反向解析有关;如果第二段特别长,那才是真正的数据传输慢,得看网络带宽、压缩、协议版本这些。这个区分特别重要,因为这两类问题的解法完全不一样。我见过太多人明明是连接建立慢,却去折腾http.postBuffer之类的传输参数,方向从一开始就错了。
还可以配合time命令做粗粒度测量:
time git ls-remote originls-remote只做连接和列举引用,不传实际对象。如果这个命令就要好几秒,那你的瓶颈基本可以确定在连接建立而不是数据量上。我当时测出来ls-remote单独跑要 8 秒多,而真正的 fetch 只多了 2 秒,问题一下就锁定了。
2.2 用 ssh -v 拆解握手过程中的每一个停顿
在 Git 命令里临时指定一个带-v的 SSH 命令,可以看到握手细节:
GIT_SSH_COMMAND="ssh -v" git ls-remote origin-v会打印出debug1:开头的详细日志,里面能看到 DNS 解析、TCP 连接、密钥交换、认证这几个阶段分别花了多久。我印象很深的一次是,日志里卡在debug1: Authenticating to github.com:22 as 'git'后面足足停了五六秒,最后查出来是服务端在做一个反向 DNS 查询,超时导致的。这种问题你在 Git 层面怎么调都没用。
顺带说一句,GIT_SSH_COMMAND这个环境变量在调试时特别好用,因为它可以临时覆盖 SSH 参数,不用去改全局的~/.ssh/config。调完之后直接不管它就行,不会污染你的长期配置。
2.3 一个简单对照:换 HTTPS 跑一次
诊断的最后一步,用 HTTPS 协议跑同一个仓库的拉取:
git ls-remote https://github.com/用户名/仓库名.git如果 HTTPS 明显比 SSH 快,那问题很可能出在 SSH 这一层(加密协商、认证、连接复用);如果两者差不多慢,那更可能是网络链路本身的带宽或延迟问题,或者是仓库体积太大。这个对照实验成本极低,但能帮你砍掉一半的排查方向。
3. 连接建立阶段:为什么每次都要等好几秒
3.1 DNS 解析和 TCP 握手的隐形成本
很多人没意识到,每执行一次 Git 命令,Git 都会新起一个 SSH 进程去连远端,这个连接是短命的,用完就关。也就是说,git pull一次就要经历一次完整的 DNS 解析、TCP 三次握手、SSH 密钥交换、用户认证。如果这些环节每个都要几百毫秒到几秒,累积起来就非常明显。
~/.ssh/config里有个参数能帮上忙:
Host github.com HostName github.com User git Compression yes ServerAliveInterval 30 ServerAliveCountMax 6但我个人更推荐先看看是不是 DNS 拖了后腿。可以在本机直接ping一下仓库域名,看解析时间;如果解析慢,考虑在/etc/hosts里写死 IP(注意仓库服务商的 IP 可能会变,写死有风险,这点后面会讲)。此外,SSH 的GSSAPIAuthentication在某些环境下会尝试做 Kerberos 认证,白白等好几秒,直接在~/.ssh/config里关掉:
Host * GSSAPIAuthentication no这一条我几乎在所有新机器上都会加上,尤其是公司内网环境,关掉之后连接建立时间能从五六秒降到不到一秒,效果立竿见影。
3.2 复用长连接:ControlMaster 是收益最大的一招
如果说有一招能让 SSH 连接速度产生质的飞跃,那就是SSH 连接复用,也就是ControlMaster。它的原理是:第一个 SSH 连接建立后,把它作为一个"主连接"留在后台,后续所有到同一主机的 SSH 连接都直接复用这个已经建立好的通道,不再重新做握手和认证。
配置写在~/.ssh/config里:
Host * ControlMaster auto ControlPath ~/.ssh/sockets/%r@%h-%p ControlPersist 600记得先建目录:mkdir -p ~/.ssh/sockets。三个参数的含义分别是:auto表示自动判断是否作为主连接;ControlPath指定复用套接字的位置(用%r@%h-%p模板可以区分不同用户/主机/端口);ControlPersist 600表示主连接在最后一个会话关闭后继续保留 600 秒,供后续命令复用。
实测效果:我这边第一次git fetch还是要走完整握手,但紧接着的第二次、第三次几乎瞬间开始传输,ls-remote从 8 秒降到 0.3 秒左右。因为你日常开发时 Git 命令是一个接一个跑的,这个复用带来的体感提升非常夸张。
注意:Windows 下原生 OpenSSH 对
ControlMaster的支持不完全一致,某些版本会报 "Control socket ... already exists" 之类的错误。如果遇到,可以先确认自己用的是哪个 SSH 客户端,或者干脆改用 Windows Terminal 里配合 Git Bash 的 OpenSSH。
3.3 换用 Unix 套接字:一些平台特有的提速技巧
在一些部署脚本里,如果你是通过 SSH 到远程服务器再执行 Git 操作,可以考虑让 Git 通过 Unix 域套接字和远端服务通信——不过这个场景偏服务端,日常开发用得少,这里只作为一个可能方向提一下。对绝大多数人来说,ControlMaster已经是连接建立阶段性价比最高的方案了。
另外补充一个细节:如果你在用 VSCode 的 Remote-SSH 连远程服务器做开发,那个扩展本身也会维护一条 SSH 连接,但 Git 命令行走的仍然是独立进程,两者不共享连接池。所以即便 VSCode 的 SSH 是通的,你在终端里跑git pull照样是冷启动。意识到这一点之后,我一般会在开始工作前先跑一条git ls-remote把连接"热"起来。
4. 数据传输阶段:让 pack 更小更快
4.1 理解 Git 传输的对象:packfile 和 delta
搞清楚连接问题之后,如果还是慢,那就是实际传输的数据量太大。这里需要理解 Git 的传输机制:fetch和push传输的不是一个个独立文件,而是打包好的packfile。Git 会把你需要的对象做差量压缩(delta 压缩),把相似的对象用引用和偏移表示,从而减小体积。传输慢,要么是网络带宽不够,要么是 pack 本身太大。
有个反直觉的点值得说:pack 压缩是会消耗 CPU 的。服务端在准备 pack 时要做大量比对和压缩计算,如果你的仓库很大、历史很乱,服务端准备阶段就可能耗掉几十秒,这段时间里客户端进度条一动不动,看起来像"网络卡住",实际是服务端在算。判断方法:用GIT_TRACE看客户端是否已经进入传输阶段;如果没进入,那慢在服务端准备。
4.2 深度控制和浅克隆:按需减少历史
最有效的减量手段是不要拉取不需要的历史。Git 支持浅克隆:
git clone --depth 1 git@github.com:用户名/仓库名.git--depth 1表示只拉最近一次提交,没有历史。对于只是要编译、部署、看最新代码的场景,这能把传输量降到原来的一小部分。我有个前端项目仓库历史很长,完整克隆要 400MB 以上,浅克隆直接降到 30MB 左右,clone时间从几分钟变成十几秒。
已经在本地的大仓库也可以做浅化:
git fetch --depth 1 origin main不过要提醒一句,浅克隆的仓库在后续操作上有限制,比如某些merge、blame、log操作会受影响,因为它没有完整历史。所以它适合特定场景(CI 构建、临时查看),不适合日常主力开发仓库。日常仓库我更倾向用局部克隆:
git clone --filter=blob:none git@github.com:用户名/仓库名.git--filter=blob:none表示只拉提交和树对象,不拉实际文件内容,文件内容在真正 checkout 时按需下载。这对大仓库非常有效,而且后续操作基本不受影响,是我现在克隆大仓库的默认姿势。
4.3 压缩和批处理的取舍
SSH 层本身有压缩,Git 也有自己的压缩配置,两者不要盲目叠加。SSH 的Compression yes对文本为主的对象有效,但对已经压缩过的二进制(比如图片、压缩包)基本没收益,反而浪费 CPU。所以你如果仓库里有大量二进制资源,开着 SSH 压缩可能还不如关掉。
Git 侧可以关注core.compression(默认 -1,也就是用 zlib 默认级别),一般不用动。真正值得调的是pack.threads,它控制打包时的并行线程数,机器核心多的话可以提高:
git config --global pack.threads 4但注意这主要影响本地打包(比如git gc、git repack),对fetch的传输速度提升有限。我看到网上很多人推荐改这个来提速 pull,实测下来效果不明显,别抱太大期望。
4.4 push 慢的特殊性:远端钩子是隐形杀手
push慢和fetch慢的成因不完全一样。push 时,客户端要把本地对象打包发给服务端,服务端接收后还要做校验、更新引用,并且触发服务端的钩子(hooks)。很多团队在服务端配置了 pre-receive、post-receive 之类的钩子做代码检查、部署、通知,这些钩子如果写得低效(比如同步调用了一个慢接口),你 push 时就会卡在最后一步。
判断方法:看 push 输出,如果卡在Writing objects那是传输慢,如果卡在remote:开头的那些输出之后,那是服务端在处理,客户端无能为力。这种情况只能推动服务端优化钩子,比如把耗时操作改成异步。我踩过这个坑:一个仓库 push 每次都卡十几秒,最后发现是服务端的通知钩子在调一个响应很慢的接口。
另一个 push 相关的配置是http.postBuffer,但那个只对 HTTPS 有效,SSH 模式下完全不起作用。经常有人 SSH 下 push 大文件报错去改这个参数,纯属白费劲。
5. 服务端与仓库本身:那些你改不了但能规避的慢
5.1 仓库膨胀:历史里的巨型文件
如果服务端准备 pack 特别慢,一个常见原因是仓库历史里存在巨型文件。哪怕你后来把这个文件删了,它依然留在历史对象里,每次 clone/fetch 都要重新打包传输。判断仓库体积可以用:
git count-objects -vH看size-pack那一项。如果发现远大于当前工作区实际大小,那历史里大概率有垃圾。这种情况的处理需要重写历史(git filter-repo之类),但这在共享仓库上是高风险操作,会改变所有提交的哈希,必须团队协调、提前通知所有人重新克隆。所以我不建议个人贸然操作,而是先反馈给仓库维护者。
日常能做的规避是:本地不要提交大文件,二进制资源用专门的存储方案管理,.gitignore写扎实。这些习惯能防止仓库在你手里继续膨胀。
5.2 服务端口和代理配置:绕开拥堵链路
有些自建 Git 服务(比如内网的 GitLab)会因为网络路径、代理、防火墙规则导致传输慢。这种时候可以考虑更换访问入口,比如走不同的端口或者不同的地址。SSH 默认走 22 端口,某些网络会对 22 端口做策略限制,可以尝试服务提供方开放的备用端口。
如果是通过公司网络访问外部仓库,网络出口的带宽和策略往往是你控制不了的变量。我在一个项目里遇到过工作时间段 Git 特别慢、午休和晚上正常的情况,基本可以判定是出口带宽被办公流量挤占了。这种情况下能做的有限,要么错峰操作,要么让大传输放到后台跑。
5.3 SSH 配置项里那些真正有用的开关
除了前面提到的ControlMaster和关闭 GSSAPI,还有几个 SSH 参数值得调:
| 配置项 | 作用 | 我的建议值 |
|---|---|---|
Compression | 传输压缩 | 文本仓库 yes,二进制多则 no |
ServerAliveInterval | 保活心跳间隔 | 30 |
ServerAliveCountMax | 心跳失败容忍次数 | 6 |
TCPKeepAlive | TCP 层保活 | yes |
GSSAPIAuthentication | Kerberos 认证尝试 | no |
ControlMaster | 连接复用 | auto |
ControlPersist | 复用保留时长 | 600 |
这一组配下来,对连接建立阶段和长连接稳定性都有明显改善。ServerAliveInterval配合ServerAliveCountMax能防止传输大 pack 时因空闲被中间设备掐断——传输大仓库时如果进度条突然中断并报连接重置,多半就是这个原因。
6. 常见问题速查与避坑心得
6.1 常见现象对照排查表
| 现象 | 可能原因 | 优先尝试 |
|---|---|---|
| 每条 Git 命令都等好几秒 | 连接建立慢,GSSAPI 或 DNS | 关 GSSAPI,配 ControlMaster |
| 卡在 Resolving deltas 很久 | 服务端准备 pack 慢 | 浅克隆 / 局部克隆 |
| 卡在 Writing objects | 上行带宽不足或 pack 大 | 分批提交,检查网络 |
| 传输中途连接重置 | 空闲被掐断 | 配 ServerAliveInterval |
| push 卡在 remote 输出后 | 服务端钩子慢 | 推动服务端优化钩子 |
| 只有某台机器慢 | 该机器网络或 DNS 特殊 | 对照 HTTPS,查本机 DNS |
这张表是我从自己的排查记录里整理的,覆盖了绝大多数我遇到过的场景。建议先按"现象"对号入座,基本能省掉一半的试错时间。
6.2 我踩过的几个坑
第一个坑是盲目改http.postBuffer。刚接触这个问题时,看网上说 push 大文件失败就改这个,我照着做了,结果 SSH 场景下毫无作用。后来才明白这个参数只走 HTTP 通道,SSH 根本不读它。这个教训让我养成一个习惯:调参数前先确认它属于哪条链路。
第二个坑是在/etc/hosts里写死仓库 IP。一开始确实快了,但仓库服务商的 IP 是可能变的,某天突然就连不上了,排查半天才发现是 hosts 里那条过期记录。现在如果一定要用 hosts,我会加上注释和记录时间,定期检查。
第三个坑是在共享仓库上乱动历史。有次为了瘦身一个内网仓库,我差点直接在共享的 origin 上跑历史重写命令,还好在最后关头意识到这会让所有同事的本地仓库失效。共享仓库的历史改动永远是团队级决策,个人不要擅自做。
6.3 一套可以照着抄的收敛配置
综合下来,我现在新机器上的标准配置是这样的。~/.ssh/config:
Host * GSSAPIAuthentication no TCPKeepAlive yes ServerAliveInterval 30 ServerAliveCountMax 6 ControlMaster auto ControlPath ~/.ssh/sockets/%r@%h-%p ControlPersist 600Git 全局配置里加上:
git config --global core.preloadindex true git config --global core.fscache true git config --global gc.auto 256core.preloadindex和core.fscache在 Windows 上对本地操作帮助明显,gc.auto调大能减少自动 gc 打断的频率。克隆大仓库时我默认用--filter=blob:none,需要完整历史时才去掉这个参数。
6.4 一点额外提醒:别把调试配置留成长期配置
调试阶段用GIT_TRACE、ssh -v这些都是临时的,调完记得清掉,否则大量日志会拖慢正常操作。GIT_SSH_COMMAND这种如果写进了 shell 配置文件而忘了删,以后每条 Git 命令都会带上额外参数,反而可能引入新问题。我自己就遇到过因为环境变量里残留了调试参数,导致某台机器上 Git 行为诡异的案例,排查了好久才想起这茬。
说到底,Git over SSH 慢这件事没有万能解药,它是一组分层的问题:连接建立、数据量、服务端处理,每一层都有对应的解法。我自己的经验是,先把ControlMaster和关 GSSAPI 这两条基础配置做上,能解决大半日常卡顿;剩下的大仓库慢,靠按需克隆把数据量降下来;再剩下的服务端问题,就不是客户端能单方面解决的了。真正有效的排查,永远从"先看清瓶颈在哪一段"开始——这一点想明白了,比记住任何一条参数都值钱。