在上一章:选项与配置解析中,我们学习了如何通过命令行和配置文件来"指挥" rsync。我们知道了如何告诉 rsync 做什么。现在,是时候揭开 rsync 最核心的秘密了:它是如何做得如此高效的。
本章,我们将深入探索 rsync 背后的"魔法"------增量传输算法。正是这个算法,让 rsync 在同步大文件时,即使网络连接很慢,也能表现得异常出色。
问题:如何只更新一本书中的一个段落?
想象一下,你和你的朋友各有一本几乎一样的书。你的版本是第二版,而你的朋友只有第一版。第二版只在第三章第五页的第二段修改了几个词,并增加了一句话。
现在,你想让你的朋友也拥有第二版。你会怎么做?
- 低效的方法:把整本第二版的书复印一遍,然后邮寄给你的朋友。这既浪费纸张又浪费邮费。
- 高效的方法:你写一张便条,告诉朋友:"请翻到第三章第五页,把第二段换成这段新文字,并在段末加上这句话。" 朋友根据你的指令,在他自己的书上修改,几分钟内就得到了和你一模一样的第二版。
Rsync 同步文件的思路,就和这第二种高效方法如出一辙。当目标位置已经存在一个旧版本的文件时,rsync 不会传输整个新文件,而是像那位聪明的作者一样,只发送一份"修改指令",告诉接收方如何利用旧文件和一小部分新数据来合成新文件。
这就是增量传输算法的核心思想。接下来,让我们看看 rsync 是如何具体实现这个过程的。
核心步骤:rsync的 "找差异"方法
整个过程可以分为四个主要步骤,由文件的发送方(Source,拥有新文件)和接收方(Destination,拥有旧文件)共同完成。
-
分块与计算校验和(在接收端):
- 接收端(拥有旧文件的一方)首先把它的文件分割成固定大小的数据块(比如 512 字节)。
- 对于每一个数据块,它会计算两个"指纹":
- 一个弱校验和 (Weak Checksum):这是一个 32 位的滚动校验和。它计算速度非常快,并且有一个神奇的特性:当数据窗口向前滚动一个字节时,可以很快地计算出新窗口的校验和,而无需重新读取整个数据块。
- 一个强校验和 (Strong Checksum):这是一个更可靠的 128 位校验和(通常是 MD4 或 MD5)。它的计算速度稍慢,但几乎不可能出现"指纹"碰撞(即不同的数据块产生相同的指纹)。
-
发送校验和列表(从接收端到发送端):
- 接收端将它为每个数据块计算出的
{弱校验和, 强校验和, 块编号}
列表,发送给发送端。
- 接收端将它为每个数据块计算出的
-
匹配与生成差异(在发送端):
- 发送端(拥有新文件的一方)收到这个校验和列表后,并不会分割自己的文件。
- 它会从文件的第 0 个字节开始,取出一个与接收端同样大小的数据块(比如 512 字节),然后: a. 计算这个块的弱校验和 。 b. 拿着这个弱校验和,去接收端发来的列表中查找。 c. 如果弱校验和没找到 :说明这个数据块在旧文件中不存在。发送端会将这个字节标记为"新数据",然后将窗口向后移动一个字节(从第 1 个字节开始),重复步骤 a。 d. 如果弱校验和找到了 :这说明可能找到了一个匹配的块!为了确认,发送端会接着计算这个块的强校验和 ,并与列表中的强校验和进行比对。 * 如果强校验和也匹配 :太棒了!找到了一个完全相同的块。发送端会记录下"重用接收端的第 X 个块",然后直接跳到这个块的末尾,继续向后扫描。 * 如果强校验和不匹配:这是一个"误报"(false alarm)。说明这个块是新的。发送端会将这个字节标记为"新数据",然后将窗口向后移动一个字节,继续扫描。
-
重建文件(在接收端):
- 发送端完成扫描后,会把一系列"指令"发送给接收端。这些指令就像这样:
- "这是一段新数据:'...'"
- "重用你本地的第 5 号块"
- "重用你本地的第 6 号块"
- "这是一段新数据:'...'"
- 接收端根据这些指令,一部分数据从自己的旧文件中复制,一部分数据直接采用发送端发来的新数据,最终在本地拼凑出完整的新文件。
- 发送端完成扫描后,会把一系列"指令"发送给接收端。这些指令就像这样:
这个过程最巧妙的地方在于滚动校验和。它让发送端无需对自己的文件进行分块,而是可以高效地、逐字节地扫描,从而找到所有可能被重用的数据,哪怕这些数据在文件中的位置发生了移动。
深入代码:魔法的实现
现在我们来深入代码,看看这些步骤是如何实现的。这个过程主要涉及发送端(Sender)和接收端(Receiver)的协作。这两个角色是 Rsync 进程角色的一部分。
阶段一:接收端生成校验和
接收端的任务很简单:读自己的文件,切块,算指纹。这个过程的核心在 generator.c
(在我们的教程结构中,这是进程角色的一部分)和 checksum.c
中。
checksum.c
提供了计算两种校验和的函数。
1. 弱滚动校验和 (Weak Rolling Checksum)
get_checksum1
函数实现了这个快速的滚动校验和。它的算法受到了 Adler-32 的启发。
c
// 文件: checksum.c
/*
一个简单的32位校验和,可以从两端更新
(灵感来自 Mark Adler 的 Adler-32 校验和)
*/
uint32 get_checksum1(char *buf1, int32 len)
{
int32 i;
uint32 s1, s2;
schar *buf = (schar *)buf1;
s1 = s2 = 0;
// ... 循环计算 ...
for (i = 0; i < len; i++) {
s1 += (buf[i]+CHAR_OFFSET); s2 += s1;
}
return (s1 & 0xffff) + (s2 << 16);
}
这个函数的精髓在于,当窗口向前滚动时,可以很容易地"减去"离开窗口的那个字节的影响,并"加上"新进入窗口的字节的影响,而无需重新计算整个窗口。
2. 强校验和 (Strong Checksum)
get_checksum2
函数负责计算更可靠的校验和,如 MD5 或更新的 xxHash。具体使用哪种算法,是在选项与配置解析阶段由用户或协议协商决定的。
c
// 文件: checksum.c
/* "sum" 缓冲区必须至少有 MAX_DIGEST_LEN 字节! */
void get_checksum2(char *buf, int32 len, char *sum)
{
// ... 根据 xfer_sum_nni->num 的值选择算法 ...
switch (xfer_sum_nni->num) {
case CSUM_MD5: {
md_context m5;
md5_begin(&m5);
// ... 更新 MD5 上下文 ...
md5_update(&m5, (uchar *)buf, len);
md5_result(&m5, (uchar *)sum);
break;
}
case CSUM_MD4:
// ... MD4 实现 ...
// ...
}
}
阶段二:发送端匹配差异
这是整个算法最核心的部分,主要发生在 match.c
和 sender.c
。
下面是一个简化的时序图,展示了发送端和接收端如何交互:
1. 构建哈希表
当发送端收到校验和列表后,为了能快速查找弱校验和,它会把所有弱校验和放进一个哈希表。这样,查找时间就从线性搜索(O(N))变成了接近常数时间(O(1))。
c
// 文件: match.c
static void build_hash_table(struct sum_struct *s)
{
// ... 动态计算哈希表大小 ...
// ... 初始化哈希表 ...
// 遍历所有从接收端收到的校验和
for (i = 0; i < s->count; i++) {
// 根据弱校验和 s->sums[i].sum1 计算哈希值
uint32 t = SUM2HASH(s->sums[i].sum1);
// 使用链地址法解决哈希冲突
s->sums[i].chain = hash_table[t];
hash_table[t] = i; // 将当前块的索引存入哈希表
}
}
2. 搜索匹配
hash_search
是执行匹配的主力。它在源文件中滚动一个窗口,计算滚动校验和,然后在哈希表中查找。
c
// 文件: match.c
static void hash_search(int f,struct sum_struct *s,
struct map_struct *buf, OFF_T len)
{
// ... 初始化 ...
// 计算第一个块的弱校验和 (s1, s2)
sum = get_checksum1((char *)map, k);
s1 = sum & 0xFFFF;
s2 = sum >> 16;
do {
// ... 核心循环 ...
// 1. 在哈希表中查找弱校验和
hash_entry = SUM2HASH2(s1,s2);
if ((i = hash_table[hash_entry]) < 0)
goto null_hash; // 未找到,继续滚动
// 2. 弱校验和匹配,开始逐个比较链表中的项
do {
if (sum != s->sums[i].sum1) // 确认弱校验和完全一致
continue;
// 3. 计算并比较强校验和
get_checksum2((char *)map,l,sum2);
if (memcmp(sum2, sum2_at(s, i), s->s2length) != 0) {
false_alarms++; // 强校验和不匹配,是误报
continue;
}
// 4. 强弱校验和都匹配!
matched(f,s,buf,offset,i); // 发送匹配指令
// ... 更新 offset 和下一次的滚动校验和 ...
break;
} while ((i = s->sums[i].chain) >= 0);
null_hash:
// ... 如果没匹配,更新滚动校验和并移动窗口 ...
} while (++offset < end);
matched(f, s, buf, len, -1); // 发送文件末尾剩余的数据
}
阶段三:编码与传输
当 hash_search
决定了是发送新数据还是重用旧块时,它会调用 matched
函数,而 matched
最终会调用 send_token
。这个 "token" 就是我们前面提到的"指令"。
token.c
定义了这些指令的格式。一个 token 可以是:
- 原生数据块 :如果找不到匹配,
send_token
会直接发送一段原始数据。 - 匹配引用 :如果找到了匹配,
send_token
会发送一个负数,例如- (索引 + 1)
,来代表"重用第索引
个块"。
c
// 文件: token.c (简化逻辑)
void send_token(int f, int32 token, struct map_struct *buf, OFF_T offset,
int32 n, int32 toklen)
{
// ...
if (n > 0) { // 如果有 n 字节的新数据
write_int(f, n); // 先发送数据长度
write_buf(f, map_ptr(buf, offset, n), n); // 再发送数据本身
}
// token = -1 表示文件结束
// token >= 0 表示一个匹配
// token = -2 表示只发送数据,不发送token(用于连续的数据块)
if (token != -2)
write_int(f, -(token+1)); // 发送匹配指令(一个负数)
}
阶段四:接收端重建文件
接收端的 receiver.c
中的 receive_data
函数负责接收并解析这些 token。
c
// 文件: receiver.c
static int receive_data(...)
{
// ... 初始化 ...
while ((i = recv_token(f_in, &data)) != 0) {
if (i > 0) { // 返回值大于 0,表示是原生数据
// 将新数据 data 写入新文件的当前位置
stats.literal_data += i;
write_file(fd, 0, offset, data, i);
offset += i;
continue;
}
// 返回值小于 0,表示是匹配引用
i = -(i+1); // 解码出块的索引
offset2 = i * (OFF_T)sum.blength; // 计算该块在旧文件中的偏移
len = ...; // 计算该块的长度
// 从旧文件(mapbuf)的 offset2 位置读取 len 字节
map = map_ptr(mapbuf,offset2,len);
// 写入新文件的当前位置
write_file(fd, 0, offset, map, len);
offset += len;
}
// ... 结束处理 ...
}
通过这样一套精密的"找不同、发指令、再重建"的流程,rsync 实现了令人难以置信的传输效率。这个算法的优雅之处在于,它将文件的比较工作巧妙地从网络传输中分离出来,大部分计算都在本地完成,只通过网络传输最关键的差异信息。
总结
在本章中,我们揭开了 rsync 的核心魔法------增量传输算法的神秘面纱。我们了解到:
- 基本原理:rsync 通过只传输文件的变化部分来节省带宽和时间,而不是整个文件。
- 双重校验和 :它使用一种快速的滚动校验和 来快速定位潜在的匹配,再用一种可靠的强校验和来确认匹配,兼顾了速度和准确性。
- 客户端与服务器的协作:接收端(拥有旧文件)生成校验和列表,发送端(拥有新文件)根据这个列表来寻找差异,并生成一份"重建指令集"。
- 文件重建:接收端根据这份指令集,利用本地的旧文件和接收到的少量新数据,完美地重建出新版本的文件。
现在我们明白了 rsync 的核心算法是如何工作的。但是,谁是"发送端"?谁是"接收端"?这些角色是如何在程序启动时被确定的呢?在下一章,我们将探讨 Rsync 进程角色 (Sender/Receiver/Generator),看看 rsync 是如何组织其内部进程来完成这一系列复杂任务的。