——基于 rsync + inotify 的轻量生产级方案。
本文记录了我在搭建双主文件同步系统时踩过的坑、最终的解决方案、完整脚本和部署步骤。
适用于两台或以上服务器互为主节点、任意一台都可能新增/修改/删除文件的场景。
在实际生产环境中,我们经常需要让两台或多台服务器保持文件实时同步,尤其是 Web 静态资源、用户上传文件、配置文件备份等。根据微客的踩坑经历最终总结了一套 真正可用于生产环境的Rsync多主文件同步方案,方案支持:
- ✔ 新增文件自动同步
- ✔ 修改文件自动同步
- ✔ 删除文件安全同步(不会复活)
- ✔ 移动文件到目录外也能正确识别为删除
- ✔ 双主模式(任意一台都可以修改)
- ✔ 不依赖 NFS / GlusterFS / Unison
- ✔ 仅使用 rsync + inotify-tools
并且包含完整脚本、部署步骤、踩坑记录。
1.背景与需求
我有两台服务器(如果你是多台同理):
- A:1.xxx.xxx.xxx
- B:2.xxx.xxx.xxx
两台服务器都运行了 Zdir 程序,上传的文件存放在:
/opt/zdir/data/public
需求:
- 任意一台新增文件 → 另一台自动同步
- 任意一台修改文件 → 另一台自动同步
- 任意一台删除文件 → 另一台也要删除
- 删除必须安全(不能误删、不能复活)
- 移动文件到目录外也要视为删除
- 基于 rsync + inotify 轻量级实现
- 不使用 NFS、GlusterFS、Unison 等重量级方案
2.遇到的问题(踩坑记录)
❌ 删除无法同步
rsync 不会把“缺失文件”当成删除事件。
❌ 删除期间对端 PUSH 导致文件复活
A 删除 → B 不知道 → B PUSH → A 文件复活。
❌ 修改文件无法判断谁是最新
双向 PUSH 会互相覆盖,谁先跑脚本谁赢。
❌ .delete_log 和 .delete_lock 推送失败
因为使用了 --relative + 绝对路径,rsync 写入失败但被静默忽略。
❌移动文件到目录外不会触发 delete 事件(最隐蔽的坑)
例如:
mv /opt/zdir/data/public/a.txt /root/
inotify 不会触发 delete,而是触发 moved_from → delete_log 不记录 → B 不会删除 → 下一轮同步 B 会把旧文件推回 A → 文件复活
3.最终设计方案(核心思想)
✔ 新增 / 修改:只 PULL,不 PUSH
rsync PULL 会自动根据 mtime 判断谁是最新版本,避免冲突。
✔ 删除:使用 delete_log + delete_lock + trash
- inotify 捕获删除事件
- 写入
.delete_log - 写入
.delete_lock - 立即推送到对端
- 同步脚本根据日志执行删除
- 删除期间禁止 PULL / PUSH
✔ 移动事件处理:监听 moved_from
当文件被移出同步目录时,视为删除。
✔ 删除移动到 .trash,而不是直接 rm
防止误删,可回滚。
4.目录结构
/opt/zdir/data/public/
├── 文件……
├── .delete_log
├── .delete_lock
└── .trash/
5. 安装 rsync
两台服务器都需要安装 rsync。
Debian / Ubuntu
apt update
apt install rsync -y
CentOS / Rocky / AlmaLinux
yum install rsync -y
验证安装
rsync --version
6. 配置 rsync 服务端(rsyncd)
两台服务器都要配置。
编辑配置文件:
vim /etc/rsyncd.conf
写入:
uid = root
gid = root
port = 873
reverse lookup = no
use chroot = no
read only = no
dont compress = *.jpg *.jpeg *.png *.gif *.webp *.avif *.mp4 *.avi *.mov *.mkv *.mp3 *.wav *.aac *.flac *.zip *.rar *.7z *.gz *.tgz *.tar *.pdf *.epub *.iso *.exe *.apk *.dmg *.rpm *.deb *.msi
hosts allow = 127.0.0.1/32 ::1/128
# hosts deny =
max connections = 100
timeout = 1800
lock file = /var/run/rsync.lock
pid file = /var/run/rsyncd.pid
log file = /var/log/rsyncd.log
# zdirpublic-START
[zdirpublic]
path = /opt/zdir/data/public
comment = public sync dir
read only = no
auth users = zdirpublic
hosts allow = *
secrets file = /etc/rsyncd.secrets
# zdirpublic-END
创建密码文件:
echo "zdirpublic:你的密码" > /etc/rsyncd.secrets
chmod 600 /etc/rsyncd.secrets
启动:
systemctl enable rsync
systemctl restart rsync
测试:
rsync zdirpublic@对端IP::zdirpublic
7.监控脚本(watch_delete.sh)
监听删除事件 → 写日志 → 写锁 → 推送到对端。
新增:监听 moved_from 事件,解决“移动到目录外不算删除”的问题
#!/bin/bash
set -euo pipefail
export RSYNC_PASSWORD="你的密码"
# 监控目录配置
WATCH_DIR="/opt/zdir/data/public"
DELETE_LOG="$WATCH_DIR/.delete_log"
DELETE_LOCK="$WATCH_DIR/.delete_lock"
TRASH="$WATCH_DIR/.trash"
# 对端 rsync 目标(分号分隔,多端时填写多个)
RSYNC_TARGETS="zdirpublic@1.xxx.xxx.xxx::zdirpublic;zdirpublic@2.xxx.xxx.xxx::zdirpublic"
mkdir -p "$TRASH"
# 将锁文件推送到对端(尽量让对端尽快看到锁)
push_lock_to_peers() {
IFS=';' read -ra TARGETS <<< "$RSYNC_TARGETS"
for t in "${TARGETS[@]}"; do
# 只同步锁文件本身,静默失败,避免阻塞监控
rsync -az "$DELETE_LOCK" "$DELETE_LOG" "$t/" >/dev/null 2>&1 || true
done
}
# 递归监听删除事件(delete / delete_self / moved_from)
inotifywait -mr -e delete -e delete_self -e moved_from --format '%w%f' "$WATCH_DIR" | while read -r FILE; do
BASENAME=$(basename "$FILE")
# 忽略 delete_log 及其临时文件
[[ "$BASENAME" == .delete_log* ]] && continue
# 忽略 delete_lock 自身
[[ "$BASENAME" == .delete_lock* ]] && continue
# 忽略 .trash 自己
[[ "$FILE" == "$TRASH"* ]] && continue
# 忽略临时文件 .xxx.txt.xxxxxx
if [[ "$BASENAME" =~ ^\..*\.[A-Za-z0-9]{5,}$ ]]; then
continue
fi
# 忽略目录(只记录文件删除)
if [ -d "$FILE" ]; then
continue
fi
# 追加一条 pending 记录:时间|路径|pending
echo "$(date '+%Y-%m-%d %H:%M:%S')|$FILE|pending" >> "$DELETE_LOG"
# 写入锁(带一点标识信息),并推送到对端
echo "$(hostname)-$$-$(date +%s)" > "$DELETE_LOCK"
push_lock_to_peers
done
8.同步脚本(sync_all.sh)
无锁 → PULL(增/改)
有锁 → 不 PULL、不 PUSH,只处理删除
#!/bin/bash
set -euo pipefail
export RSYNC_PASSWORD="你的密码"
DEST="/opt/zdir/data/public"
DELETE_LOG="$DEST/.delete_log"
DELETE_LOCK="$DEST/.delete_lock"
TRASH="$DEST/.trash"
mkdir -p "$TRASH"
# 注意:不要排除 .delete_lock(锁要同步)
EXCLUDES="--exclude 'mnt/' --exclude 'tmp/' --exclude 'cache/' --exclude '.trash/' --exclude '*.log' --exclude '*.tmp' --exclude 'README.md'"
# 另一台服务器的 rsync 源
SRC1="zdirpublic@1.xxx.xxx.xxx::zdirpublic"
SRC2="zdirpublic@2.xxx.xxx.xxx::zdirpublic"
# 0. 判断本地是否有锁
LOCK_PRESENT=false
if [ -f "$DELETE_LOCK" ]; then
LOCK_PRESENT=true
fi
# 1. 如果本地没有锁,说明当前没有删除在路上,可以先 PULL
if [ "$LOCK_PRESENT" = false ]; then
rsync -avz --progress $EXCLUDES "$SRC1/" "$DEST"
rsync -avz --progress $EXCLUDES "$SRC2/" "$DEST"
fi
# 2. 处理 delete_log:pending -> 删除 -> done(幂等)
TMP_LOG="$DELETE_LOG.tmp"
: > "$TMP_LOG"
if [ -f "$DELETE_LOG" ]; then
while IFS='|' read -r TIME FILE STATUS; do
# 跳过空行
[[ -z "$FILE" ]] && continue
# 忽略 delete_log 自身和锁本身
[[ "$FILE" == "$DELETE_LOG" ]] && continue
[[ "$FILE" == "$DELETE_LOCK" ]] && continue
# 已经是 done 的记录,原样保留
if [[ "$STATUS" == "done" ]]; then
echo "$TIME|$FILE|done" >> "$TMP_LOG"
continue
fi
# pending 的记录,尝试删除
if [ -f "$FILE" ]; then
# 移动到回收站
if mv "$FILE" "$TRASH/$(basename "$FILE").$(date +%s)"; then
# 再确认一下文件确实不存在了
if [ ! -f "$FILE" ]; then
echo "$TIME|$FILE|done" >> "$TMP_LOG"
else
# 理论上不会到这,保险起见保留 pending
echo "$TIME|$FILE|pending" >> "$TMP_LOG"
fi
else
# mv 失败,保留 pending,下次再试
echo "$TIME|$FILE|pending" >> "$TMP_LOG"
fi
elif [ -d "$FILE" ]; then
# 如果是目录,尝试删空目录,不纠结,直接标记 done 避免卡死
rmdir "$FILE" 2>/dev/null || true
echo "$TIME|$FILE|done" >> "$TMP_LOG"
else
# 文件本来就不存在了,直接标记 done
echo "$TIME|$FILE|done" >> "$TMP_LOG"
fi
done < "$DELETE_LOG"
mv "$TMP_LOG" "$DELETE_LOG"
fi
# 3. 如果日志里已经没有 pending,说明这轮删除处理完了,可以删锁
if [ -f "$DELETE_LOG" ]; then
if ! grep -q '|pending' "$DELETE_LOG"; then
rm -f "$DELETE_LOCK"
fi
else
rm -f "$DELETE_LOCK"
fi
# 4. 不再做任何 PUSH!
# - 新增文件:通过对端的 PULL 自动拉过来
# - 修改文件:通过 PULL + rsync 按 mtime 选择最新版本
# - 删除文件:通过 delete_log + inotify 推送日志来驱动
9.为监控脚本创建 systemd 服务
为了让监控脚本 随系统自动启动、崩溃自动重启、日志可控,我们需要为它创建一个 systemd 服务。
① 创建服务文件
vim /etc/systemd/system/zdirpublic_watch.service
写入:
[Unit]
Description=Zdir Public Delete Watcher
After=network.target
[Service]
Type=simple
ExecStart=/opt/zdir/sync/zdirpublic_watch_delete.sh
Restart=always
RestartSec=3
User=root
WorkingDirectory=/opt/zdir/data/public
StandardOutput=append:/opt/zdir/sync/log/zdirpublic_watch.log
StandardError=append:/opt/zdir/sync/log/zdirpublic_watch.err
[Install]
WantedBy=multi-user.target
② 重新加载 systemd
systemctl daemon-reload
③ 启动服务
systemctl start zdirpublic_watch
④ 设置开机自启
systemctl enable zdirpublic_watch
⑤ 查看运行状态
systemctl status zdirpublic_watch
如果看到:
Active: active (running)
说明监控脚本已经成功常驻后台。
10.部署步骤(从零开始)
① 安装 inotify-tools
apt install inotify-tools -y
② 配置 rsyncd(每台都要)
见上文第6步。
③ 放置脚本
/opt/zdir/sync/watch_delete.sh
/opt/zdir/sync/sync_all.sh
赋权:
chmod +x /opt/zdir/sync/*.sh
④ 启动监控脚本(后台常驻)
如果之前做过第9步,请忽略此步骤!
nohup /opt/zdir/sync/watch_delete.sh &
⑤ 定时执行同步脚本(每分钟)
如果你是使用面板程序可以直接创建,此处不再赘述。
*/1 * * * * /opt/zdir/sync/sync_all.sh
11. 验证方法
✔ 新增测试:
在 A 创建文件:a.txt ,几秒后 B 应该出现。
✔ 修改测试:
在 B 修改:a.txt ,A 应该自动同步最新版本。
✔ 删除测试
在 A 删除:a.txt ,B 应该自动删除,并且不会复活。
✔ 移动到目录外测试(最关键)
mv a.txt /root/
另一台应自动删除,不会复活。
12.常见坑(必须注意)
- 删除期间禁止 PUSH
.delete_log和.delete_lock必须同步.trash不同步- moved_from 必须监听
- 两台服务器时间必须同步(NTP)
13.完整流程图
删除事件 → inotify → 写 delete_log → 写 delete_lock → 推送到对端
↓
同步脚本检测锁 → 不 PULL、不 PUSH → 处理 delete_log → 删除文件
↓
删除完成 → 清除锁 → 恢复正常 PULL(增/改)
14.总结
这套方案的核心是:
新增/修改靠 rsync PULL,删除靠 delete_log,移动事件靠 moved_from,删除期间禁止 PUSH。
它比 Unison 更轻量,比纯 rsync 更安全,比双向 rsync 更可靠。
现在我们拥有了一个 真正可用于生产环境的双主同步系统。
微客网络

