
做数据同步这事说简单也简单说麻烦也麻烦。简单的时候A 目录拷到 B 目录一条 cp 命令就完事麻烦的时候几十 GB 数据、上百万个小文件、源目录还在被业务频繁读写这时候靠全量拷贝基本是拿命在扛。rsync 这个工具我用了很多年它靠增量算法解决了“每次只传变化部分”的问题把同步从体力活变成了技术活。不过 rsync 也不是拿来就能顺手用好的。它的参数非常多同名参数还分短写和长写组合起来之后一个命令能拉老长。真到生产环境我一个人手动敲还好换同事操作换一台机器很容易因为漏参数、漏尾部斜杠、漏排除规则给出一个看似成功实则跑偏的结果。所以我在项目里越来越倾向于把 rsync 参数固化成 shell 脚本源目录、目标目录、排除规则、日志、锁都提前写清楚剩下的事情交给脚本和定时任务去执行。这篇文章就把我一直在用的 rsync 同步 shell 脚本拆开讲。除了脚本本身的代码我会把每个参数为什么这么选、锁和日志怎么设计、挂到定时任务之后要注意什么以及这几年踩过的真实坑都整理出来。不管是运维、后端还是刚接触服务器数据管理的新人只要把脚本逻辑理解透改改路径和参数就能直接拿去用。1. 先别急着复制脚本同步脚本的设计思路1.1 rsync 到底适合解决什么问题rsync 全名是 remote sync核心能力就四个字增量同步。第一次同步的时候它会把源目录的数据完整传到目标目录之后再次运行它会根据文件大小、修改时间、校验信息判断哪些文件变了只传输变化部分。这个机制和全量拷贝相比省下来的不仅是带宽还有磁盘写入和等待时间。很多人只把它当备份工具其实它的适用面远比想象中宽。常见场景包括服务器配置目录的定时备份、静态站点发布到多台机器、日志文件归档、代码构建产物同步、数据库冷备份之后把备份文件拉到异地。这些场景有一个共同特点数据总量大但每次变化量小用增量同步能把成本压得很低。还有一点经常被忽略rsync 的同步结果是可预期的。只要两端环境正常跑出来的文件结构、权限、时间戳都能和源端保持一致不会像某些同步工具那样“传完了但细节丢了”。这一点在工作流里非常重要因为自动化脚本最怕的就是结果不确定性越是可预期的工具越适合放进无人值守的流程里。1.2 为什么非要封装成 shell 脚本而不是直接敲命令rsync 单条命令本身不复杂但真实使用场景一介入事情就变味了。看一条我在生产环境用过的完整命令rsync -avz --delete -e ssh -p 2222 \ --exclude *.log --exclude .git/ \ /data/project/ userhost:/opt/data/project/这条命令已经算规整了。可它很容易出问题*.log的匹配范围到底是哪一级目录.git/到底有没有被正确排除目标目录的父目录如果不存在rsync 能不能自动建远程端口写错了会不会要等半天超时这些细节靠人脑去记早晚会出漏子。所以我把 rsync 使用经验封装成脚本至少解决三件事。第一参数固化。所有关键选项都写在脚本里每次执行的都是同一套规则不会出现今天带了--delete明天忘了带的情况。第二日志统一。哪一次同步成功、哪一次失败、退出码是多少全都有记录事后排查不需要翻 Shell 历史。第三执行保护。脚本里加上锁机制防止两次同步任务叠在一起跑避免资源争抢和数据错乱。还有一点容易被忽视脚本本身就是同步策略的“知识沉淀”。今天加一个排除规则明天调一个带宽限制后天补一个错误通知过两个月回头看注释和配置里能清楚还原当初为什么这么做。裸敲命令永远留不下这种积累。2. 脚本核心细节拆解参数、锁和日志背后的理由2.1 基础参数-a、-v、-z 到底该不该加先看最基础的-a参数。它是 archive 模式的简写展开之后相当于-rlptgoD分别代表递归、保留软链接、保留权限、保留修改时间、保留属主、保留属组、保留设备文件。对绝大多数备份和同步场景来说-a是最稳的底座不加它同步过去的文件很可能权限错乱、时间戳丢失。但-a也有一个容易被忽略的副作用它包含-o和-g也就是会尝试保留属主和属组。如果你是用普通用户执行同步而源目录里有些文件属于另一个用户rsync 会尝试执行 chown 然后失败这会在日志里留下大量警告。生产环境里的常规做法是要么用 root 跑同步要么根据实际情况去掉-o或者用--chown把属主显式转换一遍。-v是 verbose输出过程信息。在脚本里我一般都会加因为日志里需要看到具体传了哪些文件。如果你担心日志太吵可以用-i替代它只输出变更项的简要标记适合看“到底哪些文件变了”。-z是压缩传输这个参数要看场景本机同步或内网同步加了反而白白消耗 CPU因为本地磁盘拷贝根本不存在带宽瓶颈跨公网同步时-z能明显减少传输量但也会提高 CPU 使用率。建议先不加等确认带宽确实紧张再加。2.2 --delete 和排除规则这是全脚本最需要敬畏的地方很多 rsync 教程会把--delete当成默认参数写进命令我不太赞同。--delete的含义是让目标目录和源目录完全对齐目标端多出来的文件会被删掉。如果源目录写错、尾部斜杠漏掉、或者源路径指向了一个空目录目标目录可能瞬间被清理掉大量数据。我用--delete之前会强制自己回答一个问题目标目录里那些多出来的文件是真的要删吗如果是纯备份目标端多几个历史文件不致命如果是镜像同步、发布上线--delete才能保证目标端和源端完全一致。这个判断必须由人来做不能随手加上。排除规则同样有讲究。生产环境常用的是排除日志和临时文件--exclude*.tmp --exclude*.log --exclude.git/ --excludecache/注意 rsync 的 exclude 规则是按第一个匹配结果生效的。如果你同时用了--include和--exclude顺序会影响最终结果。比如你想排除所有.log但保留important.log就必须先写--includeimportant.log再写--exclude*.log。这个顺序问题很隐蔽我第一次用的时候就被坑过一次。2.3 健壮性设计返回值、锁、日志一个都不能少给同步脚本做健壮性设计核心在于三个词返回值、锁、日志。rsync 执行完毕之后会返回退出码0 表示成功23 表示部分文件传输失败24 表示源文件在同步过程中消失了255 通常代表 SSH 连接失败。脚本里不能只判断“非 0 就是失败”因为 24 这种退出码在业务频繁读写的目录里很常见属于可以容忍的噪声。我一般会把退出码完整记录到日志里再决定是立刻告警还是等下一次同步自动补上。锁机制也非常关键。如果脚本被定时任务触发而上一轮同步还没结束下一轮任务又启动了两个 rsync 进程可能会同时读写同一批文件轻则重复传数据重则产生文件锁冲突。最常见的锁方案是用flock它比用 mkdir 那种“锁目录”的方式更可靠因为进程退出后锁会自动释放不会出现“锁残留”。日志设计上我坚持一条原则成功和失败都要写。只记录失败不记录成功时间长了你会分不清“没跑”和“跑成功了但没记录”只记录成功不记录失败那日志就变成了自欺欺人。日志里至少要包含时间、源目录、目标目录、退出码这四个要素。3. 从零到一搭一个生产可用的 rsync 同步 shell 脚本3.1 本地目录同步一个可以直接改路径用的完整脚本先给一个最基础的版本适合把本机某个目录增量同步到另一个目录。完整脚本如下#!/usr/bin/env bash # file: rsync_backup.sh # desc: 本地增量同步脚本适合目录备份和镜像 set -Eeuo pipefail readonly SRC${1:-/data/workspace/incoming/} readonly DST${2:-/data/backup/incoming/} readonly LOG_FILE/var/log/rsync_backup.log readonly LOCK_FILE/tmp/rsync_backup.lock EXCLUDE_ARGS( --exclude*.tmp --exclude*.log --exclude.cache/ ) log_info() { echo $(date %F %T) [INFO] $* ${LOG_FILE} } log_error() { echo $(date %F %T) [ERROR] $* ${LOG_FILE} } # 并发保护拿不到锁就直接退出避免脚本叠加执行 exec 9${LOCK_FILE} if ! flock -n 9; then log_error 上一次同步还没结束跳过本次执行。 exit 0 fi log_info 开始同步${SRC} - ${DST} if rsync -a ${EXCLUDE_ARGS[]} ${SRC} ${DST} ${LOG_FILE} 21; then log_info 同步成功${SRC} - ${DST} else rc$? log_error 同步失败退出码 ${rc}${SRC} - ${DST} exit ${rc} fi这段脚本有几个点值得展开说。set -Eeuo pipefail里面-e是出错即退出-u是变量未定义就报错-o pipefail是让管道命令的失败能被正确捕获。这三个组合起来可以让脚本在异常初发阶段就停下来而不是带着错误继续跑。不过在 rsync 这种“返回值需要单独处理”的场景里-e不能直接套在没有 if 包裹的命令上所以我用if rsync ...; then ... else ...的结构来捕获退出码这样既不会因为非零退出码直接中断又能拿到具体的返回状态。exec 9${LOCK_FILE}是把文件描述符 9 和锁文件绑定然后flock -n 9尝试获取独占锁。-n表示非阻塞拿不到锁直接返回失败。这个方案比mkdir锁更省心因为进程结束或者被 kill 时文件描述符会自动关闭锁也随之释放。readonly SRC${1:-...}表示脚本可以接受命令行参数第一个参数是源目录第二个是目标目录。不传参时使用默认值。这样设计是为了让脚本既能手动执行也能被其他工具调用。不过要提醒一句如果同一套脚本要服务多个不同任务锁文件最好带上任务标识否则不同任务之间会互相锁住。3.2 升级到远程同步SSH 密钥和连接超时是重点本地同步毕竟是少数场景更多时候我们是把数据从一台机器推到另一台。远程同步最常用的方式是走 SSH而不是 rsync daemon。原因很简单SSH 不用单独起服务、不用配置模块、天然支持密钥认证安全性也更高。只有在“多台客户端同时往一台服务器集中备份”的场景下我才会考虑 rsync daemon。远程同步的第一步是配好免密登录。我建议用专门的密钥而不是服务器主密钥ssh-keygen -t ed25519 -N -f ~/.ssh/id_rsa_backup ssh-copy-id -i ~/.ssh/id_rsa_backup.pub backup_userremote_host生成密钥时用-N 表示空密码否则定时任务执行时会卡在输密码环节。配合~/.ssh/config配置别名可以极大缩短 rsync 的参数长度Host backup-server HostName 10.0.0.10 Port 22 User backup_user IdentityFile ~/.ssh/id_rsa_backup配置好之后远程同步脚本可以写成这样#!/usr/bin/env bash # file: rsync_remote.sh set -Eeuo pipefail readonly REMOTEbackup-server readonly LOCAL_PATH/data/app/upload/ readonly REMOTE_PATH/backup/app/upload/ readonly LOG_FILE/var/log/rsync_remote.log # 确保远端目标目录存在 ssh -o BatchModeyes ${REMOTE} mkdir -p ${REMOTE_PATH} if rsync -a --partial \ -e ssh -o BatchModeyes -o ConnectTimeout10 \ ${LOCAL_PATH} ${REMOTE}:${REMOTE_PATH} ${LOG_FILE} 21; then echo $(date %F %T) [INFO] 远程同步成功 ${LOG_FILE} else rc$? echo $(date %F %T) [ERROR] 远程同步失败退出码 ${rc} ${LOG_FILE} exit ${rc} fi这里有两个关键细节。-o BatchModeyes会让 SSH 在无法免密登录时直接失败而不是卡住等待输入密码这对定时任务极其重要。-o ConnectTimeout10限制了连接超时时间避免目标机器网络不通时脚本挂起十分钟以上。--partial允许大文件传输中途失败后保留已传部分下次同步可以继续不用从头传。--partial这个参数我最开始没加后来同步一个 40 多 GB 的数据库文件网络抖动断了两次每次都要重传气得我直接把参数写死在脚本模板里。现在不管是大文件场景还是普通文件场景我都会带上它理由是断点续传的成本太低了不值得为了省几个字节的临时文件去冒风险。3.3 挂定时任务cron 和 PATH 的坑一起说脚本写完下一步就是让它自动跑。最常用的方式就是 crontab# 每天凌晨 2 点执行 0 2 * * * /usr/local/sbin/rsync_remote.sh /dev/null 21这里要特别提醒一个很隐蔽的坑cron 执行时的环境变量和你手动登录 Shell 是不一样的。它默认的PATH非常精简很多系统路径都不在里面。如果你的脚本内部调用了ssh、rsync、flock这些命令而它们没有被安装在/usr/bin这种默认路径下cron 执行时可能会报command not found。解决办法有两种一是在脚本开头显式定义 PATHexport PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin二是在 crontab 里配置 PATHPATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 0 2 * * * /usr/local/sbin/rsync_remote.sh /dev/null 21我一般两个都做。脚本内部设置 PATH是为了让脚本在手动执行和定时执行时行为一致crontab 里配置 PATH是为了让其他环境变量也能正确生效。另外定时任务的输出重定向不要直接丢给/dev/null至少在调试阶段这样做会把所有错误信息都吞掉。我通常先让脚本把原始输出追加到一个调试日志文件等确认稳定运行一周之后再改成静默执行只保留脚本内部的日志。4. 高发事故与排障实录把坑提前填上4.1 rsync 退出码 23 和 24别把正常的抖动当成故障先看一张我最常用的退出码速查表退出码含义常见原因处理建议0同步成功无无需处理23部分文件传输失败权限不足、磁盘满、部分文件读取失败查看日志定位具体失败文件24源文件在同步过程中消失日志切割、临时文件被清理、业务正在移动文件可接受二次同步会补上255SSH 连接失败网络不通、密钥错误、远端无法执行 rsync检查连接和认证配置我最开始写脚本时把“非 0 即失败”当作原则结果每天都在收告警。后来发现那些告警里有一半都是退出码 24场景基本是业务在同步过程中滚动日志rsync 还没读到某个文件文件就被 logrotate 改名或者删掉了。对这种情况正确做法是把它当作“需要关注但不至于告警”的信号在脚本里单独处理if [[ ${rc} -eq 24 ]]; then log_error 退出码 24源目录有文件在同步过程中被移动/删除。 log_error 首次出现可继续观察频繁出现需检查源目录是否持续有大量文件变动。 exit 0 fi如果业务侧非常在意源目录的稳定性你还可以加上--ignore-missing-args让 rsync 在源文件消失时不把这个情况当作致命错误。但我不建议无脑加因为它会把一些真正需要关注的写入问题也一并忽略掉。4.2 --delete 的“手滑现场”和防御方法这是我最想强调的坑因为一旦出事往往是事故级别。先解释一个最容易弄混的知识点rsync 目标路径尾部有没有斜杠决定了目录层级。以/data/project/为例# 把 /data/project/ 目录里的内容同步到 /backup/project/ 目录里 rsync -a --delete /data/project/ /backup/project/ # 这个写法很可能把 /data/project 作为一层目录放进了 /backup/project 下面 rsync -a --delete /data/project /backup/project/如果第二种写法配合--delete目标目录里原有的内容很容易被清空最后得到一个结构错乱的目录。更危险的情况是手滑把源路径写成了其他目录再配合--delete结果可想而知。我用两个手段来防御这类事故。第一在脚本里给--delete加开关。默认不启用删除只有显式设置环境变量时才加DELETE_FLAG() if [[ ${ENABLE_DELETE:-0} 1 ]]; then DELETE_FLAG(--delete) fi rsync -a ${DELETE_FLAG[]} ${SRC} ${DST}这样即使源路径写错了最坏的结果是同步出多余文件而不是把目标端清空。第二上线之前先跑 dry-run。-n是空跑不实际传输配合-i可以看到会产生哪些变更rsync -an --delete -i /data/project/ /backup/project/如果输出里出现一堆*deleting你就知道--delete到底要删什么心里有数再决定是否真正执行。这一步花不了几秒钟但能避免绝大多数“低级手误”。4.3 属主、权限、ACL同步结果和你看得见的不一致用-a同步之后最常遇到的疑问是文件内容明明一模一样但目标端权限和源端不一样或者属主显示成执行同步的用户。原因在于-a里的-o和-g只在当前用户有权限做 chown 时才生效。普通用户同步别人拥有的文件时chown 会被拒绝rsync 只能退而求其次保留它自己能设置的权限。解决方式要看场景。如果源和目标都是你控制范围内的机器最省心的方式就是用 root 执行同步。如果不想给同步脚本太高的权限也可以去掉-o让文件属主统一归到执行用户在脚本注释里写清楚这个决定。还有一个很容易漏的地方ACL 权限。普通 Linux 目录如果设置了 setfacl-a并不会保留这些扩展属性需要额外加-X参数。rsync 3.x 版本里-X会尝试保留 extended attributes也就是 xattr 和 ACL。如果你的同步目标是给另一个团队使用的文件目录这个参数建议加上。4.4 大量小文件同步慢先想清楚瓶颈在哪几十万个几 KB 的小文件是 rsync 最不擅长处理的场景。因为每个文件都需要建立索引、比较元数据、创建临时文件、重命名这些操作单看都不慢但乘以几十万之后就很可观了。针对这种场景我的经验是分情况处理。如果源目录里只有一小部分文件经常变化可以先用find生成变更清单再用--files-from让 rsync 只同步这些文件。这种方式适合“文件多但变化集中”的场景但缺点是不好处理删除操作。如果目标是让两端完全一致还是得老老实实全量比较。如果文件虽然多但单文件体积大那么--inplace值得考虑。默认情况下rsync 是先写临时文件再重命名避免目标端出现半个写坏的文件但对超大文件来说临时文件占用的磁盘空间和重命名的耗时都是负担。--inplace会直接改目标文件省去临时文件代价是传输过程中如果中断目标文件可能不完整。我只在对磁盘空间比较敏感、且目标端有完整备份兜底时用它。带宽占用也是大文件场景里必须要管的。同步任务最好避开业务高峰期或者在脚本里加带宽限制--bwlimit10240--bwlimit的单位是 KiB/s10240就代表最大约 10MB/s。这个参数在跨公网同步时尤其重要否则一个晚上跑完业务侧网络可能被拖出明显卡顿。5. 后期收敛让脚本从一个工具变成一套体系5.1 用配置文件把脚本和具体任务解耦脚本用久了最怕的是“每个环境一个版本”。有的机器上改了端口有的机器上改了排除规则最终散落一地谁也说不清哪份是最新。我有一个习惯把脚本里容易变的参数抽到外部配置文件里。脚本只负责逻辑配置负责描述“这一次同步要做什么”。比如维护一个/etc/rsync/sync_common.conf# sync_common.conf SOURCE_DIR/data/app/ DEST_DIRbackup-server:/backup/app/ EXCLUDE_ARGS( --exclude*.tmp --exclude*.log --excludecache/ ) EXTRA_ARGS( --partial -e ssh -o BatchModeyes -o ConnectTimeout10 )脚本里用source引入配置source /etc/rsync/sync_common.conf rsync -a ${EXCLUDE_ARGS[]} ${EXTRA_ARGS[]} \ ${SOURCE_DIR} ${DEST_DIR}这样每新增一个同步任务只需要复制一份配置并修改路径不用改动脚本主体。长期维护下来脚本保持稳定差异都集中在配置里排查问题也快得多。5.2 多一步验证比加十行告警更有效脚本跑得再顺也不能保证同步结果一定对。备份圈里有一句话我很认同没有做过恢复演练的备份只能算“存了一份数据”不能叫备份。所以我每次写完同步脚本都会先做一次恢复验证。本机同步的话直接对比文件差异diff -rq ${SRC} ${DST} | head -50远端同步的话先在远端空目录里恢复一次关键目录试着重启服务或者加载配置确认数据不只是“传过去了”而是真的能用。这个验证不用每次同步都做但重大变更之后必须来一次。5.3 告警通知别让日志睡大觉日志写得再全如果没人看也是白写。脚本进入无人值守模式后我会给失败场景加一个告警通道用最简单的 Webhook 通知if [[ ${rc} -ne 0 ]]; then curl -fsS -m 10 -X POST \ -H Content-Type: application/json \ -d {\subject\:\rsync backup failed\,\message\:\exit code ${rc}\} \ ${WEBHOOK_URL} || true fi关键是尾部加|| true。告警通知只是附属能力不能因为告警接口暂时不可用反而让同步脚本本身失败。这是我在实际运营里踩过的小坑有一阵子 Webhook 地址配错了结果同步成功之后脚本反而报错退出非常尴尬。最后再给一个容易踩的细节如果你所在环境的flock不可用退回用mkdir做锁也不是不行但一定要在trap里把锁目录删掉否则进程异常退出后锁目录会一直留在那后续所有同步任务都会被挡住。锁文件本身用起来倒是不用清空只要文件描述符正常关闭下次执行就能继续拿锁。这个细节看着小真遇到的时候最容易让人抓狂。