2024 年我写了个 rsync 脚本,把一台机器上的网站目录同步到备份机。脚本跑完那一刻我就觉得不对劲——备份机上,本该是 /backup/www/ 下面一堆站点目录,结果却凭空多出来一层 /backup/www/www/,文件全堆在了里面。我盯着脚本看了半天,命令一个字都没错,可结果就是不对。这件事最后查清,罪魁祸首是 rsync 源路径末尾那个不起眼的斜杠——加不加它,结果天差地别。这次排查逼着我把 rsync 这套东西彻底理清了,也让我对 --delete 这种"威力巨大"的参数生出了十二分的敬畏。本文复盘这次实战。
问题背景
环境:CentOS 7,把 web01 的 /data/www 同步到 backup 机
事故现象:
- 同步脚本跑完,备份机的目录结构【不对】
- 期望:/backup/www/ 下面直接是 site-a/ site-b/ ...
- 实际:/backup/www/www/ 多嵌套了一层 www
现场排查:
# 1. 看脚本里的 rsync 命令
$ cat sync.sh
rsync -avz /data/www backup:/backup/www/
# ↑ 源路径【末尾没有斜杠】
# 2. 对比一下"加斜杠"和"不加斜杠"的区别
$ rsync -avz /data/www dst/ # 源不带 / :把 www 这个【目录本身】放进 dst
# -> dst/www/...
$ rsync -avz /data/www/ dst/ # 源带 / :把 www 里的【内容】放进 dst
# -> dst/...
# ★ 真相:源路径 /data/www 末尾少了个斜杠,
# rsync 把 www 这个目录【整个】放进了 /backup/www/,
# 于是就成了 /backup/www/www/
根因(后来定位到的):
rsync 对源路径末尾的斜杠有【特殊语义】:
- 源末尾【有】斜杠 = 同步"目录里的内容"
- 源末尾【无】斜杠 = 同步"这个目录本身"
我写成了 /data/www(无斜杠),意思变成了
"把 www 这个目录搬进去",于是多了一层嵌套。
一个斜杠的差别,结果完全不同 —— 这是 rsync 头号坑。
修复 1:rsync 的源路径斜杠——头号天坑
# === rsync 的基本形态 ===
$ rsync [选项] 源 目标
# 把"源"同步成"目标",只传【有差异】的部分。
# === ★ 最关键、最反复坑人的规则:源末尾的斜杠 ===
# 假设 /data/www 里有 site-a、site-b 两个目录。
# 源【不带】斜杠 —— 同步"目录本身"
$ rsync -av /data/www /backup/
# 结果:/backup/www/site-a /backup/www/site-b
# —— www 这个目录被整个搬进了 /backup
# 源【带】斜杠 —— 同步"目录里的内容"
$ rsync -av /data/www/ /backup/
# 结果:/backup/site-a /backup/site-b
# —— 是 www 的内容被铺进了 /backup
# === 记忆口诀 ===
# 源路径末尾的 / ,可以理解成 "/*" ——
# 带 / = 同步里面的东西,不带 / = 同步这个东西。
# ★ 目标路径末尾加不加斜杠,影响不大,
# 真正要命的是【源】路径。
# === 这次正确的写法应该是 ===
$ rsync -avz /data/www/ backup:/backup/www/
# ↑ 加上这个斜杠
# === 养成习惯:写 rsync 前,先在脑子里念一遍 ===
# "我是要同步这个目录本身,还是它里面的内容?"
# 想清楚了,再决定源末尾加不加斜杠。
# 拿不准时 —— 永远先 --dry-run(见修复 4)。
修复 2:rsync 的常用参数
# === -a 归档模式:最常用,一个顶一串 ===
$ rsync -a 源 目标
# -a 等价于 -rlptgoD:
# -r 递归子目录 -l 保留软链接 -p 保留权限
# -t 保留修改时间 -g 保留属组 -o 保留属主
# -D 保留设备/特殊文件
# 一句话:-a 让目标尽量【原样复刻】源,日常同步首选。
# === -v 详细输出 / -P 看进度 ===
$ rsync -avP 源 目标
# -v 把同步了哪些文件打出来
# -P = --progress + --partial:显示进度 + 断点续传
# 传大文件、传到一半断了,-P 能接着传
# === -z 传输时压缩(适合慢网络/跨公网)===
$ rsync -avz 源 远程:目标
# -z 在传输过程中压缩,省带宽。
# 但同机房/本地磁盘对拷,-z 反而是浪费 CPU,可以不加。
# === --exclude 排除不想同步的 ===
$ rsync -av --exclude='*.log' --exclude='cache/' 源 目标
# 或把规则写进文件:
$ rsync -av --exclude-from='/opt/rsync.exclude' 源 目标
# === -n / --dry-run 演练(★ 强烈建议每次先跑)===
$ rsync -avn 源 目标
# 只【显示】会做什么,不真的动文件。见修复 4。
# === 几个实用补充项 ===
# --bwlimit=10000 限速 10MB/s,别把带宽吃满影响业务
# --delete 让目标和源【完全一致】(危险,见修复 3)
# --partial 保留传一半的文件,下次接着传
# -h 大小用人类可读单位显示
# --stats 结束后打印一份传输统计
# === 一条比较顺手的日常组合 ===
$ rsync -avhP --exclude='*.tmp' 源/ 目标/
修复 3:--delete——威力巨大,也最危险
# === 默认行为:rsync 只增不删 ===
# 不加 --delete 时,rsync 会把源的文件补到目标,
# 但【目标上多出来的文件,它不动】。
# 结果是目标 = 源 ∪ 目标原有 —— 越同步越臃肿。
# === --delete:让目标和源【严格一致】 ===
$ rsync -av --delete 源/ 目标/
# 加了它,源里没有的文件,目标上也会被【删掉】。
# 这正是"做镜像备份"需要的 —— 目标完全等于源。
# === ★ 但 --delete + 斜杠写错 = 灾难 ===
# 设想你想同步 /data/www/ 到 /backup/www/,
# 却手滑把源写成了空目录、或写错了路径:
$ rsync -av --delete /data/wwww/ /backup/www/
# ↑ 打错一个字母,源不存在/为空
# 后果:rsync 认为"源是空的",于是把
# /backup/www/ 里的东西【全删光】。
# --delete 会忠实地执行"让目标等于源"——
# 哪怕那个源是个错误的空目录。
# === 用 --delete 的几条保命纪律 ===
# 1. ★ 永远先 --dry-run 看它要删什么
$ rsync -avn --delete 源/ 目标/ # 先演练!
# 2. 源路径写【绝对路径】,并确认它真实存在、非空
$ [ -d /data/www ] && [ -n "$(ls -A /data/www)" ] || exit 1
# 3. 加 --delete 时务必把源末尾斜杠想清楚
# 4. 重要数据,目标端先做快照/备份再 --delete
# === 更安全的删除方式 ===
$ rsync -av --delete --backup --backup-dir=/backup/_trash/ 源/ 目标/
# --backup + --backup-dir:被删/被覆盖的文件不是直接没,
# 而是先挪进 _trash 目录 —— 给自己留一条后悔路。
# ★ 强烈建议:凡是 --delete,都配上 --backup-dir。
修复 4:--dry-run——动手前先演练
# === --dry-run(-n):rsync 最该用、却最常被跳过的参数 ===
$ rsync -avn 源/ 目标/
# 它会把"如果真跑,会同步哪些文件、删哪些文件"
# 全打印出来,但【一个文件都不真的动】。
# === 为什么每次都该先 -n ===
# rsync 的斜杠语义、--delete 的威力,都太容易出错。
# --dry-run 是你动手前的"预览" —— 几秒钟的事,
# 却能挡住"嵌套多一层""把目标删空"这类灾难。
# === 怎么读 dry-run 的输出 ===
$ rsync -avn --delete 源/ 目标/
sending incremental file list
site-a/index.html # 会传输的文件
deleting old-site/ # ★ 会被删除的!--delete 标出来
./
# 重点盯着 deleting 开头的行 —— 那是要被删的东西。
# 如果 deleting 列出了你不想删的,立刻停手查斜杠/路径。
# === -i 让输出更清楚:每个文件为什么被同步 ===
$ rsync -avni 源/ 目标/
>f+++++++++ newfile.txt # f=文件 +号=新增
>f.st...... changed.txt # s=大小变了 t=时间变了
# 看懂这串"变更代码",能精确知道 rsync 打算改什么。
# === 一个稳妥的工作流 ===
# 1. rsync -avn ... 先演练,肉眼核对
# 2. 确认输出符合预期(尤其 deleting 那些行)
# 3. 去掉 -n,真正执行
# 4. 用 --stats 看结果,或 du / ls 抽查目标
# ★ 把"先 dry-run"变成肌肉记忆,能躲掉绝大多数 rsync 事故。
修复 5:增量、续传、限速与排除
# === rsync 的看家本领:增量同步 ===
# rsync 默认就是增量的 —— 它先比对源和目标,
# 只传【有差异】的文件。第二次同步同一份数据,
# 几乎瞬间完成,因为大部分文件没变。
# === 它怎么判断"文件变没变" ===
# 默认:比【大小】和【修改时间】,任一不同就认为变了。
$ rsync -av 源/ 目标/
# 更严格:用校验和逐字节比对(慢,但准)
$ rsync -avc 源/ 目标/ # -c 用 checksum 判断
# === 大文件断点续传 ===
$ rsync -avP 源/ 目标/
# -P 里的 --partial:传到一半断了,保留半截,
# 下次接着传,不用从头来。同步大文件务必带 -P。
# === 限速,别把带宽吃干净 ===
$ rsync -av --bwlimit=20000 源/ 远程:目标/
# --bwlimit 单位 KB/s,20000 ≈ 20MB/s。
# 生产环境同步大数据,限速能避免拖垮线上业务。
# === 精细的排除规则 ===
$ cat /opt/rsync.exclude
*.log
*.tmp
cache/
node_modules/
.git/
$ rsync -av --exclude-from='/opt/rsync.exclude' 源/ 目标/
# 把不需要的(日志、缓存、依赖目录)排除掉,
# 同步更快、目标更干净。
# === 只同步,不传输目标已有的较新文件 ===
$ rsync -avu 源/ 目标/
# -u(--update):目标端如果文件比源还新,就跳过它,
# 避免用旧文件覆盖掉新文件。
# === 看一次同步到底干了多少活 ===
$ rsync -av --stats 源/ 目标/ | tail -20
# --stats 打印:传了几个文件、多少字节、加速比多少
修复 6:rsync over SSH 与同步纪律
# === 跨机器同步:rsync 走 SSH 通道 ===
$ rsync -avz -e ssh 源/ user@remote:/目标/
# -e ssh 指定用 ssh 做传输通道(现代 rsync 默认就是 ssh)
# 远程路径格式:user@主机:路径
# === 配合 SSH 免密 + 指定密钥/端口 ===
$ rsync -avz -e 'ssh -i ~/.ssh/backup_key -p 2222' \
/data/www/ backup@10.0.1.30:/backup/www/
# -e 后面可以带完整的 ssh 参数。
# 配好免密,rsync 才能在 cron 里无人值守地跑。
# === 把同步固化成定时任务 ===
$ crontab -e
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * /opt/scripts/sync.sh >> /var/log/sync.log 2>&1
# (cron 环境极简,记得脚本里把 PATH、路径都写对)
# === 一个稳妥的同步脚本骨架 ===
#!/bin/bash
set -euo pipefail
SRC=/data/www/ # ★ 绝对路径 + 末尾斜杠想清楚
DST=backup@10.0.1.30:/backup/www/
# 同步前先确认源存在且非空,防 --delete 误删
[ -d "${SRC%/}" ] && [ -n "$(ls -A "${SRC%/}")" ] || {
echo "源目录异常,放弃同步"; exit 1; }
rsync -avz --delete \
--backup --backup-dir="/backup/_trash/$(date +%F)" \
--exclude-from=/opt/rsync.exclude \
--bwlimit=20000 \
"$SRC" "$DST"
# === 同步纪律小结 ===
# 1. 源路径斜杠:写之前先想"目录本身还是内容"
# 2. 任何拿不准的同步,先 --dry-run
# 3. --delete 必配 --backup-dir,并先验证源非空
# 4. 跨公网加 -z,大文件加 -P,大数据加 --bwlimit
# 5. 同步脚本 set -euo pipefail,出错就停别带病跑
# 6. 同步结果要留日志、可回溯
命令速查
需求 命令
=============================================================
同步目录内容 rsync -av 源/ 目标/
同步目录本身 rsync -av 源 目标/
演练不真改(必备) rsync -avn 源/ 目标/
看进度+断点续传 rsync -avP 源/ 目标/
传输压缩(跨公网) rsync -avz 源/ 远程:目标/
镜像同步(会删多余文件) rsync -av --delete 源/ 目标/
删除留后路 --backup --backup-dir=/路径/
排除文件 rsync -av --exclude-from=规则文件 源/ 目标/
限速 rsync -av --bwlimit=20000 源/ 目标/
跨机器走 SSH rsync -avz -e ssh 源/ user@host:/目标/
看同步统计 rsync -av --stats 源/ 目标/
口诀:源末尾斜杠先想清楚 -> 任何同步先 --dry-run
-> --delete 必验源非空 + 配 --backup-dir
避坑清单
- rsync 源路径末尾带斜杠是同步目录内容,不带是同步目录本身,结果差一层
- 目标路径末尾斜杠影响不大,真正要命的是源路径的斜杠,写前先想清楚
- -a 归档模式保留权限/时间/属主/软链接,是日常同步首选
- 不加 --delete 时 rsync 只增不删,目标会越同步越臃肿
- --delete 让目标严格等于源,源若误写成空目录会把目标全删光
- 用 --delete 前必须验证源目录真实存在且非空,并配 --backup-dir 留后路
- 任何拿不准的 rsync 都先加 -n 演练,重点看输出里 deleting 开头的行
- 传大文件加 -P 支持断点续传,跨公网加 -z 压缩,大数据加 --bwlimit 限速
- rsync 默认按大小和修改时间判断差异,要逐字节精确比对用 -c
- 同步脚本用 set -euo pipefail,源异常就退出,别让它带病跑 --delete
总结
这次 rsync 的排查,把一个我曾经以为"不就是个高级版 cp 吗"的工具,在我心里彻底重新定了位。出事的时候我特别困惑,因为命令本身怎么看都是对的——参数没写错,源和目标都存在,可备份机上就是凭空多出来一层 www 的嵌套。直到我把"源路径末尾加斜杠"和"不加斜杠"这两种写法并排做了一次对比实验,我才第一次真正看清楚:rsync 对源路径末尾那个斜杠,有一套独立的、必须刻进肌肉记忆的语义。源路径末尾带斜杠,意思是"把这个目录里面的内容同步过去";源路径末尾不带斜杠,意思是"把这个目录本身,连同它这个名字,整个搬过去"。我当时写的是 /data/www,末尾光秃秃的没有斜杠,于是 rsync 一丝不苟地执行了"把 www 这个目录本身搬进 /backup/www/"的指令,结果自然就成了 /backup/www/www/。一个斜杠,一个在视觉上几乎可以忽略不计的字符,决定了同步结果的目录结构是对是错。从那以后,我给自己立下一条规矩:每次在键盘上敲下 rsync 之前,我都要先在脑子里清清楚楚地问自己一句——我到底是想同步"这个目录本身",还是想同步"这个目录里面的内容"?想明白了这个问题,我才有资格决定源路径末尾那个斜杠该不该加。这次排查更深远的影响,是让我对 rsync 这个工具生出了一种过去完全没有的敬畏,而这种敬畏的核心,落在 --delete 这个参数上。我过去用 rsync,基本只把它当成一个聪明的 cp,觉得它最多就是"复制东西",顶天了也就是复制得不够全。可这次我彻底想明白了 --delete 的真正含义:它要做的不是"复制",而是"让目标变得和源严格一致"。这意味着,源里没有的文件,目标上但凡有,它就会毫不犹豫地删掉。这本来是做镜像备份时梦寐以求的能力,可一旦和"源路径写错了"这个低级失误叠加在一起,它就会瞬间从一个有用的特性,变成一场灾难。我做了一个让自己后背发凉的推演:假如我想同步 /data/www/,却因为手滑、或者因为某次源目录恰好为空,把源指向了一个不存在或空的路径,那么 rsync 会怎么做?它会无比"忠诚"地认为——既然源是空的,那为了让目标和源一致,我就得把目标里的所有东西都删光。它不会质疑这个空源是不是你的本意,它只会忠实地执行"让目标等于源"这条命令。理解了这一点,我才真正明白,为什么用 --delete 时必须有一整套保命的纪律:动手之前,一定要先用 --dry-run 演练一遍,把输出里所有 deleting 开头的行逐条看清楚,确认它要删的确实是该删的;源路径一定要写成绝对路径,并且在脚本里主动检查它真实存在、而且非空;最关键的是,凡是带 --delete 的同步,我都会再加上 --backup 和 --backup-dir,让那些"本该被删掉"的文件不是直接消失,而是先被挪进一个带日期的回收目录里——这等于在那个不可逆的删除动作面前,给自己铺了一张安全网,留了一条能够后悔的退路。说到 --dry-run,这是这次排查教给我的、最朴素也最有效的一个习惯。rsync 的斜杠语义是个坑,--delete 的威力是把双刃剑,而 --dry-run 恰恰是化解这两者的那把钥匙:它会把"如果真的执行,会传哪些、会删哪些"原原本本地预演给你看,却一个文件都不真的去碰。它花费的不过是几秒钟,挡下的却可能是"目录多嵌套一层"乃至"目标数据被删空"这种级别的事故。这次从一个莫名其妙多出来的 www 目录出发,我最大的收获,是把 rsync 从一个"我以为很简单的复制工具",重新认识成了一个"威力强大、因而必须带着敬畏和纪律去使用的同步工具"——而这套纪律里排在第一位的,就是那条朴素到了极点、却能躲掉绝大多数事故的铁律:任何拿不准的同步,都先 --dry-run。
—— 别看了 · 2026