原文发布于 quant67.com,转载请保留出处。
上篇 把差异算法讲完了:弱滚动校验在每个字节偏移上 O(1) 试探,弱校验筛、强校验定,sqrt 启发式定块长。但算法是一回事,真实的 rsync 命令把它跑起来又是另一回事------它到底由几个进程、按什么顺序、用什么格式在管道里交换数据?为什么本地两个目录之间 rsync 默认根本不走差异算法?强校验在 3.2 之后早就不是 MD5 了,那是什么、怎么定的?
这篇接着上篇,落到实现层:三个角色的分工、一次传输的完整生命周期、token 字节流的格式、强校验协商与版本边界,最后在本机实测 delta 与 whole-file 的真实传输量差距。源码对照 rsync 3.4.4,进程角色描述对照官方文档《How Rsync Works》。
一、三个角色:generator、sender、receiver
rsync 里容易混的,是"客户端/服务端"和"发送方/接收方"两套词。官方文档《How Rsync Works》定义得很清楚:客户端(client)发起同步、服务端(server)被连接,但一旦连接建立,client/server 的区分就让位给 sender 和 receiver;真正干活的是三个角色:
- sender:能访问源文件的一方,读源文件、做滚动匹配、生成重建指令。
- receiver:目标一方,读基准文件、写临时文件、落盘改名。
- generator:在接收侧,遍历文件列表、决定每个文件要不要传、为基准文件算块校验和发给 sender。
注意 sender 和 receiver 不是"客户端和服务端"的同义词。一次 rsync /src user@host:/dst 是 push,本地是 sender;rsync user@host:/src /dst 是 pull,本地是 receiver。
一个反直觉但重要的事实:本地两个路径之间的 rsync,也是按 push 干的。 文档原话是 local jobs "are done exactly like a push"------客户端变成 sender,fork 出一个 server 进程充当 receiver,两者通过管道(pipe)通信。所以本地 rsync 同样有 sender/generator/receiver 三角色,只是它们之间没有网络,是 socketpair。这一点直接决定了后面实测要加 --no-whole-file(见第六节)。
三者构成一条单向管线:
text
generator → sender → receiver
generator 的输出是 sender 的输入,sender 的输出是 receiver 的输入。每个进程独立运行,只在管线 stall 或等待磁盘/CPU 时阻塞。这种流水线让"算下一个文件的校验和""匹配当前文件""落盘上一个文件"能重叠进行,把磁盘和网络的延迟藏起来。
二、一次传输的生命周期
把一次 rsync 从启动到落盘拆开,顺序是这样的(对照《How Rsync Works》与源码):
几个细节值得钉住:
启动握手。 双方各自把支持的最大协议版本发给对方,取最小值作为本次协议级别。daemon 模式下客户端还要把选项通过 socket 发过去。协议级别决定了之后字节流的具体含义------这是 rsync 协议一个麻烦的特性,下一节细说。
文件列表。 sender 先构建整个文件列表(路径加属主、模式、权限、大小、mtime;带 --checksum 时还含文件级校验和),边建边发。两侧各自按路径排序,此后所有文件都用它在列表里的下标引用,不再传路径。文件列表收完后,接收侧的进程 fork 成 generator 与 receiver 一对,管线就位。
逐文件处理。 generator 走文件列表,对每个文件先做快速判断:默认按 mtime 和 size,任一不同才传;指定 --checksum 则算文件级校验和比对。不跳过的文件,接收侧已有的版本成为基准文件,generator 为它算块校验和、跟在文件下标后面发给 sender。新文件或指定了 --whole-file 时,发的是空校验和集合(等于告诉 sender:没有基准,整文件传)。
重建与兜底。 receiver 打开基准文件、建临时文件,顺序消费 sender 的指令:literal 数据写进临时文件,块引用就 seek 到基准文件的对应偏移、把整块拷进临时文件。边拼边算整文件校验和,拼完和 sender 发来的文件校验和比对;不符则该文件进入第二阶段重传(此时强校验用满长度),再不符才报错。通过了才设置权限、时间并 rename 替换。
这套分工也解释了 rsync 的资源画像:sender 最吃 CPU (滚动校验加匹配搜索),receiver 最吃磁盘(在基准文件和临时文件之间随机读写,工作集超过页缓存时会触发 seek storm)。
三、token 字节流:没有边界的协议
rsync 的 wire protocol 没有教科书意义上的"好设计"。《How Rsync Works》说得很直白:数据是一条不间断的字节流,除了未匹配的文件数据本身,没有长度字段、没有包计数,每个字节的含义取决于当前协议级别定义的上下文。这让协议极难文档化、调试和扩展,但开销比带包头的正式协议小。
sender 发给 receiver 的重建指令流,就是这种"裸字节流"的典型。不压缩时由 token.c 的 simple_send_token() 产生:
c
/* token.c, rsync 3.4.4 */
static void simple_send_token(int f, int32 token, struct map_struct *buf,
OFF_T offset, int32 n)
{
if (n > 0) { /* 先冲刷 n 字节 literal 数据 */
int32 len = 0;
while (len < n) {
int32 n1 = MIN(CHUNK_SIZE, n-len);
write_int(f, n1); /* 正整数 = literal 段长度 */
write_buf(f, map_ptr(buf, offset+len, n1), n1);
len += n1;
}
}
/* a -2 token means to send data only and no token */
if (token != -2)
write_int(f, -(token+1)); /* 负整数 = 块引用(block 0 编码为 -1) */
}
规则很简单:流里读到正整数 ,表示接下来是这么多字节的 literal 数据;读到负整数 −(i+1),表示引用基准文件的第 i 个块。literal 段按 CHUNK_SIZE(32 KB)切片。没有任何分隔符,receiver 完全靠"正数还是负数"来区分。
开了压缩(-z)时格式不同,由 send_deflated_token() 产生,用一组标志字节区分内容,并且能把连续的块引用游程编码:
c
/* token.c, rsync 3.4.4:压缩流的标志字节 */
#define END_FLAG 0 /* 结束 */
#define TOKEN_LONG 0x20 /* 后跟 32 位块号 */
#define TOKENRUN_LONG 0x21 /* 同上,再跟 16 位游程计数 */
#define DEFLATED_DATA 0x40 /* + 6 位高位长度,再一字节低位长度 */
#define TOKEN_REL 0x80 /* + 6 位相对块号 */
#define TOKENRUN_REL 0xc0 /* 同上,再跟 16 位游程计数 */
TOKENRUN_* 是关键:当匹配到的块是连续的(块 i、 i+1、 i+2......),不必一个个发块号,而是发一个起始块号加一个游程长度。上篇 hash_search() 里那个 want_i"鼓励相邻匹配"的优化,正是为了喂饱这里的游程编码------这就是为什么后面实测中一万多个匹配块只占几十 KB:它们绝大多数是一整段连续块,被压成了很短的游程。
四、强校验协商与版本边界
上篇说强校验"由协商决定",这里补全。checksum.c 维护一张按优先级排好的可用校验算法表:
c
/* checksum.c, rsync 3.4.4:valid_checksums 列表(节选,按优先级) */
struct name_num_item valid_checksums_items[] = {
{ CSUM_XXH3_128, 0, "xxh128", NULL },
{ CSUM_XXH3_64, 0, "xxh3", NULL },
{ CSUM_XXH64, 0, "xxh64", NULL },
{ CSUM_MD5, NNI_BUILTIN|NNI_EVP, "md5", NULL },
{ CSUM_MD4, NNI_BUILTIN|NNI_EVP, "md4", NULL },
{ CSUM_SHA1, NNI_EVP, "sha1", NULL },
{ CSUM_NONE, 0, "none", NULL },
/* ... */
};
协议 30 引入了校验协商(negotiated checksum):两端交换各自支持的算法集合,挑出双方都支持且优先级最高的那个。在本机两个 rsync 3.4.4(protocol 32)之间,协商结果是 xxh128------这就是 --version 输出里 Checksum list 第一项排 xxh128 的原因。和 3.2 之前不支持 xxhash 的旧端通信时,回落到 MD5(protocol ≥ 30 的默认)或更老的 MD4。
需要区分两个校验,它们各自独立协商:
- 传输强校验 (
xfer_sum_nni):delta 算法里逐块比较的强校验,也是上篇get_checksum2()用的那个。 - 文件级校验 (
file_sum_nni):--checksum/-c决定"要不要传这个文件"时算的整文件校验。
get_checksum2() 还会掺入一个 checksum_seed,每次传输不同,目的是防止有人针对固定哈希构造碰撞。
这套设计意味着 rsync 的强校验性能随版本明显变化:3.2 起默认的 xxh128 比老的 MD5/MD4 快得多,而本机 --version 里的 SIMD-roll 还把弱滚动校验做了 SIMD 加速。讨论 rsync 的 CPU 开销时,必须带上版本与构建选项,否则结论无意义。
五、几个开关到底改了什么
| 开关 | 改变的行为 | 源码线索 |
|---|---|---|
--whole-file / -W |
generator 发空校验和集合,跳过 delta,整文件当 literal 传。本地传输默认开启。 | generator.c 空校验集;whole_file 默认对本地置 1 |
--no-whole-file |
强制走 delta,即使本地传输 | 同上取反 |
--checksum / -c |
跳过判断改用文件级强校验,而非 mtime+size | file_checksum()、file_sum_nni |
--inplace |
直接改写基准文件,不建临时文件 | match.c 的 updating_basis_file 分支 |
--append |
假设文件只在尾部增长,只传新增部分 | match.c 的 append_mode |
-z / --compress |
token 流走 deflate/zstd/lz4 压缩 | token.c 的 send_deflated_token() |
--whole-file 默认对本地开启这条最容易踩坑:在本机两个目录间做 rsync 测 delta,会发现它根本没跑滚动校验,因为 rsync 判断"本地复制,delta 省下的带宽没意义,不如直接拷"。要观察 delta 行为,必须显式 --no-whole-file。
--inplace 改变的不只是"省一个临时文件"。一旦原地改写,匹配过的块就可能被覆盖,match.c 里 updating_basis_file 的那段逻辑专门处理"块偏移必须不小于当前偏移,否则丢弃该哈希项",避免引用到已经被覆盖的旧数据。代价是传输中断会留下半新半旧的文件,没有临时文件的原子替换保证。
六、实测:delta 与 whole-file 的真实差距
环境:
| 项 | 值 |
|---|---|
| OS / 内核 | Arch Linux,6.6.87.2-microsoft-standard-WSL2 |
| CPU | 12th Gen Intel Core i9-12900K(24 逻辑核) |
| 内存 | 32 GiB |
| rsync | 3.4.4,protocol 32,SIMD-roll,openssl-crypto |
| 强校验(协商) | xxh128 |
口径:源文件为 100 MiB(104,857,600 字节)/dev/urandom 数据;基准文件与它只差中间一个字节。两端都是本地路径,所以用 --no-whole-file 强制 delta,用 -I(--ignore-times)强制逐文件比较(否则 size+mtime 快速判断会直接跳过已同步文件)。每组跑 3 次,结果完全一致,取该值。脚本见同目录 reproduce.sh。
需要说明:两端本地时,--stats 里的 sent/received 是两个本地进程通过 socketpair 交换的字节,不是真实网卡流量;它等于这套协议在真实 ssh 链路上要走的数据量(不含 ssh 自身开销)。
delta(--no-whole-file -aI):
text
Literal data: 10,240 bytes
Matched data: 104,847,360 bytes
Total bytes sent: 51,301 bytes
Total bytes received: 71,719 bytes
whole-file(-WaI):
text
Literal data: 104,857,600 bytes
Matched data: 0 bytes
Total bytes sent: 104,883,297 bytes
Total bytes received: 35 bytes
对比很干脆:改 1 个字节,delta 在管道上总共走约 51,301+71,719=123,020 字节(约 120 KiB),whole-file 走约 100 MiB。同一处改动,delta 的传输量是 whole-file 的约 1/850。
这些数字逐个能对上算法。打开 --debug=deltasum2:
text
count=10240 rem=0 blength=10240 s2length=3 flength=104857600
false_alarms=0 hash_hits=11675 matches=10239
blength=10240:100 MiB 的块长正好是 104857600 =10240,命中上篇的 sqrt 启发式。count=10240:块数,文件被整切成 10240 块。s2length=3:每块强校验只存 3 字节。代入上篇的公式, b=10+2⌊log2len⌋−⌊log2blength⌋=10+2×26−13=49, ⌈(49+1−32)/8⌉=3,吻合。matches=10239:10240 块里 10239 块匹配上了,唯一没匹配的那块就是被改的,于是产生Literal data: 10,240------正好一个块。false_alarms=0:本次没有"弱校验撞上、强校验否决"的假阳。hash_hits≈11675:弱校验哈希命中次数,比matches略多(多出的是弱校验命中但未成完整匹配的偏移),且随数据内容小幅波动。
再看签名开销:received: 71,719 约等于 generator 发给 sender 的块校验集合, 10240×(4字节弱校验+3字节强校验)=71,680 字节,差的几十字节是头部。而 sent: 51,301 是 sender 发给 receiver 的 token 流:10239 个连续匹配块被游程编码压得很短,加上那一个 10,240 字节的 literal 块和文件校验和。两个方向加起来才是 delta 的总传输量。
delta 不是免费的。 whole-file 那侧 receiver 几乎不读基准文件,而 delta 这侧 sender 要把整个 100 MiB 源文件读一遍做滚动校验,receiver 要把 10239 个匹配块从基准文件里读出来重写。省下的是带宽,付出的是两端的 CPU 与磁盘 I/O。所以何时用 delta 取决于瓶颈在哪:
- 跨广域网、带宽是瓶颈、文件改动比例小------delta 完胜,上面的 850 倍就是这种情形。
- 本地磁盘间复制------rsync 默认
--whole-file,因为本地没有带宽瓶颈,再去算滚动校验纯属浪费 CPU 和多一遍读盘。 - 文件几乎全变(重新生成、压缩包重打)------旧块匹配不上,delta 白算一遍校验,
--whole-file反而快。 - 海量小文件------单文件差异算法省不下多少,瓶颈在文件列表和每文件握手,delta 不是重点。
七、小结
rsync 的差异算法(上篇)落到工具里,是 generator/sender/receiver 三个进程在一条单向管线上跑:generator 为基准文件出签名,sender 用滚动校验匹配、吐出 literal 加块引用的裸字节 token 流,receiver 从基准文件拼出新文件并用整文件校验和兜底。强校验从 MD4/MD5 演进到协商出的 xxh128,性能随版本和构建选项变化。
实测把算法和数字对上了:100 MiB 改 1 字节,delta 走约 120 KiB、whole-file 走整 100 MiB,块长 10240、强校验 3 字节、匹配 10239 块全部能从公式推回。但 delta 用 CPU 和磁盘换带宽,本地传输 rsync 索性默认 --whole-file。理解这条权衡,比记住命令行参数更重要。
参考资料
源码(rsync 3.4.4):
checksum.c:valid_checksums_items、parse_checksum_choice()、get_checksum2()、file_checksum()。match.c:hash_search()、matched()、updating_basis_file与append_mode分支。token.c:simple_send_token()、send_deflated_token()及TOKEN_REL/TOKENRUN_REL等标志位。generator.c:逐文件处理、空校验集与--whole-file行为。rsync.h:CHUNK_SIZE等常量。
文档与实验:
- J.W. Schultz. How Rsync Works: A Practical Overview. rsync.samba.org。
- Andrew Tridgell, Paul Mackerras. The rsync algorithm. TR-CS-96-05, 1996。
- 本文实测脚本:同目录
reproduce.sh(环境见第六节表格)。