多台 Windows 之间的异地互备
> 版本 1.0.0 | 真机验证:多台 Windows 经组网 + OpenSSH 打通,
> 已端到端跑通 7 GB 全量备份(分卷 → 断网中断 → 重跑续传 → 回读校验 → 按天轮转 → 清理本地临时包),
> 也完整执行过一次「全部拆除」。下面每条规则都有实测日志支撑,不是纸上谈兵。
这个技能解决什么问题
工作成果只存在一台机器上是裸奔:机器坏了、系统崩了、被盗了,东西就没了。
但现成的备份工具在几台家庭 / 办公 Windows 之间很难落地——要装服务端、要配共享、走公网还不安全。
这里给的是一条只用 SSH 就能跑通的路:几台机器互为对方的后备,
源机自己打包 → 推到异地机最空的盘 → 回来读一遍确认没传坏 → 按份数轮转。
什么时候用
- 手上有两台以上 Windows,彼此能 SSH 互通(组网软件、局域网、VPN 都行)
- 想把某台机器的桌面 / 工程目录定期放到另一台上
- 数据量在 GB 级,单次传不完、网络会断,需要能续传
- 不想装任何备份服务端
先填这张机器表
脚本顶部有一张表,按你的实际情况填。<...> 都是填表前的占位符,真跑之前必须换成真值:
$Machines = @(
[pscustomobject]@{ Alias='<别名A>'; Host='^<机器名A>$'; User='<用户名>'; IP='<IP-A>'; Label='<机器A 说明:性能最好的主力机>'; BackupTo='<别名C>,<别名D>' }
[pscustomobject]@{ Alias='<别名B>'; Host='^<机器名B>$'; User='<用户名>'; IP='<IP-B>'; Label='<机器B 说明:随身携带的轻薄本>'; BackupTo='<别名A>,<别名C>' }
)
Alias:给机器起的短名,只用 ASCII(会进目录名)Host:用正则匹配本机名,脚本拿它「认自己」BackupTo:候选目标机队列,用逗号分隔,哪台连得上就往哪台备- 排队先后有讲究:实测往某台低配机传 7 GB 跑满 60 分钟还被挤断(约 2 MB/s),
那机器的 CPU 撑不动加密;性能好的机器吞吐明显更好,排前面,长开机的排后面兜底。
⚠️ 同处一个局域网时,主机名会被解析成 IPv6 链路本地地址,脚本里目标一律写 IP。
五条硬规则(不照做必然踩坑)
1. scp / sftp 要用 MSYS2 版
部分机器上的 Win32-OpenSSH 那套(C:\Windows\System32\OpenSSH\scp.exe)会报:
pipe: Unknown error # 实际是 ERROR_PIPE_BUSY
看着像在传,其实没传成。改用带 MSYS2 运行时的那份(Git for Windows / GitFull / PortableGit 里都有):
<安装目录>\usr\bin\scp.exe
<安装目录>\usr\bin\sftp.exe
打包用系统自带的 bsdtar:C:\Windows\System32\tar.exe(原生支持中文路径和 Windows 盘符)。
2. sftp 的 batch 里,两边路径各说各的行话
batch 文件里要这样写:
put "/c/Users/<用户名>/xxx.tar" "/D:/BackupHub/<别名>/<日期>/xxx.tar"
- 本地路径必须是 POSIX 形式
/c/Users/...。写C:\Users\...的话反斜杠会被 sftp
当转义字符吃掉,变成C:UsersADMINI~1AppDataLocalTemp...,报错:
stat C:UsersADMINI~1...: No such file or directory。 - 远端路径必须带前导斜杠
/D:/...。写成D:/xxx会被当成相对路径,接到用户主目录底下。
转换函数:
function To-Posix([string] $p) {
$q = $p -replace '\\', '/'
if ($q -cmatch '^([A-Za-z]):/(.*)$') { $q = '/' + $Matches[1].ToLower() + '/' + $Matches[2] }
return $q
}
3. put -a(续传)只用于「接着传」,不能用于「开个头」
对着不存在的远端文件用 -a:
sftp> put -a "/c/.../x.tar" "/D:/.../x.tar"
stat remote: No such file or directory
而且它会就此中断整个 batch,排在后面的命令一条都不执行。所以:
- 远端没有这个文件 → 用普通
put - 远端有这个文件、且
0 < 远端字节 < 本地字节→ 加-a续传
怎么确认 -a 是真的在续传而不是重写?实测方法:远端先放一个 10 字节文件,
再 put -a 一个 2139 字节的文件,结果正好 2139 —— 说明只补了缺的 2129 字节。
若是 2149 就说明变成了「追加」,内容会是坏的。
注意续传前提是本地这个包和远端那份残缺是同一个文件。每次重跑都会重新打包,
源文件若在这期间变了,续传出来的东西会是坏的。这是续传机制的固有取舍。
4. 钥匙是「哪台认哪把」,不是「主用 / 备用」
不同机器的 authorized_keys 不一样,实测会出现「A 机器只认第一把、用第二把连必被拒」。
传输中断时千万别去换钥匙——日志最后只会留下一条误导性的 Permission denied,
看着像鉴权坏了,其实是网络断了。正确逻辑:
if ($txt -match 'Permission denied') { break } # 钥匙不对,才换下一把
if ($try -lt 3) { Start-Sleep -Seconds 5 } # 其它错误:同一把钥匙重试
5. 归档目录必须按「天」命名,续传才有用武之地
目录名写成 yyyy-MM-dd_HHmm 的话,断线重跑会落到另一个新目录,
上一趟传了一半的分卷就躺在旧目录里白干,跳过和续传永远触发不了。
改成 yyyy-MM-dd:同一天重跑必定回到同一目录,包名同样只到天
({别名}_{日期}_{卷标}.tar),于是:
- 远端已有、字节数一致 →
[已有] 跳过 - 远端有残缺、比本地小 →
[续传] 接着传
代价:一天只有一份异地备份。要一天多份的话,改用 staging 目录+传完改名。
带宽现实:先算时间,别指望「几分钟搞定」
实测多台机器之间传输,走组网层封装的地址与走同一个局域网,速度差别不大——
瓶颈常在物理链路本身,GB 级数据常见只有 2~3 MB/s:
7 GB 全量 ≈ 40 分钟 1 GB 分卷 ≈ 6 分钟
所以架构上必须三件事齐全:分卷 + 跳过已完成卷 + 断点续传。
两台在同一局域网时脚本会自动切到内网 IP,少一层封装,但别指望这带来质的提升。
打包反而很快:5 GB 用免压缩(tar -cf)只要 32 秒。
gzip 在几 GB 量级是纯亏——实测压缩 5 分钟才吐出 878 MB。
标准流程
- 单实例锁:留活着的进程 PID,超时 6 小时的锁当废纸处理(叠着跑两个会把带宽挤爆)
- 认自己:拿本机名去机器表里正则匹配
- 定位工具:ssh / MSYS 的 scp+sftp / 系统 tar
- 挑目标机:按
BackupTo队列逐个试探(推 helper → probe → 剩余空间 < 3 GB 就跳过) - 同网段直连检测:问对方拿局域网 IP,与本网段比对,命中再验一次 22 端口,不通就回退
- 推 helper 到目标机:scp 一个小脚本过去,负责 probe / mkdir / verify / rotate / usage
- 按天建目录:
<盘>:\BackupHub\<别名>\<日期> - 打包 + 分卷:默认 1 GB 一卷;单件就超过一卷的立即自成卷
- 传 + 回读:每卷传完都问目标机一次字节数
- 轮转 + 清理:按名字倒序保留最近
-Keep份,删更早的整份;删本地临时包
传输段的正确写法(可直接抄)
# 传之前先问目标机:这份有多大?(-1 = 还没有)
$rsize0 = -1
foreach ($l in (Invoke-Remote -M $Target -Action 'verify' -PathArg $remotePath)) {
if ($l -match '^SIZE=(-?\d+)$') { $rsize0 = [long]$Matches[1] }
}
# 远端已经有一份一模一样的,直接跳过
if ($rsize0 -eq $ar.Size -and $ar.Size -gt 0) { $okCount++; continue }
# 只有「远端有残缺」才加 -a
$append = ''
if ($rsize0 -gt 0 -and $rsize0 -lt $ar.Size) { $append = '-a ' }
$rPut = '/' + $remotePath # /D:/BackupHub/...
$lPut = To-Posix $ar.Local # /c/Users/...
$batch = Join-Path $env:TEMP ("bk_put_" + [guid]::NewGuid().ToString('N') + '.txt')
Set-Content -LiteralPath $batch -Value @('put ' + $append + '"' + $lPut + '" "' + $rPut + '"') -Encoding ASCII
$res = Run-Proc -Exe $sftpExe -ArgList ($baseArgs + @('-i', $key, '-b', $batch, "$user@$ip")) -WaitSec 10800
Remove-Item -LiteralPath $batch -Force -ErrorAction SilentlyContinue
# 传完回读一次,字节数对不上就算失败
$rsize = -1
foreach ($l in (Invoke-Remote -M $Target -Action 'verify' -PathArg $remotePath)) {
if ($l -match '^SIZE=(-?\d+)$') { $rsize = [long]$Matches[1] }
}
远端命令输出怎么可靠地抓回来
这几台机器上,下面这些全部会挂或抛异常:& ssh ... 2>&1、PowerShell 的 > / 2>、
.NET Process 接管管道、Start-Process(环境里同时存在 PATH 和 Path 两个键时会直接抛"字典中的关键字…")。
唯一稳的姿势:cmd /c "exe ... > 文件 2> 文件" + .NET Process + WaitForExit 超时杀进程。
完整实现见 scripts/Backup.ps1 里的 Run-Proc。
另外某些版本的 ssh 会往 stderr 狂吐 pipe(notify_pipe) failed 之类的噪音,
解析输出时一定要按规则行(如 SIZE=123)取,不要整段匹配。
目标机侧的活怎么干
scp 一个小 helper 脚本到目标机临时目录,再用 ssh ... -File 调它,
动作走符号化的 KEY=value 输出(ROOT= / FREEG= / MKOK / SIZE= / ROT=DEL:xxx)。
别让远端回中文,距离一长编码必坑。helper 必须是纯 ASCII,且存成 UTF-8 BOM。
PowerShell 5.1 的两个前置(忘了就白干)
.ps1要存成 UTF-8 BOM,否则 PS 5.1 按 GBK 读,中文字面量变乱码,
报出来的错全是假的("string missing terminator" / "missing }")。- 被工具误注入过
</...>之类标记的文件,要先用正则清掉,否则报< : 无法将…识别为 cmdlet。
端到端验证协议(别拿 7 GB 试)
- 先复制主脚本做一份小数据集测试副本,把本机那份
$Sets换成一个几十 KB 的目录,
只验证链路(打包 → 上传 → 回读 → 轮转 → 清理),几秒钟出结果。 - 然后才跑真实全量。
- 必看的三个日志标志:
STAGE-7 mkdir D:/BackupHub/<别名>/<日期> ← 目录是「天」粒度 STAGE-8 pack ok <卷标> size=... secs=... ← 打包耗时(几十秒才正常) [OK] <包名> xxx MB (对方已回读确认) ← 回读校验过了才算数 - 速率核算:隔两分钟查一次远端同名文件的字节数,两次差值 ÷ 秒数。
低于 2 MB/s 先想想是不是别的进程在挤带宽。
清理协议(喊停 / 要撤销时)
每一步看清了再动手,按顺序:
- 停后台任务 / 杀残留进程(
sftp/tar) - 本机临时打包目录
%TEMP%\BackupHub—— 实测能攒到 14 GB,占地的主犯 - 目标机
<盘>:\BackupHub\<别名>\<日期>—— 那半截包 %LOCALAPPDATA%\BackupHub\running.lock—— 不删的话下次跑会被单实例锁直接拒绝
(同目录的keycache.txt/last.txt是钥匙缓存和上次备份时间,可留)
删之前先列一遍给用户看,删完再列一次复核。
每台机器都要扫,别只扫源机和目标机
只要调过脚本,每台机器都可能留下:<各盘>:\BackupHub、%TEMP%\BackupHub、
%LOCALAPPDATA%\BackupHub、%TEMP%\bk_helper.ps1、桌面上的诊断件和日志。
在远端做扫描 / 删除,用 scp 上去的 PowerShell 脚本跑,别在 ssh 命令行里拼 cmd 命令——
Windows 路径里的反斜杠和 2>nul 经 ssh 传过去会被吃掉(会拿到 参数格式不正确 - "BackupHub")。
写这种远端脚本时有三个坑(都踩过):
$env:TEMP/$env:USERPROFILE可能是空的。经 ssh 非交互拉起的会话不保证有这些变量,
Join-Path $env:TEMP 'x'会得到空值然后报参数绑定错误。一律写死绝对路径。- 脚本里的中文字面量会变乱码(同上文 BOM 那条)。要么加 BOM,
要么用字符码拼名字:[string][char]0x5907 + [string][char]0x4EFD + ...。 %TEMP%\bk_helper.ps1有可能变成目录(某次 scp 把文件写成了目录,里面还套同名文件)。
清理时对「文件」和「目录」两种形态都试一次。
删不掉的文件怎么办
先查属性:不是只读、却连改名都失败,就是远端有进程握着句柄。
⚠️ 别自己动手杀远端进程:先报给用户让他定。两个几百字节的文本文件,留着比冒风险强;
或者等下次到那台机器跟前 / 重启后再删。
排错速查
| 报错 | 真正的原因 | 怎么改 |
|---|---|---|
stat C:UsersADMINI~1...: No such file or directory |
batch 里本地路径没转 POSIX | 用 To-Posix |
No such file or directory(远端) |
远端路径缺前导斜杠 | D:/x → /D:/x |
stat remote: No such file or directory |
对不存在的文件用了 -a |
首次上传用普通 put |
Permission denied (publickey...) |
钥匙拿错了(或传输中断后误换过钥匙) | 按机器逐个试,认准后缓存 |
pipe: Unknown error |
用了 Win32-OpenSSH 的 scp/sftp | 换 MSYS2 版 |
参数格式不正确 - "xxx" |
ssh 远端命令行里的反斜杠被吃掉 | 改用上传的 ps1 脚本执行 |
Connection reset / 跑到一半断 |
带宽挤爆或对方撑不住 | 分卷调小、换目标机 |
| tar 退出码非 0 | 个别文件被占用,已跳过 | 把 tar 的 stderr 前几行打出来看漏了谁 |
边界声明
不做:跨平台的 Linux / macOS 备份;块级快照与系统镜像;备份内容的加密压缩一体化;
无人值守的计划任务配置(请自行配「计划任务」调本脚本)。
也不替用户判断「哪些目录值得备份」——这件事必须由人拍板。
资源
scripts/Backup.ps1—— 跑通版主脚本:单实例锁、候选目标机、LAN 直连检测、按天归档、
分卷、续传、回读校验、轮转。顶部机器表需自行填写。scripts/bk_helper.ps1—— 目标机侧助手(probe / mkdir / verify / usage / rotate / ips),纯 ASCII + BOMscripts/BackupRun.ps1—— 启动器模板,支持传入目标机别名
典型调用:
powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\BackupRun.ps1 -Target 你的异地机别名
本机 PowerShell 5.1 下,含中文的 .ps1 必须 UTF-8 带 BOM,否则字面量会乱码
(典型症状:某个中文文件名的判断永远为 False)。
另外,本机禁止用 < > 尖括号占位符直接写进 .bat——cmd 会把它们当重定向符。
所以启动器直接做成 .ps1,少一层坑。