土法炼钢兴趣小组的算法知识备份

【rsync 原理】协议、进程模型与实测

文章导航

分类入口
linuxnetwork
标签入口
#rsync#protocol#xxhash#delta-encoding#file-sync#benchmark

源码下载

本文相关源码已整理,共 1 个文件。

打开下载目录 →

目录

上篇 把差异算法讲完了:弱滚动校验在每个字节偏移上 \(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 不是“客户端和服务端”的同义词。一次 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.csimple_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。

需要区分两个校验,它们各自独立协商:

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.cupdating_basis_file 分支
--append 假设文件只在尾部增长,只传新增部分 match.cappend_mode
-z / --compress token 流走 deflate/zstd/lz4 压缩 token.csend_deflated_token()

--whole-file 默认对本地开启这条最容易踩坑:在本机两个目录间做 rsync 测 delta,会发现它根本没跑滚动校验,因为 rsync 判断“本地复制,delta 省下的带宽没意义,不如直接拷”。要观察 delta 行为,必须显式 --no-whole-file

--inplace 改变的不只是“省一个临时文件”。一旦原地改写,匹配过的块就可能被覆盖,match.cupdating_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

再看签名开销: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 取决于瓶颈在哪:


七、小结

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):

文档与实验:

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-29 · linux / network

【rsync 原理】差异同步算法:滚动校验与两级匹配

源端看不到目标端文件,rsync 凭什么只传改动的几 KB?从 Tridgell 的 rsync 算法出发,用 rsync 3.4.4 源码钉住弱滚动校验的 O(1) 更新、弱+强两级匹配、sqrt 块大小与签名长度随文件增长的权衡,讲清差异是怎么在不传整文件的前提下被算出来的。

2025-07-28 · network

【网络工程】DNS 协议解剖:查询格式、记录类型与响应码

DNS 是互联网最基础的目录服务,也是最脆弱的单点之一。本文从 wire format 出发逐字段解析 DNS 报文结构,详解 A/AAAA/CNAME/MX/SRV/TXT/NS/SOA 等记录类型的工程用途,分析 EDNS0 扩展与 DNS over TCP 的触发条件,结合 dig +trace 完整实操展示 DNS 解析的真实链路。


By .