上篇
把差异算法讲完了:弱滚动校验在每个字节偏移上 \(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(见第六节)。
三者构成一条单向管线:
generator → sender → receiver
generator 的输出是 sender 的输入,sender 的输出是 receiver 的输入。每个进程独立运行,只在管线 stall 或等待磁盘/CPU 时阻塞。这种流水线让“算下一个文件的校验和”“匹配当前文件”“落盘上一个文件”能重叠进行,把磁盘和网络的延迟藏起来。
二、一次传输的生命周期
把一次 rsync 从启动到落盘拆开,顺序是这样的(对照《How Rsync Works》与源码):
sequenceDiagram
participant G as generator(接收侧)
participant S as sender(源侧)
participant R as receiver(接收侧)
Note over G,R: 启动:双方交换最大协议版本,取较小值;协商校验算法
S->>R: 构建并发送文件列表(路径/属主/模式/大小/mtime)
Note over G,R: 两侧按路径排序,此后文件一律用列表下标引用
loop 每个需要传输的文件
G->>G: 快速判断能否跳过(size+mtime,或 --checksum)
G->>S: 文件下标 + 基准文件的块校验和集合
S->>S: 建哈希表,滚动匹配(上篇的算法)
S->>R: literal 数据 + 块引用(token 流)
S->>R: 整文件校验和
R->>R: 读基准文件,拼临时文件,边拼边算文件校验和
R->>R: 校验和不符则进入第二阶段重传
R->>R: 设权限/时间,rename 替换基准文件
end
几个细节值得钉住:
启动握手。 双方各自把支持的最大协议版本发给对方,取最小值作为本次协议级别。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()
产生:
/* 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()
产生,用一组标志字节区分内容,并且能把连续的块引用游程编码:
/* 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
维护一张按优先级排好的可用校验算法表:
/* 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 \(\ge\) 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):
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):
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:
count=10240 rem=0 blength=10240 s2length=3 flength=104857600
false_alarms=0 hash_hits=11675 matches=10239
blength=10240:100 MiB 的块长正好是 \(\sqrt{104857600} = 10240\),命中上篇的 sqrt 启发式。count=10240:块数,文件被整切成 10240 块。s2length=3:每块强校验只存 3 字节。代入上篇的公式,\(b = 10 + 2\lfloor\log_2 \text{len}\rfloor - \lfloor\log_2 \text{blength}\rfloor = 10 + 2\times26 - 13 = 49\),\(\lceil (49 + 1 - 32)/8 \rceil = 3\),吻合。matches=10239:10240 块里 10239 块匹配上了,唯一没匹配的那块就是被改的,于是产生Literal data: 10,240——正好一个块。false_alarms=0:本次没有“弱校验撞上、强校验否决”的假阳。hash_hits≈11675:弱校验哈希命中次数,比matches略多(多出的是弱校验命中但未成完整匹配的偏移),且随数据内容小幅波动。
再看签名开销:received: 71,719 约等于
generator 发给 sender 的块校验集合,\(10240 \times (4\,\text{字节弱校验} +
3\,\text{字节强校验}) = 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(环境见第六节表格)。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【rsync 原理】差异同步算法:滚动校验与两级匹配
源端看不到目标端文件,rsync 凭什么只传改动的几 KB?从 Tridgell 的 rsync 算法出发,用 rsync 3.4.4 源码钉住弱滚动校验的 O(1) 更新、弱+强两级匹配、sqrt 块大小与签名长度随文件增长的权衡,讲清差异是怎么在不传整文件的前提下被算出来的。
【io_uring 系列】实战:基于 io_uring 的 TCP Echo Server
深入网络编程,实现一个异步 TCP 服务器。学习如何使用 user_data 管理连接上下文,处理 Accept, Read, Write 链式调用。
【Linux 网络子系统深度拆解】内核网络调优方法论:从基准测试到生产验证
系统化的 Linux 内核网络调优方法论:从基准测试建立性能基线,到 sysctl 参数与内核数据结构的对应关系,再到中断亲和性、NUMA 拓扑、ring buffer、qdisc 的逐层调优,最终通过 A/B 对比验证生产效果。
【网络工程】DNS 协议解剖:查询格式、记录类型与响应码
DNS 是互联网最基础的目录服务,也是最脆弱的单点之一。本文从 wire format 出发逐字段解析 DNS 报文结构,详解 A/AAAA/CNAME/MX/SRV/TXT/NS/SOA 等记录类型的工程用途,分析 EDNS0 扩展与 DNS over TCP 的触发条件,结合 dig +trace 完整实操展示 DNS 解析的真实链路。