写这个Demo的起因其实挺实在的:生产环境里经常要做文件分发,全量拷贝太浪费带宽,尤其文件一多、改动又小的时候,rsync那套“只传差异”的思路就显得特别香。但rsync本身是个庞大的工具,很多人用了好几年也只停留在命令行参数层面,根本不清楚它背后到底怎么算出“哪些块要传、哪些块能复用”。为了把这块原理彻底吃透,我用C++从零写了一个简化版的增量同步引擎,核心是rsync的rolling checksum加weak/strong double-check机制。这篇东西就记录整个实现过程、踩过的坑,以及怎么验证它真的能干活。适合刚接触同步原理、或者想在自己项目里内嵌一个轻量级增量同步模块的开发者参考。
1. 整体设计思路:为什么增量同步能省流量
1.1 全量同步的痛点
假设你有一台业务服务器要和其他节点保持一致,目录里是几百个文件,其中大部分是几百KB的图片和日志,每天只有零星几个文件发生小改动。全量同步的做法是每次把整个目录打包传一遍,哪怕只改了几个字节,也要重传全部文件。网络层面看,这种浪费非常可观——尤其是在跨机房、跨地域的场景下,带宽成本会被无效流量直接拉高。
另一个坑是磁盘IO。全量同步要把所有文件都读一遍,生成校验、写入目标,文件越大、数量越多,耗时越长。而增量同步的核心思路只有一句话:只传“变化的部分”。至于哪些部分变了,就需要一套快速、可靠的比对机制。
1.2 rsync算法的本质
rsync之所以能在增量同步领域称王几十年,靠的是一个很朴素的算法框架:把文件切分成固定大小的块,然后对每一块做两次“签名”——一个很快的弱哈希,一个很慢但几乎不会碰撞的强哈希。接收方拿着这些签名去匹配自己手上的旧文件,匹配上的块就直接本地复用,匹配不上的块才需要从发送方拉取。
这里有个关键点:接收方能识别的变化不仅包括“内容改动”,还包括“插入、删除、移动”。因为它是按固定块滚动扫描的,而不是简单地从文件头到尾逐块比对,所以中间插入几个字节导致后续全部错位这种问题,也能用滚动哈希找到最优匹配位置。这个特性正是rsync强大之处,也是我这次Demo里想重点验证的。
1.3 为什么用C++而不是脚本语言
如果只是想验证算法,Python脚本会更快。但我的目标是用这个Demo模拟真实同步工具的性能特征,所以选了C++。原因有三:
- 文件IO和哈希计算都是高频操作,C++能通过内存映射、堆外缓冲等机制最大程度减少拷贝;
- 滚动哈希的窗口滑动在C++里可以直接用指针操作,避免脚本语言的解释器开销;
- 这个Demo本身可以当成一个可嵌入的模块,后续接进其他C++项目不需要跨语言桥接。
当然,代价是需要手动管理缓冲区、注意字节序和整数溢出,这些坑在后面的实现部分会详细讲。
2. 核心细节拆解:弱哈希、强哈希与滚动窗口
2.1 分块策略
rsync的传统做法是把文件切成固定大小的块,常见块大小是700字节到1KB。块太小,签名数量会爆炸,网络传输的“元数据”开销反而超过数据本身;块太大,粒度又太粗,一个小改动会连带重传整块数据。
我在这版Demo里使用了默认8KB块长,并提供了一个可配置参数。选8KB的原因是实测下来,对于大多数文本、配置、日志类文件,8KB的块长能在“签名数量”和“重传粒度”之间取得不错的平衡。如果你要同步的是大量小碎文件,建议把块长降到4KB;如果是大视频、大压缩包这类很少局部改动的文件,可以提高到16KB甚至32KB。
分块之后,每个块需要两个信息:
- 弱哈希:一个32位整数的滚动校验值,用于快速比对;
- 强哈希:一个128位的MD5摘要,用于确认弱哈希匹配时内容确实一致。
为什么需要两级校验?因为弱哈希只是“大概率不同”,但可能出现碰撞——尤其异构数据、不同字符串算出同一个整数校验值并不罕见。如果只靠弱哈希做决定,个别错误匹配会导致目标文件出现零星坏块,而且难以排查。强哈希把这种概率压到了极低,实际工程里可以认为是可靠的。
2.2 滚动哈希的实现细节
滚动哈希是整个增量同步的核心。它要解决的问题是:当我扫描旧文件时,不是每个位置都重新计算整块的校验值,而是每移动一个字节,就基于上一个位置的校验值做一个O(1)增量更新。
常用的滚动哈希是Adler-32,它把数据块看作一串字节,定义两个累加值a和b,初始a=1、b=0,每加入一个新字节x就执行:
a = (a + x) % MOD b = (b + a) % MOD一个块的校验值就是(b << 16) | a。当窗口向前滑动一个字节,移出的字节是out,移入的字节是in,那么新的a和b可以这样更新:
a = (a - out + in) % MOD b = (b - out * windowSize + a) % MOD注意这里的windowSize是块长。这段逻辑是整个Demo性能的关键,我单独封装成一个类,避免在扫描循环里重复造轮子。
一个容易被忽略的问题是取模的负数处理。C++里负数的%运算结果是负数,所以必须加MOD再取模。我在第一版实现里在这里踩了坑,后面排查那节会详说。
2.3 强哈希选型
强哈希我用了MD5。虽然从安全角度MD5早已不适合做加密,但做数据一致性校验完全够用,关键是有现成的开源实现可以直接集成,不需要自己造轮子。如果你在意哈希碰撞概率,可以换成SHA-256,但计算量会显著增加,测试下来对整体性能影响不小。
MD5输出固定16字节,可以和块序号一起组成签名表。这个签名表就是发送方与接收方之间传递的核心元数据。
2.4 匹配搜索的效率问题
接收方持有旧文件,发送方持有新文件。实际流程是:发送方把新文件的每一块算好签名,发给接收方;接收方拿这些签名和自己的旧文件逐块对比,标记出哪些块是相同的,哪些是新增的、需要拉取的。
但接收方怎么快速查“某个弱哈希是否在我的旧文件里”?最自然的做法是建一个哈希表,key是弱哈希值,value是块序号列表。因为弱哈希会碰撞,所以一个key可能对应多块。匹配流程是:
- 用弱哈希查表,如果不存在,判定为“无匹配”;
- 如果存在,再逐一对比这块的强哈希;
- 强哈希一致才算真正匹配。
这个哈希表在设计时要注意“块序号列表”可能很长,如果某个弱哈希碰撞特别严重,查找效率会退化。工程上可以增加一级筛选——比如再存一个“块长度”的快速校验字段,长度不一致的直接跳过。我这次Demo没有做这个优化,但如果你想接到生产环境,强烈建议加上。
3. 实操过程:从零搭一个C++增量同步Demo
3.1 工程结构与依赖
整个Demo只依赖标准库和一个哈希实现。MD5我直接找了一份公共领域实现,放进了md5.h/md5.cpp,没有引入OpenSSL,原因是让编译环境更轻量,任何人拿到代码都能直接g++ -o demo *.cpp编译,不需要额外安装依赖。
文件结构如下:
sync_demo/ ├── main.cpp // 入口,解析参数、调用同步引擎 ├── rolling_hash.h // 滚动哈希类(Adler-32简化版) ├── block_signer.h // 签名表生成与序列化 ├── sync_engine.h // 同步核心,匹配与差异计算 ├── md5.h ├── md5.cpp └── test/ ├── old_file.txt // 模拟旧版本文件 └── new_file.txt // 模拟新版本文件main.cpp的角色很简单:接收三个参数,旧文件路径、新文件路径、输出文件路径。内部流程是:
- 读取旧文件,按块生成签名表;
- 读取新文件,按滚动窗口扫描并查找匹配;
- 生成差异描述结构,根据差异描述重建新文件;
- 对比重建结果和真实新文件,确认一致。
3.2 签名表生成
签名表是我整个同步流程的锚点。每个条目至少包含:
struct BlockSig { uint32_t weak_hash; uint8_t strong_hash[16]; uint32_t index; // 块序号 };生成签名表时,我按固定块长切分旧文件,最后一块如果不满8KB就按实际长度算。这里有个细节:最后一块的块长可能小于windowSize,匹配时要把它当特例处理,否则滚动哈希的滑动窗口会越界。
签名表还需要序列化,便于模拟“发送方把签名发给接收方”的过程。我在这版Demo里直接写成了一个二进制文件sig_table.dat,结构是:
文件头: magic + 版本号 + 块长 条目: 每个BlockSig依次排列序列化时注意字节序,如果你的项目有跨平台传输需求,建议显式用小端序。这次Demo只跑在x86_64 Linux上,我就直接按内存布局写了,省了很多转换逻辑。
3.3 关键实现:滚动匹配循环
这是整个Demo的灵魂代码。我在sync_engine.cpp里实现了核心匹配逻辑,大致的伪代码结构如下:
std::vector<MatchResult> findMatches( const uint8_t* oldData, size_t oldSize, const uint8_t* newData, size_t newSize, size_t blockSize, const std::unordered_map<uint32_t, std::vector<BlockSig>>& sigMap) { std::vector<MatchResult> results; size_t pos = 0; RollingHash rh(blockSize); if (newSize < blockSize) { // 整个新文件作为一个块,直接比对 return tryMatchWholeFile(...); } // 初始化滚动窗口 for (size_t i = 0; i < blockSize; i++) { rh.update(newData[i]); } while (pos + blockSize <= newSize) { uint32_t weak = rh.getHash(); auto it = sigMap.find(weak); if (it != sigMap.end()) { // 弱哈希命中,检查强哈希 for (const auto& sig : it->second) { if (memcmp(sig.strong_hash, md5(newData + pos, blockSize), 16) == 0) { results.push_back({pos, sig.index, blockSize}); // 整块命中,跳到下一块 if (pos + blockSize < newSize) { pos += blockSize; rh.reset(); for (size_t i = 0; i < blockSize; i++) { rh.update(newData[pos + i]); } } else { pos += blockSize; } break; } } // 弱哈希命中但强哈希失败,滑动一个字节继续 if (!results.empty() && results.back().newPos == pos - blockSize) { // 已处理,不重复滑动 } else { if (pos + blockSize < newSize) { rh.roll(newData[pos], newData[pos + blockSize]); pos++; } else { break; } } } else { // 弱哈希未命中,滑动一个字节 if (pos + blockSize < newSize) { rh.roll(newData[pos], newData[pos + blockSize]); pos++; } else { break; } } } // 处理尾块 if (pos < newSize) { // 将剩余部分作为最后一个块处理 } return results; }这段代码里有几个细节值得展开讲。
第一,整块命中后不需要逐字节滑动。因为如果我确认newData[pos ... pos+blockSize)和旧文件的某一块相同,那直接从下一块开始继续匹配是最高效的。这时候需要重置滚动窗口,重新填满新窗口,而不是继续roll。很多初学实现会在这里漏掉reset逻辑,导致后面的哈希全部错位。
第二,弱哈希命中但强哈希失败的情况要小心死循环。代码里必须有个明确的信号告诉调用方“这次迭代没有产生匹配”,然后才能执行rh.roll(newData[pos], newData[pos+blockSize])并pos++。我在写的时候用了一个bool matchedThisRound变量来控制,比直接判断results.back()要清晰得多。
3.4 差异描述与文件重建
匹配结果存下来之后,接收方要能区分“哪些块可以直接从旧文件复制,哪些块需要从发送方接收”。差异描述我用一个简单的结构体拼接:
enum class OpType { COPY, ADD }; struct SyncOp { OpType type; size_t srcOffset; // COPY时旧文件偏移,ADD时新文件偏移 size_t length; uint32_t blockIndex; // COPY时对应的旧文件块序号 };当新文件的某个区域匹配上旧文件的某一块,就产生一条COPY操作;没匹配上的连续区域,合并成一条ADD操作。这里有一个合并优化:如果连续多个字节都没匹配,与其每次只记录一个字节,不如把中间所有未匹配区域收集成一个累积buffer,等遇到下一个匹配或文件结束时一次性生成一条ADD记录。这个优化能显著压缩差异元数据,对大范围插入场景特别有用。
重建逻辑很直接:打开旧文件,遍历SyncOp列表,COPY就pread旧文件对应区域,ADD就从新文件的源buffer拷贝对应区域,最后按序写入目标文件。
3.5 编译与测试
编译命令很简单:
g++ -O2 -std=c++17 -o sync_demo main.cpp sync_engine.cpp block_signer.cpp md5.cpp测试时我准备了一组故意制造不同差异的用例:
- Case 1:只改文件中间一个字符,预期只重传一个块;
- Case 2:在文件头部插入一行,预期只有第一个块匹配失败,其余块因为错位也能被滚动哈希找到;
- Case 3:删除文件中间连续一段,预期后半部分的块能被命中;
- Case 4:完全重写整个文件,预期没有任何COPY操作,全部走ADD。
从结果看,Case 2是最能体现滚动哈希价值的:如果不做滚动匹配,只是固定块对齐,那么插入一行之后后续所有块全部对不齐,只能全量重传;而滚动哈希能够错位找到后续匹配块,传输量只比实际改动量多一个块左右。
4. 常见问题与排查实录
4.1 滚动窗口起始位置越界
这是我最早遇到的一个崩溃问题。初始化滚动窗口时,如果文件大小小于blockSize,按固定块扫描的循环根本不应该执行,而我第一版代码没做这个特判,直接对不足blockSize的文件调用rh.getHash(),导致读取越界。
排查思路不难:用一个小文件跑单测,gdb回溯发现是在RollingHash::update里访问了data[i],检查后发现i已经大于fileSize。修复方式就是开头加一个if (newSize < blockSize)分支,把整个文件作为一个特例块处理。
4.2 哈希取模负数导致匹配异常
前面提到过,% MOD对负数结果不是我们想要的。假设移出字节的值大于当前a,a - out + in可能变成负数,取模后得到的是一个无符号大整数,而不是回绕到正数区间。这会导致滚动哈希值完全乱掉,进而所有匹配都失效。
我之前给MOD选了65536这个2的16次方数,目的其实是为了用位运算加速,但取模逻辑必须自己写:
inline uint32_t mod65536(int64_t v) { return static_cast<uint32_t>(v & 0xFFFF); }只有保证MOD是2的幂,这种位运算才等价于取模。如果你把MOD改成大质数比如65521,就必须走真正的%运算,同时处理负数偏移。
4.3 块大小选择对效果的影响
我起初拿1MB块长测试,结果发现一个小改动会重传整个1MB块——因为改动位置落在哪个块,那个块整体就需要重传。后来降到8KB,重传量立刻降下来了。但这又不是说块越小越好。
块小会导致签名数量线性增长,接收方建哈希表的时间、传输签名表的体积都会变大。我做过一组对比:
| 块长 | 文件大小 | 签名表大小 | 单字节改动重传量 |
|---|---|---|---|
| 4KB | 1MB | 256条 | 4KB |
| 8KB | 1MB | 128条 | 8KB |
| 16KB | 1MB | 64条 | 16KB |
结论很直白:如果大多数改动集中在几百字节到几KB,4KB块长的总体传输量最小;但如果文件经常整段替换,16KB反而因为签名表小,总耗时更短。所以块长应该根据你的真实文件变更特征来定,不是越大越好也不是越小越好。
4.4 大文件的哈希表内存占用
1GB文件按8KB切块,会产生131072条签名,每条签名包含16字节MD5、4字节弱哈希、4字节序号,再加上unordered_map的节点开销,粗略估算要几十MB内存。这对现代服务器不算什么,但在嵌入式设备上就需要注意。
我后来做了一版优化:把弱哈希表改成排序数组加二分查找,内存占用降低了约40%,查找速度在某些场景反而更快。不过这次Demo里为了代码清晰,还是保留了unordered_map版本,内存问题在注释里做了提示。
4.5 校验与一致性验证
写同步工具最容易犯的错误是“以为自己同步对了,实际文件已经坏掉”。每次重建完文件,我都会重新计算整个文件的MD5,和源文件做一次全文比对。这虽然违背了“只读差异块”的初衷,但只用于测试,不会进入生产路径。
测试里所有Case我都跑了这个校验,结果全部一致。如果哪天你改了代码发现校验不过,优先怀疑滚动窗口的reset逻辑和弱哈希更新公式,这两个地方是错误高发区。
5. 实测结果与性能对比
5.1 相同文件、无改动场景
这是最理想的情况:新文件和旧文件完全一致。同步过程生成128条签名,发送方匹配了128个块,差异列表为空,实际网络传输只有签名表本身。整个流程耗时主要是读文件、算MD5、建哈希表。
我拿一个6MB的日志文件测试,全量拷贝需要传输6MB,而纯增量同步只传输约40KB的签名表数据。这个对比最直观,也是给团队科普时最常用的数据。
5.2 小改动场景
在6MB文件中间改了一个字符。固定块对齐的做法会命中126块,漏掉2块,重传2个8KB块。滚动匹配的结果是只漏掉1块,额外还有一个跨越边界的ADD操作——因为改动点刚好落在块的边界附近,滚动哈希没能完全对齐。
最终重传量约16KB,与理论最优值8KB有差距,但已经远小于6MB的全量传输。这个8KB的“边界损失”是固定块方案不可避免的,除非改用可变块切割,比如内容定义分块,但那会引入更复杂的指纹算法,后续可以单开一篇来讲。
5.3 插入场景
在文件开头插入了100字节。固定块对齐方案几乎全军覆没,只有尾部末尾几个块能碰巧对上。滚动匹配方案则不同——第一块没匹配上,从第2个字节开始滑动窗口,很快就能找到后续块的正确位置,之后全部恢复匹配。
实测重传量为8KB块的1块加100字节,约8292字节。对比全量6MB,优化比接近700倍。这个压测案例最没争议地证明了滚动哈希的价值。
5.4 零散多改动场景
模拟了一个文本文件在5个不同位置各修改了几十个字节的场景。滚动匹配产生了5个ADD区域和123个COPY操作,重传量约2.5KB,比小改动场景更接近理论最优,原因是改动之间距离较远,块边界损失被摊薄了。
6. 后续怎么扩展成可用工具
Demo跑通之后,我已经想好了几条升级路径,这里列出来供你参考。
引入内容定义分块(CDC)。固定块大小有个明显缺点:两个文件如果整体发生了偏移,哪怕只改了一个字节,边界也会错乱。CDC通过文件内容本身来决定块边界,比如用某个指纹位模式作为切割点,能大幅提高相似度很高但偏移较大的文件的同步效率。
支持目录层级同步。现在的Demo只处理单文件,生产环境需要遍历目录树,生成相对路径列表,并为每个文件单独跑签名匹配。这一步还会涉及“新增文件”“删除文件”这类整文件级别的差异,逻辑上比单文件复杂不少。
断点续传和压缩。当网络带宽不稳时,增量同步最怕传了一半断开。可以通过记录已传ADD块序号来支持续传;另外,ADD区域的数据往往有压缩潜力,可以按块做zstd压缩后再传。
并发哈希计算。对大文件来说,MD5是全流程最耗时的部分。我目前是按块顺序计算MD5,完全可以用线程池把各个块的强哈希计算并行化,多核服务器上能把签名生成时间压到单核的四分之一甚至更低。
7. 写在最后的一点体会
我做这个Demo最大的收获不是写出一段能跑的同步代码,而是终于理解了为什么rsync能成为Linux生态里不可替代的基础工具。它的原理看起来简单——切块、弱哈希、强哈希、滚动匹配——但每个环节都有大量工程细节在支撑,任何一个细节没处理好,同步结果就是错的。
你在自己实现时,建议按这个顺序推进:先写死固定块长、只做整块匹配,跑通全流程;再引入滚动哈希;最后再优化内存和并发。每一步的验证门槛都很明确,不会出现那种“写完程序在跑但不知道它对不对”的状态。
如果你只是想在业务里用现成方案,那直接用系统自带的rsync就好;但如果你想在嵌入式环境里做一个超轻量的同步模块,或者想深入理解同步协议背后的算法,这个Demo的框架可以直接作为起点。代码本身很小,读一遍花不了半小时,但它能帮你把“增量同步”这四个字从口号变成真正有手感的东西。