凌晨的告警群里甩出一条消息:“rsync 同步脚本跑完了,每个节点都显示 sending incremental file list,返回码也是 0,但到 DataNode 上看 hdfs-site.xml 还是旧版本。”
这不是我第一次在 Hadoop 集群里遇到这种“同步假成功”。所谓目录同步显示成功、实际文件未更新,多数情况下并不是脚本写错了,也不是网络断了,而是 rsync 的默认行为和你预期的不一样。它在用一套“只检查元数据、不检查内容”的规则工作,只要规则认为“无需传输”,它就不会去碰文件。而你看到的成功,只是它按自己的规则检查完毕,并不是目标文件真的和源文件一致了。
这类问题在 Hadoop 集群里特别容易让人翻车,因为集群里多台机器要同步配置、jar 包、脚本,节点一多,你不可能每次同步完都逐台登录上去确认。下面我会把这次排查的完整过程、真正的原因,以及我现在使用的加固同步方案都写出来,尽量让遇到同类问题的人不用再折腾一遍。
1. 故障现象:每个节点都在说 sending incremental file list,但什么都没发生
先说现场。集群规模不大,3 个 NameNode 节点加 5 个 DataNode 节点。因为要调整 HDFS 的 hdfs-site.xml 和 core-site.xml,我写了一个最简单的同步脚本逐个节点分发:
bash复制#!/bin/bash
NODES="hadoop01 hadoop02 hadoop03 hadoop04 hadoop05"
SRC="/data/opt/hadoop-3.3.6/etc/hadoop/"
DST="/data/opt/hadoop-3.3.6/etc/hadoop/"
for node in $NODES; do
echo "=== syncing to ${node} ==="
rsync -avz --timeout=30 "$SRC" root@"${node}":"${DST}"
echo "${node} exit=$?"
done
脚本执行得很安静,没有报错,每个节点都输出类似这样的结果:
code复制sending incremental file list
./
sent 584 bytes received 119 bytes 234.00 bytes/sec
total size is 23,203,175 speedup is 31,294.66
注意看这个输出:除了目录 ./ 之外,没有任何具体文件名。这就是第一个线索——rsync 压根没有把 hdfs-site.xml 列为待传输文件。在 rsync 的认知里,它认为两边文件已经一致,或者目标端文件不需要被更新。
当时第一反应是 SSH 认证或者目录权限出问题了。于是单独登录其中一台 DataNode 验证:
bash复制ssh hadoop02 "cat /data/opt/hadoop-3.3.6/etc/hadoop/hdfs-site.xml" | head -n 5
登录正常,执行命令也正常,文件确实还是旧的。再看源端文件,时间戳是今天刚改过的:
code复制Modify: 2025-06-12 09:22:33.592564578 +0800
目标端:
code复制Modify: 2025-06-10 18:14:02.000000000 +0800
目标端文件时间戳明显比源端旧,理论上该传了,可 rsync 就是没传。这就很有意思了,说明问题不在外层,而在 rsync 本身的传输判定逻辑上。
1.1 容易被忽略的事实:exit code 0 不等于文件一致
很多人对“同步成功”的理解是:rsync 退出码为 0,说明目标端文件已经和源端完全一致。这是一个很普遍的误解。
rsync 的退出码 0 只代表它按自己的规则完成了检查,并且没有发生 I/O 错误、网络错误、权限错误。至于文件是否真的逐字节一致,默认模式下 rsync 根本不关心。
Hadoop 集群里最常见的场景是同步配置文件、jar 包、安装包。这些文件的数量可能不算多,但接收端节点上的文件状态可能千差万别,同一个文件在不同节点上可能来自不同批次的手工修改、解压、回滚。如果我们不对同步结果做二次确认,就很容易出现“脚本跑完了,服务起不来,一看配置还是旧的”的尴尬情况。
1.2 最容易迷惑人的几个“假线索”
这类故障排查时,有几个信息会干扰判断:
- SSH 能连通,说明不是网络问题。
- rsync 没有输出错误信息,说明不是权限问题,也不是目标目录不存在。
- 手动在目标节点上执行
cat、ls都正常,说明系统本身没问题。 - 源文件确实是新的,说明不是改错文件了。
顺着这些表面现象继续查,你可能怀疑脚本变量写错、循环没生效、主机名写错。实际单独执行一遍同样的 rsync 命令,就会发现命令行直接跑也是同样的结果。这时问题定位范围就缩小了:是 rsync 的传输判定规则导致它主动跳过了文件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 根因之一:rsync 的 quick check 只认大小和 mtime,不认内容差异
rsync 默认使用的快速检查机制是很多人理解不了的盲区。
在没有加额外参数时,rsync 判断一个文件是否需要同步的逻辑很简单:
- 目标文件不存在,直接传。
- 目标文件存在,比较文件大小,大小不同,直接传。
- 大小相同,再比较修改时间,如果源端文件的 mtime 比目标端文件的 mtime 新,则传。
- 如果源端文件的 mtime 和目标端相同,或者比目标端旧,rsync 就认为文件没有变化,跳过。
也就是说,rsync 默认不看文件内容,只看文件的“元数据标签”。如果大小一致且目标端时间戳不旧于源端,它就会假装这个文件不存在差异。
这在大多数场景下是合理的优化,因为 stat 一个文件的元数据比读取文件内容算哈希要快得多,能节省大量 IO。但正因为它的判断依据不是内容,所以出现下面几种情况时,文件内容明明是不同,rsync 还是会放过:
- 目标端文件的 mtime 比源端新,内容却是旧版。比如某台机器上有人手动 touch 过,或者服务启动脚本在初始化时重写过文件。
- 源文件来自 tar 包解压、git 导出、镜像构建,文件的 mtime 是历史时间,而目标端文件是最近生成的。
- 源端和目标端机器系统时间有漂移,导致时间戳比较不准确。
- 你在做配置回滚,源端放的是旧备份文件,时间戳相对于目标上“当前在线配置”更旧。
- 同一个安装包被分发到多台节点后,各节点上后续操作不一样,文件被不同程序用不同时间写了一遍。
在 Hadoop 集群的运维里,这些情况一点都不罕见。尤其是用 tar 包分发 Hadoop 安装目录时,tar 解压出来的文件 mtime 完全取决于包内记录的时间。如果包是半年前构建的,文件 mtime 就是半年前。目标节点上如果有进程在启动时改写过这个配置文件,目标端 mtime 就成了最近的时间。这时 rsync 看到源端文件时间更旧,直接跳过,于是出现了“同步成功但文件没变”。
2.1 验证方法:加 -i 参数观察 itemize 输出
要验证是不是 quick check 造成的跳过,有两个办法。
第一个办法是外加 -i 参数,让 rsync 输出它准备做的具体操作:
bash复制rsync -avzi --dry-run /data/opt/hadoop-3.3.6/etc/hadoop/ hadoop02:/data/opt/hadoop-3.3.6/etc/hadoop/
执行后如果只是输出类似这样的行:
code复制.d..t...... ./
注意这里只有目录属性变化标记,没有任何文件级别条目,说明 rsync 认为文件都不需要传。如果执行时加上 -c 后立刻能看到文件被列出来,那基本就能确认是 mtime 判断导致的问题。
第二个办法是直接对比两端文件的 stat 信息,看 mtime 是否符合预期。如果你发现源端 mtime 和内容都变了,但 mtime 数值上有问题,那就是 mtime 欺骗了 rsync。
2.2 解决办法:改用内容校验模式
最简单的修法是在 rsync 参数里加 -c 或者 --checksum:
bash复制rsync -avzc /data/opt/hadoop-3.3.6/etc/hadoop/ hadoop02:/data/opt/hadoop-3.3.6/etc/hadoop/
加上这个参数后,rsync 不再只比较大小和 mtime,而是会把源端和目标端的文件都读出来,计算校验和,再判断内容是否一致。内容不同就传,内容相同就不传。
我当时实测的现象是这样:
加上 -c 后,原本安安静静的输出立刻变了:
code复制sending incremental file list
./
hdfs-site.xml
core-site.xml
log4j.properties
这些文件被一个接一个列出来,目标端文件才真正更新。问题解决。
这里有一个取舍问题:-c 需要两侧都读文件内容,肯定比默认模式消耗更多 IO。但同步配置文件和 jar 包这种量级,完全在可接受范围内。如果拿 rsync 去同步几十 TB 的 HDFS 数据盘,那就不建议无脑加 -c,否则两侧磁盘 IO 会被校验和计算吃满。当时的场景里我同步的是 etc 目录,文件总量不过几十 MB,加 -c 没有任何压力。
2.3 相关参数对比:-c、-I、--ignore-times 的区别
这里顺便把常用的几个“强制同步”参数梳理一下,免得脚本里写错:
| 参数 | 作用 | 代价 |
|---|---|---|
-c / --checksum |
对比文件内容校验和,内容不同才传 | 两端都要读文件,IO 较高 |
-I / --ignore-times |
不做 mtime/大小比较,所有文件都传 | 无论有没有变化都全量传,速度最慢 |
-u / --update |
目标端较新时跳过,比默认规则更保守 | 可能造成目标端文件永远不更新 |
--size-only |
只要大小相同就不传 | 文件大小不变但内容变了时会漏传 |
我在实际项目里更倾向于用 -c,因为配置和 JAR 包总体积不大,但对内容正确性要求很高。如果是分发大文件,可以考虑先把源端文件 touch 成当前时间,然后继续用默认快速检查,或者阶段性使用 -c 做一次全面核对。
3. 根因之二:源目录里有 symlink 时,rsync 只是把链接“搬”了过去
mtime 问题是第一层,排查完这次事件之后,我又在另一个 Hadoop 集群的同步脚本里遇到过看起来一模一样的问题,但这次根因完全不同。
当时要同步的是整个 Hadoop 安装目录到所有节点。目录结构大概是这样的:
code复制/data/soft/hadoop-3.3.6/
├── conf.d -> /data/config/hadoop-conf/ # symlink
├── etc/
├── lib/
└── logs -> /data/logs/hadoop/ # symlink
同步命令写的是:
bash复制rsync -avz /data/soft/hadoop-3.3.6/ hadoop02:/data/soft/hadoop-3.3.6/
跑完之后发现目标节点上确实多了一堆文件,但 conf.d 目录在目标机上是一个指向 /data/config/hadoop-conf/ 的链接。由于目标机并没有这个目录,进入 conf.d 看到的是一堆空目录,配置文件根本没同步过去。
这就是 symlink 处理规则导致的问题。rsync 参数 -a 里隐含了一个 -l,表示保留符号链接。保留链接的意思是:rsync 不会跟随源端的 symlink 去读取它指向的真实文件,而是把这个 symlink 条目本身同步到目标端。结果就是目标端创建了一个指向同一路径的链接,如果链接目标在目标端不存在,就成了悬空链接。
在 Hadoop 目录里,logs 指向 /data/logs/hadoop、current 指向某个版本目录,或者某些 conf 目录是指到外部配置中心的链接,这些情况非常常见。一旦脚本同步的是这类目录树,rsync 默认行为就会导致“同步显示成功,但真实数据没过去”。
3.1 不同参数处理 symlink 的规则
这里我把几个相关参数列一下:
| 参数 | 行为 | 适用场景 |
|---|---|---|
-l / --links |
保留 symlink,不跟随源端链接 | 默认的 -a 已包含,绝大多数情况 |
-L / --copy-links |
跟随源端 symlink,把链接指向的真实文件复制到目标端 | 希望所有节点拿到真实内容时 |
--copy-unsafe-links |
只处理指向源目录树之外的链接,指向树内的链接仍保留 | 少数特殊结构目录 |
如果集群里所有节点的 Hadoop 安装路径完全一致,而且链接指向的目录也一致,用默认 -l 保留链接没有太大问题。但如果链接指向的是某台节点独有的本地路径,或者在同步时需要把解引用后的实际文件复制过去,那就必须使用 -L。
比如目标是把配置中心的内容真正同步到每台节点的本地目录,而不是让每台节点去引用
