Windows 上没有官方 rsync。本文用 MSYS2 装一个原生 rsync.exe,全程放在非系统盘、不碰 C 盘,再通过 SSH 密钥同步到远端服务器。

文中用 D:\tools\msys64(MSYS2 里写作 /d/tools/msys64)代表 MSYS2 安装目录,用 <本地目录> / <远端目录> 代表同步的源和目标,用 203.0.113.10(RFC 5737 文档专用地址)代表服务器地址。照抄时替换成自己的。

方案选择

方案 许可证 真 rsync 形态 可装到别盘 结论
MSYS2 + rsync GPLv3 ✅ 原生 exe ✅ 本文方案
WSL2 + rsync GPLv3 ✅ 本体 Linux 需迁 VHDX 语义最纯,但 /mnt/c 慢
rclone MIT ❌ 单文件 ✅ 零依赖,适合多云
cwRsync 商业打包 ✅ exe ✅ 发行条款非纯开源
Acrosync / Syncovery 闭源 ✅ GUI — 需要 GUI 时才考虑

选 MSYS2 的理由:开源、原生 exe、无虚拟机、I/O 接近原生(不像 WSL 的 /mnt/c 走 9p 协议),而且能装到任意盘。

MSYS2 是什么

一个跑在 Windows 用户态的 POSIX 翻译层(msys-2.0.dll),把 open() / fork() / chmod() 翻译成 Win32 调用。它不是虚拟机、不是内核。

你装的 rsync.exe 就是 GNU rsync 源码编译出来的,只是链接了这层运行时。所有”怪”(/c/... 路径、假的权限位)都来自这层翻译。

1. 把 MSYS2 装到非系统盘

1.1 选路径

官方要求:短、纯 ASCII、无空格、无中文、NTFS 本地盘,不能是 subst 或网络驱动器。

D:\tools\msys64          ✅
D:\项目\msys64            ❌ 中文
D:\Program Files\msys64  ❌ 空格

中文路径是硬伤

MSYS2 里大量工具(autotools、cmake、部分 pkg-config 脚本)处理不了非 ASCII 路径,会报出截断的路径错误。这条规则对后面的同步源目录同样适用。

1.2 解压(不要用 GUI 安装器)

从 msys2-installer releases 下载 msys2-base-x86_64-latest.sfx.exe:

cd D:\
.\msys2-base-x86_64-latest.sfx.exe -y -oD:\tools\

为什么不用 GUI 安装器 / winget / scoop / choco:

分发形式 是否写 C 盘
msys2-x86_64-*.exe(GUI 安装器) 注册表卸载项、开始菜单快捷方式
*.sfx.exe 自解压归档 无(绿色版,可随时整个删掉)
winget install MSYS2.MSYS2 强制装 C:\msys64,改不了

-o 是父目录,不是最终目录

归档里自带一个 msys64 顶层文件夹,所以:

  • -oD:\tools\ → ✅ 得到 D:\tools\msys64
  • -oD:\tools\msys64 → ❌ 得到 D:\tools\msys64\msys64

解压完先验证:Test-Path D:\tools\msys64\usr\bin\bash.exe

1.3 首次初始化

D:\tools\msys64\msys2_shell.cmd -defterm -here -no-start -msys

首次登录会看到类似输出,这是一次性的:

'./.bashrc' -> '/home/yourname/.bashrc'
'./.bash_profile' -> '/home/yourname/.bash_profile'
'./.profile' -> '/home/yourname/.profile'
'C:\WINDOWS\system32\drivers\etc\hosts' -> '/etc/hosts'
'C:\WINDOWS\system32\drivers\etc\protocol' -> '/etc/protocols'
'C:\WINDOWS\system32\drivers\etc\services' -> '/etc/services'
'C:\WINDOWS\system32\drivers\etc\networks' -> '/etc/networks'
They will never be overwritten nor automatically updated.

它来自 base-files 包的 postinstall 脚本,干两件事:

  1. 把 /etc/skel 的骨架配置复制进你的 $HOME
  2. 把 Windows 的 hosts / protocol / services / networks 拷进 MSYS2 的 /etc/

那句 never overwritten nor automatically updated 是重点:脚本只在目标文件不存在时才拷贝,所以 /etc/hosts 是一次性快照——以后你在 Windows 改了 hosts,MSYS2 里不会同步。需要时手动覆盖:

cp /c/WINDOWS/system32/drivers/etc/hosts /etc/hosts

1.4 把 HOME 钉在非系统盘

安装目录在 D 盘 ≠ 运行时数据在 D 盘。$HOME 决定 ~/.ssh、~/.config、~/.cache 落哪儿。

echo "$HOME"
cygpath -w "$HOME"       # 看物理路径落在哪个盘
结果 处置
/home/yourname ✅ 已在 MSYS2 安装目录下,无需处理
/c/Users/... 或 C:\Users\... ❌ 要改

改法(只影响 MSYS2,不污染全局环境):

echo 'db_home: /home/%U' >> /etc/nsswitch.conf

不要设 Windows 全局 HOME 环境变量

MSYS2 会优先认它。一旦设成 C:\Users\...,整个 ~ 体系(含 .ssh)就被拽回 C 盘了。

1.5 换国内镜像提速

MSYS2 现在只有两个镜像列表文件,注意两者用的变量不同:

文件 用途 占位符
/etc/pacman.d/mirrorlist.msys MSYS 环境的包 $arch
/etc/pacman.d/mirrorlist.mingw mingw 工具链的包 $repo

官方列表里其实已经有清华、中科大、南大、北外,但排在第 8~11 位,轮不到。不用删任何东西,插到第一行即可(pacman 从上往下试,第一个成功的就用,缺包会自动 fallback):

cp /etc/pacman.d/mirrorlist.msys{,.bak}
cp /etc/pacman.d/mirrorlist.mingw{,.bak}

sed -i '1i Server = https://mirrors.tuna.tsinghua.edu.cn/msys2/msys/$arch/' /etc/pacman.d/mirrorlist.msys
sed -i '1i Server = https://mirrors.tuna.tsinghua.edu.cn/msys2/mingw/$repo/' /etc/pacman.d/mirrorlist.mingw

pacman -Syu

其他国内镜像(完整列表见 MSYS2 Mirrors):

镜像 备注
https://mirrors.tuna.tsinghua.edu.cn/msys2/ Tier 1,每天至少同步一次,最稳
https://mirrors.ustc.edu.cn/msys2/ Tier 1
https://mirror.nju.edu.cn/msys2/ Tier 1
https://mirrors.bfsu.edu.cn/msys2/ Tier 1
https://mirrors.aliyun.com/msys2/ Tier 2,同步频率低,更容易 404

下载时报 404 是怎么回事

error: failed retrieving file 'xxx.pkg.tar.zst' from mirror.msys2.org : 404

mirror.msys2.org 是地理重定向服务,把你转到就近的 tier-1 镜像。如果那个镜像上某个包还没同步完,就会 404。

pacman 会自动降级去试列表里的下一个镜像,只要后面出现 installing xxx [###] 100% 就是成功了。这不是故障,是镜像同步延迟。

1.6 装 rsync 和 openssh

pacman -S rsync openssh

rsync --version
ssh -V

1.7 「不占 C 盘」核对表

位置 默认落点 状态
安装目录 D:\tools\msys64 ✅
$HOME(.ssh / .config / .cache) 见 1.4 需确认
pacman 缓存 /var/cache/pacman/pkg 安装目录内 ✅ 定期 pacman -Scc 清
pacman 日志/数据库 /var/log、/var/lib 安装目录内 ✅
MSYS 工具的临时文件 /tmp MSYS2 安装目录下 ✅
原生 Windows 子进程的 %TEMP% C:\Users\...\AppData\Local\Temp ⚠️ 只在调用原生 exe 时触发
快捷方式 / 卸载项 仅 GUI 安装器会产生 ✅ 用归档方式则无

核对命令:

case "$HOME" in /c/*|[Cc]:*) echo "❌ HOME 在 C 盘";; *) echo "✅ HOME: $HOME";; esac
mount | grep -E ' / | /home| /tmp| /var'
du -sh /var/cache/pacman/pkg
Get-ChildItem C:\ -Directory -Filter *msys* -ErrorAction SilentlyContinue

1.8 三个「不要做」

  1. 不要把 D:\tools\msys64\usr\bin 加进系统 PATH。 里面有 bash.exe / find.exe / sort.exe / link.exe,会和 Windows 自带命令、Git、Cygwin 抢 msys-2.0.dll。用 msys2_shell.cmd 进环境,或需要时写绝对路径。
  2. 不要用 winget / scoop / choco 装 MSYS2 的 rsync。 它们会落到 C:\Users\...\scoop 或 C:\ProgramData\chocolatey,正好和目的相反。
  3. 不要把 MSYS2 目录放进 OneDrive / 坚果云同步,或放在 exFAT 盘上。 MSYS2 大量使用符号链接和 POSIX 权限,同步盘会打架、exFAT 不支持。

2. 配置密钥登录

2.1 私钥就位

mkdir -p ~/.ssh && chmod 700 ~/.ssh

# 从 Windows 侧拷进来(按实际路径改)
cp /d/keys/key.pem ~/.ssh/key.pem

# 必须 chmod:cp 过来默认 644,会被 ssh 直接拒绝
chmod 600 ~/.ssh/key.pem
ls -l ~/.ssh/key.pem        # 应为 -rw-------

MSYS2 的 ssh 认 POSIX 权限位,不是 Windows ACL

所以 chmod 600 就够了,不需要 icacls。这也是建议用 MSYS2 自带 ssh 而不是 C:\Windows\System32\OpenSSH\ssh.exe 的原因——两套权限模型混用最容易在私钥上翻车。

2.2 ~/.ssh/config

Host aliyun
    HostName 203.0.113.10
    User root
    Port 2222
    IdentityFile ~/.ssh/key.pem
    IdentitiesOnly yes
    ServerAliveInterval 30
    ServerAliveCountMax 6

写好之后,ssh / scp / rsync / git 全部复用,命令里再也不用写 -i 和 -p。

2.3 三条验证

# 密钥能登 + 主机名对 + 对端装了 rsync
ssh aliyun 'echo SSH-OK; hostname; rsync --version | head -1'

第三条很关键:对端没装 rsync 会报 rsync: command not found。

sudo apt install -y rsync     # Debian / Ubuntu
sudo yum install -y rsync     # RHEL / CentOS / Alibaba Cloud Linux

3. rsync 用法要点

3.1 冒号决定方向

方向 写法
上传 rsync [选项] "<本地目录>/" aliyun:"<远端目录>/"
下载 rsync [选项] aliyun:"<远端目录>/" "<本地目录>/"

忘了冒号,就变成两个本地目录互相同步了。

3.2 结尾斜杠(第一大坑)

源是目录时:

写法 结果
"<本地目录>/" 把目录里面的内容放到目标 → <远端目录>/index.html ✅
"<本地目录>" 把整个目录放进目标 → <远端目录>/<目录名>/index.html ❌

记法:源路径结尾加 / = 「要里面的东西」。

3.3 为什么是 -avz --no-owner --no-group 而不是纯 -a

-a 展开是 -rlptgoD。其中 -o(保留属主)和 -g(保留属组)在 MSYS2 → Linux 场景下必须去掉:

  • MSYS2 报告的文件属主是数字 UID(不是用户名)、属组是 None
  • rsync 会老老实实在服务器上执行 chown 1001:None,然后刷一屏 chown ... failed: Invalid argument
  • 非致命,但很吵,还可能把文件属主改坏

去掉后 -a --no-owner --no-group ≡ -rlptD,权限位和时间戳照样保留。

权限位为什么天然是对的

MSYS2 的 / 挂载带 noacl 选项,会把文件统一报告成 644、目录 755、.exe/.dll 类报告成 755。

传过去之后服务器上就是标准的 Web 目录权限,不需要额外 --chmod。

反方向(服务器 → Windows) 则相反,要加 --no-perms:NTFS 存不了 POSIX 权限位,不加的话 MSYS2 会去动只读属性并刷警告。

3.4 常用参数

参数 作用
-a 归档模式 = -rlptgoD,基本必带
-v / -z 详细输出 / 传输压缩(文本划算,已压缩文件无益)
-P = --partial --progress,断点续传 + 逐文件进度
--info=progress2 只显示一条总进度条(文件多时比 -P 清爽)
-n / --dry-run 干跑,只打印不执行
--delete 目标端删除源端已不存在的文件(镜像同步,危险)
-i / --itemize-changes 逐文件打印变更类型
--stats 传输结束打印统计汇总
--exclude= / --exclude-from= 排除
--bwlimit=2M 限速
--rsync-path="sudo rsync" 对端用 sudo 跑 rsync
--backup --backup-dir=DIR 被删/被覆盖的文件挪进 DIR 而不是消失

3.5 排除规则

--exclude='.git' \
--exclude='node_modules' \
--exclude='*.log' \
--exclude='.env'

多了就写文件,一行一条:

# <本地目录>/.rsyncignore
.git/
node_modules/
*.log
.env
.cache/
--exclude-from="<本地目录>/.rsyncignore"

顺序有意义

--exclude 从上往下匹配,先命中先生效。想只排除根目录的 .git 就写 /.git,写 .git 会连子目录里的一起排掉。

3.6 四种典型配方

场景 命令
镜像同步(远端与本地完全一致) rsync -avz --no-owner --no-group --delete "<本地目录>/" aliyun:"<远端目录>/"
首次全量(大文件可断点续传) rsync -avzP --no-owner --no-group "<本地目录>/" aliyun:"<远端目录>/"
拉回本地备份 rsync -avz --no-perms --no-owner --no-group aliyun:"<远端目录>/" "<本地目录>/"
只增量、不删除(去掉 --delete) rsync -avz --no-owner --no-group "<本地目录>/" aliyun:"<远端目录>/"

3.7 三处可选加固

--info=progress2              # 替代 -P 的逐文件进度条
--chmod=D755,F644             # 把权限钉死,免疫本地挂载选项变化
--compress-choice=zstd        # rsync 3.2+,比默认 zlib 快很多

--compress-choice=zstd 需要两端都支持

先查:ssh aliyun 'rsync --version | head -1'。对端低于 3.2 会直接报 unknown compressor,那就保持默认。

4. 执行同步

4.1 整体流程

4.2 最小流程

# 第一步:在本地生成要同步的产物(按你的项目而定)
<构建命令>

# 第二步:镜像同步
rsync -avz --partial --info=progress2 --no-owner --no-group --delete \
  "<本地目录>/" aliyun:"<远端目录>/"

发布类场景建议先干跑:

# 插入 --dry-run,确认增删清单无误后再去掉
rsync -avz --no-owner --no-group --delete --dry-run \
  "<本地目录>/" aliyun:"<远端目录>/"

4.3 一键脚本

把脚本放进项目目录(例如 <本地目录>\deploy.cmd),用 %~dp0 取自身路径,文件里一个中文都不需要:

@echo off
setlocal
cd /d "%~dp0"

set "NOPAUSE="
set "RC=0"
if /i "%~1"=="/nopause" set "NOPAUSE=1"

echo [1/2] Building...
call <构建命令>
if errorlevel 1 (
    set "RC=1"
    echo.
    echo *** Build failed. Aborted. ***
    goto :done
)

echo [2/2] Syncing...
set "CHERE_INVOKING=1"
"D:\tools\msys64\usr\bin\bash.exe" -lc "rsync -avz --partial --info=progress2 --no-owner --no-group --delete ./<产物目录>/ aliyun:<远端目录>/"
if errorlevel 1 (
    set "RC=1"
    echo.
    echo *** Sync failed. ***
    goto :done
)

echo.
echo Done.

:done
if not defined NOPAUSE (
    echo.
    pause
)
endlocal & exit /b %RC%

为什么要这么写:

要点 原因
用 goto :done 而不是 exit /b 1 exit /b 会立刻结束批处理,错误路径上的 pause 永远执行不到
set "RC=1" 记住返回值 goto 之后 errorlevel 会变,最后用 exit /b %RC% 把失败状态还给调用方(任务计划能感知失败)
if not defined NOPAUSE 交互式双击时暂停;挂任务计划时加 /nopause,否则会永久挂住等按键
endlocal & exit /b %RC% 写一行是必须的:endlocal 会丢弃 setlocal 内的变量,同一行展开 %RC% 才能取到值
set "CHERE_INVOKING=1" 不加这行,cd /d "%~dp0" 等于白做 —— bash -l 会读 /etc/profile,那里会 cd "$HOME",把工作目录冲掉(详见 4.4)

批处理文件千万别存成 UTF-8

cmd.exe 按系统 ANSI 代码页(中文 Windows = GBK/936)解析 .cmd 文件,不是 UTF-8。存成 UTF-8 会引发一条连锁故障:

[1/2] 鐢熸垚闈欐€佹枃浠?..      ← 中文 echo 变乱码
系统找不到指定的路径。          ← cd /d "含中文的路径" 解成乱码,目录进不去
Need to install xxx@1.2.3      ← 停在错误目录,找不到本地依赖,包管理器要去联网下载

所以脚本必须满足两条:① 文件内容纯 ASCII;② 用 %~dp0 而不是硬编码路径。%~dp0 由 cmd 在运行时从文件真实路径展开(操作系统给的是 UTF-16 字符串),完全绕过文件编码问题。

注意 rsync 的源路径写的是相对路径 ./<产物目录>/,原因见下一节。

依赖命令别用裸 npx

npx xxx 在本地找不到 xxx 时会联网下载一个新版本,并且阻塞在交互提示上:

Need to install the following packages: xxx@9.9.9
Ok to proceed? (y)

自动化脚本里这是灾难(版本不可控、断网就跑不了)。三种写法按优先级:

情况 写法
本地有依赖 call "%~dp0node_modules\.bin\<命令>.cmd"
全局安装过 call <命令>
一定要用 npx call npx --no-install <命令>(找不到就报错,不下载)

4.4 工作目录与中文路径的三层坑

中文路径会在两个不同的地方出问题,症状相似但根因不同。

第一层:cmd 解析 .cmd 文件本身

cmd.exe 按 GBK(936) 解析批处理文件。文件存成 UTF-8 时,cd /d "D:\项目\..." 里的中文直接变乱码 → 系统找不到指定的路径。。解法就是 4.3 的两条:纯 ASCII + %~dp0。

第二层:cmd 把参数传给 bash

即使目录进对了,bash 的参数仍要经过 cmd → bash 这一层字节传递,而 bash 期望 UTF-8:

:: 路径含中文时,这样传过去大概率报“找不到文件”
bash.exe -lc "rsync -avz /d/项目/输出/ aliyun:/srv/app/"

诡异之处在于:你在 MSYS2 终端里手敲同样的命令却是好的(那里全程 UTF-8),所以极难定位。

规避方式:

方式 做法
只传相对路径(推荐,脚本里已用) cd /d "%~dp0" 之后 rsync 的源写成 ./<产物目录>/。工作目录是 bash 从操作系统继承的(不是字符串),参数里根本不出现中文。⚠️ 必须同时设 CHERE_INVOKING=1,否则工作目录会被冲掉,见下
把逻辑写进 .sh 建一个 UTF-8 无 BOM 的 ~/sync.sh,中文路径写在 .sh 里由 bash 自己读;.cmd 只负责 bash -l /home/yourname/sync.sh
改成 ASCII 路径 本地目录改成纯英文,两层坑一起消失,一劳永逸

第三层:bash -l 会偷偷改掉工作目录

即使前面两关都过了,还有第三个陷阱:登录 shell 会重置当前目录。Cygwin / MSYS2 的 /etc/profile 里有这么一段:

if [ -z "${CHERE_INVOKING}" ]; then
  cd "${HOME}" || echo "WARNING: Failed to cd to ${HOME}"
fi

所以 bash -lc "..." 一启动就跳到了 $HOME,.cmd 里 cd /d "%~dp0" 设好的目录白设了,./<产物目录>/ 被解析到 $HOME 下面:

rsync: [sender] change_dir "/home/yourname/./public" failed: No such file or directory (2)

解法是在调用 bash 之前设置一个环境变量:

set "CHERE_INVOKING=1"
"D:\tools\msys64\usr\bin\bash.exe" -lc "rsync ..."

CHERE_INVOKING 是 Cygwin 系专门为「保留当前目录」提供的开关。它走环境变量而不是命令行参数,所以顺带也不受编码问题影响。

名字必须全大写

shell 环境变量大小写敏感,Chere_Invoking 不生效。

不想被这层 shell 折腾,也可以完全绕开 bash:rsync.exe 本身就是可执行程序,直接调用即可,工作目录原样保留:

set "PATH=D:\tools\msys64\usr\bin;%PATH%"
"D:\tools\msys64\usr\bin\rsync.exe" -avz --partial --info=progress2 --no-owner --no-group --delete ./<产物目录>/ aliyun:<远端目录>/

临时设 PATH 是为了让 rsync.exe 能找到同目录下的 ssh.exe(它靠 PATH 查找远程 shell)。这个 set 只影响当前批处理进程,不会污染系统环境变量。

方式二的脚本:

#!/bin/bash
set -euo pipefail

SRC="<本地目录>/"
DST="aliyun:<远端目录>/"

echo "=== $(date '+%F %T') 同步开始 ==="
rsync -avz --partial --info=progress2 \
      --no-owner --no-group --delete \
      "$SRC" "$DST"
echo "=== $(date '+%F %T') 同步完成 ==="
"D:\tools\msys64\usr\bin\bash.exe" -l /home/yourname/sync.sh

症状识别

  • 批处理输出里中文变乱码、cd 报「系统找不到指定的路径」 → 第一层,文件编码问题
  • 报 No such file or directory 但你确认文件明明在 → 第二层,参数传递问题
  • 报 change_dir "/home/xxx/./..." failed,路径里出现了家目录 → 第三层,bash -l 改了工作目录

前两层的根治办法是同一个:让中文既不出现在 .cmd 文件里,也不出现在跨 cmd→bash 的参数里。 第三层则是永远要记得 CHERE_INVOKING=1。

4.5 验证同步结果

# 远端文件数与权限抽样
ssh aliyun 'du -sh "<远端目录>"; find "<远端目录>" -type f | wc -l'

# 找权限异常的文件(应该只剩 644 和 755)
ssh aliyun 'find "<远端目录>" -type f ! -perm 644 | head'

本地对照:

find "<本地目录>" -type f | wc -l

两个数字一致 = 完全镜像。

4.6 回滚保险

把 --delete 换成带备份的版本,被删的文件不会直接消失:

--delete --backup --backup-dir=/srv/.trash-$(date +%F)

远端被”删”的文件会挪进 /srv/.trash-2026-10-10/,定期清旧日期目录即可。

5. 自动化(可选)

5.1 挂 Windows 任务计划

# 路径含空格的话,/tr 里的引号会被再解析一次,很麻烦——这也是尽量别用空格路径的理由
schtasks /create /tn "sync-task" /sc daily /st 09:00 /tr "<本地目录>\deploy.cmd /nopause"

任务计划的三个前提

  1. 必须加 /nopause —— 否则脚本末尾的 pause 会一直等按键,任务永久挂住不退出,下次触发还会叠加一个
  2. 全部用绝对路径,工作目录也要显式设置
  3. 私钥必须已解锁(无 passphrase)或已加进 ssh-agent,否则会静默失败

5.2 如果项目自带部署插件

不少项目框架自带部署命令(本质是调 rsync),它们要求 rsync 在 Windows 的 PATH 里——这和 1.8 的「不要污染 PATH」冲突。

只在脚本内临时设 PATH(只影响该进程,不动系统环境变量):

set "PATH=D:\tools\msys64\usr\bin;%PATH%"
call <部署命令>

但既然已经有一条验证通过的 rsync 命令,用脚本包起来更可控:参数、日志、失败中止都在你手里。

6. 排错表

报错 原因 / 处置
Permissions 0644 for 'xxx.pem' are too open 忘了 chmod 600
Permission denied (publickey) 用户名错 / 私钥不对 / 公钥没进 authorized_keys
rsync: command not found 对端没装 rsync
chown ... failed: Invalid Argument 刷屏 忘了 --no-owner --no-group
mkdir "<远端目录>" failed: Permission denied 加 --rsync-path="sudo rsync",或用有权限的用户
批处理输出中文乱码 + 系统找不到指定的路径 .cmd 存成了 UTF-8,被 cmd 按 GBK 解析(见 4.3)
No such file or directory 但文件确实在 中文路径跨 cmd→bash 参数编码问题(见 4.4)
change_dir "/home/xxx/./..." failed: No such file or directory bash -l 读 /etc/profile 时 cd "$HOME" 了,加 set "CHERE_INVOKING=1"(见 4.4)
Need to install xxx@y.z 卡在 Ok to proceed? 裸 npx 在联网下载,改用本地依赖或 --no-install
unknown compressor 对端 rsync < 3.2,去掉 --compress-choice=zstd
Connection refused / 超时 端口错 / 安全组没放行 / sshd 没在听
下载时报 404 from mirror 镜像同步延迟,pacman 会自动 fallback,无害
--delete 删多了 没干跑。任何带 --delete 的新命令都必须先 --dry-run
WSL 里私钥 chmod 无效 那是 WSL 的 DrvFs 特性,与本方案无关

7. 参考